Spaces:
Build error
Build error
| import streamlit as st | |
| import json | |
| import time | |
| from openai import AzureOpenAI, OpenAI | |
| from datetime import datetime | |
| from character_tracker import CharacterTracker | |
| # Initialize character tracker and Azure OpenAI client | |
| character_tracker = CharacterTracker() | |
| client = AzureOpenAI( | |
| azure_endpoint = 'https://storymii-openai.openai.azure.com/', | |
| api_key="8u2PbSBg9U29jGIUJVYy67pWaKU2UYe9l1TnAZvtXKbTg62xr5KlJQQJ99ALACYeBjFXJ3w3AAABACOG2gJ2", | |
| api_version="2024-08-01-preview", | |
| ) | |
| # Helper functions for story generation | |
| def system_dislecxic(age_group, word_limit): | |
| return f""" | |
| "TaleWeaver is a specialized story creation tool in the given structured format, crafted to weave accessible and enchanting narratives for children with dyslexia, targeted at the age group of {age_group} years. Each tale is thoughtfully designed, considering factors such as language simplicity, story flow, character relatability, and visual support, to ensure an engaging reading experience for diverse learners. Our approach focuses on crafting stories that encapsulate captivating themes, straightforward plots, and clear character motivations within a {word_limit}-word limit per page, structured cohesively across four pages without using 'Once upon a time' at the beginning or 'The End' at the conclusion. | |
| The story contains 4 pages with {word_limit} words per page. Each page contains two paragraphs. | |
| **Objective:** The goal is to craft a short, engaging story for children with dyslexia, aged {age_group} years, revolving around universally appealing themes like animal adventures, space exploration, or magical fantasies. The narrative should: | |
| 1. **Theme and Content:** | |
| - Choose a compelling theme that sparks interest in children of the specified age group. Keep the plot clear and straightforward, making it easy for the reader to follow. | |
| 2. **Main Character:** | |
| - Introduce a protagonist who is easy to relate to, with unique traits or a specific challenge they need to overcome. This fosters a deeper connection and engagement from the readers. | |
| 3. **Story Structure:** | |
| - **Beginning:** Initiate the story with an immediate problem or action to captivate the reader's attention. | |
| - **Middle:** Develop the story through direct, action-driven events, gradually leading towards the resolution. | |
| - **End:** Conclude with a positive outcome, underscoring a simple yet impactful message or moral. | |
| 4. **Language and Style:** | |
| - Opt for simple, clear language emphasizing short sentences and straightforward vocabulary. Avoid complex constructions and idiomatic expressions that could challenge readers with dyslexia. | |
| - Implement repetition of key words and phrases for better retention and comprehension. | |
| 5. **Formatting for Accessibility:** | |
| - Ensure clear, visual separation between paragraphs and sections to minimize visual stress. If applicable, incorporate illustrations or visual aids that complement and reinforce the textual content. | |
| Make sure the word \"THE END\" is never mentioned and is prohibited.""" | |
| def user_dislecxic(age_group, word_limit, style, theme, language, setting, character, attributes, other_details, tone): | |
| character_tracker.add_character(character, { | |
| 'appearance': attributes, | |
| 'personality': tone, | |
| 'distinguishing_features': other_details | |
| }) | |
| return f""" | |
| Generate a children's story in the following JSON format: | |
| {{ | |
| "title": "Story Title", | |
| "cover_image_prompt": "Detailed description for cover image", | |
| "pages": [ | |
| {{ | |
| "page_number": 1, | |
| "text": "First page narrative ({word_limit} words)", | |
| "image_prompt": "Detailed scene description for page 1" | |
| }}, | |
| {{ | |
| "page_number": 2, | |
| "text": "Second page narrative ({word_limit} words)", | |
| "image_prompt": "Detailed scene description for page 2" | |
| }}, | |
| {{ | |
| "page_number": 3, | |
| "text": "Third page narrative ({word_limit} words)", | |
| "image_prompt": "Detailed scene description for page 3" | |
| }}, | |
| {{ | |
| "page_number": 4, | |
| "text": "Fourth page narrative ({word_limit} words)", | |
| "image_prompt": "Detailed scene description for page 4" | |
| }} | |
| ] | |
| }} | |
| Story Details: | |
| - Style: {style} | |
| - Age Group: {age_group} | |
| - Theme: {theme} | |
| - Language: {language} | |
| - Setting: {setting} | |
| - Main Character: {character} | |
| - Character Attributes: {attributes} | |
| - Story Tone: {tone} | |
| {other_details} | |
| Requirements: | |
| 1. Each page should have exactly {word_limit} words | |
| 2. Use simple, clear language suitable for children with dyslexia | |
| 3. Maintain character consistency throughout the story | |
| 4. Include detailed image prompts that match the story | |
| 5. Create engaging, visual descriptions | |
| """ | |
| # Page configuration | |
| st.set_page_config( | |
| page_title="StoryMii - AI Story Generator", | |
| page_icon="π", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| # Custom CSS for dark modern UI | |
| st.markdown(""" | |
| <style> | |
| /* Dark modern theme */ | |
| :root { | |
| --primary-color: #6C63FF; | |
| --secondary-color: #FF6B6B; | |
| --background-color: #1A1B1E; | |
| --text-color: #E2E8F0; | |
| --card-background: #2D3748; | |
| --input-background: #2D3748; | |
| --input-text: #E2E8F0; | |
| --border-color: #4A5568; | |
| } | |
| .main { | |
| background-color: var(--background-color); | |
| color: var(--text-color); | |
| font-family: 'Inter', sans-serif; | |
| } | |
| .stButton>button { | |
| width: 100%; | |
| background-color: var(--primary-color); | |
| color: white; | |
| font-weight: 600; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 0.75rem; | |
| transition: all 0.3s ease; | |
| } | |
| .stButton>button:hover { | |
| background-color: #5651D5; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 6px rgba(108, 99, 255, 0.2); | |
| } | |
| .main > div { | |
| padding: 2rem; | |
| border-radius: 12px; | |
| background-color: var(--card-background); | |
| margin-bottom: 1.5rem; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
| border: 1px solid var(--border-color); | |
| } | |
| h1, h2, h3 { | |
| color: var(--text-color); | |
| font-weight: 700; | |
| margin-bottom: 1.5rem; | |
| } | |
| .stTextInput>div>div>input, | |
| .stSelectbox>div>div>div { | |
| background-color: var(--input-background) !important; | |
| color: var(--input-text) !important; | |
| border-radius: 8px; | |
| border: 2px solid var(--border-color); | |
| padding: 0.5rem; | |
| transition: all 0.3s ease; | |
| } | |
| .stTextInput>div>div>input:focus, | |
| .stSelectbox>div>div>div:focus { | |
| border-color: var(--primary-color); | |
| box-shadow: 0 0 0 2px rgba(108, 99, 255, 0.2); | |
| } | |
| /* Success message */ | |
| .stSuccess { | |
| background-color: #48BB78; | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 8px; | |
| } | |
| /* Error message */ | |
| .stError { | |
| background-color: #F56565; | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 8px; | |
| } | |
| /* AI suggestion button */ | |
| .suggestion-button { | |
| background-color: transparent; | |
| color: var(--primary-color); | |
| border: 2px solid var(--primary-color); | |
| border-radius: 8px; | |
| padding: 0.5rem; | |
| transition: all 0.3s ease; | |
| } | |
| .suggestion-button:hover { | |
| background-color: var(--primary-color); | |
| color: white; | |
| } | |
| /* Dark theme specific adjustments */ | |
| .stSelectbox>div>div>div { | |
| background-color: var(--input-background); | |
| color: var(--input-text); | |
| } | |
| .stMarkdown { | |
| color: var(--text-color); | |
| } | |
| .streamlit-expanderHeader { | |
| background-color: var(--card-background); | |
| color: var(--text-color); | |
| } | |
| /* Story display animations and styling */ | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| @keyframes cursorBlink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0; } | |
| } | |
| .story-display { | |
| max-width: 800px; | |
| margin: 0 auto; | |
| padding: 2rem; | |
| } | |
| .story-title { | |
| font-size: 2.5rem; | |
| margin-bottom: 2rem; | |
| text-align: center; | |
| color: var(--primary-color); | |
| } | |
| .story-text { | |
| font-size: 1.2rem; | |
| line-height: 1.8; | |
| margin: 1.5rem 0; | |
| padding: 1rem; | |
| background: rgba(0, 0, 0, 0.1); | |
| border-radius: 8px; | |
| border-left: 4px solid var(--primary-color); | |
| } | |
| .story-page { | |
| margin: 2rem 0; | |
| padding: 1rem; | |
| border-radius: 12px; | |
| background: var(--card-background); | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # Initialize session state | |
| if 'saved_stories' not in st.session_state: | |
| st.session_state.saved_stories = [] | |
| if 'story_details' not in st.session_state: | |
| st.session_state.story_details = {} | |
| # Helper function to get AI suggestions | |
| def get_ai_suggestion(field_name, existing_values=None): | |
| try: | |
| prompt = f"Suggest a creative {field_name} for a children's story." | |
| if existing_values: | |
| prompt += f" Consider these existing details: {existing_values}" | |
| response = client.chat.completions.create( | |
| model="gpt-4o", | |
| messages=[ | |
| {"role": "system", "content": "You are a creative children's story assistant."}, | |
| {"role": "user", "content": prompt} | |
| ], | |
| max_tokens=4096, | |
| temperature=0 | |
| ) | |
| return response.choices[0].message.content | |
| except Exception as e: | |
| st.error(f"Error getting suggestion: {str(e)}") | |
| return None | |
| # Story generation function | |
| def generate_story(story_details): | |
| try: | |
| word_limits = { | |
| "1-3": 50, | |
| "4-6": 100, | |
| "7-9": 150, | |
| "10-12": 200 | |
| } | |
| word_limit = word_limits[story_details.get('target_age_group', '7-9')] | |
| system_prompt = system_dislecxic(story_details.get('target_age_group', '7-9'), word_limit) | |
| user_prompt = user_dislecxic( | |
| story_details.get('target_age_group', '7-9'), | |
| word_limit, | |
| story_details.get('story_style', ''), | |
| story_details.get('theme', ''), | |
| story_details.get('language', 'English'), | |
| story_details.get('plot_setting', ''), | |
| story_details.get('main_character', ''), | |
| story_details.get('main_character_attributes', ''), | |
| story_details.get('other_details', ''), | |
| story_details.get('story_tone', '') | |
| ) | |
| response = client.chat.completions.create( | |
| model="gpt-4", # Changed from gpt-4o to gpt-4 | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| max_tokens=4096, | |
| temperature=0.7, # Added some creativity | |
| response_format={"type": "json_object"} | |
| ) | |
| # Validate JSON structure | |
| story_json = json.loads(response.choices[0].message.content) | |
| if 'pages' not in story_json: | |
| raise ValueError("Story response missing 'pages' structure") | |
| if len(story_json['pages']) != 4: | |
| raise ValueError("Story must have exactly 4 pages") | |
| # Validate each page has required fields | |
| for page in story_json['pages']: | |
| if not all(key in page for key in ['page_number', 'text', 'image_prompt']): | |
| raise ValueError("Invalid page structure in story response") | |
| return response.choices[0].message.content | |
| except json.JSONDecodeError: | |
| st.error("Invalid JSON response from story generation") | |
| return None | |
| except ValueError as ve: | |
| st.error(f"Story generation error: {str(ve)}") | |
| return None | |
| except Exception as e: | |
| st.error(f"Error generating story: {str(e)}") | |
| return None | |
| # Image generation function | |
| def generate_images(image_prompts, art_style): | |
| try: | |
| styled_prompts = [] | |
| style_descriptions = { | |
| "Abstract Art": "Digital art in Abstract style with vibrant shapes and patterns, dynamic composition, non-representational elements, ", | |
| "Art Deco": "Digital art in Art Deco style with symmetrical designs, bold geometric forms, radiant colors, 1920s aesthetic, ", | |
| "Art Nouveau": "Digital illustration in Art Nouveau style with intricate floral motifs, curvilinear designs, flowing organic patterns, ", | |
| "Bauhaus": "Digital art inspired by Bauhaus movement with geometric shapes, functional form, modernist approach, ", | |
| "Celtic Art": "Digital art in Celtic style with intricate knotwork, spiral designs, medieval influence, ", | |
| "Chinese Brush Painting": "Digital art in Chinese Brush Painting style with fluid brush strokes, serene composition, nature-inspired, ", | |
| "Concept Art": "Detailed concept art illustration with rich details, atmospheric lighting, cinematic composition, ", | |
| "Cyber Folk": "Digital art blending traditional folk patterns with futuristic cyber aesthetics, neon accents, ", | |
| "Fairy Tale": "Classic storybook illustration with whimsical elements, soft colors, enchanted atmosphere, ", | |
| "Fantasy": "Magical and imaginative digital art with ethereal lighting, fantastical elements, ", | |
| "Pixar": "3D animated style with emotional depth, vibrant colors, expressive characters, cinematic quality, ", | |
| "Watercolor": "Digital watercolor style with soft edges, flowing colors, artistic textures, delicate details, ", | |
| "Abstract Geometry": "Digital art with clean geometric shapes, interlocking triangles and circles, modern minimal style, ", | |
| "Bokeh Art": "Digital art with soft focus effects, luminous spots, dreamy atmosphere, ethereal lighting, ", | |
| "Brutalism": "Digital art with raw textures, bold shapes, monolithic structures, stark contrasts, ", | |
| "Byzantine": "Digital art with rich gold tones, mosaic-like textures, religious icon style, ornate details, ", | |
| "Charcoal": "Digital art emulating charcoal drawing with bold strokes, rich textures, dramatic shadows, ", | |
| "Chiptune": "Digital art in retro pixel art style, 8-bit aesthetic, vibrant colors, nostalgic gaming feel, " | |
| } | |
| style_prefix = style_descriptions.get(art_style, "Digital illustration in a ") | |
| for prompt in image_prompts: | |
| styled_prompts.append(f"{style_prefix}{prompt}") | |
| image_urls = [] | |
| for prompt in styled_prompts: | |
| response = client.images.generate( | |
| model="dall-e-3", | |
| prompt=prompt, | |
| size="1024x1024", | |
| quality="standard", | |
| n=1 | |
| ) | |
| image_urls.append(response.data[0].url) | |
| return image_urls | |
| except Exception as e: | |
| st.error(f"Error generating images: {str(e)}") | |
| return None | |
| # Main UI | |
| st.title("β¨ StoryMii") | |
| # Sidebar navigation | |
| page = st.sidebar.radio("Navigation", ["Create Story", "Story Library"]) | |
| if page == "Create Story": | |
| with st.form("story_form"): | |
| st.subheader("π Story Details") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| story_details = {} | |
| story_details["plot_setting"] = st.text_input( | |
| "Plot Setting", | |
| help="Where does your story take place?" | |
| ) | |
| suggest_plot = st.form_submit_button("π― Suggest Plot Setting") | |
| if suggest_plot: | |
| suggestion = get_ai_suggestion("plot setting") | |
| if suggestion: | |
| st.session_state.story_details["plot_setting"] = suggestion | |
| story_details["plot_setting"] = suggestion | |
| # Updated art style selection with detailed descriptions | |
| art_styles = { | |
| "Abstract Art": "Uses shapes, colors, and forms for creative expression", | |
| "Art Deco": "Bold geometric forms and bright colors from the 1920s", | |
| "Art Nouveau": "Flowing designs inspired by natural forms", | |
| "Bauhaus": "Geometric shapes and functional design", | |
| "Celtic Art": "Intricate patterns, spirals, and knotwork", | |
| "Chinese Brush Painting": "Fluid brush strokes capturing nature's essence", | |
| "Concept Art": "Detailed visual representations of ideas", | |
| "Cyber Folk": "Traditional folk elements with futuristic aesthetics", | |
| "Fairy Tale": "Classic storybook illustration style", | |
| "Fantasy": "Magical and imaginative scenes", | |
| "Pixar": "3D animated style with emotional depth", | |
| "Watercolor": "Soft, flowing colors with artistic texture" | |
| } | |
| story_details["preferred_art_style"] = st.selectbox( | |
| "Art Style", | |
| options=list(art_styles.keys()), | |
| help="Choose the visual style for your story", | |
| format_func=lambda x: f"{x} - {art_styles[x]}" | |
| ) | |
| story_details["story_style"] = st.selectbox( | |
| "Story Style", | |
| ["Fairy Tale", "Adventure", "Mystery", "Fantasy", "Educational", "Science Fiction"] | |
| ) | |
| story_details["theme"] = st.selectbox( | |
| "Theme", | |
| ["Friendship", "Courage", "Discovery", "Family", "Nature", "Magic"] | |
| ) | |
| story_details["target_age_group"] = st.select_slider( | |
| "Target Age Group", | |
| options=["1-3", "4-6", "7-9", "10-12"], | |
| value="7-9", | |
| help="Select the target age group for your story" | |
| ) | |
| # Word limit based on age group | |
| word_limits = { | |
| "1-3": 50, # Simpler, shorter stories for toddlers | |
| "4-6": 100, # Slightly longer for preschoolers | |
| "7-9": 150, # More complex for early readers | |
| "10-12": 200 # Longer stories for confident readers | |
| } | |
| word_limit = word_limits[story_details["target_age_group"]] | |
| # Updated story tones | |
| story_details["story_tone"] = st.selectbox( | |
| "Story Tone", | |
| ["Whimsical", "Educational", "Neutral", "Gentle", "Encouraging"], | |
| help="Choose the overall tone of your story" | |
| ) | |
| # Updated story styles | |
| story_details["story_style"] = st.radio( | |
| "Story Style", | |
| [ | |
| "Picture Book", | |
| "Early Reader", | |
| "Educational Story", | |
| "Bedtime Story", | |
| "Interactive Story", | |
| "Simple Chapter Story" | |
| ], | |
| help="Select the style that best fits your story" | |
| ) | |
| story_details["main_character"] = st.text_input( | |
| "Main Character", | |
| help="Who is your story's protagonist?", | |
| value=st.session_state.story_details.get("main_character", "") | |
| ) | |
| suggest_character = st.form_submit_button("π¦Έ Suggest Character") | |
| if suggest_character: | |
| suggestion = get_ai_suggestion("main character") | |
| if suggestion: | |
| st.session_state.story_details["main_character"] = suggestion | |
| story_details["main_character"] = suggestion | |
| story_details["main_character_attributes"] = st.text_area( | |
| "Character Attributes", | |
| help="Describe your main character's personality and traits", | |
| value=st.session_state.story_details.get("main_character_attributes", "") | |
| ) | |
| suggest_attributes = st.form_submit_button("β¨ Suggest Attributes") | |
| if suggest_attributes: | |
| suggestion = get_ai_suggestion("character attributes", story_details["main_character"]) | |
| if suggestion: | |
| st.session_state.story_details["main_character_attributes"] = suggestion | |
| story_details["main_character_attributes"] = suggestion | |
| story_details["story_tone"] = st.select_slider( | |
| "Story Tone", | |
| options=["Playful", "Educational", "Adventurous", "Mysterious", "Calm"] | |
| ) | |
| story_details["language"] = st.selectbox( | |
| "Language", | |
| ["English", "Spanish", "French", "German", "Italian"] | |
| ) | |
| submit = st.form_submit_button("π¨ Generate Story") | |
| if submit: | |
| if not story_details["plot_setting"] or not story_details["main_character"]: | |
| st.error("Please fill in all required fields.") | |
| else: | |
| try: | |
| with st.spinner("Creating your story..."): | |
| story = generate_story(story_details) | |
| if story: | |
| story_json = json.loads(story) | |
| else: | |
| st.error("Failed to generate story") | |
| st.stop() # Use st.stop() instead of return | |
| except json.JSONDecodeError: | |
| st.error("Invalid story format received") | |
| st.stop() # Use st.stop() instead of return | |
| except Exception as e: | |
| st.error(f"An error occurred: {str(e)}") | |
| st.stop() # Use st.stop() instead of return | |
| # Validate character consistency in story text | |
| # Validate character consistency for each page | |
| has_consistency_error = False | |
| for page in story_json['pages']: | |
| is_consistent, message = character_tracker.validate_consistency(page['text']) | |
| if not is_consistent: | |
| st.error(f"Character consistency error: {message}") | |
| has_consistency_error = True | |
| break | |
| if has_consistency_error: | |
| st.stop() | |
| # Generate character-consistent image prompts | |
| character_desc = character_tracker.get_character_prompt(story_details['main_character']) | |
| image_prompts = [f"{story_json['cover_image_prompt']}. {character_desc}"] | |
| image_prompts.extend(f"{page['image_prompt']}. {character_desc}" for page in story_json['pages']) | |
| image_urls = generate_images(image_prompts, story_details["preferred_art_style"]) | |
| if image_urls: | |
| story_data = { | |
| "title": story_json['title'], | |
| "date_created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| "story_json": story_json, | |
| "image_urls": image_urls | |
| } | |
| st.session_state.saved_stories.append(story_data) | |
| st.success("Story generated successfully!") | |
| # Create a container for the story display with enhanced features | |
| story_container = st.container() | |
| with story_container: | |
| # Add a loading animation while content streams | |
| with st.spinner("Preparing your story..."): | |
| # Animated title reveal | |
| st.markdown(f"<div class='story-display' style='opacity: 0; animation: fadeIn 1s forwards;'>", unsafe_allow_html=True) | |
| st.markdown(f"<h2 class='story-title' style='text-align: center;'>π {story_json['title']}</h2>", unsafe_allow_html=True) | |
| # Display cover image with fade-in effect | |
| st.markdown(f"<div style='opacity: 0; animation: fadeIn 1s forwards 0.5s;'>", unsafe_allow_html=True) | |
| st.image(image_urls[0], caption="Cover Image", width=800) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # Stream each page with animated transitions | |
| for i, page in enumerate(story_json['pages'], 1): | |
| st.markdown(f"<div class='story-page' style='opacity: 0; animation: fadeIn 1s forwards {i}s;'>", unsafe_allow_html=True) | |
| st.subheader(f"Page {i}") | |
| # Stream text character by character | |
| text_placeholder = st.empty() | |
| displayed_text = "" | |
| for char in page['text']: | |
| displayed_text += char | |
| text_placeholder.markdown(f"<div class='story-text'>{displayed_text}β</div>", unsafe_allow_html=True) | |
| time.sleep(0.02) # Adjust speed as needed | |
| text_placeholder.markdown(f"<div class='story-text'>{page['text']}</div>", unsafe_allow_html=True) | |
| # Display illustration with fade-in effect | |
| st.markdown(f"<div style='opacity: 0; animation: fadeIn 1s forwards;'>", unsafe_allow_html=True) | |
| st.image(image_urls[i], caption=f"Page {i} Illustration", width=800) | |
| st.markdown("</div></div>", unsafe_allow_html=True) | |
| # Add subtle page transition effect | |
| if i < len(story_json['pages']): | |
| st.markdown("<hr style='margin: 2rem 0; opacity: 0.2;'>", unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| for i, page in enumerate(story_json['pages'], 1): | |
| st.subheader(f"Page {i}") | |
| st.write(page['text']) | |
| st.image(image_urls[i], caption=f"Page {i} Illustration", width=800) | |
| else: # Story Library page | |
| st.title("π Story Library") | |
| if not st.session_state.saved_stories: | |
| st.info("Your library is empty. Create your first story!") | |
| else: | |
| for story in st.session_state.saved_stories: | |
| with st.expander(f"{story['title']} - {story['date_created']}"): | |
| st.image(story['image_urls'][0], caption="Cover Image", use_container_width=True) | |
| for i, page in enumerate(story['story_json']['pages'], 1): | |
| st.subheader(f"Page {i}") | |
| st.write(page['text']) | |
| st.image(story['image_urls'][i], caption=f"Page {i} Illustration", use_container_width=True) | |