mgbam commited on
Commit
09b00d7
Β·
verified Β·
1 Parent(s): 87d0863

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +150 -144
app.py CHANGED
@@ -26,7 +26,7 @@ from typing import List, Optional, Literal
26
 
27
  # Video and audio processing
28
  from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips
29
- from moviepy.config import change_settings # Potential for setting imagemagick path if needed
30
 
31
  # Type hints
32
  import typing_extensions as typing
@@ -51,8 +51,8 @@ Generate multiple, branching story timelines from a single theme using AI, compl
51
  # Text/JSON Model
52
  TEXT_MODEL_ID = "models/gemini-1.5-flash" # Or "gemini-1.5-pro" for potentially higher quality/cost
53
  # Audio Model Config
54
- AUDIO_API_VERSION = 'v1alpha' # Required for audio modality
55
- AUDIO_MODEL_ID = f"models/gemini-1.5-flash" # Model used via the v1alpha endpoint
56
  AUDIO_SAMPLING_RATE = 24000 # Standard for TTS models like Google's
57
  # Image Model Config
58
  IMAGE_MODEL_ID = "imagen-3" # Or specific version like "imagen-3.0-generate-002"
@@ -65,8 +65,6 @@ AUDIO_CODEC = "aac" # Common audio codec for MP4
65
  TEMP_DIR_BASE = ".chrono_temp" # Base name for temporary directories
66
 
67
  # --- API Key Handling ---
68
- # This section correctly handles the missing secret. The error in the traceback means
69
- # the secret wasn't set in the *deployment environment*.
70
  GOOGLE_API_KEY = None
71
  try:
72
  # Preferred way: Use Streamlit secrets when deployed
@@ -83,29 +81,35 @@ except KeyError:
83
  "🚨 **Google API Key Not Found!**\n"
84
  "Please configure your Google API Key:\n"
85
  "1. **Streamlit Cloud/Hugging Face Spaces:** Add it as a Secret named `GOOGLE_API_KEY` in your app's settings.\n"
86
- "2. **Local Development:** Set the `GOOGLE_API_KEY` environment variable.",
87
  icon="🚨"
88
  )
89
  st.stop() # Halt execution
90
 
91
  # --- Initialize Google Clients ---
 
92
  try:
 
93
  genai.configure(api_key=GOOGLE_API_KEY)
 
94
 
95
- # Client for Text/Imagen Generation (using standard API endpoint)
96
  client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
97
  logger.info(f"Initialized standard GenerativeModel for {TEXT_MODEL_ID}.")
98
 
99
- # Client specifically for the Live API (Audio) using the v1alpha endpoint
100
- # Note: Ensure the 'audiomodality' package or relevant parts of google-cloud-aiplatform are available if needed,
101
- # depending on the exact library version and API stability.
102
- # As of late 2023/early 2024, using a separate client instance targeting the specific endpoint is often necessary.
103
- client_live = genai.Client(
104
- client_options={'api_endpoint': f'{AUDIO_API_VERSION}.generativelanguage.googleapis.com'}
105
- )
106
- live_model = client_live.get_model(AUDIO_MODEL_ID) # Get the model handle via the live client
107
- logger.info(f"Initialized live client for audio generation ({AUDIO_MODEL_ID} via {AUDIO_API_VERSION}).")
108
-
 
 
 
109
  except Exception as e:
110
  logger.exception("Failed to initialize Google AI Clients.")
111
  st.error(f"🚨 Failed to initialize Google AI Clients: {e}", icon="🚨")
@@ -123,9 +127,7 @@ class StorySegment(BaseModel):
123
  @validator('image_prompt')
124
  def image_prompt_no_humans(cls, v):
125
  if any(word in v.lower() for word in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]):
126
- # Instead of raising error, we'll try to guide the LLM better in the main prompt
127
- # raise ValueError("Image prompt must not contain descriptions of humans.")
128
- logger.warning(f"Image prompt '{v[:50]}...' may contain human descriptions. Relying on API-level controls.")
129
  return v
130
 
131
  class Timeline(BaseModel):
@@ -164,13 +166,15 @@ def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLIN
164
  raise # Re-raise the exception
165
  finally:
166
  if wf:
167
- wf.close()
168
- # logger.debug(f"Closed wave file: {filename}")
 
 
169
 
170
 
171
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
172
  """
173
- Generates audio using Gemini Live API (async version) with improved error handling.
174
  Returns the path to the generated audio file or None on failure.
175
  """
176
  collected_audio = bytearray()
