mgbam commited on
Commit
394ae41
Β·
verified Β·
1 Parent(s): 90e64d1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +131 -209
app.py CHANGED
@@ -104,35 +104,31 @@ except Exception as e:
104
 
105
  # --- Define Pydantic Schemas (Using V2 Syntax) ---
106
  class StorySegment(BaseModel):
107
- scene_id: int = Field(..., ge=0, description="Scene number within the timeline, starting from 0.")
108
- image_prompt: str = Field(..., min_length=10, max_length=250, # Keep increased limit
109
- description="Concise visual description (target 15-35 words). Focus on non-human characters, setting, action, style.")
110
- audio_text: str = Field(..., min_length=5, max_length=150, description="Single sentence of narration/dialogue (max 30 words).")
111
- # Increased max_length for character_description
112
- character_description: str = Field(..., max_length=250, # <-- Increased from 100
113
- description="Brief description of key non-human characters/objects in *this* scene's prompt (target under 20 words).")
114
- timeline_visual_modifier: Optional[str] = Field(None, max_length=50, description="Optional subtle visual style hint.")
115
-
116
- # Pydantic v2 style field validator
117
  @field_validator('image_prompt')
118
  @classmethod
119
  def image_prompt_no_humans(cls, v: str) -> str:
120
  if any(word in v.lower() for word in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]):
121
- logger.warning(f"Image prompt '{v[:50]}...' may contain human descriptions. Relying on API-level controls & prompt instructions.")
122
  return v
123
 
124
  class Timeline(BaseModel):
125
- timeline_id: int = Field(..., ge=0, description="Unique identifier for this timeline.")
 
126
  divergence_reason: str = Field(..., min_length=5, description="Clear reason why this timeline branched off.")
127
- segments: List[StorySegment] = Field(..., min_items=1, description="List of scenes composing this timeline.")
128
 
129
  class ChronoWeaveResponse(BaseModel):
130
- core_theme: str = Field(..., min_length=5, description="The central theme provided by the user.")
131
- timelines: List[Timeline] = Field(..., min_items=1, description="List of generated timelines.")
132
- total_scenes_per_timeline: int = Field(..., gt=0, description="The requested number of scenes per timeline.")
133
 
134
- # Pydantic v2 style model validator
135
- @model_validator(mode='after') # Use 'after' to access validated fields
136
  def check_timeline_segment_count(self) -> 'ChronoWeaveResponse':
137
  expected_scenes = self.total_scenes_per_timeline
138
  for i, timeline in enumerate(self.timelines):
@@ -149,7 +145,7 @@ def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLIN
149
  try:
150
  wf = wave.open(filename, "wb")
151
  wf.setnchannels(channels)
152
- wf.setsampwidth(sample_width) # 2 bytes for 16-bit audio
153
  wf.setframerate(rate)
154
  yield wf
155
  except Exception as e:
@@ -157,10 +153,8 @@ def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLIN
157
  raise
158
  finally:
159
  if wf:
160
- try:
161
- wf.close()
162
- except Exception as e_close:
163
- logger.error(f"Error closing wave file {filename}: {e_close}")
164
 
165
 
