Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,146 +1,103 @@
|
|
1 |
-
# Copyright 2025 Google LLC.
|
2 |
-
#
|
3 |
-
#
|
4 |
-
#
|
5 |
-
# You may
|
|
|
6 |
|
7 |
-
import streamlit as st
|
8 |
-
import google.generativeai as genai
|
9 |
import os
|
10 |
import json
|
11 |
-
import numpy as np
|
12 |
-
from io import BytesIO
|
13 |
import time
|
14 |
-
import
|
15 |
-
import contextlib
|
16 |
import asyncio
|
17 |
-
import
|
18 |
-
import shutil
|
19 |
-
import
|
|
|
|
|
|
|
20 |
|
21 |
-
|
|
|
22 |
from PIL import Image
|
|
|
23 |
# Pydantic for data validation
|
24 |
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
25 |
-
from typing import List, Optional, Literal, Dict, Any
|
26 |
|
27 |
# Video and audio processing
|
28 |
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips
|
29 |
-
# from moviepy.config import change_settings # Potential for setting imagemagick path if needed
|
30 |
-
|
31 |
-
# Type hints
|
32 |
-
import typing_extensions as typing
|
33 |
|
34 |
-
#
|
|
|
35 |
import nest_asyncio
|
36 |
-
nest_asyncio.apply()
|
37 |
|
38 |
# --- Logging Setup ---
|
39 |
-
logging.basicConfig(level=logging.INFO, format=
|
40 |
logger = logging.getLogger(__name__)
|
41 |
|
42 |
-
# ---
|
43 |
-
|
44 |
-
|
45 |
-
st.markdown("""
|
46 |
-
Generate multiple, branching story timelines from a single theme using AI, complete with images and narration.
|
47 |
-
*Based on the work by Yousif Ahmed. Copyright 2025 Google LLC.*
|
48 |
-
""")
|
49 |
-
|
50 |
-
# --- Constants ---
|
51 |
-
# Text/JSON Model
|
52 |
-
TEXT_MODEL_ID = "models/gemini-1.5-flash" # Or "gemini-1.5-pro"
|
53 |
-
# Audio Model Config
|
54 |
-
AUDIO_MODEL_ID = "models/gemini-1.5-flash" # Model used for audio tasks
|
55 |
AUDIO_SAMPLING_RATE = 24000
|
56 |
-
#
|
57 |
-
IMAGE_MODEL_ID = "imagen-3" # <<< NOTE: Likely needs Vertex AI SDK access
|
58 |
DEFAULT_ASPECT_RATIO = "1:1"
|
59 |
-
# Video Config
|
60 |
VIDEO_FPS = 24
|
61 |
VIDEO_CODEC = "libx264"
|
62 |
AUDIO_CODEC = "aac"
|
63 |
-
# File Management
|
64 |
TEMP_DIR_BASE = ".chrono_temp"
|
65 |
|
66 |
-
|
67 |
-
|
68 |
-
try:
|
69 |
-
GOOGLE_API_KEY = st.secrets["GOOGLE_API_KEY"]
|
70 |
-
logger.info("Google API Key loaded from Streamlit secrets.")
|
71 |
-
except KeyError:
|
72 |
-
GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY')
|
73 |
-
if GOOGLE_API_KEY:
|
74 |
-
logger.info("Google API Key loaded from environment variable.")
|
75 |
-
else:
|
76 |
-
st.error("π¨ **Google API Key Not Found!** Please configure it.", icon="π¨"); st.stop()
|
77 |
-
|
78 |
-
# --- Initialize Google Clients ---
|
79 |
-
# Initialize handles for Text, Audio (using Text model), and potentially Image models
|
80 |
-
try:
|
81 |
-
genai.configure(api_key=GOOGLE_API_KEY)
|
82 |
-
logger.info("Configured google-generativeai with API key.")
|
83 |
-
client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
|
84 |
-
logger.info(f"Initialized text/JSON model handle: {TEXT_MODEL_ID}.")
|
85 |
-
live_model = genai.GenerativeModel(AUDIO_MODEL_ID)
|
86 |
-
logger.info(f"Initialized audio model handle: {AUDIO_MODEL_ID}.")
|
87 |
-
# This handle remains, but the call in generate_image_imagen is likely incorrect for this library
|
88 |
-
image_model_genai = genai.GenerativeModel(IMAGE_MODEL_ID)
|
89 |
-
logger.info(f"Initialized google-generativeai handle for image model: {IMAGE_MODEL_ID} (May require Vertex AI SDK).")
|
90 |
-
# ---> TODO: Initialize Vertex AI client here if switching SDK <---
|
91 |
-
# from google.cloud import aiplatform
|
92 |
-
# aiplatform.init(project='YOUR_PROJECT_ID', location='YOUR_REGION') # Example
|
93 |
-
# logger.info("Initialized Vertex AI Platform.")
|
94 |
-
|
95 |
-
except AttributeError as ae:
|
96 |
-
logger.exception("AttributeError during Client Init."); st.error(f"π¨ Init Error: {ae}. Update library?", icon="π¨"); st.stop()
|
97 |
-
except Exception as e:
|
98 |
-
logger.exception("Failed to initialize Google Clients/Models."); st.error(f"π¨ Failed Init: {e}", icon="π¨"); st.stop()
|
99 |
-
|
100 |
-
# --- Define Pydantic Schemas (Using V2 Syntax) ---
|
101 |
class StorySegment(BaseModel):
|
102 |
scene_id: int = Field(..., ge=0)
|
103 |
image_prompt: str = Field(..., min_length=10, max_length=250)
|
104 |
audio_text: str = Field(..., min_length=5, max_length=150)
|
105 |
character_description: str = Field(..., max_length=250)
|
106 |
timeline_visual_modifier: Optional[str] = Field(None, max_length=50)
|
107 |
-
|
|
|
108 |
@classmethod
|
109 |
def image_prompt_no_humans(cls, v: str) -> str:
|
110 |
-
if any(
|
|
|
111 |
return v
|
|
|
|
|
112 |
class Timeline(BaseModel):
|
113 |
timeline_id: int = Field(..., ge=0)
|
114 |
divergence_reason: str = Field(..., min_length=5)
|
115 |
segments: List[StorySegment] = Field(..., min_items=1)
|
|
|
|
|
116 |
class ChronoWeaveResponse(BaseModel):
|
117 |
core_theme: str = Field(..., min_length=5)
|
118 |
timelines: List[Timeline] = Field(..., min_items=1)
|
119 |
total_scenes_per_timeline: int = Field(..., gt=0)
|
120 |
-
|
121 |
-
|
|
|
122 |
expected = self.total_scenes_per_timeline
|
123 |
for i, t in enumerate(self.timelines):
|
124 |
-
if len(t.segments) != expected:
|
|
|
125 |
return self
|
126 |
|
127 |
-
# --- Helper Functions ---
|
128 |
|
129 |
-
#
|
130 |
@contextlib.contextmanager
|
131 |
def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLING_RATE, sample_width: int = 2):
|
132 |
-
"""
|
133 |
wf = None
|
134 |
try:
|
135 |
-
# Indent these lines correctly under the try:
|
136 |
wf = wave.open(filename, "wb")
|
137 |
wf.setnchannels(channels)
|
138 |
-
wf.setsampwidth(sample_width)
|
139 |
wf.setframerate(rate)
|
140 |
-
yield wf
|
141 |
-
except Exception as
|
142 |
-
logger.error(f"Error
|
143 |
-
raise
|
144 |
finally:
|
145 |
if wf:
|
146 |
try:
|
@@ -149,209 +106,417 @@ def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLIN
|
|
149 |
logger.error(f"Error closing wave file {filename}: {e_close}")
|
150 |
|
151 |
|
152 |
-
|
153 |
-
|
154 |
-
collected_audio = bytearray(); task_id = os.path.basename(output_filename).split('.')[0]
|
155 |
-
logger.info(f"ποΈ [{task_id}] Requesting audio: '{api_text[:60]}...'")
|
156 |
-
try:
|
157 |
-
config = {"response_modalities": ["AUDIO"], "audio_config": {"audio_encoding": "LINEAR16", "sample_rate_hertz": AUDIO_SAMPLING_RATE}}
|
158 |
-
directive_prompt = f"Narrate directly: \"{api_text}\""
|
159 |
-
async with live_model.connect(config=config) as session:
|
160 |
-
await session.send_request([directive_prompt])
|
161 |
-
async for response in session.stream_content():
|
162 |
-
if response.audio_chunk and response.audio_chunk.data: collected_audio.extend(response.audio_chunk.data)
|
163 |
-
if hasattr(response, 'error') and response.error: logger.error(f" β [{task_id}] Audio stream error: {response.error}"); st.error(f"Audio stream error {task_id}: {response.error}", icon="π"); return None
|
164 |
-
if not collected_audio: logger.warning(f"β οΈ [{task_id}] No audio data received."); st.warning(f"No audio data for {task_id}.", icon="π"); return None
|
165 |
-
with wave_file_writer(output_filename, rate=AUDIO_SAMPLING_RATE) as wf: wf.writeframes(bytes(collected_audio))
|
166 |
-
logger.info(f" β
[{task_id}] Audio saved: {os.path.basename(output_filename)} ({len(collected_audio)} bytes)")
|
167 |
-
return output_filename
|
168 |
-
except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f" β [{task_id}] Audio blocked: {bpe}"); st.error(f"Audio blocked {task_id}.", icon="π"); return None
|
169 |
-
except Exception as e: logger.exception(f" β [{task_id}] Audio failed: {e}"); st.error(f"Audio failed {task_id}: {e}", icon="π"); return None
|
170 |
-
|
171 |
-
|
172 |
-
def generate_story_sequence_chrono(theme: str, num_scenes: int, num_timelines: int, divergence_prompt: str = "") -> Optional[ChronoWeaveResponse]:
|
173 |
-
"""Generates branching story sequences using Gemini structured output and validates with Pydantic."""
|
174 |
-
st.info(f"π Generating {num_timelines} timeline(s) x {num_scenes} scenes for: '{theme}'...")
|
175 |
-
logger.info(f"Requesting story structure: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
|
176 |
-
divergence_instruction = (f"Introduce clear points of divergence between timelines, after first scene if possible. Hint: '{divergence_prompt}'. State divergence reason clearly. **For timeline_id 0, use 'Initial path' or 'Baseline scenario'.**")
|
177 |
-
prompt = f"""Act as narrative designer. Create story for theme: "{theme}". Instructions: 1. Exactly **{num_timelines}** timelines. 2. Each timeline exactly **{num_scenes}** scenes. 3. **NO humans/humanoids**. Focus: animals, fantasy creatures, animated objects, nature. 4. {divergence_instruction}. 5. Style: **'Simple, friendly kids animation, bright colors, rounded shapes'**, unless `timeline_visual_modifier` alters. 6. `audio_text`: single concise sentence (max 30 words). 7. `image_prompt`: descriptive, concise (target 15-35 words MAX). Focus on scene elements. **AVOID repeating general style**. 8. `character_description`: VERY brief (name, features). Target < 20 words. Output: ONLY valid JSON object adhering to schema. No text before/after. JSON Schema: ```json\n{json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}\n```"""
|
178 |
-
try:
|
179 |
-
response = client_standard.generate_content(contents=prompt, generation_config=genai.types.GenerationConfig(response_mime_type="application/json", temperature=0.7))
|
180 |
-
try: raw_data = json.loads(response.text)
|
181 |
-
except json.JSONDecodeError as json_err: logger.error(f"Failed JSON decode: {json_err}\nResponse:\n{response.text}"); st.error(f"π¨ Failed parse story: {json_err}", icon="π"); st.text_area("Problem Response:", response.text, height=150); return None
|
182 |
-
except Exception as e: logger.error(f"Error processing text: {e}"); st.error(f"π¨ Error processing AI response: {e}", icon="π"); return None
|
183 |
-
try: validated_data = ChronoWeaveResponse.model_validate(raw_data); logger.info("β
Story structure OK!"); st.success("β
Story structure OK!"); return validated_data
|
184 |
-
except ValidationError as val_err: logger.error(f"JSON validation failed: {val_err}\nData:\n{json.dumps(raw_data, indent=2)}"); st.error(f"π¨ Gen structure invalid: {val_err}", icon="π§¬"); st.json(raw_data); return None
|
185 |
-
except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f"Story gen blocked: {bpe}"); st.error("π¨ Story prompt blocked.", icon="π«"); return None
|
186 |
-
except Exception as e: logger.exception("Error during story gen:"); st.error(f"π¨ Story gen error: {e}", icon="π₯"); return None
|
187 |
-
|
188 |
-
|
189 |
-
def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str = "IMG") -> Optional[Image.Image]:
|
190 |
"""
|
191 |
-
|
192 |
-
|
193 |
-
(google-cloud-aiplatform) to correctly call Imagen models.
|
194 |
-
The current implementation using google-generativeai's generate_content
|
195 |
-
is likely incompatible with the 'imagen-3' model ID on the standard endpoint. >>>
|
196 |
"""
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
220 |
else:
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
st.error(f" - {msg}")
|
347 |
-
else: st.info("No generation errors recorded.")
|
348 |
-
|
349 |
-
# --- 4. Cleanup ---
|
350 |
-
st.info(f"Attempting cleanup: {temp_dir}")
|
351 |
-
try: shutil.rmtree(temp_dir); logger.info(f"β
Temp dir removed: {temp_dir}"); st.success("β
Temp files cleaned.")
|
352 |
-
except Exception as e: logger.error(f"β οΈ Failed remove temp dir {temp_dir}: {e}"); st.warning(f"Could not remove temp files: {temp_dir}.", icon="β οΈ")
|
353 |
-
|
354 |
-
elif not chrono_response: logger.error("Story gen/validation failed.")
|
355 |
-
else: st.error("Unexpected issue post-gen.", icon="π"); logger.error("Chrono_response truthy but invalid.")
|
356 |
-
|
357 |
-
else: st.info("Configure settings and click 'β¨ Generate ChronoWeave β¨' to start.")
|
|
|
1 |
+
# Copyright 2025 Google LLC.
|
2 |
+
# Based on work by Yousif Ahmed.
|
3 |
+
# Concept: ChronoWeave β Branching Narrative Generation
|
4 |
+
# Licensed under the Apache License, Version 2.0 (the "License").
|
5 |
+
# You may not use this file except in compliance with the License.
|
6 |
+
# Obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0
|
7 |
|
|
|
|
|
8 |
import os
|
9 |
import json
|
|
|
|
|
10 |
import time
|
11 |
+
import uuid
|
|
|
12 |
import asyncio
|
13 |
+
import logging
|
14 |
+
import shutil
|
15 |
+
import contextlib
|
16 |
+
import wave
|
17 |
+
from io import BytesIO
|
18 |
+
from typing import List, Optional, Tuple, Dict, Any
|
19 |
|
20 |
+
import streamlit as st
|
21 |
+
import numpy as np
|
22 |
from PIL import Image
|
23 |
+
|
24 |
# Pydantic for data validation
|
25 |
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
|
|
26 |
|
27 |
# Video and audio processing
|
28 |
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips
|
|
|
|
|
|
|
|
|
29 |
|
30 |
+
# Google generative API and async patch
|
31 |
+
import google.generativeai as genai
|
32 |
import nest_asyncio
|
33 |
+
nest_asyncio.apply() # Make asyncio work in Streamlit/Jupyter
|
34 |
|
35 |
# --- Logging Setup ---
|
36 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
37 |
logger = logging.getLogger(__name__)
|
38 |
|
39 |
+
# --- Constants & Configurations ---
|
40 |
+
TEXT_MODEL_ID = "models/gemini-1.5-flash" # Alternatively "gemini-1.5-pro"
|
41 |
+
AUDIO_MODEL_ID = "models/gemini-1.5-flash" # Audio generation uses the text model for now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
AUDIO_SAMPLING_RATE = 24000
|
43 |
+
IMAGE_MODEL_ID = "imagen-3" # NOTE: Requires Vertex AI SDK update for production
|
|
|
44 |
DEFAULT_ASPECT_RATIO = "1:1"
|
|
|
45 |
VIDEO_FPS = 24
|
46 |
VIDEO_CODEC = "libx264"
|
47 |
AUDIO_CODEC = "aac"
|
|
|
48 |
TEMP_DIR_BASE = ".chrono_temp"
|
49 |
|
50 |
+
|
51 |
+
# --- Pydantic Schemas ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
class StorySegment(BaseModel):
|
53 |
scene_id: int = Field(..., ge=0)
|
54 |
image_prompt: str = Field(..., min_length=10, max_length=250)
|
55 |
audio_text: str = Field(..., min_length=5, max_length=150)
|
56 |
character_description: str = Field(..., max_length=250)
|
57 |
timeline_visual_modifier: Optional[str] = Field(None, max_length=50)
|
58 |
+
|
59 |
+
@field_validator("image_prompt")
|
60 |
@classmethod
|
61 |
def image_prompt_no_humans(cls, v: str) -> str:
|
62 |
+
if any(word in v.lower() for word in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]):
|
63 |
+
logger.warning(f"Image prompt '{v[:50]}...' may contain human-related descriptors.")
|
64 |
return v
|
65 |
+
|
66 |
+
|
67 |
class Timeline(BaseModel):
|
68 |
timeline_id: int = Field(..., ge=0)
|
69 |
divergence_reason: str = Field(..., min_length=5)
|
70 |
segments: List[StorySegment] = Field(..., min_items=1)
|
71 |
+
|
72 |
+
|
73 |
class ChronoWeaveResponse(BaseModel):
|
74 |
core_theme: str = Field(..., min_length=5)
|
75 |
timelines: List[Timeline] = Field(..., min_items=1)
|
76 |
total_scenes_per_timeline: int = Field(..., gt=0)
|
77 |
+
|
78 |
+
@model_validator(mode="after")
|
79 |
+
def check_timeline_segment_count(self) -> "ChronoWeaveResponse":
|
80 |
expected = self.total_scenes_per_timeline
|
81 |
for i, t in enumerate(self.timelines):
|
82 |
+
if len(t.segments) != expected:
|
83 |
+
raise ValueError(f"Timeline {i} (ID: {t.timeline_id}): Expected {expected} segments, got {len(t.segments)}.")
|
84 |
return self
|
85 |
|
|
|
86 |
|
87 |
+
# --- Helper Functions ---
|
88 |
@contextlib.contextmanager
|
89 |
def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLING_RATE, sample_width: int = 2):
|
90 |
+
"""Safely writes a WAV file using a context manager."""
|
91 |
wf = None
|
92 |
try:
|
|
|
93 |
wf = wave.open(filename, "wb")
|
94 |
wf.setnchannels(channels)
|
95 |
+
wf.setsampwidth(sample_width) # 16-bit audio (2 bytes)
|
96 |
wf.setframerate(rate)
|
97 |
+
yield wf
|
98 |
+
except Exception as exc:
|
99 |
+
logger.error(f"Error writing wave file {filename}: {exc}")
|
100 |
+
raise
|
101 |
finally:
|
102 |
if wf:
|
103 |
try:
|
|
|
106 |
logger.error(f"Error closing wave file {filename}: {e_close}")
|
107 |
|
108 |
|
109 |
+
# --- ChronoWeave Generator Class ---
|
110 |
+
class ChronoWeaveGenerator:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
111 |
"""
|
112 |
+
Encapsulates the logic for generating branching narratives, processing assets (audio, image)
|
113 |
+
and assembling final videos.
|
|
|
|
|
|
|
114 |
"""
|
115 |
+
|
116 |
+
def __init__(self, api_key: str):
|
117 |
+
self.api_key = api_key
|
118 |
+
genai.configure(api_key=self.api_key)
|
119 |
+
|
120 |
+
try:
|
121 |
+
self.client_text = genai.GenerativeModel(TEXT_MODEL_ID)
|
122 |
+
logger.info(f"Initialized text model: {TEXT_MODEL_ID}")
|
123 |
+
self.client_audio = genai.GenerativeModel(AUDIO_MODEL_ID)
|
124 |
+
logger.info(f"Initialized audio model: {AUDIO_MODEL_ID}")
|
125 |
+
self.client_image = genai.GenerativeModel(IMAGE_MODEL_ID)
|
126 |
+
logger.info(f"Initialized image model: {IMAGE_MODEL_ID} (Placeholder: Update to Vertex AI SDK)")
|
127 |
+
except Exception as exc:
|
128 |
+
logger.exception("Failed to initialize Google Clients/Models.")
|
129 |
+
raise exc
|
130 |
+
|
131 |
+
def generate_story_structure(
|
132 |
+
self, theme: str, num_scenes: int, num_timelines: int, divergence_prompt: str = ""
|
133 |
+
) -> Optional[ChronoWeaveResponse]:
|
134 |
+
"""
|
135 |
+
Generates a story structure as JSON using the text model and validates it via Pydantic.
|
136 |
+
"""
|
137 |
+
st.info(f"Generating {num_timelines} timeline(s) each with {num_scenes} scene(s) for theme: '{theme}'")
|
138 |
+
logger.info(f"Story generation request: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
|
139 |
+
|
140 |
+
divergence_instruction = (
|
141 |
+
f"Introduce clear divergence after the first scene. Hint: '{divergence_prompt}'. "
|
142 |
+
f"For timeline_id 0, use 'Initial path' or 'Baseline scenario'."
|
143 |
+
)
|
144 |
+
|
145 |
+
prompt = f"""Act as a narrative designer. Create a story for the theme: "{theme}".
|
146 |
+
Instructions:
|
147 |
+
1. Exactly **{num_timelines}** timelines.
|
148 |
+
2. Each timeline must consist of exactly **{num_scenes}** scenes.
|
149 |
+
3. **NO humans/humanoids**; focus on animals, fantasy creatures, animated objects, and nature.
|
150 |
+
4. {divergence_instruction}
|
151 |
+
5. Style: **'Simple, friendly kids animation, bright colors, rounded shapes'** unless modified by `timeline_visual_modifier`.
|
152 |
+
6. `audio_text`: One concise sentence (max 30 words).
|
153 |
+
7. `image_prompt`: Descriptive prompt (15β35 words) that emphasizes scene elements. **Avoid repeating general style.**
|
154 |
+
8. `character_description`: Very brief (name and features; < 20 words).
|
155 |
+
|
156 |
+
Output only a valid JSON object conforming exactly to this schema:
|
157 |
+
JSON Schema: ```json
|
158 |
+
{json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}
|
159 |
+
```"""
|
160 |
+
|
161 |
+
try:
|
162 |
+
response = self.client_text.generate_content(
|
163 |
+
contents=prompt,
|
164 |
+
generation_config=genai.types.GenerationConfig(
|
165 |
+
response_mime_type="application/json", temperature=0.7
|
166 |
+
),
|
167 |
+
)
|
168 |
+
raw_data = json.loads(response.text)
|
169 |
+
validated_data = ChronoWeaveResponse.model_validate(raw_data)
|
170 |
+
st.success("Story structure validated successfully!")
|
171 |
+
return validated_data
|
172 |
+
|
173 |
+
except json.JSONDecodeError as json_err:
|
174 |
+
logger.error(f"JSON decode failed: {json_err}\nResponse: {response.text}")
|
175 |
+
st.error(f"π¨ JSON Parsing Error: {json_err}", icon="π")
|
176 |
+
st.text_area("Response", response.text, height=150)
|
177 |
+
except ValidationError as val_err:
|
178 |
+
logger.error(f"Pydantic validation error: {val_err}\nData: {json.dumps(raw_data, indent=2)}")
|
179 |
+
st.error(f"π¨ Invalid story structure: {val_err}", icon="π§¬")
|
180 |
+
st.json(raw_data)
|
181 |
+
except Exception as e:
|
182 |
+
logger.exception("Story generation error:")
|
183 |
+
st.error(f"π¨ Error generating story: {e}", icon="π₯")
|
184 |
+
return None
|
185 |
+
|
186 |
+
async def generate_audio(self, text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
|
187 |
+
"""
|
188 |
+
Asynchronously generates audio using the Gemini Live API.
|
189 |
+
"""
|
190 |
+
task_id = os.path.basename(output_filename).split(".")[0]
|
191 |
+
collected_audio = bytearray()
|
192 |
+
logger.info(f"ποΈ [{task_id}] Generating audio for text: '{text[:60]}...'")
|
193 |
+
try:
|
194 |
+
config = {
|
195 |
+
"response_modalities": ["AUDIO"],
|
196 |
+
"audio_config": {"audio_encoding": "LINEAR16", "sample_rate_hertz": AUDIO_SAMPLING_RATE},
|
197 |
+
}
|
198 |
+
directive = f"Narrate directly: \"{text}\""
|
199 |
+
async with self.client_audio.connect(config=config) as session:
|
200 |
+
await session.send_request([directive])
|
201 |
+
async for response in session.stream_content():
|
202 |
+
if response.audio_chunk and response.audio_chunk.data:
|
203 |
+
collected_audio.extend(response.audio_chunk.data)
|
204 |
+
if hasattr(response, "error") and response.error:
|
205 |
+
logger.error(f"β [{task_id}] Audio error: {response.error}")
|
206 |
+
st.error(f"Audio stream error {task_id}: {response.error}", icon="π")
|
207 |
+
return None
|
208 |
+
|
209 |
+
if not collected_audio:
|
210 |
+
logger.warning(f"β οΈ [{task_id}] No audio data received.")
|
211 |
+
st.warning(f"No audio data for {task_id}.", icon="π")
|
212 |
+
return None
|
213 |
+
|
214 |
+
with wave_file_writer(output_filename) as wf:
|
215 |
+
wf.writeframes(bytes(collected_audio))
|
216 |
+
logger.info(f"β
[{task_id}] Audio saved: {os.path.basename(output_filename)} ({len(collected_audio)} bytes)")
|
217 |
+
return output_filename
|
218 |
+
|
219 |
+
except genai.types.generation_types.BlockedPromptException as bpe:
|
220 |
+
logger.error(f"β [{task_id}] Audio blocked: {bpe}")
|
221 |
+
st.error(f"Audio blocked for {task_id}.", icon="π")
|
222 |
+
except Exception as e:
|
223 |
+
logger.exception(f"β [{task_id}] Audio generation failed: {e}")
|
224 |
+
st.error(f"Audio generation failed for {task_id}: {e}", icon="π")
|
225 |
+
return None
|
226 |
+
|
227 |
+
async def generate_image_async(self, prompt: str, aspect_ratio: str, task_id: str) -> Optional[Image.Image]:
|
228 |
+
"""
|
229 |
+
Wraps the synchronous image generation function in a thread pool to allow asynchronous invocation.
|
230 |
+
Currently, this function is a stub pending Vertex AI SDK integration.
|
231 |
+
"""
|
232 |
+
loop = asyncio.get_event_loop()
|
233 |
+
logger.info(f"πΌοΈ [{task_id}] Requesting image for prompt: '{prompt[:70]}...' (Aspect Ratio: {aspect_ratio})")
|
234 |
+
# Placeholder: the real implementation would call a Vertex AI SDK function.
|
235 |
+
def gen_image():
|
236 |
+
logger.error(f"β [{task_id}] Image generation not implemented. Update required for Vertex AI.")
|
237 |
+
return None
|
238 |
+
|
239 |
+
image_result = await loop.run_in_executor(None, gen_image)
|
240 |
+
if image_result is None:
|
241 |
+
st.error(f"Image generation for {task_id} skipped: Requires Vertex AI SDK implementation.", icon="πΌοΈ")
|
242 |
+
return image_result
|
243 |
+
|
244 |
+
async def process_scene(
|
245 |
+
self,
|
246 |
+
timeline_id: int,
|
247 |
+
segment: StorySegment,
|
248 |
+
temp_dir: str,
|
249 |
+
aspect_ratio: str,
|
250 |
+
audio_voice: Optional[str] = None,
|
251 |
+
) -> Tuple[Optional[str], Optional[str], Optional[Any], List[str]]:
|
252 |
+
"""
|
253 |
+
Processes a single scene: generates image and audio concurrently, creates a video clip if both succeed.
|
254 |
+
Returns a tuple of (image_path, audio_path, video_clip, [error messages]).
|
255 |
+
"""
|
256 |
+
errors: List[str] = []
|
257 |
+
task_id = f"T{timeline_id}_S{segment.scene_id}"
|
258 |
+
image_path = os.path.join(temp_dir, f"{task_id}_image.png")
|
259 |
+
audio_path = os.path.join(temp_dir, f"{task_id}_audio.wav")
|
260 |
+
video_clip = None
|
261 |
+
|
262 |
+
# Launch image and audio generation concurrently.
|
263 |
+
image_future = asyncio.create_task(self.generate_image_async(
|
264 |
+
prompt=f"{segment.image_prompt} Featuring: {segment.character_description} {'Style hint: ' + segment.timeline_visual_modifier if segment.timeline_visual_modifier else ''}",
|
265 |
+
aspect_ratio=aspect_ratio,
|
266 |
+
task_id=task_id,
|
267 |
+
))
|
268 |
+
audio_future = asyncio.create_task(self.generate_audio(segment.audio_text, audio_path, audio_voice))
|
269 |
+
|
270 |
+
image_result, audio_result = await asyncio.gather(image_future, audio_future)
|
271 |
+
|
272 |
+
# Handle image result (if available, save and preview)
|
273 |
+
if image_result:
|
274 |
+
try:
|
275 |
+
image_result.save(image_path)
|
276 |
+
st.image(image_result, width=180, caption=f"Scene {segment.scene_id + 1}")
|
277 |
+
except Exception as e:
|
278 |
+
logger.error(f"β [{task_id}] Error saving image: {e}")
|
279 |
+
errors.append(f"Scene {segment.scene_id + 1}: Image save error.")
|
280 |
+
else:
|
281 |
+
errors.append(f"Scene {segment.scene_id + 1}: Image generation failed.")
|
282 |
+
|
283 |
+
# Handle audio result and preview
|
284 |
+
if audio_result:
|
285 |
+
try:
|
286 |
+
with open(audio_result, "rb") as ap:
|
287 |
+
st.audio(ap.read(), format="audio/wav")
|
288 |
+
except Exception as e:
|
289 |
+
logger.warning(f"β οΈ [{task_id}] Audio preview error: {e}")
|
290 |
+
else:
|
291 |
+
errors.append(f"Scene {segment.scene_id + 1}: Audio generation failed.")
|
292 |
+
|
293 |
+
# Create video clip if both image and audio exist.
|
294 |
+
if not errors and os.path.exists(image_path) and os.path.exists(audio_path):
|
295 |
+
try:
|
296 |
+
audio_clip = AudioFileClip(audio_path)
|
297 |
+
np_img = np.array(Image.open(image_path))
|
298 |
+
img_clip = ImageClip(np_img).set_duration(audio_clip.duration)
|
299 |
+
video_clip = img_clip.set_audio(audio_clip)
|
300 |
+
logger.info(f"β
[{task_id}] Video clip created (Duration: {audio_clip.duration:.2f}s).")
|
301 |
+
except Exception as e:
|
302 |
+
logger.exception(f"β [{task_id}] Failed to create video clip: {e}")
|
303 |
+
errors.append(f"Scene {segment.scene_id + 1}: Video clip creation failed.")
|
304 |
+
finally:
|
305 |
+
# Cleanup moviepy instances.
|
306 |
+
try:
|
307 |
+
if 'audio_clip' in locals():
|
308 |
+
audio_clip.close()
|
309 |
+
if 'img_clip' in locals():
|
310 |
+
img_clip.close()
|
311 |
+
except Exception:
|
312 |
+
pass
|
313 |
+
|
314 |
+
return (image_path if os.path.exists(image_path) else None,
|
315 |
+
audio_path if os.path.exists(audio_path) else None,
|
316 |
+
video_clip,
|
317 |
+
errors)
|
318 |
+
|
319 |
+
async def process_timeline(
|
320 |
+
self,
|
321 |
+
timeline: Timeline,
|
322 |
+
temp_dir: str,
|
323 |
+
aspect_ratio: str,
|
324 |
+
audio_voice: Optional[str] = None,
|
325 |
+
) -> Tuple[Optional[str], List[str]]:
|
326 |
+
"""
|
327 |
+
Processes an entire timeline by concurrently processing all scenes,
|
328 |
+
and then assembling a final video if all scenes succeed.
|
329 |
+
Returns the final video path and a list of error messages.
|
330 |
+
"""
|
331 |
+
timeline_id = timeline.timeline_id
|
332 |
+
scene_tasks = [
|
333 |
+
self.process_scene(timeline_id, segment, temp_dir, aspect_ratio, audio_voice)
|
334 |
+
for segment in timeline.segments
|
335 |
+
]
|
336 |
+
results = await asyncio.gather(*scene_tasks)
|
337 |
+
video_clips = []
|
338 |
+
timeline_errors: List[str] = []
|
339 |
+
for idx, (img_path, aud_path, clip, errs) in enumerate(results):
|
340 |
+
if errs:
|
341 |
+
timeline_errors.extend(errs)
|
342 |
+
if clip is not None:
|
343 |
+
video_clips.append(clip)
|
344 |
+
|
345 |
+
# Assemble the timeline video only if every scene produced a valid clip.
|
346 |
+
if video_clips and len(video_clips) == len(timeline.segments):
|
347 |
+
output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4")
|
348 |
+
try:
|
349 |
+
final_video = concatenate_videoclips(video_clips, method="compose")
|
350 |
+
final_video.write_videofile(
|
351 |
+
output_filename,
|
352 |
+
fps=VIDEO_FPS,
|
353 |
+
codec=VIDEO_CODEC,
|
354 |
+
audio_codec=AUDIO_CODEC,
|
355 |
+
logger=None
|
356 |
+
)
|
357 |
+
logger.info(f"β
Timeline {timeline_id} video saved: {output_filename}")
|
358 |
+
# Cleanup the clips.
|
359 |
+
for clip in video_clips:
|
360 |
+
clip.close()
|
361 |
+
final_video.close()
|
362 |
+
return output_filename, timeline_errors
|
363 |
+
except Exception as e:
|
364 |
+
logger.exception(f"β Timeline {timeline_id} video assembly failed: {e}")
|
365 |
+
timeline_errors.append(f"Timeline {timeline_id}: Video assembly failed.")
|
366 |
+
else:
|
367 |
+
timeline_errors.append(f"Timeline {timeline_id}: Incomplete scenes; skipping video assembly.")
|
368 |
+
return None, timeline_errors
|
369 |
+
|
370 |
+
|
371 |
+
# --- Streamlit UI and Main Process ---
|
372 |
+
def main():
|
373 |
+
# --- API Key Retrieval ---
|
374 |
+
GOOGLE_API_KEY: Optional[str] = None
|
375 |
+
try:
|
376 |
+
GOOGLE_API_KEY = st.secrets["GOOGLE_API_KEY"]
|
377 |
+
logger.info("Google API Key loaded from Streamlit secrets.")
|
378 |
+
except KeyError:
|
379 |
+
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
|
380 |
+
if GOOGLE_API_KEY:
|
381 |
+
logger.info("Google API Key loaded from environment variable.")
|
382 |
+
else:
|
383 |
+
st.error("π¨ **Google API Key Not Found!** Please configure it.", icon="π¨")
|
384 |
+
st.stop()
|
385 |
+
|
386 |
+
# --- UI Configuration ---
|
387 |
+
st.set_page_config(page_title="ChronoWeave", layout="wide", initial_sidebar_state="expanded")
|
388 |
+
st.title("π ChronoWeave: Advanced Branching Narrative Generator")
|
389 |
+
st.markdown("""
|
390 |
+
Generate multiple, branching story timelines from a single theme using AI β complete with images and narration.
|
391 |
+
*Based on work by Yousif Ahmed. Copyright 2025 Google LLC.*
|
392 |
+
""")
|
393 |
+
|
394 |
+
st.sidebar.header("βοΈ Configuration")
|
395 |
+
if GOOGLE_API_KEY:
|
396 |
+
st.sidebar.success("Google API Key Loaded", icon="β
")
|
397 |
else:
|
398 |
+
st.sidebar.error("Google API Key Missing!", icon="π¨")
|
399 |
+
|
400 |
+
theme = st.sidebar.text_input("π Story Theme:", "A curious squirrel finds a mysterious, glowing acorn")
|
401 |
+
num_scenes = st.sidebar.slider("π¬ Scenes per Timeline:", min_value=2, max_value=7, value=3)
|
402 |
+
num_timelines = st.sidebar.slider("πΏ Number of Timelines:", min_value=1, max_value=4, value=2)
|
403 |
+
divergence_prompt = st.sidebar.text_input("βοΈ Divergence Hint (Optional):", placeholder="e.g., What if a bird tried to steal it?")
|
404 |
+
st.sidebar.subheader("π¨ Visual & Audio Settings")
|
405 |
+
aspect_ratio = st.sidebar.selectbox("πΌοΈ Image Aspect Ratio:", ["1:1", "16:9", "9:16"], index=0)
|
406 |
+
audio_voice = None
|
407 |
+
|
408 |
+
generate_button = st.sidebar.button("β¨ Generate ChronoWeave β¨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
|
409 |
+
st.sidebar.markdown("---")
|
410 |
+
st.sidebar.info("β³ Generation may take several minutes.")
|
411 |
+
st.sidebar.markdown(f"<small>Txt: {TEXT_MODEL_ID}, Img: {IMAGE_MODEL_ID}, Aud: {AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
|
412 |
+
|
413 |
+
if generate_button:
|
414 |
+
if not theme:
|
415 |
+
st.error("Please enter a story theme.", icon="π")
|
416 |
+
return
|
417 |
+
|
418 |
+
# Create a unique temporary directory for this run
|
419 |
+
run_id = str(uuid.uuid4()).split('-')[0]
|
420 |
+
temp_dir = os.path.join(TEMP_DIR_BASE, f"run_{run_id}")
|
421 |
+
try:
|
422 |
+
os.makedirs(temp_dir, exist_ok=True)
|
423 |
+
logger.info(f"Created temporary directory: {temp_dir}")
|
424 |
+
except OSError as e:
|
425 |
+
st.error(f"π¨ Failed to create temporary directory {temp_dir}: {e}", icon="π")
|
426 |
+
st.stop()
|
427 |
+
|
428 |
+
# Instantiate the ChronoWeave generator
|
429 |
+
generator = ChronoWeaveGenerator(GOOGLE_API_KEY)
|
430 |
+
chrono_response = None
|
431 |
+
with st.spinner("Generating narrative structure... π€"):
|
432 |
+
chrono_response = generator.generate_story_structure(theme, num_scenes, num_timelines, divergence_prompt)
|
433 |
+
|
434 |
+
if not chrono_response:
|
435 |
+
logger.error("Story generation or validation failed.")
|
436 |
+
return
|
437 |
+
|
438 |
+
overall_start_time = time.time()
|
439 |
+
final_video_paths: Dict[int, str] = {}
|
440 |
+
generation_errors: Dict[int, List[str]] = {}
|
441 |
+
|
442 |
+
async def process_all_timelines():
|
443 |
+
timeline_tasks = {}
|
444 |
+
for timeline in chrono_response.timelines:
|
445 |
+
timeline_tasks[timeline.timeline_id] = asyncio.create_task(
|
446 |
+
generator.process_timeline(timeline, temp_dir, aspect_ratio, audio_voice)
|
447 |
+
)
|
448 |
+
return await asyncio.gather(*timeline_tasks.values(), return_exceptions=False)
|
449 |
+
|
450 |
+
with st.spinner("Processing scenes and assembling videos..."):
|
451 |
+
timeline_results = asyncio.run(process_all_timelines())
|
452 |
+
|
453 |
+
# Collect results per timeline.
|
454 |
+
for timeline, (video_path, errors) in zip(chrono_response.timelines, timeline_results):
|
455 |
+
generation_errors[timeline.timeline_id] = errors
|
456 |
+
if video_path:
|
457 |
+
final_video_paths[timeline.timeline_id] = video_path
|
458 |
+
|
459 |
+
overall_duration = time.time() - overall_start_time
|
460 |
+
# Display status messages
|
461 |
+
if final_video_paths:
|
462 |
+
st.success(f"Complete! ({len(final_video_paths)} video(s) created in {overall_duration:.2f}s)")
|
463 |
+
else:
|
464 |
+
st.error(f"Failed. No final videos generated in {overall_duration:.2f}s")
|
465 |
+
|
466 |
+
# --- Display Final Videos ---
|
467 |
+
st.header("π¬ Generated Timelines")
|
468 |
+
if final_video_paths:
|
469 |
+
sorted_ids = sorted(final_video_paths.keys())
|
470 |
+
num_cols = min(len(sorted_ids), 3)
|
471 |
+
cols = st.columns(num_cols)
|
472 |
+
for idx, timeline_id in enumerate(sorted_ids):
|
473 |
+
video_path = final_video_paths[timeline_id]
|
474 |
+
timeline_data = next((t for t in chrono_response.timelines if t.timeline_id == timeline_id), None)
|
475 |
+
divergence = timeline_data.divergence_reason if timeline_data else "Unknown"
|
476 |
+
with cols[idx % num_cols]:
|
477 |
+
st.subheader(f"Timeline {timeline_id}")
|
478 |
+
st.caption(f"Divergence: {divergence}")
|
479 |
+
try:
|
480 |
+
with open(video_path, "rb") as vf:
|
481 |
+
video_bytes = vf.read()
|
482 |
+
st.video(video_bytes)
|
483 |
+
st.download_button(
|
484 |
+
f"Download Timeline {timeline_id}",
|
485 |
+
video_bytes,
|
486 |
+
file_name=f"timeline_{timeline_id}.mp4",
|
487 |
+
mime="video/mp4",
|
488 |
+
key=f"dl_{timeline_id}"
|
489 |
+
)
|
490 |
+
if generation_errors.get(timeline_id):
|
491 |
+
scene_errs = generation_errors[timeline_id]
|
492 |
+
if scene_errs:
|
493 |
+
with st.expander(f"β οΈ View Scene Issues ({len(scene_errs)})"):
|
494 |
+
for err in scene_errs:
|
495 |
+
st.warning(f"- {err}")
|
496 |
+
except FileNotFoundError:
|
497 |
+
st.error(f"Error: Video for Timeline {timeline_id} is missing.", icon="π¨")
|
498 |
+
except Exception as e:
|
499 |
+
st.error(f"Display error for Timeline {timeline_id}: {e}", icon="π¨")
|
500 |
+
else:
|
501 |
+
st.warning("No final videos were successfully generated.")
|
502 |
+
with st.expander("View All Generation Errors", expanded=True):
|
503 |
+
for tid, errs in generation_errors.items():
|
504 |
+
if errs:
|
505 |
+
st.error(f"Timeline {tid}:")
|
506 |
+
for msg in errs:
|
507 |
+
st.error(f" - {msg}")
|
508 |
+
|
509 |
+
# --- Cleanup ---
|
510 |
+
st.info(f"Cleaning up temporary files: {temp_dir}")
|
511 |
+
try:
|
512 |
+
shutil.rmtree(temp_dir)
|
513 |
+
st.success("β
Temporary files cleaned up.")
|
514 |
+
logger.info(f"Temporary directory removed: {temp_dir}")
|
515 |
+
except Exception as e:
|
516 |
+
st.warning(f"Could not remove temporary files at: {temp_dir}", icon="β οΈ")
|
517 |
+
logger.error(f"Failed to remove temporary directory {temp_dir}: {e}")
|
518 |
+
else:
|
519 |
+
st.info("Configure settings and click 'β¨ Generate ChronoWeave β¨' to start.")
|
520 |
+
|
521 |
+
if __name__ == "__main__":
|
522 |
+
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|