mgbam commited on
Commit
9e990a1
Β·
verified Β·
1 Parent(s): e9679bf

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +137 -167
app.py CHANGED
@@ -44,7 +44,7 @@ st.set_page_config(page_title="ChronoWeave", layout="wide", initial_sidebar_stat
44
  st.title("πŸŒ€ ChronoWeave: Advanced Branching Narrative Generator")
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 of Yousif Ahmed. Copyright 2025 Google LLC.*
48
  """)
49
 
50
  # --- Constants ---
@@ -54,7 +54,7 @@ TEXT_MODEL_ID = "models/gemini-1.5-flash" # Or "gemini-1.5-pro"
54
  AUDIO_MODEL_ID = "models/gemini-1.5-flash" # Model used for audio tasks
55
  AUDIO_SAMPLING_RATE = 24000
56
  # Image Model Config
57
- IMAGE_MODEL_ID = "imagen-3" # <<< YOUR IMAGE MODEL
58
  DEFAULT_ASPECT_RATIO = "1:1"
59
  # Video Config
60
  VIDEO_FPS = 24
@@ -73,87 +73,68 @@ except KeyError:
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="🚨")
77
- st.stop()
78
 
79
  # --- Initialize Google Clients ---
80
- # Initialize handles for Text, Audio (using Text model), and Image models
81
  try:
82
  genai.configure(api_key=GOOGLE_API_KEY)
83
  logger.info("Configured google-generativeai with API key.")
84
-
85
- # Handle for Text/JSON Generation
86
  client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
87
  logger.info(f"Initialized text/JSON model handle: {TEXT_MODEL_ID}.")
88
-
89
- # Handle for Audio Generation (uses a text-capable model via connect)
90
  live_model = genai.GenerativeModel(AUDIO_MODEL_ID)
91
  logger.info(f"Initialized audio model handle: {AUDIO_MODEL_ID}.")
92
-
93
- # Handle for Image Generation <<<<------ NEW/CORRECTED
94
- image_model = genai.GenerativeModel(IMAGE_MODEL_ID)
95
- logger.info(f"Initialized image model handle: {IMAGE_MODEL_ID}.")
 
 
 
96
 
97
  except AttributeError as ae:
98
- logger.exception("AttributeError during Google AI Client Initialization.")
99
- st.error(f"🚨 Initialization Error: {ae}. Ensure library is up-to-date.", icon="🚨")
100
- st.stop()
101
  except Exception as e:
102
- # Catch potential errors if a model ID is invalid or inaccessible
103
- logger.exception("Failed to initialize Google AI Clients/Models.")
104
- st.error(f"🚨 Failed to initialize Google AI Clients/Models: {e}", icon="🚨")
105
- st.stop()
106
-
107
 
108
  # --- Define Pydantic Schemas (Using V2 Syntax) ---
 
109
  class StorySegment(BaseModel):
110
  scene_id: int = Field(..., ge=0)
111
  image_prompt: str = Field(..., min_length=10, max_length=250)
112
  audio_text: str = Field(..., min_length=5, max_length=150)
113
  character_description: str = Field(..., max_length=250)
114
  timeline_visual_modifier: Optional[str] = Field(None, max_length=50)
115
-
116
  @field_validator('image_prompt')
117
  @classmethod
118
  def image_prompt_no_humans(cls, v: str) -> str:
119
- if any(word in v.lower() for word in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]):
120
- logger.warning(f"Image prompt '{v[:50]}...' may contain human descriptions.")
121
  return v
122
-
123
  class Timeline(BaseModel):
124
  timeline_id: int = Field(..., ge=0)
125
- divergence_reason: str = Field(..., min_length=5) # Relying on prompt for 1st timeline
126
  segments: List[StorySegment] = Field(..., min_items=1)
127
-
128
  class ChronoWeaveResponse(BaseModel):
129
  core_theme: str = Field(..., min_length=5)
130
  timelines: List[Timeline] = Field(..., min_items=1)
131
  total_scenes_per_timeline: int = Field(..., gt=0)
132
-
133
  @model_validator(mode='after')
134
  def check_timeline_segment_count(self) -> 'ChronoWeaveResponse':
135
- expected_scenes = self.total_scenes_per_timeline
136
- for i, timeline in enumerate(self.timelines):
137
- if len(timeline.segments) != expected_scenes:
138
- raise ValueError(f"Timeline {i} ID {timeline.timeline_id}: Expected {expected_scenes} segments, found {len(timeline.segments)}.")
139
  return self
140
 
141
  # --- Helper Functions ---
142
-
143
  @contextlib.contextmanager
144
  def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLING_RATE, sample_width: int = 2):
145
  """Context manager to safely write WAV files."""
146
- wf = None
147
- try:
148
- wf = wave.open(filename, "wb")
149
- wf.setnchannels(channels); wf.setsampwidth(sample_width); wf.setframerate(rate)
150
- yield wf
151
  except Exception as e: logger.error(f"Error opening/configuring wave file {filename}: {e}"); raise
152
  finally:
153
- if wf:
154
- try: wf.close()
155
- except Exception as e_close: logger.error(f"Error closing wave file {filename}: {e_close}")
156
-
157
 
158
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
159
  """Generates audio using Gemini Live API (async version) via the GenerativeModel."""
@@ -161,7 +142,7 @@ async def generate_audio_live_async(api_text: str, output_filename: str, voice:
161
  logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio: '{api_text[:60]}...'")
162
  try:
163
  config = {"response_modalities": ["AUDIO"], "audio_config": {"audio_encoding": "LINEAR16", "sample_rate_hertz": AUDIO_SAMPLING_RATE}}
164
- directive_prompt = f"Narrate directly: \"{api_text}\"" # Shorter directive
165
  async with live_model.connect(config=config) as session:
166
  await session.send_request([directive_prompt])
167
  async for response in session.stream_content():
@@ -179,82 +160,73 @@ def generate_story_sequence_chrono(theme: str, num_scenes: int, num_timelines: i
179
  """Generates branching story sequences using Gemini structured output and validates with Pydantic."""
180
  st.info(f"πŸ“š Generating {num_timelines} timeline(s) x {num_scenes} scenes for: '{theme}'...")
181
  logger.info(f"Requesting story structure: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
182
- divergence_instruction = (
183
- f"Introduce clear points of divergence between timelines, after the first scene if possible. "
184
- f"Use hint if provided: '{divergence_prompt}'. "
185
- f"State divergence reason clearly. **For timeline_id 0, use 'Initial path' or 'Baseline scenario'.**" # Explicit instruction for first timeline
186
- )
187
- prompt = f"""
188
- Act as narrative designer. Create story based on theme: "{theme}".
189
- **Instructions:**
190
- 1. Generate exactly **{num_timelines}** timelines.
191
- 2. Each timeline exactly **{num_scenes}** scenes.
192
- 3. **NO humans/humanoids**. Focus: animals, fantasy creatures, animated objects, nature.
193
- 4. {divergence_instruction}
194
- 5. Maintain consistent style: **'Simple, friendly kids animation, bright colors, rounded shapes'**, unless `timeline_visual_modifier` alters it.
195
- 6. `audio_text`: single concise sentence (max 30 words).
196
- 7. `image_prompt`: descriptive, concise (target 15-35 words MAX). Focus on scene elements. **AVOID repeating general style description**.
197
- 8. `character_description`: VERY brief description of characters in scene prompt (name, features). Target < 20 words total.
198
- **Output Format:** ONLY valid JSON object adhering to schema. No text before/after.
199
- **JSON Schema:** ```json\n{json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}\n```"""
200
  try:
201
  response = client_standard.generate_content(contents=prompt, generation_config=genai.types.GenerationConfig(response_mime_type="application/json", temperature=0.7))
202
  try: raw_data = json.loads(response.text)
203
  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
204
  except Exception as e: logger.error(f"Error processing text: {e}"); st.error(f"🚨 Error processing AI response: {e}", icon="πŸ“„"); return None
205
- try:
206
- validated_data = ChronoWeaveResponse.model_validate(raw_data)
207
- logger.info("βœ… Story structure generated and validated successfully!")
208
- st.success("βœ… Story structure generated and validated!")
209
- return validated_data
210
- except ValidationError as val_err: logger.error(f"JSON validation failed: {val_err}\nData:\n{json.dumps(raw_data, indent=2)}"); st.error(f"🚨 Generated structure invalid: {val_err}", icon="🧬"); st.json(raw_data); return None
211
  except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f"Story gen blocked: {bpe}"); st.error("🚨 Story prompt blocked.", icon="🚫"); return None
212
  except Exception as e: logger.exception("Error during story gen:"); st.error(f"🚨 Story gen error: {e}", icon="πŸ’₯"); return None
213
 
214
 
215
  def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str = "IMG") -> Optional[Image.Image]:
216
- """Generates an image using the dedicated image model handle."""
 
 
 
 
 
 
217
  logger.info(f"πŸ–ΌοΈ [{task_id}] Requesting image: '{prompt[:70]}...' (Aspect: {aspect_ratio})")
218
- full_prompt = (f"Simple kids animation style, bright colors, rounded shapes. NO humans/humanoids. Aspect ratio {aspect_ratio}. Scene: {prompt}")
219
- try:
220
- # Use the dedicated image_model handle <<<<<------ CORRECTED CALL
221
- response = image_model.generate_content(
222
- full_prompt, generation_config=genai.types.GenerationConfig(candidate_count=1)
223
- )
224
- image_bytes, safety_ratings, block_reason, finish_reason = None, [], None, None
225
- if hasattr(response, 'candidates') and response.candidates:
226
- candidate = response.candidates[0]
227
- if hasattr(candidate, 'finish_reason'): finish_reason = getattr(candidate.finish_reason, 'name', str(candidate.finish_reason))
228
- if hasattr(candidate, 'content') and candidate.content and hasattr(candidate.content, 'parts') and candidate.content.parts:
229
- part = candidate.content.parts[0]
230
- if hasattr(part, 'inline_data') and part.inline_data and hasattr(part.inline_data, 'data'): image_bytes = part.inline_data.data
231
- if hasattr(candidate, 'safety_ratings'): safety_ratings = candidate.safety_ratings
232
- if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
233
- if hasattr(response.prompt_feedback, 'block_reason') and response.prompt_feedback.block_reason.name != 'BLOCK_REASON_UNSPECIFIED': block_reason = response.prompt_feedback.block_reason.name
234
- if hasattr(response.prompt_feedback, 'safety_ratings'): safety_ratings.extend(response.prompt_feedback.safety_ratings)
235
-
236
- if image_bytes:
237
- try:
238
- image = Image.open(BytesIO(image_bytes)); logger.info(f" βœ… [{task_id}] Image generated.")
239
- filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
240
- if filtered_ratings: logger.warning(f" ⚠️ [{task_id}] Image flagged: {', '.join(filtered_ratings)}."); st.warning(f"Image {task_id} flagged: {', '.join(filtered_ratings)}", icon="⚠️")
241
- return image
242
- except Exception as img_err: logger.error(f" ❌ [{task_id}] Img decode error: {img_err}"); st.warning(f"Decode image data {task_id} failed.", icon="πŸ–ΌοΈ"); return None
243
- else:
244
- fail_reason = "Unknown reason."
245
- if block_reason: fail_reason = f"Blocked ({block_reason})."
246
- elif finish_reason and finish_reason not in ['STOP', 'FINISH_REASON_UNSPECIFIED']: fail_reason = f"Finished early ({finish_reason})."
247
- else:
248
- filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
249
- if filtered_ratings: fail_reason = f"Safety filters: {', '.join(filtered_ratings)}."
250
- # Log full response only if reason remains unknown
251
- if fail_reason == "Unknown reason.": logger.warning(f" ⚠️ [{task_id}] Full API response object: {response}") # Keep this debug log for now
252
- logger.warning(f" ⚠️ [{task_id}] No image data. Reason: {fail_reason} Prompt: '{prompt[:70]}...'")
253
- st.warning(f"No image data {task_id}. Reason: {fail_reason}", icon="πŸ–ΌοΈ"); return None
254
- except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f" ❌ [{task_id}] Image blocked (exception): {bpe}"); st.error(f"Image blocked {task_id} (exception).", icon="🚫"); return None
255
- except Exception as e: logger.exception(f" ❌ [{task_id}] Image gen failed: {e}"); st.error(f"Image gen failed {task_id}: {e}", icon="πŸ–ΌοΈ"); return None
 
 
 
 
256
 
257
  # --- Streamlit UI Elements ---
 
258
  st.sidebar.header("βš™οΈ Configuration")
259
  if GOOGLE_API_KEY: st.sidebar.success("Google API Key Loaded", icon="βœ…")
260
  else: st.sidebar.error("Google API Key Missing!", icon="🚨")
@@ -266,9 +238,7 @@ st.sidebar.subheader("🎨 Visual & Audio Settings")
266
  aspect_ratio = st.sidebar.selectbox("πŸ–ΌοΈ Image Aspect Ratio:", ["1:1", "16:9", "9:16"], index=0)
267
  audio_voice = None
268
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
269
- st.sidebar.markdown("---")
270
- st.sidebar.info("⏳ Generation can take several minutes.", icon="⏳")
271
- st.sidebar.markdown(f"<small>Txt:{TEXT_MODEL_ID}, Img:{IMAGE_MODEL_ID}, Aud:{AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
272
 
273
  # --- Main Logic ---
274
  if generate_button:
@@ -277,7 +247,7 @@ if generate_button:
277
  run_id = str(uuid.uuid4()).split('-')[0]; temp_dir = os.path.join(TEMP_DIR_BASE, f"run_{run_id}")
278
  try: os.makedirs(temp_dir, exist_ok=True); logger.info(f"Created temp dir: {temp_dir}")
279
  except OSError as e: st.error(f"🚨 Failed create temp dir {temp_dir}: {e}", icon="πŸ“‚"); st.stop()
280
- final_video_paths = {}; generation_errors = {}
281
 
282
  # --- 1. Generate Narrative Structure ---
283
  chrono_response: Optional[ChronoWeaveResponse] = None
@@ -288,6 +258,7 @@ if generate_button:
288
  overall_start_time = time.time(); all_timelines_successful = True
289
  with st.status("Generating assets and composing videos...", expanded=True) as status:
290
  for timeline_index, timeline in enumerate(chrono_response.timelines):
 
291
  timeline_id, divergence, segments = timeline.timeline_id, timeline.divergence_reason, timeline.segments
292
  timeline_label = f"Timeline {timeline_id}"; st.subheader(f"Processing {timeline_label}: {divergence}")
293
  logger.info(f"--- Processing {timeline_label} (Idx: {timeline_index}) ---"); generation_errors[timeline_id] = []
@@ -295,6 +266,7 @@ if generate_button:
295
  timeline_start_time = time.time(); scene_success_count = 0
296
 
297
  for scene_index, segment in enumerate(segments):
 
298
  scene_id = segment.scene_id; task_id = f"T{timeline_id}_S{scene_id}"
299
  status.update(label=f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}...")
300
  st.markdown(f"--- **Scene {scene_id + 1} ({task_id})** ---")
@@ -303,93 +275,77 @@ if generate_button:
303
  st.write(f" *Img Prompt:* {segment.image_prompt}" + (f" *(Mod: {segment.timeline_visual_modifier})*" if segment.timeline_visual_modifier else "")); st.write(f" *Audio Text:* {segment.audio_text}")
304
 
305
  # --- 2a. Image Generation ---
 
306
  generated_image: Optional[Image.Image] = None
307
  with st.spinner(f"[{task_id}] Generating image... 🎨"):
308
  combined_prompt = segment.image_prompt
309
  if segment.character_description: combined_prompt += f" Featuring: {segment.character_description}"
310
  if segment.timeline_visual_modifier: combined_prompt += f" Style hint: {segment.timeline_visual_modifier}."
311
- generated_image = generate_image_imagen(combined_prompt, aspect_ratio, task_id)
312
- if generated_image:
 
313
  image_path = os.path.join(temp_dir, f"{task_id}_image.png")
314
  try: generated_image.save(image_path); temp_image_files[scene_id] = image_path; st.image(generated_image, width=180, caption=f"Scene {scene_id+1}")
315
  except Exception as e: logger.error(f" ❌ [{task_id}] Img save error: {e}"); st.error(f"Save image {task_id} failed.", icon="πŸ’Ύ"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img save fail.")
316
- else: scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img gen fail."); continue
 
 
 
317
 
318
  # --- 2b. Audio Generation ---
 
319
  generated_audio_path: Optional[str] = None
320
  if not scene_has_error:
 
321
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
322
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
323
  try: generated_audio_path = asyncio.run(generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice))
324
  except RuntimeError as e: logger.error(f" ❌ [{task_id}] Asyncio error: {e}"); st.error(f"Asyncio audio error {task_id}: {e}", icon="⚑"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio async err.")
325
  except Exception as e: logger.exception(f" ❌ [{task_id}] Audio error: {e}"); st.error(f"Audio error {task_id}: {e}", icon="πŸ’₯"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen err.")
326
  if generated_audio_path:
327
- temp_audio_files[scene_id] = generated_audio_path
328
- try:
329
- with open(generated_audio_path, 'rb') as ap: st.audio(ap.read(), format='audio/wav')
330
  except Exception as e: logger.warning(f" ⚠️ [{task_id}] Audio preview error: {e}")
331
- else:
332
- scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen fail.")
333
- if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]):
334
- try: os.remove(temp_image_files[scene_id]); logger.info(f" πŸ—‘οΈ [{task_id}] Removed img due to audio fail."); del temp_image_files[scene_id]
335
- except OSError as e: logger.warning(f" ⚠️ [{task_id}] Failed remove img after audio fail: {e}")
336
- continue
337
 
338
  # --- 2c. Create Video Clip ---
 
339
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
340
- st.write(f" 🎬 Creating clip S{scene_id+1}...")
341
- img_path, aud_path = temp_image_files[scene_id], temp_audio_files[scene_id]
342
  audio_clip_instance, image_clip_instance, composite_clip = None, None, None
343
  try:
344
  if not os.path.exists(img_path): raise FileNotFoundError(f"Img missing: {img_path}")
345
  if not os.path.exists(aud_path): raise FileNotFoundError(f"Aud missing: {aud_path}")
346
  audio_clip_instance = AudioFileClip(aud_path); np_image = np.array(Image.open(img_path))
347
  image_clip_instance = ImageClip(np_image).set_duration(audio_clip_instance.duration)
348
- composite_clip = image_clip_instance.set_audio(audio_clip_instance)
349
- video_clips.append(composite_clip); logger.info(f" βœ… [{task_id}] Clip created (Dur: {audio_clip_instance.duration:.2f}s).")
350
- st.write(f" βœ… Clip created (Dur: {audio_clip_instance.duration:.2f}s)."); scene_success_count += 1
351
- except Exception as e:
352
- logger.exception(f" ❌ [{task_id}] Failed clip creation: {e}"); st.error(f"Failed clip {task_id}: {e}", icon="🎬")
353
- scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Clip fail.")
354
  if audio_clip_instance: audio_clip_instance.close();
355
  if image_clip_instance: image_clip_instance.close()
356
- try:
357
- if os.path.exists(img_path): os.remove(img_path)
358
- if os.path.exists(aud_path): os.remove(aud_path)
359
- except OSError as e_rem: logger.warning(f" ⚠️ [{task_id}] Failed remove files after clip err: {e_rem}")
360
 
361
  # --- 2d. Assemble Timeline Video ---
 
362
  timeline_duration = time.time() - timeline_start_time
363
- if video_clips and scene_success_count == len(segments):
364
- status.update(label=f"Composing video {timeline_label}...")
365
- st.write(f"🎞️ Assembling video {timeline_label}..."); logger.info(f"🎞️ Assembling video {timeline_label}...")
366
- output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4"); final_timeline_video = None
367
- try:
368
- final_timeline_video = concatenate_videoclips(video_clips, method="compose")
369
- final_timeline_video.write_videofile(output_filename, fps=VIDEO_FPS, codec=VIDEO_CODEC, audio_codec=AUDIO_CODEC, logger=None)
370
- final_video_paths[timeline_id] = output_filename; logger.info(f" βœ… [{timeline_label}] Video saved: {os.path.basename(output_filename)}")
371
- st.success(f"βœ… Video {timeline_label} completed in {timeline_duration:.2f}s.")
372
- except Exception as e:
373
- logger.exception(f" ❌ [{timeline_label}] Video assembly failed: {e}"); st.error(f"Assemble video {timeline_label} failed: {e}", icon="πŸ“Ό")
374
- all_timelines_successful = False; generation_errors[timeline_id].append(f"T{timeline_id}: Assembly failed.")
375
- finally:
376
- logger.debug(f"[{timeline_label}] Closing clips...");
377
- for i, clip in enumerate(video_clips):
378
- try:
379
- if clip:
380
- if clip.audio: clip.audio.close()
381
- clip.close()
382
- except Exception as e_close: logger.warning(f" ⚠️ [{timeline_label}] Clip close err {i}: {e_close}")
383
- if final_timeline_video:
384
- try:
385
- if final_timeline_video.audio: final_timeline_video.audio.close()
386
- final_timeline_video.close()
387
- except Exception as e_close_final: logger.warning(f" ⚠️ [{timeline_label}] Final vid close err: {e_close_final}")
388
  elif not video_clips: logger.warning(f"[{timeline_label}] No clips. Skip assembly."); st.warning(f"No scenes for {timeline_label}. No video.", icon="🚫"); all_timelines_successful = False
389
- else: error_count = len(segments) - scene_success_count; logger.warning(f"[{timeline_label}] {error_count} scene err(s). Skip assembly."); st.warning(f"{timeline_label}: {error_count} err(s). Video not assembled.", icon="⚠️"); all_timelines_successful = False
390
  if generation_errors[timeline_id]: logger.error(f"Errors {timeline_label}: {generation_errors[timeline_id]}")
391
 
392
  # --- End of Timelines Loop ---
 
393
  overall_duration = time.time() - overall_start_time
394
  if all_timelines_successful and final_video_paths: status_msg = f"Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"; status.update(label=status_msg, state="complete", expanded=False); logger.info(status_msg)
395
  elif final_video_paths: status_msg = f"Partially Complete ({len(final_video_paths)} videos, errors). {overall_duration:.2f}s"; status.update(label=status_msg, state="warning", expanded=True); logger.warning(status_msg)
@@ -398,6 +354,7 @@ if generate_button:
398
  # --- 3. Display Results ---
399
  st.header("🎬 Generated Timelines")
400
  if final_video_paths:
 
401
  sorted_timeline_ids = sorted(final_video_paths.keys()); num_cols = min(len(sorted_timeline_ids), 3); cols = st.columns(num_cols)
402
  for idx, timeline_id in enumerate(sorted_timeline_ids):
403
  col = cols[idx % num_cols]; video_path = final_video_paths[timeline_id]
@@ -409,20 +366,33 @@ if generate_button:
409
  with open(video_path, 'rb') as vf: video_bytes = vf.read()
410
  st.video(video_bytes); logger.info(f"Displaying T{timeline_id}")
411
  st.download_button(f"Download T{timeline_id}", video_bytes, f"timeline_{timeline_id}.mp4", "video/mp4", key=f"dl_{timeline_id}")
412
- if generation_errors.get(timeline_id):
413
- with st.expander(f"⚠️ View {len(generation_errors[timeline_id])} Issues"): [st.warning(f"- {err}") for err in generation_errors[timeline_id]]
 
 
 
 
414
  except FileNotFoundError: logger.error(f"Video missing: {video_path}"); st.error(f"Error: Video missing T{timeline_id}.", icon="🚨")
415
  except Exception as e: logger.exception(f"Display error {video_path}: {e}"); st.error(f"Display error T{timeline_id}: {e}", icon="🚨")
416
- else:
417
  st.warning("No final videos were successfully generated.")
418
- all_errors = [msg for err_list in generation_errors.values() for msg in err_list]
419
- if all_errors:
420
- st.subheader("Summary of Generation Issues");
 
421
  with st.expander("View All Errors", expanded=True):
422
  for tid, errors in generation_errors.items():
423
- if errors: st.error(f"T{tid}:"); [st.error(f" - {msg}") for msg in errors]
 
 
 
 
 
 
 
424
 
425
  # --- 4. Cleanup ---
 
426
  st.info(f"Attempting cleanup: {temp_dir}")
427
  try: shutil.rmtree(temp_dir); logger.info(f"βœ… Temp dir removed: {temp_dir}"); st.success("βœ… Temp files cleaned.")
428
  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="⚠️")
 
44
  st.title("πŸŒ€ ChronoWeave: Advanced Branching Narrative Generator")
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 ---
 
54
  AUDIO_MODEL_ID = "models/gemini-1.5-flash" # Model used for audio tasks
55
  AUDIO_SAMPLING_RATE = 24000
56
  # Image Model Config
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
 
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
+ # (Schemas remain the same as previous version)
102
  class StorySegment(BaseModel):
103
  scene_id: int = Field(..., ge=0)
104
  image_prompt: str = Field(..., min_length=10, max_length=250)
105
  audio_text: str = Field(..., min_length=5, max_length=150)
106
  character_description: str = Field(..., max_length=250)
107
  timeline_visual_modifier: Optional[str] = Field(None, max_length=50)
 
108
  @field_validator('image_prompt')
109
  @classmethod
110
  def image_prompt_no_humans(cls, v: str) -> str:
111
+ if any(w in v.lower() for w in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]): logger.warning(f"Prompt '{v[:50]}...' may contain humans.")
 
112
  return v
 
113
  class Timeline(BaseModel):
114
  timeline_id: int = Field(..., ge=0)
115
+ divergence_reason: str = Field(..., min_length=5)
116
  segments: List[StorySegment] = Field(..., min_items=1)
 
117
  class ChronoWeaveResponse(BaseModel):
118
  core_theme: str = Field(..., min_length=5)
119
  timelines: List[Timeline] = Field(..., min_items=1)
120
  total_scenes_per_timeline: int = Field(..., gt=0)
 
121
  @model_validator(mode='after')
122
  def check_timeline_segment_count(self) -> 'ChronoWeaveResponse':
123
+ expected = self.total_scenes_per_timeline
124
+ for i, t in enumerate(self.timelines):
125
+ if len(t.segments) != expected: raise ValueError(f"Timeline {i} ID {t.timeline_id}: Expected {expected} segments, found {len(t.segments)}.")
 
126
  return self
127
 
128
  # --- Helper Functions ---
129
+ # (wave_file_writer and generate_audio_live_async remain the same)
130
  @contextlib.contextmanager
131
  def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLING_RATE, sample_width: int = 2):
132
  """Context manager to safely write WAV files."""
133
+ wf = None; try: wf = wave.open(filename, "wb"); wf.setnchannels(channels); wf.setsampwidth(sample_width); wf.setframerate(rate); yield wf
 
 
 
 
134
  except Exception as e: logger.error(f"Error opening/configuring wave file {filename}: {e}"); raise
135
  finally:
136
+ if wf: try: wf.close()
137
+ except Exception as e_close: logger.error(f"Error closing wave file {filename}: {e_close}")
 
 
138
 
139
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
140
  """Generates audio using Gemini Live API (async version) via the GenerativeModel."""
 
142
  logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio: '{api_text[:60]}...'")
143
  try:
144
  config = {"response_modalities": ["AUDIO"], "audio_config": {"audio_encoding": "LINEAR16", "sample_rate_hertz": AUDIO_SAMPLING_RATE}}
145
+ directive_prompt = f"Narrate directly: \"{api_text}\""
146
  async with live_model.connect(config=config) as session:
147
  await session.send_request([directive_prompt])
148
  async for response in session.stream_content():
 
160
  """Generates branching story sequences using Gemini structured output and validates with Pydantic."""
161
  st.info(f"πŸ“š Generating {num_timelines} timeline(s) x {num_scenes} scenes for: '{theme}'...")
162
  logger.info(f"Requesting story structure: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
163
+ 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'.**")
164
+ 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```"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  try:
166
  response = client_standard.generate_content(contents=prompt, generation_config=genai.types.GenerationConfig(response_mime_type="application/json", temperature=0.7))
167
  try: raw_data = json.loads(response.text)
168
  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
169
  except Exception as e: logger.error(f"Error processing text: {e}"); st.error(f"🚨 Error processing AI response: {e}", icon="πŸ“„"); return None
170
+ try: validated_data = ChronoWeaveResponse.model_validate(raw_data); logger.info("βœ… Story structure OK!"); st.success("βœ… Story structure OK!"); return validated_data
171
+ 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
 
 
 
 
172
  except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f"Story gen blocked: {bpe}"); st.error("🚨 Story prompt blocked.", icon="🚫"); return None
173
  except Exception as e: logger.exception("Error during story gen:"); st.error(f"🚨 Story gen error: {e}", icon="πŸ’₯"); return None
174
 
175
 
176
  def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str = "IMG") -> Optional[Image.Image]:
177
+ """
178
+ Generates an image.
179
+ <<< IMPORTANT: This function needs to be rewritten using the Vertex AI SDK
180
+ (google-cloud-aiplatform) to correctly call Imagen models.
181
+ The current implementation using google-generativeai's generate_content
182
+ is likely incompatible with the 'imagen-3' model ID on the standard endpoint. >>>
183
+ """
184
  logger.info(f"πŸ–ΌοΈ [{task_id}] Requesting image: '{prompt[:70]}...' (Aspect: {aspect_ratio})")
185
+ logger.error(f" ❌ [{task_id}] Image generation skipped: Function needs update to use Vertex AI SDK for Imagen.")
186
+ st.error(f"Image generation for {task_id} skipped: Requires Vertex AI SDK implementation.", icon="πŸ–ΌοΈ")
187
+
188
+ # --- Placeholder for Vertex AI SDK Implementation ---
189
+ # Example conceptual structure (replace with actual Vertex AI SDK code):
190
+ # try:
191
+ # from vertexai.preview.generative_models import ImageGenerationModel # Example import
192
+ #
193
+ # # Assuming vertex_image_model is initialized globally or passed in
194
+ # # vertex_image_model = ImageGenerationModel.from_pretrained("imagegeneration@006") # Example init
195
+ #
196
+ # response = vertex_image_model.generate_images(
197
+ # prompt=f"Simple kids animation style... NO humans... Aspect ratio {aspect_ratio}. Scene: {prompt}",
198
+ # number_of_images=1,
199
+ # # Add other relevant parameters like negative_prompt, seed, etc.
200
+ # )
201
+ #
202
+ # if response.images:
203
+ # image_bytes = response.images[0]._image_bytes # Access image bytes (check actual attribute name)
204
+ # image = Image.open(BytesIO(image_bytes))
205
+ # logger.info(f" βœ… [{task_id}] Image generated (Vertex AI).")
206
+ # # Check safety attributes if available in Vertex AI response
207
+ # return image
208
+ # else:
209
+ # # Check Vertex AI response for errors / blocking reasons
210
+ # logger.warning(f" ⚠️ [{task_id}] No image data received from Vertex AI.")
211
+ # st.warning(f"No image data {task_id} (Vertex AI).", icon="πŸ–ΌοΈ")
212
+ # return None
213
+ #
214
+ # except ImportError:
215
+ # logger.error(f" ❌ [{task_id}] Vertex AI SDK ('google-cloud-aiplatform') not installed.")
216
+ # st.error(f"Vertex AI SDK not installed for image generation.", icon="🚨")
217
+ # return None
218
+ # except Exception as e:
219
+ # logger.exception(f" ❌ [{task_id}] Vertex AI image generation failed: {e}")
220
+ # st.error(f"Image gen failed {task_id} (Vertex AI): {e}", icon="πŸ–ΌοΈ")
221
+ # return None
222
+ # --- End Placeholder ---
223
+
224
+ # Keep the old failing logic commented out or remove, returning None for now
225
+ return None # Return None until Vertex AI SDK is implemented
226
+
227
 
228
  # --- Streamlit UI Elements ---
229
+ # (Identical to previous version)
230
  st.sidebar.header("βš™οΈ Configuration")
231
  if GOOGLE_API_KEY: st.sidebar.success("Google API Key Loaded", icon="βœ…")
232
  else: st.sidebar.error("Google API Key Missing!", icon="🚨")
 
238
  aspect_ratio = st.sidebar.selectbox("πŸ–ΌοΈ Image Aspect Ratio:", ["1:1", "16:9", "9:16"], index=0)
239
  audio_voice = None
240
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
241
+ st.sidebar.markdown("---"); st.sidebar.info("⏳ Generation can take minutes."); st.sidebar.markdown(f"<small>Txt:{TEXT_MODEL_ID}, Img:{IMAGE_MODEL_ID}, Aud:{AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
 
 
242
 
243
  # --- Main Logic ---
244
  if generate_button:
 
247
  run_id = str(uuid.uuid4()).split('-')[0]; temp_dir = os.path.join(TEMP_DIR_BASE, f"run_{run_id}")
248
  try: os.makedirs(temp_dir, exist_ok=True); logger.info(f"Created temp dir: {temp_dir}")
249
  except OSError as e: st.error(f"🚨 Failed create temp dir {temp_dir}: {e}", icon="πŸ“‚"); st.stop()
250
+ final_video_paths, generation_errors = {}, {}
251
 
252
  # --- 1. Generate Narrative Structure ---
253
  chrono_response: Optional[ChronoWeaveResponse] = None
 
258
  overall_start_time = time.time(); all_timelines_successful = True
259
  with st.status("Generating assets and composing videos...", expanded=True) as status:
260
  for timeline_index, timeline in enumerate(chrono_response.timelines):
261
+ # ... (Timeline setup - same as before) ...
262
  timeline_id, divergence, segments = timeline.timeline_id, timeline.divergence_reason, timeline.segments
263
  timeline_label = f"Timeline {timeline_id}"; st.subheader(f"Processing {timeline_label}: {divergence}")
264
  logger.info(f"--- Processing {timeline_label} (Idx: {timeline_index}) ---"); generation_errors[timeline_id] = []
 
266
  timeline_start_time = time.time(); scene_success_count = 0
267
 
268
  for scene_index, segment in enumerate(segments):
269
+ # ... (Scene setup - same as before) ...
270
  scene_id = segment.scene_id; task_id = f"T{timeline_id}_S{scene_id}"
271
  status.update(label=f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}...")
272
  st.markdown(f"--- **Scene {scene_id + 1} ({task_id})** ---")
 
275
  st.write(f" *Img Prompt:* {segment.image_prompt}" + (f" *(Mod: {segment.timeline_visual_modifier})*" if segment.timeline_visual_modifier else "")); st.write(f" *Audio Text:* {segment.audio_text}")
276
 
277
  # --- 2a. Image Generation ---
278
+ # !!! This call will currently return None until Vertex AI SDK is implemented !!!
279
  generated_image: Optional[Image.Image] = None
280
  with st.spinner(f"[{task_id}] Generating image... 🎨"):
281
  combined_prompt = segment.image_prompt
282
  if segment.character_description: combined_prompt += f" Featuring: {segment.character_description}"
283
  if segment.timeline_visual_modifier: combined_prompt += f" Style hint: {segment.timeline_visual_modifier}."
284
+ generated_image = generate_image_imagen(combined_prompt, aspect_ratio, task_id) # Needs Vertex AI SDK update
285
+
286
+ if generated_image: # This block will likely not execute currently
287
  image_path = os.path.join(temp_dir, f"{task_id}_image.png")
288
  try: generated_image.save(image_path); temp_image_files[scene_id] = image_path; st.image(generated_image, width=180, caption=f"Scene {scene_id+1}")
289
  except Exception as e: logger.error(f" ❌ [{task_id}] Img save error: {e}"); st.error(f"Save image {task_id} failed.", icon="πŸ’Ύ"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img save fail.")
290
+ else:
291
+ # Error logged within generate_image_imagen if it fails
292
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img gen fail.")
293
+ continue # Skip rest of scene processing if image fails
294
 
295
  # --- 2b. Audio Generation ---
296
+ # (Audio generation logic remains the same, but won't be reached if image fails)
297
  generated_audio_path: Optional[str] = None
298
  if not scene_has_error:
299
+ # ... (Audio generation logic - same as before) ...
300
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
301
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
302
  try: generated_audio_path = asyncio.run(generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice))
303
  except RuntimeError as e: logger.error(f" ❌ [{task_id}] Asyncio error: {e}"); st.error(f"Asyncio audio error {task_id}: {e}", icon="⚑"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio async err.")
304
  except Exception as e: logger.exception(f" ❌ [{task_id}] Audio error: {e}"); st.error(f"Audio error {task_id}: {e}", icon="πŸ’₯"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen err.")
305
  if generated_audio_path:
306
+ temp_audio_files[scene_id] = generated_audio_path; try: open(generated_audio_path,'rb') as ap: st.audio(ap.read(), format='audio/wav')
 
 
307
  except Exception as e: logger.warning(f" ⚠️ [{task_id}] Audio preview error: {e}")
308
+ else: scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen fail."); continue # Skip clip if audio fails
 
 
 
 
 
309
 
310
  # --- 2c. Create Video Clip ---
311
+ # (Clip creation logic remains the same, but won't be reached if image/audio fails)
312
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
313
+ # ... (Video clip creation logic - same as before) ...
314
+ st.write(f" 🎬 Creating clip S{scene_id+1}..."); img_path, aud_path = temp_image_files[scene_id], temp_audio_files[scene_id]
315
  audio_clip_instance, image_clip_instance, composite_clip = None, None, None
316
  try:
317
  if not os.path.exists(img_path): raise FileNotFoundError(f"Img missing: {img_path}")
318
  if not os.path.exists(aud_path): raise FileNotFoundError(f"Aud missing: {aud_path}")
319
  audio_clip_instance = AudioFileClip(aud_path); np_image = np.array(Image.open(img_path))
320
  image_clip_instance = ImageClip(np_image).set_duration(audio_clip_instance.duration)
321
+ composite_clip = image_clip_instance.set_audio(audio_clip_instance); video_clips.append(composite_clip)
322
+ logger.info(f" βœ… [{task_id}] Clip created (Dur: {audio_clip_instance.duration:.2f}s)."); st.write(f" βœ… Clip created (Dur: {audio_clip_instance.duration:.2f}s)."); scene_success_count += 1
323
+ except Exception as e: logger.exception(f" ❌ [{task_id}] Failed clip creation: {e}"); st.error(f"Failed clip {task_id}: {e}", icon="🎬"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Clip fail.")
324
+ finally: # Ensure clips are closed even on error here
 
 
325
  if audio_clip_instance: audio_clip_instance.close();
326
  if image_clip_instance: image_clip_instance.close()
327
+ # Don't remove files here on error, let assembly logic handle based on overall success
 
 
 
328
 
329
  # --- 2d. Assemble Timeline Video ---
330
+ # (Video assembly logic remains the same)
331
  timeline_duration = time.time() - timeline_start_time
332
+ if video_clips and scene_success_count == len(segments): # Only if ALL scenes succeeded
333
+ # ... (Video assembly logic) ...
334
+ status.update(label=f"Composing video {timeline_label}...")
335
+ st.write(f"🎞️ Assembling video {timeline_label}..."); logger.info(f"🎞️ Assembling video {timeline_label}...")
336
+ output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4"); final_timeline_video = None
337
+ try: final_timeline_video = concatenate_videoclips(video_clips, method="compose"); final_timeline_video.write_videofile(output_filename, fps=VIDEO_FPS, codec=VIDEO_CODEC, audio_codec=AUDIO_CODEC, logger=None); final_video_paths[timeline_id] = output_filename; logger.info(f" βœ… [{timeline_label}] Video saved: {os.path.basename(output_filename)}"); st.success(f"βœ… Video {timeline_label} completed in {timeline_duration:.2f}s.")
338
+ except Exception as e: logger.exception(f" ❌ [{timeline_label}] Video assembly failed: {e}"); st.error(f"Assemble video {timeline_label} failed: {e}", icon="πŸ“Ό"); all_timelines_successful = False; generation_errors[timeline_id].append(f"T{timeline_id}: Assembly fail.")
339
+ finally: # Close clips used in assembly
340
+ logger.debug(f"[{timeline_label}] Closing {len(video_clips)} clips...");
341
+ for i, clip in enumerate(video_clips): try: clip.close() except Exception as e_close: logger.warning(f" ⚠️ [{timeline_label}] Clip close err {i}: {e_close}")
342
+ if final_timeline_video: try: final_timeline_video.close() except Exception as e_close_final: logger.warning(f" ⚠️ [{timeline_label}] Final vid close err: {e_close_final}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  elif not video_clips: logger.warning(f"[{timeline_label}] No clips. Skip assembly."); st.warning(f"No scenes for {timeline_label}. No video.", icon="🚫"); all_timelines_successful = False
344
+ else: error_count = len(generation_errors[timeline_id]); logger.warning(f"[{timeline_label}] {error_count} scene err(s). Skip assembly."); st.warning(f"{timeline_label}: {error_count} err(s). Video not assembled.", icon="⚠️"); all_timelines_successful = False
345
  if generation_errors[timeline_id]: logger.error(f"Errors {timeline_label}: {generation_errors[timeline_id]}")
346
 
347
  # --- End of Timelines Loop ---
348
+ # (Final status update logic remains the same)
349
  overall_duration = time.time() - overall_start_time
350
  if all_timelines_successful and final_video_paths: status_msg = f"Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"; status.update(label=status_msg, state="complete", expanded=False); logger.info(status_msg)
351
  elif final_video_paths: status_msg = f"Partially Complete ({len(final_video_paths)} videos, errors). {overall_duration:.2f}s"; status.update(label=status_msg, state="warning", expanded=True); logger.warning(status_msg)
 
354
  # --- 3. Display Results ---
355
  st.header("🎬 Generated Timelines")
356
  if final_video_paths:
357
+ # ... (Display logic - same as before) ...
358
  sorted_timeline_ids = sorted(final_video_paths.keys()); num_cols = min(len(sorted_timeline_ids), 3); cols = st.columns(num_cols)
359
  for idx, timeline_id in enumerate(sorted_timeline_ids):
360
  col = cols[idx % num_cols]; video_path = final_video_paths[timeline_id]
 
366
  with open(video_path, 'rb') as vf: video_bytes = vf.read()
367
  st.video(video_bytes); logger.info(f"Displaying T{timeline_id}")
368
  st.download_button(f"Download T{timeline_id}", video_bytes, f"timeline_{timeline_id}.mp4", "video/mp4", key=f"dl_{timeline_id}")
369
+ if generation_errors.get(timeline_id): # Check if errors exist for this timeline
370
+ # Filter out non-assembly errors for display below video
371
+ scene_errors = [err for err in generation_errors[timeline_id] if not err.startswith(f"T{timeline_id}:")]
372
+ if scene_errors:
373
+ with st.expander(f"⚠️ View {len(scene_errors)} Scene Issues"):
374
+ for err in scene_errors: st.warning(f"- {err}") # Use standard loop
375
  except FileNotFoundError: logger.error(f"Video missing: {video_path}"); st.error(f"Error: Video missing T{timeline_id}.", icon="🚨")
376
  except Exception as e: logger.exception(f"Display error {video_path}: {e}"); st.error(f"Display error T{timeline_id}: {e}", icon="🚨")
377
+ else: # No videos generated
378
  st.warning("No final videos were successfully generated.")
379
+ # Display summary of ALL errors using a standard loop to avoid ValueError
380
+ st.subheader("Summary of Generation Issues")
381
+ has_errors = any(generation_errors.values())
382
+ if has_errors:
383
  with st.expander("View All Errors", expanded=True):
384
  for tid, errors in generation_errors.items():
385
+ if errors:
386
+ st.error(f"**Timeline {tid}:**") # Use markdown bold
387
+ # Use standard for loop here - FIX for ValueError
388
+ for msg in errors:
389
+ st.error(f" - {msg}")
390
+ else: # Should not happen if no videos, but handle defensively
391
+ st.info("No generation errors were recorded.")
392
+
393
 
394
  # --- 4. Cleanup ---
395
+ # (Cleanup logic remains the same)
396
  st.info(f"Attempting cleanup: {temp_dir}")
397
  try: shutil.rmtree(temp_dir); logger.info(f"βœ… Temp dir removed: {temp_dir}"); st.success("βœ… Temp files cleaned.")
398
  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="⚠️")