166
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
@@ -170,37 +164,24 @@ async def generate_audio_live_async(api_text: str, output_filename: str, voice:
170
  logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio for: '{api_text[:60]}...'")
171
 
172
  try:
173
- config = {
174
- "response_modalities": ["AUDIO"],
175
- "audio_config": {
176
- "audio_encoding": "LINEAR16",
177
- "sample_rate_hertz": AUDIO_SAMPLING_RATE,
178
- }
179
- }
180
- directive_prompt = (
181
- "Narrate the following sentence directly and engagingly. "
182
- "Do not add any introductory or concluding remarks. "
183
- "Speak only the sentence itself:\n\n"
184
- f'"{api_text}"'
185
- )
186
 
187
  async with live_model.connect(config=config) as session:
188
  await session.send_request([directive_prompt])
189
  async for response in session.stream_content():
190
- if response.audio_chunk and response.audio_chunk.data:
191
- collected_audio.extend(response.audio_chunk.data)
192
  if hasattr(response, 'error') and response.error:
193
  logger.error(f" ❌ [{task_id}] Error during audio stream: {response.error}")
194
  st.error(f"Audio stream error for scene {task_id}: {response.error}", icon="πŸ”Š")
195
  return None
196
 
197
  if not collected_audio:
198
- logger.warning(f"⚠️ [{task_id}] No audio data received for: '{api_text[:60]}...'")
199
  st.warning(f"No audio data generated for scene {task_id}.", icon="πŸ”Š")
200
  return None
201
 
202
- with wave_file_writer(output_filename, rate=AUDIO_SAMPLING_RATE) as wf:
203
- wf.writeframes(bytes(collected_audio))
204
  logger.info(f" βœ… [{task_id}] Audio saved: {os.path.basename(output_filename)} ({len(collected_audio)} bytes)")
205
  return output_filename
206
 
@@ -224,13 +205,13 @@ def generate_story_sequence_chrono(
224
  st.info(f"πŸ“š Generating {num_timelines} timeline(s) x {num_scenes} scenes for theme: '{theme}'...")
225
  logger.info(f"Requesting story structure: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
226
 
 
227
  divergence_instruction = (
228
  f"Introduce clear points of divergence between timelines, starting potentially after the first scene. "
229
  f"If provided, use this hint for divergence: '{divergence_prompt}'. "
230
- f"Clearly state the divergence reason for each timeline (except potentially the first)."
231
  )
232
 
233
- # Updated prompt with stricter guidance on description lengths
234
  prompt = f"""
235
  Act as an expert narrative designer specializing in short, visual, branching stories for children.
236
  Create a story based on the core theme: "{theme}".
@@ -240,10 +221,10 @@ def generate_story_sequence_chrono(
240
  2. Each timeline must contain exactly **{num_scenes}** sequential scenes.
241
  3. **Crucially, DO NOT include any humans, people, or humanoid figures** in the descriptions or actions. Focus strictly on animals, fantasy creatures, animated objects, or natural elements.
242
  4. {divergence_instruction}
243
- 5. Maintain a consistent visual style across all scenes and timelines: **'Simple, friendly kids animation style with bright colors and rounded shapes'**, unless a `timeline_visual_modifier` subtly alters it.
244
- 6. Each scene's narration (`audio_text`) should be a single, concise sentence (approx. 5-10 seconds spoken length, max 30 words).
245
- 7. Image prompts (`image_prompt`) should be descriptive **and concise (target 15-35 words MAXIMUM)**, focusing only on the non-human character(s), setting, action, and essential visual style elements for *this specific scene*. Explicitly mention the main character(s) for consistency. **Do NOT repeat the general style description** unless essential for a specific visual change.
246
- 8. `character_description` should **very briefly** describe recurring non-human characters mentioned *in the specific scene's image prompt* (name, key visual features). **Keep descriptions extremely concise (e.g., "Nutsy: fluffy squirrel"). Aim for under 20 words total per scene.** Keep consistent within a timeline.
247
 
248
  **Output Format:**
249
  Respond ONLY with a valid JSON object adhering strictly to the provided schema. Do not include any text before or after the JSON object.
@@ -252,7 +233,7 @@ def generate_story_sequence_chrono(
252
  ```json
253
  {json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}
254
  ```
255
- """ # Using .model_json_schema() for Pydantic v2.
256
 
257
  try:
258
  response = client_standard.generate_content(
@@ -266,32 +247,31 @@ def generate_story_sequence_chrono(
266
  try:
267
  raw_data = json.loads(response.text)
268
  except json.JSONDecodeError as json_err:
269
- logger.error(f"Failed to decode JSON response: {json_err}")
270
- logger.error(f"Problematic Response Text:\n{response.text}")
271
- st.error(f"🚨 Failed to parse story structure from AI: {json_err}", icon="πŸ“„")
272
  st.text_area("Problematic AI Response:", response.text, height=200)
273
  return None
274
  except Exception as e:
275
- logger.error(f"Error accessing or decoding response text: {e}")
276
  st.error(f"🚨 Error processing AI response: {e}", icon="πŸ“„")
277
  return None
278
 
279
  try:
280
- # Use model_validate for Pydantic v2
281
  validated_data = ChronoWeaveResponse.model_validate(raw_data)
282
  logger.info("βœ… Story structure generated and validated successfully!")
283
  st.success("βœ… Story structure generated and validated!")
284
  return validated_data
285
  except ValidationError as val_err:
286
- logger.error(f"JSON structure validation failed: {val_err}")
287
  logger.error(f"Received Data:\n{json.dumps(raw_data, indent=2)}")
288
- st.error(f"🚨 The generated story structure is invalid: {val_err}", icon="🧬")
289
  st.json(raw_data)
290
  return None
291
 
292
  except genai.types.generation_types.BlockedPromptException as bpe:
293
  logger.error(f"Story generation prompt blocked: {bpe}")
294
- st.error("🚨 Story generation prompt blocked (safety filters). Try rephrasing.", icon="🚫")
295
  return None
296
  except Exception as e:
297
  logger.exception("Error during story sequence generation:")
@@ -301,113 +281,88 @@ def generate_story_sequence_chrono(
301
 
302
  def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str = "IMG") -> Optional[Image.Image]:
303
  """Generates an image using Imagen via the standard client."""
304
- logger.info(f"πŸ–ΌοΈ [{task_id}] Requesting image for: '{prompt[:70]}...' (Aspect: {aspect_ratio})")
305
 
306
  full_prompt = (
307
  f"Generate an image in a child-friendly, simple animation style with bright colors and rounded shapes. "
308
- f"Ensure absolutely NO humans or human-like figures are present. Focus on animals or objects. "
309
- f"Aspect ratio should be {aspect_ratio}. "
310
- f"Scene Description: {prompt}"
311
  )
312
 
313
  try:
314
  response = client_standard.generate_content(
315
- full_prompt,
316
- generation_config=genai.types.GenerationConfig(
317
- candidate_count=1,
318
- ),
319
  )
320
 
321
- image_bytes = None
322
- safety_ratings = []
323
- block_reason = None
324
- finish_reason = None # Store finish reason if available
325
 
326
- # Consolidate response checking
327
  if hasattr(response, 'candidates') and response.candidates:
328
  candidate = response.candidates[0]
329
- if hasattr(candidate, 'finish_reason'):
330
- finish_reason = candidate.finish_reason.name if hasattr(candidate.finish_reason, 'name') else str(candidate.finish_reason)
331
  if hasattr(candidate, 'content') and candidate.content and hasattr(candidate.content, 'parts') and candidate.content.parts:
332
  part = candidate.content.parts[0]
333
- if hasattr(part, 'inline_data') and part.inline_data and hasattr(part.inline_data, 'data'):
334
- image_bytes = part.inline_data.data
335
- if hasattr(candidate, 'safety_ratings'):
336
- safety_ratings = candidate.safety_ratings
337
 
338
  if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
339
- if hasattr(response.prompt_feedback, 'block_reason') and response.prompt_feedback.block_reason.name != 'BLOCK_REASON_UNSPECIFIED':
340
- block_reason = response.prompt_feedback.block_reason.name
341
- if hasattr(response.prompt_feedback, 'safety_ratings'):
342
- safety_ratings.extend(response.prompt_feedback.safety_ratings)
343
-
344
 
345
  if image_bytes:
346
  try:
347
  image = Image.open(BytesIO(image_bytes))
348
- logger.info(f" βœ… [{task_id}] Image generated successfully.")
349
  filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
350
  if filtered_ratings:
351
- logger.warning(f" ⚠️ [{task_id}] Image flagged by safety filters: {', '.join(filtered_ratings)}.")
352
- st.warning(f"Image for scene {task_id} flagged: {', '.join(filtered_ratings)}", icon="⚠️")
353
  return image
354
  except Exception as img_err:
355
- logger.error(f" ❌ [{task_id}] Failed to decode generated image data: {img_err}")
356
- st.warning(f"Failed to decode image data for scene {task_id}.", icon="πŸ–ΌοΈ")
357
  return None
358
  else:
359
- # Enhanced logging for failure reasons
360
  fail_reason = "Unknown reason."
361
- if block_reason:
362
- fail_reason = f"Blocked (Reason: {block_reason})."
363
- elif finish_reason and finish_reason not in ['STOP', 'FINISH_REASON_UNSPECIFIED']:
364
- fail_reason = f"Finished early (Reason: {finish_reason})."
365
  else:
366
- filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
367
- if filtered_ratings:
368
- fail_reason = f"Safety filters triggered: {', '.join(filtered_ratings)}."
369
-
370
- logger.warning(f" ⚠️ [{task_id}] No image data received. Reason: {fail_reason} Prompt: '{prompt[:70]}...'")
371
- st.warning(f"No image data received for scene {task_id}. Reason: {fail_reason}", icon="πŸ–ΌοΈ")
372
- # Uncomment to log full response only on unknown failures
373
- # if fail_reason == "Unknown reason.":
374
- # logger.debug(f" ⚠️ [{task_id}] Full API response object: {response}")
375
  return None
376
 
377
  except genai.types.generation_types.BlockedPromptException as bpe:
378
  logger.error(f" ❌ [{task_id}] Image generation blocked (exception): {bpe}")
379
- st.error(f"Image generation blocked for scene {task_id} (exception).", icon="🚫")
380
  return None
381
  except Exception as e:
382
  logger.exception(f" ❌ [{task_id}] Image generation failed unexpectedly: {e}")
383
- st.error(f"Image generation failed for scene {task_id}: {e}", icon="πŸ–ΌοΈ")
384
  return None
385
 
386
-
387
  # --- Streamlit UI Elements ---
 
388
  st.sidebar.header("βš™οΈ Configuration")
389
-
390
- if GOOGLE_API_KEY:
391
- st.sidebar.success("Google API Key Loaded", icon="βœ…")
392
- else:
393
- st.sidebar.error("Google API Key Missing!", icon="🚨")
394
-
395
  theme = st.sidebar.text_input("πŸ“– Story Theme:", "A curious squirrel finds a mysterious, glowing acorn")
396
  num_scenes = st.sidebar.slider("🎬 Scenes per Timeline:", min_value=2, max_value=7, value=3)
397
  num_timelines = st.sidebar.slider("🌿 Number of Timelines:", min_value=1, max_value=4, value=2)
398
  divergence_prompt = st.sidebar.text_input("↔️ Divergence Hint (Optional):", placeholder="e.g., What if a bird tried to steal it?")
399
-
400
  st.sidebar.subheader("🎨 Visual & Audio Settings")
401
  aspect_ratio = st.sidebar.selectbox("πŸ–ΌοΈ Image Aspect Ratio:", ["1:1", "16:9", "9:16"], index=0)
402
  audio_voice = None
403
-
404
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
405
-
406
  st.sidebar.markdown("---")
407
  st.sidebar.info("⏳ Generation can take several minutes.", icon="⏳")
408
  st.sidebar.markdown(f"<small>Models: Text={TEXT_MODEL_ID}, Image={IMAGE_MODEL_ID}, Audio={AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
409
 
410
-
411
  # --- Main Logic ---
412
  if generate_button:
413
  if not theme:
@@ -467,10 +422,8 @@ if generate_button:
467
  generated_image: Optional[Image.Image] = None
468
  with st.spinner(f"[{task_id}] Generating image... 🎨"):
469
  combined_prompt = segment.image_prompt
470
- if segment.character_description:
471
- combined_prompt += f" Featuring: {segment.character_description}"
472
- if segment.timeline_visual_modifier:
473
- combined_prompt += f" Style hint: {segment.timeline_visual_modifier}."
474
  generated_image = generate_image_imagen(combined_prompt, aspect_ratio, task_id)
475
 
476
  if generated_image:
@@ -481,85 +434,73 @@ if generate_button:
481
  st.image(generated_image, width=180, caption=f"Scene {scene_id+1} Image")
482
  except Exception as e:
483
  logger.error(f" ❌ [{task_id}] Failed to save image: {e}")
484
- st.error(f"Failed to save image for scene {task_id}.", icon="πŸ’Ύ")
485
- scene_has_error = True
486
- generation_errors[timeline_id].append(f"Scene {scene_id+1}: Image save failed.")
487
  else:
488
- # Error/warning already logged by generate_image_imagen
489
- scene_has_error = True
490
- generation_errors[timeline_id].append(f"Scene {scene_id+1}: Image generation failed.")
491
- continue
492
 
493
  # --- 2b. Audio Generation ---
494
  generated_audio_path: Optional[str] = None
495
- if not scene_has_error:
496
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
497
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
498
  try:
499
- generated_audio_path = asyncio.run(
500
- generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice)
501
- )
502
  except RuntimeError as e:
503
- logger.error(f" ❌ [{task_id}] Asyncio runtime error: {e}")
504
- st.error(f"Asyncio error during audio gen for {task_id}: {e}", icon="⚑")
505
- scene_has_error = True
506
- generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio async error.")
507
  except Exception as e:
508
  logger.exception(f" ❌ [{task_id}] Unexpected audio error: {e}")
509
- st.error(f"Unexpected audio error for {task_id}: {e}", icon="πŸ’₯")
510
- scene_has_error = True
511
- generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio generation error.")
512
 
513
  if generated_audio_path:
514
  temp_audio_files[scene_id] = generated_audio_path
515
  try:
516
- with open(generated_audio_path, 'rb') as ap:
517
- st.audio(ap.read(), format='audio/wav')
518
- except Exception as e:
519
- logger.warning(f" ⚠️ [{task_id}] Could not display audio preview: {e}")
520
- else:
521
- # Error/warning already logged by generate_audio_live_async
522
- scene_has_error = True
523
- generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio generation failed.")
524
  if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]):
525
- try: os.remove(temp_image_files[scene_id]); logger.info(f" πŸ—‘οΈ [{task_id}] Removed image file due to audio failure."); del temp_image_files[scene_id]
526
- except OSError as e: logger.warning(f" ⚠️ [{task_id}] Failed to remove image after audio failure: {e}")
527
- continue
528
 
529
  # --- 2c. Create Video Clip ---
530
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
531
- st.write(f" 🎬 Creating video clip for Scene {scene_id+1}...")
532
- img_path = temp_image_files[scene_id]
533
- aud_path = temp_audio_files[scene_id]
534
  audio_clip_instance, image_clip_instance, composite_clip = None, None, None
535
  try:
536
- if not os.path.exists(img_path): raise FileNotFoundError(f"Image file missing: {img_path}")
537
- if not os.path.exists(aud_path): raise FileNotFoundError(f"Audio file missing: {aud_path}")
538
-
539
  audio_clip_instance = AudioFileClip(aud_path)
540
  np_image = np.array(Image.open(img_path))
541
  image_clip_instance = ImageClip(np_image).set_duration(audio_clip_instance.duration)
542
  composite_clip = image_clip_instance.set_audio(audio_clip_instance)
543
  video_clips.append(composite_clip)
544
- logger.info(f" βœ… [{task_id}] Video clip created (Duration: {audio_clip_instance.duration:.2f}s).")
545
- st.write(f" βœ… Clip created (Duration: {audio_clip_instance.duration:.2f}s).")
546
  scene_success_count += 1
547
-
548
  except Exception as e:
549
- logger.exception(f" ❌ [{task_id}] Failed to create video clip: {e}")
550
- st.error(f"Failed to create video clip for {task_id}: {e}", icon="🎬")
551
- scene_has_error = True
552
- generation_errors[timeline_id].append(f"Scene {scene_id+1}: Video clip creation failed.")
553
  if audio_clip_instance: audio_clip_instance.close()
554
  if image_clip_instance: image_clip_instance.close()
555
  try:
556
  if os.path.exists(img_path): os.remove(img_path)
557
  if os.path.exists(aud_path): os.remove(aud_path)
558
- except OSError as e_rem: logger.warning(f" ⚠️ [{task_id}] Failed to remove files after clip error: {e_rem}")
559
 
560
  # --- 2d. Assemble Timeline Video ---
561
  timeline_duration = time.time() - timeline_start_time
562
  if video_clips and scene_success_count == len(segments):
 
563
  status.update(label=f"Composing final video for {timeline_label}...")
564
  st.write(f"🎞️ Assembling final video for {timeline_label}...")
565
  logger.info(f"🎞️ Assembling final video for {timeline_label} ({len(video_clips)} clips)...")
@@ -573,11 +514,10 @@ if generate_button:
573
  st.success(f"βœ… Video for {timeline_label} completed in {timeline_duration:.2f}s.")
574
  except Exception as e:
575
  logger.exception(f" ❌ [{timeline_label}] Failed to write final video: {e}")
576
- st.error(f"Failed to assemble video for {timeline_label}: {e}", icon="πŸ“Ό")
577
- all_timelines_successful = False
578
- generation_errors[timeline_id].append(f"Timeline {timeline_id}: Final video assembly failed.")
579
  finally:
580
- logger.debug(f"[{timeline_label}] Closing {len(video_clips)} source clips and final video...")
581
  for i, clip in enumerate(video_clips):
582
  try:
583
  if clip:
@@ -589,88 +529,70 @@ if generate_button:
589
  if final_timeline_video.audio: final_timeline_video.audio.close()
590
  final_timeline_video.close()
591
  except Exception as e_close_final: logger.warning(f" ⚠️ [{timeline_label}] Error closing final video object: {e_close_final}")
592
-
593
  elif not video_clips:
594
- logger.warning(f"[{timeline_label}] No video clips generated. Skipping final assembly.")
595
- st.warning(f"No scenes processed for {timeline_label}. Video cannot be created.", icon="🚫")
596
- all_timelines_successful = False
597
  else: # Some scenes failed
598
  error_count = len(segments) - scene_success_count
599
- logger.warning(f"[{timeline_label}] Errors in {error_count} scene(s). Skipping final video assembly.")
600
- st.warning(f"{timeline_label} had errors in {error_count} scene(s). Final video not assembled.", icon="⚠️")
601
  all_timelines_successful = False
602
-
603
- if generation_errors[timeline_id]:
604
- logger.error(f"Summary of errors in {timeline_label}: {generation_errors[timeline_id]}")
605
 
606
  # --- End of Timelines Loop ---
607
  overall_duration = time.time() - overall_start_time
608
- if all_timelines_successful and final_video_paths:
609
- status_msg = f"ChronoWeave Generation Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"
610
- status.update(label=status_msg, state="complete", expanded=False); logger.info(status_msg)
611
- elif final_video_paths:
612
- status_msg = f"ChronoWeave Partially Complete ({len(final_video_paths)} videos, errors occurred). Time: {overall_duration:.2f}s"
613
- status.update(label=status_msg, state="warning", expanded=True); logger.warning(status_msg)
614
- else:
615
- status_msg = f"ChronoWeave Generation Failed. No videos produced. Time: {overall_duration:.2f}s"
616
- status.update(label=status_msg, state="error", expanded=True); logger.error(status_msg)
617
 
618
  # --- 3. Display Results ---
619
  st.header("🎬 Generated Timelines")
620
  if final_video_paths:
 
621
  sorted_timeline_ids = sorted(final_video_paths.keys())
622
  num_cols = min(len(sorted_timeline_ids), 3)
623
  cols = st.columns(num_cols)
624
-
625
  for idx, timeline_id in enumerate(sorted_timeline_ids):
626
  col = cols[idx % num_cols]
627
  video_path = final_video_paths[timeline_id]
628
  timeline_data = next((t for t in chrono_response.timelines if t.timeline_id == timeline_id), None)
629
- reason = timeline_data.divergence_reason if timeline_data else "Unknown Divergence"
630
-
631
  with col:
632
- st.subheader(f"Timeline {timeline_id}")
633
- st.caption(f"Divergence: {reason}")
634
  try:
635
- with open(video_path, 'rb') as video_file: video_bytes = video_file.read()
636
  st.video(video_bytes)
637
- logger.info(f"Displaying video for Timeline {timeline_id}")
638
- st.download_button(label=f"Download T{timeline_id} Video", data=video_bytes, file_name=f"chronoweave_timeline_{timeline_id}.mp4", mime="video/mp4", key=f"dl_{timeline_id}")
639
  if generation_errors.get(timeline_id):
640
  with st.expander(f"⚠️ View {len(generation_errors[timeline_id])} Issues"):
641
- for error_msg in generation_errors[timeline_id]: st.warning(f"- {error_msg}")
642
- except FileNotFoundError:
643
- logger.error(f"Video file not found for display: {video_path}")
644
- st.error(f"Error: Video file missing for T{timeline_id}.", icon="🚨")
645
- except Exception as e:
646
- logger.exception(f"Could not display video {video_path}: {e}")
647
- st.error(f"Error displaying video T{timeline_id}: {e}", icon="🚨")
648
  else:
649
  st.warning("No final videos were successfully generated.")
 
650
  all_errors = [msg for err_list in generation_errors.values() for msg in err_list]
651
  if all_errors:
652
  st.subheader("Summary of Generation Issues")
653
  with st.expander("View All Errors", expanded=True):
654
  for tid, errors in generation_errors.items():
655
- if errors:
656
- st.error(f"Timeline {tid}:")
657
- for msg in errors: st.error(f" - {msg}")
658
 
659
  # --- 4. Cleanup ---
660
- st.info(f"Attempting to clean up temporary directory: {temp_dir}")
661
  try:
662
  shutil.rmtree(temp_dir)
663
- logger.info(f"βœ… Temporary directory removed: {temp_dir}")
664
  st.success("βœ… Temporary files cleaned up.")
665
  except Exception as e:
666
- logger.error(f"⚠️ Could not remove temporary directory {temp_dir}: {e}")
667
- st.warning(f"Could not automatically remove temporary files: {temp_dir}. Please remove it manually if needed.", icon="⚠️")
668
 
669
- elif not chrono_response:
670
- logger.error("Story generation or validation failed, cannot proceed.")
671
- else:
672
- st.error("An unexpected issue occurred after story generation.", icon="πŸ›‘")
673
- logger.error("Chrono_response existed but was falsy in main logic.")
674
 
675
  else:
676
- st.info("Configure settings in the sidebar and click '✨ Generate ChronoWeave ✨' to start.")
 
104
 
105
  # --- Define Pydantic Schemas (Using V2 Syntax) ---
106
  class StorySegment(BaseModel):
107
+ scene_id: int = Field(..., ge=0)
108
+ image_prompt: str = Field(..., min_length=10, max_length=250) # Keep increased limits
109
+ audio_text: str = Field(..., min_length=5, max_length=150)
110
+ character_description: str = Field(..., max_length=250) # Keep increased limits
111
+ timeline_visual_modifier: Optional[str] = Field(None, max_length=50)
112
+
 
 
 
 
113
  @field_validator('image_prompt')
114
  @classmethod
115
  def image_prompt_no_humans(cls, v: str) -> str:
116
  if any(word in v.lower() for word in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]):
117
+ logger.warning(f"Image prompt '{v[:50]}...' may contain human descriptions.")
118
  return v
119
 
120
  class Timeline(BaseModel):
121
+ timeline_id: int = Field(..., ge=0)
122
+ # Keep min_length=5 for divergence_reason, rely on improved prompt
123
  divergence_reason: str = Field(..., min_length=5, description="Clear reason why this timeline branched off.")
124
+ segments: List[StorySegment] = Field(..., min_items=1)
125
 
126
  class ChronoWeaveResponse(BaseModel):
127
+ core_theme: str = Field(..., min_length=5)
128
+ timelines: List[Timeline] = Field(..., min_items=1)
129
+ total_scenes_per_timeline: int = Field(..., gt=0)
130
 
131
+ @model_validator(mode='after')
 
132
  def check_timeline_segment_count(self) -> 'ChronoWeaveResponse':
133
  expected_scenes = self.total_scenes_per_timeline
134
  for i, timeline in enumerate(self.timelines):
 
145
  try:
146
  wf = wave.open(filename, "wb")
147
  wf.setnchannels(channels)
148
+ wf.setsampwidth(sample_width)
149
  wf.setframerate(rate)
150
  yield wf
151
  except Exception as e:
 
153
  raise
154
  finally:
155
  if wf:
156
+ try: wf.close()
157
+ except Exception as e_close: logger.error(f"Error closing wave file {filename}: {e_close}")
 
 
158
 
159
 
160
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
 
164
  logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio for: '{api_text[:60]}...'")
165
 
166
  try:
167
+ config = {"response_modalities": ["AUDIO"], "audio_config": {"audio_encoding": "LINEAR16", "sample_rate_hertz": AUDIO_SAMPLING_RATE}}
168
+ directive_prompt = f"Narrate the following sentence directly and engagingly. Do not add any introductory or concluding remarks. Speak only the sentence itself:\n\n\"{api_text}\""
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  async with live_model.connect(config=config) as session:
171
  await session.send_request([directive_prompt])
172
  async for response in session.stream_content():
173
+ if response.audio_chunk and response.audio_chunk.data: collected_audio.extend(response.audio_chunk.data)
 
174
  if hasattr(response, 'error') and response.error:
175
  logger.error(f" ❌ [{task_id}] Error during audio stream: {response.error}")
176
  st.error(f"Audio stream error for scene {task_id}: {response.error}", icon="πŸ”Š")
177
  return None
178
 
179
  if not collected_audio:
180
+ logger.warning(f"⚠️ [{task_id}] No audio data received.")
181
  st.warning(f"No audio data generated for scene {task_id}.", icon="πŸ”Š")
182
  return None
183
 
184
+ with wave_file_writer(output_filename, rate=AUDIO_SAMPLING_RATE) as wf: wf.writeframes(bytes(collected_audio))
 
185
  logger.info(f" βœ… [{task_id}] Audio saved: {os.path.basename(output_filename)} ({len(collected_audio)} bytes)")
186
  return output_filename
187
 
 
205
  st.info(f"πŸ“š Generating {num_timelines} timeline(s) x {num_scenes} scenes for theme: '{theme}'...")
206
  logger.info(f"Requesting story structure: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
207
 
208
+ # Updated divergence instruction to guide the first timeline's reason
209
  divergence_instruction = (
210
  f"Introduce clear points of divergence between timelines, starting potentially after the first scene. "
211
  f"If provided, use this hint for divergence: '{divergence_prompt}'. "
212
+ f"Clearly state the divergence reason for each timeline. **For the first timeline (timeline_id 0), use a descriptive reason like 'Initial path' or 'Baseline scenario' that is at least 5 characters long.**"
213
  )
214
 
 
215
  prompt = f"""
216
  Act as an expert narrative designer specializing in short, visual, branching stories for children.
217
  Create a story based on the core theme: "{theme}".
 
221
  2. Each timeline must contain exactly **{num_scenes}** sequential scenes.
222
  3. **Crucially, DO NOT include any humans, people, or humanoid figures** in the descriptions or actions. Focus strictly on animals, fantasy creatures, animated objects, or natural elements.
223
  4. {divergence_instruction}
224
+ 5. Maintain a consistent visual style: **'Simple, friendly kids animation style with bright colors and rounded shapes'**, unless a `timeline_visual_modifier` subtly alters it.
225
+ 6. `audio_text` should be a single, concise sentence (max 30 words).
226
+ 7. `image_prompt` should be descriptive **and concise (target 15-35 words MAXIMUM)**, focusing only on the non-human character(s), setting, action, and essential visual style elements for *this specific scene*. **Do NOT repeat the general style description** unless essential.
227
+ 8. `character_description` should **very briefly** describe recurring non-human characters mentioned *in the scene's image prompt* (name, key features). **Keep descriptions extremely concise (target under 20 words total per scene).**
228
 
229
  **Output Format:**
230
  Respond ONLY with a valid JSON object adhering strictly to the provided schema. Do not include any text before or after the JSON object.
 
233
  ```json
234
  {json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}
235
  ```
236
+ """
237
 
238
  try:
239
  response = client_standard.generate_content(
 
247
  try:
248
  raw_data = json.loads(response.text)
249
  except json.JSONDecodeError as json_err:
250
+ logger.error(f"Failed to decode JSON: {json_err}")
251
+ logger.error(f"Response Text:\n{response.text}")
252
+ st.error(f"🚨 Failed to parse story structure: {json_err}", icon="πŸ“„")
253
  st.text_area("Problematic AI Response:", response.text, height=200)
254
  return None
255
  except Exception as e:
256
+ logger.error(f"Error processing response text: {e}")
257
  st.error(f"🚨 Error processing AI response: {e}", icon="πŸ“„")
258
  return None
259
 
260
  try:
 
261
  validated_data = ChronoWeaveResponse.model_validate(raw_data)
262
  logger.info("βœ… Story structure generated and validated successfully!")
263
  st.success("βœ… Story structure generated and validated!")
264
  return validated_data
265
  except ValidationError as val_err:
266
+ logger.error(f"JSON validation failed: {val_err}")
267
  logger.error(f"Received Data:\n{json.dumps(raw_data, indent=2)}")
268
+ st.error(f"🚨 Generated story structure invalid: {val_err}", icon="🧬")
269
  st.json(raw_data)
270
  return None
271
 
272
  except genai.types.generation_types.BlockedPromptException as bpe:
273
  logger.error(f"Story generation prompt blocked: {bpe}")
274
+ st.error("🚨 Story generation prompt blocked (safety filters).", icon="🚫")
275
  return None
276
  except Exception as e:
277
  logger.exception("Error during story sequence generation:")
 
281
 
282
  def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str = "IMG") -> Optional[Image.Image]:
283
  """Generates an image using Imagen via the standard client."""
284
+ logger.info(f"πŸ–ΌοΈ [{task_id}] Requesting image: '{prompt[:70]}...' (Aspect: {aspect_ratio})")
285
 
286
  full_prompt = (
287
  f"Generate an image in a child-friendly, simple animation style with bright colors and rounded shapes. "
288
+ f"Ensure absolutely NO humans or human-like figures. Focus on animals or objects. "
289
+ f"Aspect ratio {aspect_ratio}. Scene: {prompt}"
 
290
  )
291
 
292
  try:
293
  response = client_standard.generate_content(
294
+ full_prompt, generation_config=genai.types.GenerationConfig(candidate_count=1)
 
 
 
295
  )
296
 
297
+ image_bytes, safety_ratings, block_reason, finish_reason = None, [], None, None
 
 
 
298
 
 
299
  if hasattr(response, 'candidates') and response.candidates:
300
  candidate = response.candidates[0]
301
+ if hasattr(candidate, 'finish_reason'): finish_reason = getattr(candidate.finish_reason, 'name', str(candidate.finish_reason))
 
302
  if hasattr(candidate, 'content') and candidate.content and hasattr(candidate.content, 'parts') and candidate.content.parts:
303
  part = candidate.content.parts[0]
304
+ if hasattr(part, 'inline_data') and part.inline_data and hasattr(part.inline_data, 'data'): image_bytes = part.inline_data.data
305
+ if hasattr(candidate, 'safety_ratings'): safety_ratings = candidate.safety_ratings
 
 
306
 
307
  if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
308
+ 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
309
+ if hasattr(response.prompt_feedback, 'safety_ratings'): safety_ratings.extend(response.prompt_feedback.safety_ratings)
 
 
 
310
 
311
  if image_bytes:
312
  try:
313
  image = Image.open(BytesIO(image_bytes))
314
+ logger.info(f" βœ… [{task_id}] Image generated.")
315
  filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
316
  if filtered_ratings:
317
+ logger.warning(f" ⚠️ [{task_id}] Image flagged: {', '.join(filtered_ratings)}.")
318
+ st.warning(f"Image {task_id} flagged: {', '.join(filtered_ratings)}", icon="⚠️")
319
  return image
320
  except Exception as img_err:
321
+ logger.error(f" ❌ [{task_id}] Failed to decode image data: {img_err}")
322
+ st.warning(f"Failed decode image data {task_id}.", icon="πŸ–ΌοΈ")
323
  return None
324
  else:
 
325
  fail_reason = "Unknown reason."
326
+ if block_reason: fail_reason = f"Blocked (Reason: {block_reason})."
327
+ elif finish_reason and finish_reason not in ['STOP', 'FINISH_REASON_UNSPECIFIED']: fail_reason = f"Finished early (Reason: {finish_reason})."
 
 
328
  else:
329
+ filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
330
+ if filtered_ratings: fail_reason = f"Safety filters triggered: {', '.join(filtered_ratings)}."
331
+
332
+ # Add the full response logging here for persistent unknown failures
333
+ if fail_reason == "Unknown reason.":
334
+ logger.warning(f" ⚠️ [{task_id}] Full API response object: {response}")
335
+
336
+ logger.warning(f" ⚠️ [{task_id}] No image data. Reason: {fail_reason} Prompt: '{prompt[:70]}...'")
337
+ st.warning(f"No image data for {task_id}. Reason: {fail_reason}", icon="πŸ–ΌοΈ")
338
  return None
339
 
340
  except genai.types.generation_types.BlockedPromptException as bpe:
341
  logger.error(f" ❌ [{task_id}] Image generation blocked (exception): {bpe}")
342
+ st.error(f"Image generation blocked for {task_id} (exception).", icon="🚫")
343
  return None
344
  except Exception as e:
345
  logger.exception(f" ❌ [{task_id}] Image generation failed unexpectedly: {e}")
346
+ st.error(f"Image generation failed for {task_id}: {e}", icon="πŸ–ΌοΈ")
347
  return None
348
 
 
349
  # --- Streamlit UI Elements ---
350
+ # (Identical to previous version - No changes needed here)
351
  st.sidebar.header("βš™οΈ Configuration")
352
+ if GOOGLE_API_KEY: st.sidebar.success("Google API Key Loaded", icon="βœ…")
353
+ else: st.sidebar.error("Google API Key Missing!", icon="🚨")
 
 
 
 
354
  theme = st.sidebar.text_input("πŸ“– Story Theme:", "A curious squirrel finds a mysterious, glowing acorn")
355
  num_scenes = st.sidebar.slider("🎬 Scenes per Timeline:", min_value=2, max_value=7, value=3)
356
  num_timelines = st.sidebar.slider("🌿 Number of Timelines:", min_value=1, max_value=4, value=2)
357
  divergence_prompt = st.sidebar.text_input("↔️ Divergence Hint (Optional):", placeholder="e.g., What if a bird tried to steal it?")
 
358
  st.sidebar.subheader("🎨 Visual & Audio Settings")
359
  aspect_ratio = st.sidebar.selectbox("πŸ–ΌοΈ Image Aspect Ratio:", ["1:1", "16:9", "9:16"], index=0)
360
  audio_voice = None
 
361
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
 
362
  st.sidebar.markdown("---")
363
  st.sidebar.info("⏳ Generation can take several minutes.", icon="⏳")
364
  st.sidebar.markdown(f"<small>Models: Text={TEXT_MODEL_ID}, Image={IMAGE_MODEL_ID}, Audio={AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
365
 
 
366
  # --- Main Logic ---
367
  if generate_button:
368
  if not theme:
 
422
  generated_image: Optional[Image.Image] = None
423
  with st.spinner(f"[{task_id}] Generating image... 🎨"):
424
  combined_prompt = segment.image_prompt
425
+ if segment.character_description: combined_prompt += f" Featuring: {segment.character_description}"
426
+ if segment.timeline_visual_modifier: combined_prompt += f" Style hint: {segment.timeline_visual_modifier}."
 
 
427
  generated_image = generate_image_imagen(combined_prompt, aspect_ratio, task_id)
428
 
429
  if generated_image:
 
434
  st.image(generated_image, width=180, caption=f"Scene {scene_id+1} Image")
435
  except Exception as e:
436
  logger.error(f" ❌ [{task_id}] Failed to save image: {e}")
437
+ st.error(f"Failed save image {task_id}.", icon="πŸ’Ύ")
438
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img save fail.")
 
439
  else:
440
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img gen fail.")
441
+ continue # Skip rest of scene processing
 
 
442
 
443
  # --- 2b. Audio Generation ---
444
  generated_audio_path: Optional[str] = None
445
+ if not scene_has_error: # Only proceed if image succeeded
446
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
447
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
448
  try:
449
+ generated_audio_path = asyncio.run(generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice))
 
 
450
  except RuntimeError as e:
451
+ logger.error(f" ❌ [{task_id}] Asyncio error: {e}")
452
+ st.error(f"Asyncio audio error {task_id}: {e}", icon="⚑")
453
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio async err.")
 
454
  except Exception as e:
455
  logger.exception(f" ❌ [{task_id}] Unexpected audio error: {e}")
456
+ st.error(f"Unexpected audio error {task_id}: {e}", icon="πŸ’₯")
457
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen err.")
 
458
 
459
  if generated_audio_path:
460
  temp_audio_files[scene_id] = generated_audio_path
461
  try:
462
+ with open(generated_audio_path, 'rb') as ap: st.audio(ap.read(), format='audio/wav')
463
+ except Exception as e: logger.warning(f" ⚠️ [{task_id}] Audio preview error: {e}")
464
+ else: # Audio generation failed
465
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen fail.")
466
+ # Clean up corresponding image file
 
 
 
467
  if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]):
468
+ try: os.remove(temp_image_files[scene_id]); logger.info(f" πŸ—‘οΈ [{task_id}] Removed image due to audio fail."); del temp_image_files[scene_id]
469
+ except OSError as e: logger.warning(f" ⚠️ [{task_id}] Failed remove image after audio fail: {e}")
470
+ continue # Skip video clip creation
471
 
472
  # --- 2c. Create Video Clip ---
473
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
474
+ st.write(f" 🎬 Creating video clip S{scene_id+1}...")
475
+ img_path, aud_path = temp_image_files[scene_id], temp_audio_files[scene_id]
 
476
  audio_clip_instance, image_clip_instance, composite_clip = None, None, None
477
  try:
478
+ if not os.path.exists(img_path): raise FileNotFoundError(f"Img missing: {img_path}")
479
+ if not os.path.exists(aud_path): raise FileNotFoundError(f"Aud missing: {aud_path}")
 
480
  audio_clip_instance = AudioFileClip(aud_path)
481
  np_image = np.array(Image.open(img_path))
482
  image_clip_instance = ImageClip(np_image).set_duration(audio_clip_instance.duration)
483
  composite_clip = image_clip_instance.set_audio(audio_clip_instance)
484
  video_clips.append(composite_clip)
485
+ logger.info(f" βœ… [{task_id}] Clip created (Dur: {audio_clip_instance.duration:.2f}s).")
486
+ st.write(f" βœ… Clip created (Dur: {audio_clip_instance.duration:.2f}s).")
487
  scene_success_count += 1
 
488
  except Exception as e:
489
+ logger.exception(f" ❌ [{task_id}] Failed clip creation: {e}")
490
+ st.error(f"Failed create clip {task_id}: {e}", icon="🎬")
491
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Clip creation fail.")
492
+ # Cleanup resources from failed clip attempt
493
  if audio_clip_instance: audio_clip_instance.close()
494
  if image_clip_instance: image_clip_instance.close()
495
  try:
496
  if os.path.exists(img_path): os.remove(img_path)
497
  if os.path.exists(aud_path): os.remove(aud_path)
498
+ except OSError as e_rem: logger.warning(f" ⚠️ [{task_id}] Failed remove files after clip error: {e_rem}")
499
 
500
  # --- 2d. Assemble Timeline Video ---
501
  timeline_duration = time.time() - timeline_start_time
502
  if video_clips and scene_success_count == len(segments):
503
+ # ... (Video assembly logic - same as before) ...
504
  status.update(label=f"Composing final video for {timeline_label}...")
505
  st.write(f"🎞️ Assembling final video for {timeline_label}...")
506
  logger.info(f"🎞️ Assembling final video for {timeline_label} ({len(video_clips)} clips)...")
 
514
  st.success(f"βœ… Video for {timeline_label} completed in {timeline_duration:.2f}s.")
515
  except Exception as e:
516
  logger.exception(f" ❌ [{timeline_label}] Failed to write final video: {e}")
517
+ st.error(f"Failed assemble video {timeline_label}: {e}", icon="πŸ“Ό")
518
+ all_timelines_successful = False; generation_errors[timeline_id].append(f"Timeline {timeline_id}: Video assembly failed.")
 
519
  finally:
520
+ logger.debug(f"[{timeline_label}] Closing clips...")
521
  for i, clip in enumerate(video_clips):
522
  try:
523
  if clip:
 
529
  if final_timeline_video.audio: final_timeline_video.audio.close()
530
  final_timeline_video.close()
531
  except Exception as e_close_final: logger.warning(f" ⚠️ [{timeline_label}] Error closing final video object: {e_close_final}")
 
532
  elif not video_clips:
533
+ logger.warning(f"[{timeline_label}] No clips generated. Skipping assembly.")
534
+ st.warning(f"No scenes processed for {timeline_label}. Cannot create video.", icon="🚫")
535
+ all_timelines_successful = False
536
  else: # Some scenes failed
537
  error_count = len(segments) - scene_success_count
538
+ logger.warning(f"[{timeline_label}] Errors in {error_count} scene(s). Skipping assembly.")
539
+ st.warning(f"{timeline_label} had errors in {error_count} scene(s). Video not assembled.", icon="⚠️")
540
  all_timelines_successful = False
541
+ if generation_errors[timeline_id]: logger.error(f"Error summary {timeline_label}: {generation_errors[timeline_id]}")
 
 
542
 
543
  # --- End of Timelines Loop ---
544
  overall_duration = time.time() - overall_start_time
545
+ # ... (Final status update logic - same as before) ...
546
+ if all_timelines_successful and final_video_paths: status_msg = f"ChronoWeave Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"; status.update(label=status_msg, state="complete", expanded=False); logger.info(status_msg)
547
+ elif final_video_paths: status_msg = f"ChronoWeave Partially Complete ({len(final_video_paths)} videos, errors). Time: {overall_duration:.2f}s"; status.update(label=status_msg, state="warning", expanded=True); logger.warning(status_msg)
548
+ else: status_msg = f"ChronoWeave Failed. No videos. Time: {overall_duration:.2f}s"; status.update(label=status_msg, state="error", expanded=True); logger.error(status_msg)
 
 
 
 
 
549
 
550
  # --- 3. Display Results ---
551
  st.header("🎬 Generated Timelines")
552
  if final_video_paths:
553
+ # ... (Display logic - same as before) ...
554
  sorted_timeline_ids = sorted(final_video_paths.keys())
555
  num_cols = min(len(sorted_timeline_ids), 3)
556
  cols = st.columns(num_cols)
 
557
  for idx, timeline_id in enumerate(sorted_timeline_ids):
558
  col = cols[idx % num_cols]
559
  video_path = final_video_paths[timeline_id]
560
  timeline_data = next((t for t in chrono_response.timelines if t.timeline_id == timeline_id), None)
561
+ reason = timeline_data.divergence_reason if timeline_data else "Unknown"
 
562
  with col:
563
+ st.subheader(f"Timeline {timeline_id}"); st.caption(f"Divergence: {reason}")
 
564
  try:
565
+ with open(video_path, 'rb') as vf: video_bytes = vf.read()
566
  st.video(video_bytes)
567
+ logger.info(f"Displaying video T{timeline_id}")
568
+ st.download_button(f"Download T{timeline_id}", video_bytes, f"timeline_{timeline_id}.mp4", "video/mp4", key=f"dl_{timeline_id}")
569
  if generation_errors.get(timeline_id):
570
  with st.expander(f"⚠️ View {len(generation_errors[timeline_id])} Issues"):
571
+ for err in generation_errors[timeline_id]: st.warning(f"- {err}")
572
+ except FileNotFoundError: logger.error(f"Video file missing: {video_path}"); st.error(f"Error: Video file missing T{timeline_id}.", icon="🚨")
573
+ except Exception as e: logger.exception(f"Display error {video_path}: {e}"); st.error(f"Error display T{timeline_id}: {e}", icon="🚨")
 
 
 
 
574
  else:
575
  st.warning("No final videos were successfully generated.")
576
+ # ... (Error summary display - same as before) ...
577
  all_errors = [msg for err_list in generation_errors.values() for msg in err_list]
578
  if all_errors:
579
  st.subheader("Summary of Generation Issues")
580
  with st.expander("View All Errors", expanded=True):
581
  for tid, errors in generation_errors.items():
582
+ if errors: st.error(f"T{tid}:"); [st.error(f" - {msg}") for msg in errors]
 
 
583
 
584
  # --- 4. Cleanup ---
585
+ st.info(f"Attempting cleanup: {temp_dir}")
586
  try:
587
  shutil.rmtree(temp_dir)
588
+ logger.info(f"βœ… Temp dir removed: {temp_dir}")
589
  st.success("βœ… Temporary files cleaned up.")
590
  except Exception as e:
591
+ logger.error(f"⚠️ Failed remove temp dir {temp_dir}: {e}")
592
+ st.warning(f"Could not remove temp files: {temp_dir}.", icon="⚠️")
593
 
594
+ elif not chrono_response: logger.error("Story generation/validation failed.")
595
+ else: st.error("Unexpected issue post-generation.", icon="πŸ›‘"); logger.error("Chrono_response truthy but invalid state.")
 
 
 
596
 
597
  else:
598
+ st.info("Configure settings and click '✨ Generate ChronoWeave ✨' to start.")