@@ -178,18 +182,17 @@ async def generate_audio_live_async(api_text: str, output_filename: str, voice:
178
  logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio for: '{api_text[:60]}...'")
179
 
180
  try:
181
- # Use the 'live_model' obtained from the 'client_live' instance.
182
  config = {
183
  "response_modalities": ["AUDIO"],
184
- "audio_config": { # Optional: Specify voice, etc.
185
  "audio_encoding": "LINEAR16", # Required format for WAV output
186
  "sample_rate_hertz": AUDIO_SAMPLING_RATE,
187
- # "voice": voice if voice else "aura-asteria-en" # Example voice - check availability
188
  }
189
  }
190
 
191
- # Add a strong negative prompt to avoid conversational filler
192
- # This is prepended here, but could also be part of the main Gemini prompt structure
193
  directive_prompt = (
194
  "Narrate the following sentence directly and engagingly. "
195
  "Do not add any introductory or concluding remarks like 'Okay', 'Sure', or 'Here is the narration'. "
@@ -197,16 +200,14 @@ async def generate_audio_live_async(api_text: str, output_filename: str, voice:
197
  f'"{api_text}"'
198
  )
199
 
200
- # Use the live client's model to connect
201
  async with live_model.connect(config=config) as session:
202
- # Send the refined request
203
  await session.send_request([directive_prompt])
204
-
205
  async for response in session.stream_content():
206
  if response.audio_chunk and response.audio_chunk.data:
207
  collected_audio.extend(response.audio_chunk.data)
208
  # Handle potential errors within the stream if the API provides them
209
- if response.error:
210
  logger.error(f" ❌ [{task_id}] Error during audio stream: {response.error}")
211
  st.error(f"Audio stream error for scene {task_id}: {response.error}", icon="πŸ”Š")
212
  return None # Stop processing this audio request
@@ -227,6 +228,7 @@ async def generate_audio_live_async(api_text: str, output_filename: str, voice:
227
  st.error(f"Audio generation blocked for scene {task_id} due to safety settings.", icon="πŸ”‡")
228
  return None
229
  except Exception as e:
 
230
  logger.exception(f" ❌ [{task_id}] Audio generation failed unexpectedly for '{api_text[:60]}...': {e}")
231
  st.error(f"Audio generation failed for scene {task_id}: {e}", icon="πŸ”Š")
232
  return None
@@ -270,11 +272,12 @@ def generate_story_sequence_chrono(
270
 
271
  **JSON Schema:**
272
  ```json
273
- {json.dumps(ChronoWeaveResponse.schema_json(indent=2))}
274
  ```
275
- """
276
 
277
  try:
 
278
  response = client_standard.generate_content(
279
  contents=prompt,
280
  generation_config=genai.types.GenerationConfig(
@@ -288,6 +291,7 @@ def generate_story_sequence_chrono(
288
 
289
  # Attempt to parse the JSON
290
  try:
 
291
  raw_data = json.loads(response.text)
292
  except json.JSONDecodeError as json_err:
293
  logger.error(f"Failed to decode JSON response: {json_err}")
@@ -295,9 +299,17 @@ def generate_story_sequence_chrono(
295
  st.error(f"🚨 Failed to parse the story structure from the AI. Error: {json_err}", icon="πŸ“„")
296
  st.text_area("Problematic AI Response:", response.text, height=200)
297
  return None
 
 
 
 
 
 
 
298
 
299
  # Validate the parsed data using Pydantic
300
  try:
 
301
  validated_data = ChronoWeaveResponse.parse_obj(raw_data)
302
  logger.info("βœ… Story structure generated and validated successfully!")
303
  st.success("βœ… Story structure generated and validated!")
@@ -332,33 +344,48 @@ def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str =
332
  full_prompt = (
333
  f"Generate an image in a child-friendly, simple animation style with bright colors and rounded shapes. "
334
  f"Ensure absolutely NO humans or human-like figures are present. Focus on animals or objects. "
 
335
  f"Prompt: {prompt}"
336
  )
337
 
338
  try:
339
  # Use the standard client's generate_content method.
340
- # How aspect ratio and negative prompts are passed can vary slightly with API versions.
341
- # This uses the model's understanding of the text prompt.
342
- # For more explicit control, future API versions might use parameters in GenerationConfig or Tools.
343
  response = client_standard.generate_content(
344
  full_prompt,
345
  generation_config=genai.types.GenerationConfig(
346
  candidate_count=1,
347
- # The following are conceptual parameters for Imagen via the unified API.
348
- # Check the latest google-generativeai library documentation for the exact syntax.
349
- # stop_sequences=["human", "person"], # May not be directly supported this way
350
- # custom_params={"aspect_ratio": aspect_ratio, "negative_prompt": "human, person, people, child, realistic, photo"}
351
- # As of now, embedding these in the text prompt is the most reliable way.
352
  ),
353
- # safety_settings={'HARM_CATEGORY_DANGEROUS_CONTENT': 'BLOCK_NONE'} # Adjust safety cautiously if needed
 
354
  )
355
 
356
  # Check for valid response and image data
357
- if response.parts and response.parts[0].inline_data and response.parts[0].inline_data.data:
358
- image_bytes = response.parts[0].inline_data.data
 
 
 
 
 
 
 
 
 
 
 
 
359
  try:
360
  image = Image.open(BytesIO(image_bytes))
361
  logger.info(f" βœ… [{task_id}] Image generated successfully.")
 
 
 
 
 
 
 
 
362
  return image
363
  except Exception as img_err:
364
  logger.error(f" ❌ [{task_id}] Failed to decode generated image data: {img_err}")
@@ -366,43 +393,24 @@ def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str =
366
  return None
367
  else:
368
  # Check for blocking or other issues
369
- block_reason = getattr(response.prompt_feedback, 'block_reason', None)
370
- safety_ratings = getattr(response.prompt_feedback, 'safety_ratings', [])
 
 
 
371
  if block_reason:
372
  logger.warning(f" ⚠️ [{task_id}] Image generation blocked. Reason: {block_reason}. Prompt: '{prompt[:70]}...'")
373
  st.warning(f"Image generation blocked for scene {task_id}. Reason: {block_reason}", icon="🚫")
374
- elif safety_ratings:
375
- filtered_ratings = [f"{r.category}: {r.probability}" for r in safety_ratings if r.probability != 'NEGLIGIBLE']
376
- if filtered_ratings:
377
- logger.warning(f" ⚠️ [{task_id}] Image generated but flagged by safety filters: {', '.join(filtered_ratings)}. Prompt: '{prompt[:70]}...'")
378
- st.warning(f"Image generation for scene {task_id} flagged by safety filters: {', '.join(filtered_ratings)}", icon="⚠️")
379
- # Proceeding, but warning the user. Consider returning None if strict safety is needed.
380
- # return None # Uncomment this line to block flagged images
381
- # If we proceed, we need to extract the image data despite the warning
382
- if response.parts and response.parts[0].inline_data and response.parts[0].inline_data.data:
383
- image_bytes = response.parts[0].inline_data.data
384
- try:
385
- image = Image.open(BytesIO(image_bytes))
386
- logger.info(f" βœ… [{task_id}] Image generated (with safety flags).")
387
- return image
388
- except Exception as img_err:
389
- logger.error(f" ❌ [{task_id}] Failed to decode flagged image data: {img_err}")
390
- st.warning(f"Failed to decode flagged image data for scene {task_id}.", icon="πŸ–ΌοΈ")
391
- return None
392
- else:
393
- # Should not happen if flagged but still contains data, but handle defensively
394
- logger.warning(f" ⚠️ [{task_id}] Image flagged but no image data found. Prompt: '{prompt[:70]}...'")
395
- st.warning(f"No image data received for scene {task_id} (safety flagged).", icon="πŸ–ΌοΈ")
396
- return None
397
  else:
398
  logger.warning(f" ⚠️ [{task_id}] No image data received, unknown reason. Prompt: '{prompt[:70]}...'")
399
  st.warning(f"No image data received for scene {task_id}, reason unclear.", icon="πŸ–ΌοΈ")
400
- # You might want to inspect the full 'response' object here for clues
401
  # logger.debug(f"Full Imagen response object: {response}")
402
  return None
403
 
404
  except genai.types.generation_types.BlockedPromptException as bpe:
405
- logger.error(f" ❌ [{task_id}] Image generation blocked for prompt '{prompt[:70]}...': {bpe}")
 
406
  st.error(f"Image generation blocked for scene {task_id} due to safety settings.", icon="🚫")
407
  return None
408
  except Exception as e:
@@ -414,12 +422,11 @@ def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str =
414
  # --- Streamlit UI Elements ---
415
  st.sidebar.header("βš™οΈ Configuration")
416
 
417
- # API Key Status (already handled, just display status)
418
  if GOOGLE_API_KEY:
419
  st.sidebar.success("Google API Key Loaded", icon="βœ…")
420
  else:
421
- # This part should technically not be reached due to st.stop() earlier
422
- st.sidebar.error("Google API Key Missing!", icon="🚨")
423
 
424
  # Story Parameters
425
  theme = st.sidebar.text_input("πŸ“– Story Theme:", "A curious squirrel finds a mysterious, glowing acorn")
@@ -433,13 +440,13 @@ aspect_ratio = st.sidebar.selectbox("πŸ–ΌοΈ Image Aspect Ratio:", ["1:1", "16:9
433
  # Add audio voice selection if API supports it and voices are known
434
  # available_voices = ["aura-asteria-en", "aura-luna-en", "aura-stella-en"] # Example
435
  # audio_voice = st.sidebar.selectbox("πŸ—£οΈ Narration Voice:", available_voices, index=0)
436
- audio_voice = None # Placeholder if voice selection isn't implemented/stable
437
 
438
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
439
 
440
  st.sidebar.markdown("---")
441
  st.sidebar.info("⏳ Generation can take several minutes, especially with more scenes or timelines.", icon="⏳")
442
- st.sidebar.markdown(f"<small>Models: Text={TEXT_MODEL_ID}, Image={IMAGE_MODEL_ID}, Audio={AUDIO_MODEL_ID} ({AUDIO_API_VERSION})</small>", unsafe_allow_html=True)
443
 
444
 
445
  # --- Main Logic ---
@@ -483,19 +490,18 @@ if generate_button:
483
  timeline_label = f"Timeline {timeline_id}" # Consistent label
484
  st.subheader(f"Processing {timeline_label}: {divergence}")
485
  logger.info(f"--- Processing {timeline_label} (Index: {timeline_index}) ---")
486
- generation_errors[timeline_id] = [] # Initialize error list for this timeline
487
 
488
- # Store paths for this timeline's assets
489
  temp_image_files = {} # {scene_id: path}
490
  temp_audio_files = {} # {scene_id: path}
491
- video_clips = [] # List of moviepy clips for concatenation
492
  timeline_start_time = time.time()
493
  scene_success_count = 0
494
 
495
 
496
  for scene_index, segment in enumerate(segments):
497
  scene_id = segment.scene_id
498
- task_id = f"T{timeline_id}_S{scene_id}" # Unique ID for logging/filenames
499
  status_message = f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}..."
500
  status.update(label=status_message)
501
  st.markdown(f"--- **Scene {scene_id + 1} ({task_id})** ---")
@@ -506,15 +512,14 @@ if generate_button:
506
  # Log scene details
507
  st.write(f" *Image Prompt:* {segment.image_prompt}" + (f" *(Modifier: {segment.timeline_visual_modifier})*" if segment.timeline_visual_modifier else ""))
508
  st.write(f" *Audio Text:* {segment.audio_text}")
509
- # st.write(f"* Character Desc: {segment.character_description}") # Optional verbosity
510
 
511
  # --- 2a. Image Generation ---
 
512
  with st.spinner(f"[{task_id}] Generating image... 🎨"):
513
  combined_prompt = f"{segment.image_prompt}. {segment.character_description}"
514
  if segment.timeline_visual_modifier:
515
  combined_prompt += f" Visual style hint: {segment.timeline_visual_modifier}."
516
-
517
- generated_image: Optional[Image.Image] = generate_image_imagen(combined_prompt, aspect_ratio, task_id)
518
 
519
  if generated_image:
520
  image_path = os.path.join(temp_dir, f"{task_id}_image.png")
@@ -531,21 +536,19 @@ if generate_button:
531
  st.warning(f"Image generation failed for scene {task_id}. Skipping scene.", icon="πŸ–ΌοΈ")
532
  scene_has_error = True
533
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Image generation failed.")
534
- # No image, so skip audio and video for this scene
535
- continue
536
 
537
  # --- 2b. Audio Generation ---
538
  generated_audio_path: Optional[str] = None
539
- if not scene_has_error: # Only generate audio if image succeeded
540
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
541
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
542
  try:
543
- # Run the async function using asyncio.run() which works with nest_asyncio
544
  generated_audio_path = asyncio.run(
545
  generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice)
546
  )
547
  except RuntimeError as e:
548
- # Catch potential issues if asyncio loop is misconfigured despite nest_asyncio
549
  logger.error(f" ❌ [{task_id}] Asyncio runtime error during audio gen: {e}")
550
  st.error(f"Asyncio error during audio generation for {task_id}: {e}", icon="⚑")
551
  scene_has_error = True
@@ -556,10 +559,8 @@ if generate_button:
556
  scene_has_error = True
557
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio generation error.")
558
 
559
-
560
  if generated_audio_path:
561
  temp_audio_files[scene_id] = generated_audio_path
562
- # Optional: Preview audio
563
  try:
564
  with open(generated_audio_path, 'rb') as ap:
565
  st.audio(ap.read(), format='audio/wav')
@@ -569,7 +570,7 @@ if generate_button:
569
  st.warning(f"Audio generation failed for {task_id}. Skipping video clip.", icon="πŸ”Š")
570
  scene_has_error = True
571
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio generation failed.")
572
- # Clean up the image for this failed segment if audio fails
573
  if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]):
574
  try:
575
  os.remove(temp_image_files[scene_id])
@@ -582,54 +583,59 @@ if generate_button:
582
  # --- 2c. Create Video Clip ---
583
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
584
  st.write(f" 🎬 Creating video clip for Scene {scene_id+1}...")
 
 
 
 
 
585
  try:
586
- # Ensure files exist before creating clips
587
- img_path = temp_image_files[scene_id]
588
- aud_path = temp_audio_files[scene_id]
589
  if not os.path.exists(img_path): raise FileNotFoundError(f"Image file not found: {img_path}")
590
  if not os.path.exists(aud_path): raise FileNotFoundError(f"Audio file not found: {aud_path}")
591
 
592
- audio_clip = AudioFileClip(aud_path)
593
- # Use numpy array for ImageClip to avoid potential PIL issues with moviepy versions
594
  np_image = np.array(Image.open(img_path))
595
- image_clip = ImageClip(np_image).set_duration(audio_clip.duration)
596
 
597
- # Composite the clip
598
- composite_clip = image_clip.set_audio(audio_clip)
599
- video_clips.append(composite_clip)
600
- logger.info(f" βœ… [{task_id}] Video clip created (Duration: {audio_clip.duration:.2f}s).")
601
- st.write(f" βœ… Clip created (Duration: {audio_clip.duration:.2f}s).")
602
  scene_success_count += 1
 
603
 
604
  except Exception as e:
605
  logger.exception(f" ❌ [{task_id}] Failed to create video clip for scene {scene_id+1}: {e}")
606
  st.error(f"Failed to create video clip for {task_id}: {e}", icon="🎬")
607
  scene_has_error = True
608
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Video clip creation failed.")
609
- # Attempt cleanup of related files if clip creation fails
610
- if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]): os.remove(temp_image_files[scene_id])
611
- if scene_id in temp_audio_files and os.path.exists(temp_audio_files[scene_id]): os.remove(temp_audio_files[scene_id])
 
 
 
612
 
613
  # --- End of Scene Loop ---
614
 
615
  # --- 2d. Assemble Timeline Video ---
616
  timeline_duration = time.time() - timeline_start_time
617
- if video_clips and scene_success_count == len(segments): # Only assemble if all scenes were successful
 
 
618
  status.update(label=f"Composing final video for {timeline_label}...")
619
  st.write(f"🎞️ Assembling final video for {timeline_label}...")
620
  logger.info(f"🎞️ Assembling final video for {timeline_label} ({len(video_clips)} clips)...")
621
  output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4")
622
  final_timeline_video = None # Define before try block
623
  try:
 
624
  final_timeline_video = concatenate_videoclips(video_clips, method="compose")
625
- # Write video file with specified codecs and fps
626
  final_timeline_video.write_videofile(
627
  output_filename,
628
  fps=VIDEO_FPS,
629
  codec=VIDEO_CODEC,
630
  audio_codec=AUDIO_CODEC,
631
  logger=None # Suppress moviepy console spam
632
- # threads=4 # Optional: specify threads
633
  )
634
  final_video_paths[timeline_id] = output_filename
635
  logger.info(f" βœ… [{timeline_label}] Final video saved: {os.path.basename(output_filename)}")
@@ -641,41 +647,36 @@ if generate_button:
641
  all_timelines_successful = False
642
  generation_errors[timeline_id].append(f"Timeline {timeline_id}: Final video assembly failed.")
643
  finally:
644
- # Crucially, close all clips to release file handles
645
- logger.debug(f"[{timeline_label}] Closing {len(video_clips)} video clips...")
646
- for clip in video_clips:
647
  try:
648
- clip.close()
649
- if clip.audio:
650
- clip.audio.close()
651
  except Exception as e_close:
652
- logger.warning(f" ⚠️ [{timeline_label}] Error closing clip: {e_close}")
653
  if final_timeline_video:
654
  try:
 
655
  final_timeline_video.close()
 
656
  except Exception as e_close_final:
657
  logger.warning(f" ⚠️ [{timeline_label}] Error closing final video object: {e_close_final}")
658
- logger.debug(f"[{timeline_label}] Clips closed.")
659
 
660
  elif not video_clips:
661
  logger.warning(f"[{timeline_label}] No video clips successfully generated. Skipping final assembly.")
662
  st.warning(f"No scenes were successfully processed for {timeline_label}. Video cannot be created.", icon="🚫")
663
  all_timelines_successful = False
664
- else: # Some scenes failed
665
- logger.warning(f"[{timeline_label}] Encountered errors in {len(generation_errors[timeline_id])} scene(s). Skipping final video assembly.")
666
- st.warning(f"{timeline_label} had errors in {len(generation_errors[timeline_id])} scene(s). Final video not assembled.", icon="⚠️")
 
667
  all_timelines_successful = False
668
 
669
- # Log errors for the timeline if any occurred
670
  if generation_errors[timeline_id]:
671
- logger.error(f"Errors occurred in {timeline_label}: {generation_errors[timeline_id]}")
672
-
673
- # Intermediate cleanup (optional, can free up disk space during long runs)
674
- # logger.debug(f"[{timeline_label}] Cleaning up intermediate files...")
675
- # for scene_id, fpath in temp_image_files.items():
676
- # if os.path.exists(fpath): os.remove(fpath)
677
- # for scene_id, fpath in temp_audio_files.items():
678
- # if os.path.exists(fpath): os.remove(fpath)
679
 
680
  # --- End of Timelines Loop ---
681
 
@@ -685,11 +686,11 @@ if generate_button:
685
  status_msg = f"ChronoWeave Generation Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"
686
  status.update(label=status_msg, state="complete", expanded=False)
687
  logger.info(status_msg)
688
- elif final_video_paths:
689
  status_msg = f"ChronoWeave Partially Complete ({len(final_video_paths)} videos, some errors occurred). Total time: {overall_duration:.2f}s"
690
- status.update(label=status_msg, state="warning", expanded=True) # Keep expanded if errors
691
  logger.warning(status_msg)
692
- else:
693
  status_msg = f"ChronoWeave Generation Failed. No videos produced. Total time: {overall_duration:.2f}s"
694
  status.update(label=status_msg, state="error", expanded=True)
695
  logger.error(status_msg)
@@ -698,29 +699,30 @@ if generate_button:
698
  st.header("🎬 Generated Timelines")
699
  if final_video_paths:
700
  sorted_timeline_ids = sorted(final_video_paths.keys())
701
- cols = st.columns(len(sorted_timeline_ids)) # Create columns for side-by-side display
 
 
702
 
703
  for idx, timeline_id in enumerate(sorted_timeline_ids):
 
704
  video_path = final_video_paths[timeline_id]
705
- # Find matching timeline data for context
706
  timeline_data = next((t for t in chrono_response.timelines if t.timeline_id == timeline_id), None)
707
  reason = timeline_data.divergence_reason if timeline_data else "Unknown Divergence"
708
- col = cols[idx]
709
  with col:
710
  st.subheader(f"Timeline {timeline_id}")
711
  st.caption(f"Divergence: {reason}")
712
  try:
713
- # Read video bytes for display
714
  with open(video_path, 'rb') as video_file:
715
  video_bytes = video_file.read()
716
  st.video(video_bytes)
717
  logger.info(f"Displaying video for Timeline {timeline_id}")
718
- # Add download button
719
  st.download_button(
720
  label=f"Download T{timeline_id} Video",
721
  data=video_bytes,
722
  file_name=f"chronoweave_timeline_{timeline_id}.mp4",
723
- mime="video/mp4"
 
724
  )
725
  # Display errors for this timeline if any occurred
726
  if generation_errors.get(timeline_id):
@@ -736,15 +738,19 @@ if generate_button:
736
  st.error(f"Error displaying video for Timeline {timeline_id}: {e}", icon="🚨")
737
  else:
738
  st.warning("No final videos were successfully generated in this run.")
739
- # Display global errors if no videos were made
740
  all_errors = [msg for err_list in generation_errors.values() for msg in err_list]
741
  if all_errors:
742
  st.subheader("Summary of Generation Issues")
743
- for error_msg in all_errors:
744
- st.error(f"- {error_msg}")
 
 
 
 
745
 
746
  # --- 4. Cleanup ---
747
- st.info(f"Cleaning up temporary directory: {temp_dir}")
748
  try:
749
  shutil.rmtree(temp_dir)
750
  logger.info(f"βœ… Temporary directory removed: {temp_dir}")
@@ -754,10 +760,10 @@ if generate_button:
754
  st.warning(f"Could not automatically remove temporary files: {temp_dir}. Please remove it manually if needed.", icon="⚠️")
755
 
756
  elif not chrono_response:
757
- # Error message already shown by generate_story_sequence_chrono
758
  logger.error("Story generation failed, cannot proceed.")
759
  else:
760
- # This case implies chrono_response exists but somehow failed validation logic (should be caught earlier)
761
  st.error("An unexpected issue occurred after story generation. Cannot proceed.", icon="πŸ›‘")
762
  logger.error("Chrono_response existed but was falsy in the main logic block.")
763
 
 
26
 
27
  # Video and audio processing
28
  from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips
29
+ # from moviepy.config import change_settings # Potential for setting imagemagick path if needed
30
 
31
  # Type hints
32
  import typing_extensions as typing
 
51
  # Text/JSON Model
52
  TEXT_MODEL_ID = "models/gemini-1.5-flash" # Or "gemini-1.5-pro" for potentially higher quality/cost
53
  # Audio Model Config
54
+ AUDIO_API_VERSION = 'v1alpha' # Required for audio modality (though endpoint set implicitly now)
55
+ AUDIO_MODEL_ID = f"models/gemini-1.5-flash" # Model used for audio tasks
56
  AUDIO_SAMPLING_RATE = 24000 # Standard for TTS models like Google's
57
  # Image Model Config
58
  IMAGE_MODEL_ID = "imagen-3" # Or specific version like "imagen-3.0-generate-002"
 
65
  TEMP_DIR_BASE = ".chrono_temp" # Base name for temporary directories
66
 
67
  # --- API Key Handling ---
 
 
68
  GOOGLE_API_KEY = None
69
  try:
70
  # Preferred way: Use Streamlit secrets when deployed
 
81
  "🚨 **Google API Key Not Found!**\n"
82
  "Please configure your Google API Key:\n"
83
  "1. **Streamlit Cloud/Hugging Face Spaces:** Add it as a Secret named `GOOGLE_API_KEY` in your app's settings.\n"
84
+ "2. **Local Development:** Set the `GOOGLE_API_KEY` environment variable or create a `.streamlit/secrets.toml` file.",
85
  icon="🚨"
86
  )
87
  st.stop() # Halt execution
88
 
89
  # --- Initialize Google Clients ---
90
+ # CORRECTED SECTION: Uses genai.GenerativeModel for both models
91
  try:
92
+ # Configure globally
93
  genai.configure(api_key=GOOGLE_API_KEY)
94
+ logger.info("Configured google-generativeai with API key.")
95
 
96
+ # Model/Client Handle for Text/Imagen Generation
97
  client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
98
  logger.info(f"Initialized standard GenerativeModel for {TEXT_MODEL_ID}.")
99
 
100
+ # Model Handle for Audio Generation
101
+ # Use the standard GenerativeModel initialization.
102
+ # The necessary methods (like .connect) are part of this object.
103
+ live_model = genai.GenerativeModel(AUDIO_MODEL_ID) # Use GenerativeModel here
104
+ logger.info(f"Initialized GenerativeModel handle for audio ({AUDIO_MODEL_ID}).")
105
+ # We no longer use or need 'client_live' or explicit endpoint setting here.
106
+ # The audio config is handled within the generate_audio_live_async function.
107
+
108
+ except AttributeError as ae:
109
+ # Keep this specific error catch just in case library structure is very old/unexpected
110
+ logger.exception("AttributeError during Google AI Client Initialization.")
111
+ st.error(f"🚨 Failed to initialize Google AI Clients due to an unexpected library structure error: {ae}. Please ensure 'google-generativeai' is up-to-date.", icon="🚨")
112
+ st.stop()
113
  except Exception as e:
114
  logger.exception("Failed to initialize Google AI Clients.")
115
  st.error(f"🚨 Failed to initialize Google AI Clients: {e}", icon="🚨")
 
127
  @validator('image_prompt')
128
  def image_prompt_no_humans(cls, v):
129
  if any(word in v.lower() for word in ["person", "people", "human", "man", "woman", "boy", "girl", "child"]):
130
+ logger.warning(f"Image prompt '{v[:50]}...' may contain human descriptions. Relying on API-level controls & prompt instructions.")
 
 
131
  return v
132
 
133
  class Timeline(BaseModel):
 
166
  raise # Re-raise the exception
167
  finally:
168
  if wf:
169
+ try:
170
+ wf.close()
171
+ except Exception as e_close:
172
+ logger.error(f"Error closing wave file {filename}: {e_close}")
173
 
174
 
175
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
176
  """
177
+ Generates audio using Gemini Live API (async version) via the GenerativeModel.
178
  Returns the path to the generated audio file or None on failure.
179
  """
180
  collected_audio = bytearray()
 
182
  logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio for: '{api_text[:60]}...'")
183
 
184
  try:
185
+ # Use the 'live_model' (a GenerativeModel instance) initialized earlier.
186
  config = {
187
  "response_modalities": ["AUDIO"],
188
+ "audio_config": {
189
  "audio_encoding": "LINEAR16", # Required format for WAV output
190
  "sample_rate_hertz": AUDIO_SAMPLING_RATE,
191
+ # "voice": voice if voice else "aura-asteria-en" # Optional: Specify voice if needed and available
192
  }
193
  }
194
 
195
+ # Prepend directive to discourage conversational filler
 
196
  directive_prompt = (
197
  "Narrate the following sentence directly and engagingly. "
198
  "Do not add any introductory or concluding remarks like 'Okay', 'Sure', or 'Here is the narration'. "
 
200
  f'"{api_text}"'
201
  )
202
 
203
+ # Connect and stream using the GenerativeModel instance
204
  async with live_model.connect(config=config) as session:
 
205
  await session.send_request([directive_prompt])
 
206
  async for response in session.stream_content():
207
  if response.audio_chunk and response.audio_chunk.data:
208
  collected_audio.extend(response.audio_chunk.data)
209
  # Handle potential errors within the stream if the API provides them
210
+ if hasattr(response, 'error') and response.error:
211
  logger.error(f" ❌ [{task_id}] Error during audio stream: {response.error}")
212
  st.error(f"Audio stream error for scene {task_id}: {response.error}", icon="πŸ”Š")
213
  return None # Stop processing this audio request
 
228
  st.error(f"Audio generation blocked for scene {task_id} due to safety settings.", icon="πŸ”‡")
229
  return None
230
  except Exception as e:
231
+ # Catch other potential errors during connect/send/stream
232
  logger.exception(f" ❌ [{task_id}] Audio generation failed unexpectedly for '{api_text[:60]}...': {e}")
233
  st.error(f"Audio generation failed for scene {task_id}: {e}", icon="πŸ”Š")
234
  return None
 
272
 
273
  **JSON Schema:**
274
  ```json
275
+ {json.dumps(ChronoWeaveResponse.schema(), indent=2)}
276
  ```
277
+ """ # Using .schema() which is the Pydantic v1 way, adjust if using v2 (.model_json_schema())
278
 
279
  try:
280
+ # Use the standard client (GenerativeModel instance) for text generation
281
  response = client_standard.generate_content(
282
  contents=prompt,
283
  generation_config=genai.types.GenerationConfig(
 
291
 
292
  # Attempt to parse the JSON
293
  try:
294
+ # Use response.text which should contain the JSON string
295
  raw_data = json.loads(response.text)
296
  except json.JSONDecodeError as json_err:
297
  logger.error(f"Failed to decode JSON response: {json_err}")
 
299
  st.error(f"🚨 Failed to parse the story structure from the AI. Error: {json_err}", icon="πŸ“„")
300
  st.text_area("Problematic AI Response:", response.text, height=200)
301
  return None
302
+ except Exception as e:
303
+ logger.error(f"Error accessing or decoding response text: {e}")
304
+ st.error(f"🚨 Error processing AI response: {e}", icon="πŸ“„")
305
+ # Log the response object itself if possible
306
+ # logger.debug(f"Response object: {response}")
307
+ return None
308
+
309
 
310
  # Validate the parsed data using Pydantic
311
  try:
312
+ # Use parse_obj for Pydantic v1, or YourModel.model_validate(raw_data) for v2
313
  validated_data = ChronoWeaveResponse.parse_obj(raw_data)
314
  logger.info("βœ… Story structure generated and validated successfully!")
315
  st.success("βœ… Story structure generated and validated!")
 
344
  full_prompt = (
345
  f"Generate an image in a child-friendly, simple animation style with bright colors and rounded shapes. "
346
  f"Ensure absolutely NO humans or human-like figures are present. Focus on animals or objects. "
347
+ f"Aspect ratio should be {aspect_ratio}. " # Explicitly state aspect ratio in prompt too
348
  f"Prompt: {prompt}"
349
  )
350
 
351
  try:
352
  # Use the standard client's generate_content method.
 
 
 
353
  response = client_standard.generate_content(
354
  full_prompt,
355
  generation_config=genai.types.GenerationConfig(
356
  candidate_count=1,
357
+ # Add other config like temperature if desired
 
 
 
 
358
  ),
359
+ # Safety settings can be adjusted here if necessary and permitted
360
+ # safety_settings={'HARM_CATEGORY_DANGEROUS_CONTENT': 'BLOCK_NONE'} # Use cautiously
361
  )
362
 
363
  # Check for valid response and image data
364
+ # Accessing image data might depend slightly on the exact API response structure
365
+ # common pattern is response.candidates[0].content.parts[0].inline_data.data
366
+ # or directly response.parts if simpler structure
367
+ image_bytes = None
368
+ if response.candidates and response.candidates[0].content and response.candidates[0].content.parts:
369
+ part = response.candidates[0].content.parts[0]
370
+ if hasattr(part, 'inline_data') and part.inline_data and hasattr(part.inline_data,'data'):
371
+ image_bytes = part.inline_data.data
372
+ elif hasattr(part, 'file_data') and part.file_data: # Handle potential file URIs if API changes
373
+ logger.warning(f" ⚠️ [{task_id}] Received file URI instead of inline data. Handling not implemented.")
374
+ # Potentially download from part.file_data.file_uri here
375
+ return None # Or implement download
376
+
377
+ if image_bytes:
378
  try:
379
  image = Image.open(BytesIO(image_bytes))
380
  logger.info(f" βœ… [{task_id}] Image generated successfully.")
381
+ # Check safety feedback even on success
382
+ safety_ratings = getattr(response.candidates[0], 'safety_ratings', [])
383
+ if safety_ratings:
384
+ filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if r.probability.name != 'NEGLIGIBLE']
385
+ if filtered_ratings:
386
+ logger.warning(f" ⚠️ [{task_id}] Image generated but flagged by safety filters: {', '.join(filtered_ratings)}.")
387
+ st.warning(f"Image for scene {task_id} flagged by safety filters: {', '.join(filtered_ratings)}", icon="⚠️")
388
+
389
  return image
390
  except Exception as img_err:
391
  logger.error(f" ❌ [{task_id}] Failed to decode generated image data: {img_err}")
 
393
  return None
394
  else:
395
  # Check for blocking or other issues
396
+ block_reason = None
397
+ prompt_feedback = getattr(response, 'prompt_feedback', None)
398
+ if prompt_feedback:
399
+ block_reason = getattr(prompt_feedback, 'block_reason', None)
400
+
401
  if block_reason:
402
  logger.warning(f" ⚠️ [{task_id}] Image generation blocked. Reason: {block_reason}. Prompt: '{prompt[:70]}...'")
403
  st.warning(f"Image generation blocked for scene {task_id}. Reason: {block_reason}", icon="🚫")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  else:
405
  logger.warning(f" ⚠️ [{task_id}] No image data received, unknown reason. Prompt: '{prompt[:70]}...'")
406
  st.warning(f"No image data received for scene {task_id}, reason unclear.", icon="πŸ–ΌοΈ")
407
+ # Log the full response for debugging
408
  # logger.debug(f"Full Imagen response object: {response}")
409
  return None
410
 
411
  except genai.types.generation_types.BlockedPromptException as bpe:
412
+ # This might be caught by the block_reason check above, but good to have explicit catch
413
+ logger.error(f" ❌ [{task_id}] Image generation blocked (exception): {bpe}")
414
  st.error(f"Image generation blocked for scene {task_id} due to safety settings.", icon="🚫")
415
  return None
416
  except Exception as e:
 
422
  # --- Streamlit UI Elements ---
423
  st.sidebar.header("βš™οΈ Configuration")
424
 
425
+ # API Key Status
426
  if GOOGLE_API_KEY:
427
  st.sidebar.success("Google API Key Loaded", icon="βœ…")
428
  else:
429
+ st.sidebar.error("Google API Key Missing!", icon="🚨") # Should not be reached if st.stop() works
 
430
 
431
  # Story Parameters
432
  theme = st.sidebar.text_input("πŸ“– Story Theme:", "A curious squirrel finds a mysterious, glowing acorn")
 
440
  # Add audio voice selection if API supports it and voices are known
441
  # available_voices = ["aura-asteria-en", "aura-luna-en", "aura-stella-en"] # Example
442
  # audio_voice = st.sidebar.selectbox("πŸ—£οΈ Narration Voice:", available_voices, index=0)
443
+ audio_voice = None # Placeholder
444
 
445
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
446
 
447
  st.sidebar.markdown("---")
448
  st.sidebar.info("⏳ Generation can take several minutes, especially with more scenes or timelines.", icon="⏳")
449
+ st.sidebar.markdown(f"<small>Models: Text={TEXT_MODEL_ID}, Image={IMAGE_MODEL_ID}, Audio={AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
450
 
451
 
452
  # --- Main Logic ---
 
490
  timeline_label = f"Timeline {timeline_id}" # Consistent label
491
  st.subheader(f"Processing {timeline_label}: {divergence}")
492
  logger.info(f"--- Processing {timeline_label} (Index: {timeline_index}) ---")
493
+ generation_errors[timeline_id] = [] # Initialize error list
494
 
 
495
  temp_image_files = {} # {scene_id: path}
496
  temp_audio_files = {} # {scene_id: path}
497
+ video_clips = [] # List of moviepy clips
498
  timeline_start_time = time.time()
499
  scene_success_count = 0
500
 
501
 
502
  for scene_index, segment in enumerate(segments):
503
  scene_id = segment.scene_id
504
+ task_id = f"T{timeline_id}_S{scene_id}" # Unique ID
505
  status_message = f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}..."
506
  status.update(label=status_message)
507
  st.markdown(f"--- **Scene {scene_id + 1} ({task_id})** ---")
 
512
  # Log scene details
513
  st.write(f" *Image Prompt:* {segment.image_prompt}" + (f" *(Modifier: {segment.timeline_visual_modifier})*" if segment.timeline_visual_modifier else ""))
514
  st.write(f" *Audio Text:* {segment.audio_text}")
 
515
 
516
  # --- 2a. Image Generation ---
517
+ generated_image: Optional[Image.Image] = None # Define before spinner
518
  with st.spinner(f"[{task_id}] Generating image... 🎨"):
519
  combined_prompt = f"{segment.image_prompt}. {segment.character_description}"
520
  if segment.timeline_visual_modifier:
521
  combined_prompt += f" Visual style hint: {segment.timeline_visual_modifier}."
522
+ generated_image = generate_image_imagen(combined_prompt, aspect_ratio, task_id)
 
523
 
524
  if generated_image:
525
  image_path = os.path.join(temp_dir, f"{task_id}_image.png")
 
536
  st.warning(f"Image generation failed for scene {task_id}. Skipping scene.", icon="πŸ–ΌοΈ")
537
  scene_has_error = True
538
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Image generation failed.")
539
+ continue # Skip audio/video for this scene
 
540
 
541
  # --- 2b. Audio Generation ---
542
  generated_audio_path: Optional[str] = None
543
+ if not scene_has_error:
544
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
545
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
546
  try:
547
+ # Run the async function using asyncio.run()
548
  generated_audio_path = asyncio.run(
549
  generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice)
550
  )
551
  except RuntimeError as e:
 
552
  logger.error(f" ❌ [{task_id}] Asyncio runtime error during audio gen: {e}")
553
  st.error(f"Asyncio error during audio generation for {task_id}: {e}", icon="⚑")
554
  scene_has_error = True
 
559
  scene_has_error = True
560
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio generation error.")
561
 
 
562
  if generated_audio_path:
563
  temp_audio_files[scene_id] = generated_audio_path
 
564
  try:
565
  with open(generated_audio_path, 'rb') as ap:
566
  st.audio(ap.read(), format='audio/wav')
 
570
  st.warning(f"Audio generation failed for {task_id}. Skipping video clip.", icon="πŸ”Š")
571
  scene_has_error = True
572
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Audio generation failed.")
573
+ # Clean up image if audio fails
574
  if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]):
575
  try:
576
  os.remove(temp_image_files[scene_id])
 
583
  # --- 2c. Create Video Clip ---
584
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
585
  st.write(f" 🎬 Creating video clip for Scene {scene_id+1}...")
586
+ img_path = temp_image_files[scene_id]
587
+ aud_path = temp_audio_files[scene_id]
588
+ audio_clip_instance = None # Define before try
589
+ image_clip_instance = None # Define before try
590
+ composite_clip = None # Define before try
591
  try:
 
 
 
592
  if not os.path.exists(img_path): raise FileNotFoundError(f"Image file not found: {img_path}")
593
  if not os.path.exists(aud_path): raise FileNotFoundError(f"Audio file not found: {aud_path}")
594
 
595
+ audio_clip_instance = AudioFileClip(aud_path)
 
596
  np_image = np.array(Image.open(img_path))
597
+ image_clip_instance = ImageClip(np_image).set_duration(audio_clip_instance.duration)
598
 
599
+ composite_clip = image_clip_instance.set_audio(audio_clip_instance)
600
+ video_clips.append(composite_clip) # Add the clip to be concatenated later
601
+ logger.info(f" βœ… [{task_id}] Video clip created (Duration: {audio_clip_instance.duration:.2f}s).")
602
+ st.write(f" βœ… Clip created (Duration: {audio_clip_instance.duration:.2f}s).")
 
603
  scene_success_count += 1
604
+ # Don't close individual clips here yet, needed for concatenation
605
 
606
  except Exception as e:
607
  logger.exception(f" ❌ [{task_id}] Failed to create video clip for scene {scene_id+1}: {e}")
608
  st.error(f"Failed to create video clip for {task_id}: {e}", icon="🎬")
609
  scene_has_error = True
610
  generation_errors[timeline_id].append(f"Scene {scene_id+1}: Video clip creation failed.")
611
+ # Cleanup resources if clip creation failed for *this* scene
612
+ if audio_clip_instance: audio_clip_instance.close()
613
+ if image_clip_instance: image_clip_instance.close()
614
+ # Attempt cleanup of related files
615
+ if os.path.exists(img_path): os.remove(img_path)
616
+ if os.path.exists(aud_path): os.remove(aud_path)
617
 
618
  # --- End of Scene Loop ---
619
 
620
  # --- 2d. Assemble Timeline Video ---
621
  timeline_duration = time.time() - timeline_start_time
622
+ # Only assemble if clips were created and no *fatal* errors occurred during scene processing
623
+ # (We check scene_success_count against expected number)
624
+ if video_clips and scene_success_count == len(segments):
625
  status.update(label=f"Composing final video for {timeline_label}...")
626
  st.write(f"🎞️ Assembling final video for {timeline_label}...")
627
  logger.info(f"🎞️ Assembling final video for {timeline_label} ({len(video_clips)} clips)...")
628
  output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4")
629
  final_timeline_video = None # Define before try block
630
  try:
631
+ # Concatenate the collected clips
632
  final_timeline_video = concatenate_videoclips(video_clips, method="compose")
 
633
  final_timeline_video.write_videofile(
634
  output_filename,
635
  fps=VIDEO_FPS,
636
  codec=VIDEO_CODEC,
637
  audio_codec=AUDIO_CODEC,
638
  logger=None # Suppress moviepy console spam
 
639
  )
640
  final_video_paths[timeline_id] = output_filename
641
  logger.info(f" βœ… [{timeline_label}] Final video saved: {os.path.basename(output_filename)}")
 
647
  all_timelines_successful = False
648
  generation_errors[timeline_id].append(f"Timeline {timeline_id}: Final video assembly failed.")
649
  finally:
650
+ # Now close all individual clips and the final concatenated clip
651
+ logger.debug(f"[{timeline_label}] Closing {len(video_clips)} source clips...")
652
+ for i, clip in enumerate(video_clips):
653
  try:
654
+ if clip: # Check if clip object exists
655
+ if clip.audio: clip.audio.close()
656
+ clip.close()
657
  except Exception as e_close:
658
+ logger.warning(f" ⚠️ [{timeline_label}] Error closing source clip {i}: {e_close}")
659
  if final_timeline_video:
660
  try:
661
+ if final_timeline_video.audio: final_timeline_video.audio.close()
662
  final_timeline_video.close()
663
+ logger.debug(f"[{timeline_label}] Closed final video object.")
664
  except Exception as e_close_final:
665
  logger.warning(f" ⚠️ [{timeline_label}] Error closing final video object: {e_close_final}")
 
666
 
667
  elif not video_clips:
668
  logger.warning(f"[{timeline_label}] No video clips successfully generated. Skipping final assembly.")
669
  st.warning(f"No scenes were successfully processed for {timeline_label}. Video cannot be created.", icon="🚫")
670
  all_timelines_successful = False
671
+ else: # Some scenes failed, so scene_success_count < len(segments)
672
+ error_count = len(segments) - scene_success_count
673
+ logger.warning(f"[{timeline_label}] Encountered errors in {error_count} scene(s). Skipping final video assembly.")
674
+ st.warning(f"{timeline_label} had errors in {error_count} scene(s). Final video not assembled.", icon="⚠️")
675
  all_timelines_successful = False
676
 
677
+ # Log accumulated errors for the timeline if any occurred
678
  if generation_errors[timeline_id]:
679
+ logger.error(f"Summary of errors in {timeline_label}: {generation_errors[timeline_id]}")
 
 
 
 
 
 
 
680
 
681
  # --- End of Timelines Loop ---
682
 
 
686
  status_msg = f"ChronoWeave Generation Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"
687
  status.update(label=status_msg, state="complete", expanded=False)
688
  logger.info(status_msg)
689
+ elif final_video_paths: # Some videos made, but errors occurred
690
  status_msg = f"ChronoWeave Partially Complete ({len(final_video_paths)} videos, some errors occurred). Total time: {overall_duration:.2f}s"
691
+ status.update(label=status_msg, state="warning", expanded=True)
692
  logger.warning(status_msg)
693
+ else: # No videos made
694
  status_msg = f"ChronoWeave Generation Failed. No videos produced. Total time: {overall_duration:.2f}s"
695
  status.update(label=status_msg, state="error", expanded=True)
696
  logger.error(status_msg)
 
699
  st.header("🎬 Generated Timelines")
700
  if final_video_paths:
701
  sorted_timeline_ids = sorted(final_video_paths.keys())
702
+ # Adjust column count based on number of videos, max 3-4 wide?
703
+ num_cols = min(len(sorted_timeline_ids), 3)
704
+ cols = st.columns(num_cols)
705
 
706
  for idx, timeline_id in enumerate(sorted_timeline_ids):
707
+ col = cols[idx % num_cols] # Cycle through columns
708
  video_path = final_video_paths[timeline_id]
 
709
  timeline_data = next((t for t in chrono_response.timelines if t.timeline_id == timeline_id), None)
710
  reason = timeline_data.divergence_reason if timeline_data else "Unknown Divergence"
711
+
712
  with col:
713
  st.subheader(f"Timeline {timeline_id}")
714
  st.caption(f"Divergence: {reason}")
715
  try:
 
716
  with open(video_path, 'rb') as video_file:
717
  video_bytes = video_file.read()
718
  st.video(video_bytes)
719
  logger.info(f"Displaying video for Timeline {timeline_id}")
 
720
  st.download_button(
721
  label=f"Download T{timeline_id} Video",
722
  data=video_bytes,
723
  file_name=f"chronoweave_timeline_{timeline_id}.mp4",
724
+ mime="video/mp4",
725
+ key=f"download_btn_{timeline_id}" # Unique key for download button
726
  )
727
  # Display errors for this timeline if any occurred
728
  if generation_errors.get(timeline_id):
 
738
  st.error(f"Error displaying video for Timeline {timeline_id}: {e}", icon="🚨")
739
  else:
740
  st.warning("No final videos were successfully generated in this run.")
741
+ # Display summary of all errors if no videos were made
742
  all_errors = [msg for err_list in generation_errors.values() for msg in err_list]
743
  if all_errors:
744
  st.subheader("Summary of Generation Issues")
745
+ with st.expander("View All Errors", expanded=True):
746
+ for tid, errors in generation_errors.items():
747
+ if errors:
748
+ st.error(f"Timeline {tid}:")
749
+ for msg in errors:
750
+ st.error(f" - {msg}")
751
 
752
  # --- 4. Cleanup ---
753
+ st.info(f"Attempting to clean up temporary directory: {temp_dir}")
754
  try:
755
  shutil.rmtree(temp_dir)
756
  logger.info(f"βœ… Temporary directory removed: {temp_dir}")
 
760
  st.warning(f"Could not automatically remove temporary files: {temp_dir}. Please remove it manually if needed.", icon="⚠️")
761
 
762
  elif not chrono_response:
763
+ # Error message likely already shown by generate_story_sequence_chrono
764
  logger.error("Story generation failed, cannot proceed.")
765
  else:
766
+ # Fallback for unexpected state
767
  st.error("An unexpected issue occurred after story generation. Cannot proceed.", icon="πŸ›‘")
768
  logger.error("Chrono_response existed but was falsy in the main logic block.")
769