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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +139 -304
app.py CHANGED
@@ -21,7 +21,6 @@ import logging # For better logging
21
  # Image handling
22
  from PIL import Image
23
  # Pydantic for data validation
24
- # Updated imports for Pydantic v2 syntax
25
  from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
26
  from typing import List, Optional, Literal, Dict, Any
27
 
@@ -50,20 +49,19 @@ Generate multiple, branching story timelines from a single theme using AI, compl
50
 
51
  # --- Constants ---
52
  # Text/JSON Model
53
- TEXT_MODEL_ID = "models/gemini-1.5-flash" # Or "gemini-1.5-pro" for potentially higher quality/cost
54
  # Audio Model Config
55
- AUDIO_API_VERSION = 'v1alpha' # May not be strictly needed for endpoint if library handles it
56
- AUDIO_MODEL_ID = f"models/gemini-1.5-flash" # Model used for audio tasks
57
- AUDIO_SAMPLING_RATE = 24000 # Standard for TTS models like Google's
58
  # Image Model Config
59
- IMAGE_MODEL_ID = "imagen-3" # Or specific version like "imagen-3.0-generate-002"
60
  DEFAULT_ASPECT_RATIO = "1:1"
61
  # Video Config
62
  VIDEO_FPS = 24
63
- VIDEO_CODEC = "libx264" # Widely compatible H.264
64
- AUDIO_CODEC = "aac" # Common audio codec for MP4
65
  # File Management
66
- TEMP_DIR_BASE = ".chrono_temp" # Base name for temporary directories
67
 
68
  # --- API Key Handling ---
69
  GOOGLE_API_KEY = None
@@ -75,39 +73,44 @@ except KeyError:
75
  if GOOGLE_API_KEY:
76
  logger.info("Google API Key loaded from environment variable.")
77
  else:
78
- st.error(
79
- "🚨 **Google API Key Not Found!** Please configure it via Streamlit secrets or environment variable.",
80
- icon="🚨"
81
- )
82
  st.stop()
83
 
84
  # --- Initialize Google Clients ---
 
85
  try:
86
  genai.configure(api_key=GOOGLE_API_KEY)
87
  logger.info("Configured google-generativeai with API key.")
88
 
 
89
  client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
90
- logger.info(f"Initialized standard GenerativeModel for {TEXT_MODEL_ID}.")
91
 
 
92
  live_model = genai.GenerativeModel(AUDIO_MODEL_ID)
93
- logger.info(f"Initialized GenerativeModel handle for audio ({AUDIO_MODEL_ID}).")
 
 
 
 
94
 
95
  except AttributeError as ae:
96
  logger.exception("AttributeError during Google AI Client Initialization.")
97
- st.error(f"🚨 Initialization Error: {ae}. Ensure 'google-generativeai' is up-to-date.", icon="🚨")
98
  st.stop()
99
  except Exception as e:
100
- logger.exception("Failed to initialize Google AI Clients.")
101
- st.error(f"🚨 Failed to initialize Google AI Clients: {e}", icon="🚨")
 
102
  st.stop()
103
 
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')
@@ -119,8 +122,7 @@ class StorySegment(BaseModel):
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):
@@ -133,7 +135,7 @@ class ChronoWeaveResponse(BaseModel):
133
  expected_scenes = self.total_scenes_per_timeline
134
  for i, timeline in enumerate(self.timelines):
135
  if len(timeline.segments) != expected_scenes:
136
- raise ValueError(f"Timeline {i} (ID: {timeline.timeline_id}) has {len(timeline.segments)} segments, but expected {expected_scenes}.")
137
  return self
138
 
139
  # --- Helper Functions ---
@@ -144,13 +146,9 @@ def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLIN
144
  wf = None
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:
152
- logger.error(f"Error opening/configuring wave file {filename}: {e}")
153
- raise
154
  finally:
155
  if wf:
156
  try: wf.close()
@@ -159,143 +157,71 @@ def wave_file_writer(filename: str, channels: int = 1, rate: int = AUDIO_SAMPLIN
159
 
160
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
161
  """Generates audio using Gemini Live API (async version) via the GenerativeModel."""
162
- collected_audio = bytearray()
163
- task_id = os.path.basename(output_filename).split('.')[0]
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
 
188
- except genai.types.generation_types.BlockedPromptException as bpe:
189
- logger.error(f" ❌ [{task_id}] Audio generation blocked: {bpe}")
190
- st.error(f"Audio generation blocked for scene {task_id}.", icon="πŸ”‡")
191
- return None
192
- except Exception as e:
193
- logger.exception(f" ❌ [{task_id}] Audio generation failed unexpectedly: {e}")
194
- st.error(f"Audio generation failed for scene {task_id}: {e}", icon="πŸ”Š")
195
- return None
196
-
197
-
198
- def generate_story_sequence_chrono(
199
- theme: str,
200
- num_scenes: int,
201
- num_timelines: int,
202
- divergence_prompt: str = ""
203
- ) -> Optional[ChronoWeaveResponse]:
204
  """Generates branching story sequences using Gemini structured output and validates with Pydantic."""
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}".
218
-
219
  **Instructions:**
220
- 1. Generate exactly **{num_timelines}** distinct timelines.
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.
231
-
232
- **JSON Schema:**
233
- ```json
234
- {json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}
235
- ```
236
- """
237
-
238
  try:
239
- response = client_standard.generate_content(
240
- contents=prompt,
241
- generation_config=genai.types.GenerationConfig(
242
- response_mime_type="application/json",
243
- temperature=0.7
244
- )
245
- )
246
-
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:")
278
- st.error(f"🚨 Unexpected error during story generation: {e}", icon="πŸ’₯")
279
- return None
280
 
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))
@@ -303,51 +229,32 @@ def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str =
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="🚨")
@@ -361,62 +268,39 @@ 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:
369
- st.error("Please enter a story theme in the sidebar.", icon="πŸ‘ˆ")
370
  else:
371
- run_id = str(uuid.uuid4()).split('-')[0]
372
- temp_dir = os.path.join(TEMP_DIR_BASE, f"run_{run_id}")
373
- try:
374
- os.makedirs(temp_dir, exist_ok=True)
375
- logger.info(f"Created temporary directory: {temp_dir}")
376
- except OSError as e:
377
- st.error(f"🚨 Failed to create temporary directory {temp_dir}: {e}", icon="πŸ“‚")
378
- st.stop()
379
-
380
- final_video_paths = {}
381
- generation_errors = {}
382
 
383
  # --- 1. Generate Narrative Structure ---
384
  chrono_response: Optional[ChronoWeaveResponse] = None
385
- with st.spinner("Generating narrative structure... πŸ€”"):
386
- chrono_response = generate_story_sequence_chrono(theme, num_scenes, num_timelines, divergence_prompt)
387
 
388
  if chrono_response:
389
  # --- 2. Process Each Timeline ---
390
- overall_start_time = time.time()
391
- all_timelines_successful = True
392
-
393
  with st.status("Generating assets and composing videos...", expanded=True) as status:
394
  for timeline_index, timeline in enumerate(chrono_response.timelines):
395
- timeline_id = timeline.timeline_id
396
- divergence = timeline.divergence_reason
397
- segments = timeline.segments
398
- timeline_label = f"Timeline {timeline_id}"
399
- st.subheader(f"Processing {timeline_label}: {divergence}")
400
- logger.info(f"--- Processing {timeline_label} (Index: {timeline_index}) ---")
401
- generation_errors[timeline_id] = []
402
-
403
- temp_image_files = {}
404
- temp_audio_files = {}
405
- video_clips = []
406
- timeline_start_time = time.time()
407
- scene_success_count = 0
408
 
409
  for scene_index, segment in enumerate(segments):
410
- scene_id = segment.scene_id
411
- task_id = f"T{timeline_id}_S{scene_id}"
412
- status_message = f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}..."
413
- status.update(label=status_message)
414
  st.markdown(f"--- **Scene {scene_id + 1} ({task_id})** ---")
415
- logger.info(status_message)
416
  scene_has_error = False
417
-
418
- st.write(f" *Image Prompt:* {segment.image_prompt}" + (f" *(Mod: {segment.timeline_visual_modifier})*" if segment.timeline_visual_modifier else ""))
419
- st.write(f" *Audio Text:* {segment.audio_text}")
420
 
421
  # --- 2a. Image Generation ---
422
  generated_image: Optional[Image.Image] = None
@@ -425,174 +309,125 @@ if generate_button:
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:
430
  image_path = os.path.join(temp_dir, f"{task_id}_image.png")
431
- try:
432
- generated_image.save(image_path)
433
- temp_image_files[scene_id] = image_path
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)...")
507
- output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4")
508
- final_timeline_video = None
509
  try:
510
  final_timeline_video = concatenate_videoclips(video_clips, method="compose")
511
  final_timeline_video.write_videofile(output_filename, fps=VIDEO_FPS, codec=VIDEO_CODEC, audio_codec=AUDIO_CODEC, logger=None)
512
- final_video_paths[timeline_id] = output_filename
513
- logger.info(f" βœ… [{timeline_label}] Final video saved: {os.path.basename(output_filename)}")
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:
524
  if clip.audio: clip.audio.close()
525
  clip.close()
526
- except Exception as e_close: logger.warning(f" ⚠️ [{timeline_label}] Error closing source clip {i}: {e_close}")
527
  if final_timeline_video:
528
  try:
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.")
 
21
  # Image handling
22
  from PIL import Image
23
  # Pydantic for data validation
 
24
  from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
25
  from typing import List, Optional, Literal, Dict, Any
26
 
 
49
 
50
  # --- Constants ---
51
  # Text/JSON Model
52
+ TEXT_MODEL_ID = "models/gemini-1.5-flash" # Or "gemini-1.5-pro"
53
  # Audio Model Config
54
+ AUDIO_MODEL_ID = "models/gemini-1.5-flash" # Model used for audio tasks
55
+ AUDIO_SAMPLING_RATE = 24000
 
56
  # Image Model Config
57
+ IMAGE_MODEL_ID = "imagen-3" # <<< YOUR IMAGE MODEL
58
  DEFAULT_ASPECT_RATIO = "1:1"
59
  # Video Config
60
  VIDEO_FPS = 24
61
+ VIDEO_CODEC = "libx264"
62
+ AUDIO_CODEC = "aac"
63
  # File Management
64
+ TEMP_DIR_BASE = ".chrono_temp"
65
 
66
  # --- API Key Handling ---
67
  GOOGLE_API_KEY = None
 
73
  if GOOGLE_API_KEY:
74
  logger.info("Google API Key loaded from environment variable.")
75
  else:
76
+ st.error("🚨 **Google API Key Not Found!** Please configure it.", icon="🚨")
 
 
 
77
  st.stop()
78
 
79
  # --- Initialize Google Clients ---
80
+ # Initialize handles for Text, Audio (using Text model), and Image models
81
  try:
82
  genai.configure(api_key=GOOGLE_API_KEY)
83
  logger.info("Configured google-generativeai with API key.")
84
 
85
+ # Handle for Text/JSON Generation
86
  client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
87
+ logger.info(f"Initialized text/JSON model handle: {TEXT_MODEL_ID}.")
88
 
89
+ # Handle for Audio Generation (uses a text-capable model via connect)
90
  live_model = genai.GenerativeModel(AUDIO_MODEL_ID)
91
+ logger.info(f"Initialized audio model handle: {AUDIO_MODEL_ID}.")
92
+
93
+ # Handle for Image Generation <<<<------ NEW/CORRECTED
94
+ image_model = genai.GenerativeModel(IMAGE_MODEL_ID)
95
+ logger.info(f"Initialized image model handle: {IMAGE_MODEL_ID}.")
96
 
97
  except AttributeError as ae:
98
  logger.exception("AttributeError during Google AI Client Initialization.")
99
+ st.error(f"🚨 Initialization Error: {ae}. Ensure library is up-to-date.", icon="🚨")
100
  st.stop()
101
  except Exception as e:
102
+ # Catch potential errors if a model ID is invalid or inaccessible
103
+ logger.exception("Failed to initialize Google AI Clients/Models.")
104
+ st.error(f"🚨 Failed to initialize Google AI Clients/Models: {e}", icon="🚨")
105
  st.stop()
106
 
107
 
108
  # --- Define Pydantic Schemas (Using V2 Syntax) ---
109
  class StorySegment(BaseModel):
110
  scene_id: int = Field(..., ge=0)
111
+ image_prompt: str = Field(..., min_length=10, max_length=250)
112
  audio_text: str = Field(..., min_length=5, max_length=150)
113
+ character_description: str = Field(..., max_length=250)
114
  timeline_visual_modifier: Optional[str] = Field(None, max_length=50)
115
 
116
  @field_validator('image_prompt')
 
122
 
123
  class Timeline(BaseModel):
124
  timeline_id: int = Field(..., ge=0)
125
+ divergence_reason: str = Field(..., min_length=5) # Relying on prompt for 1st timeline
 
126
  segments: List[StorySegment] = Field(..., min_items=1)
127
 
128
  class ChronoWeaveResponse(BaseModel):
 
135
  expected_scenes = self.total_scenes_per_timeline
136
  for i, timeline in enumerate(self.timelines):
137
  if len(timeline.segments) != expected_scenes:
138
+ raise ValueError(f"Timeline {i} ID {timeline.timeline_id}: Expected {expected_scenes} segments, found {len(timeline.segments)}.")
139
  return self
140
 
141
  # --- Helper Functions ---
 
146
  wf = None
147
  try:
148
  wf = wave.open(filename, "wb")
149
+ wf.setnchannels(channels); wf.setsampwidth(sample_width); wf.setframerate(rate)
 
 
150
  yield wf
151
+ except Exception as e: logger.error(f"Error opening/configuring wave file {filename}: {e}"); raise
 
 
152
  finally:
153
  if wf:
154
  try: wf.close()
 
157
 
158
  async def generate_audio_live_async(api_text: str, output_filename: str, voice: Optional[str] = None) -> Optional[str]:
159
  """Generates audio using Gemini Live API (async version) via the GenerativeModel."""
160
+ collected_audio = bytearray(); task_id = os.path.basename(output_filename).split('.')[0]
161
+ logger.info(f"πŸŽ™οΈ [{task_id}] Requesting audio: '{api_text[:60]}...'")
 
 
162
  try:
163
  config = {"response_modalities": ["AUDIO"], "audio_config": {"audio_encoding": "LINEAR16", "sample_rate_hertz": AUDIO_SAMPLING_RATE}}
164
+ directive_prompt = f"Narrate directly: \"{api_text}\"" # Shorter directive
 
165
  async with live_model.connect(config=config) as session:
166
  await session.send_request([directive_prompt])
167
  async for response in session.stream_content():
168
  if response.audio_chunk and response.audio_chunk.data: collected_audio.extend(response.audio_chunk.data)
169
+ if hasattr(response, 'error') and response.error: logger.error(f" ❌ [{task_id}] Audio stream error: {response.error}"); st.error(f"Audio stream error {task_id}: {response.error}", icon="πŸ”Š"); return None
170
+ if not collected_audio: logger.warning(f"⚠️ [{task_id}] No audio data received."); st.warning(f"No audio data for {task_id}.", icon="πŸ”Š"); return None
 
 
 
 
 
 
 
 
171
  with wave_file_writer(output_filename, rate=AUDIO_SAMPLING_RATE) as wf: wf.writeframes(bytes(collected_audio))
172
  logger.info(f" βœ… [{task_id}] Audio saved: {os.path.basename(output_filename)} ({len(collected_audio)} bytes)")
173
  return output_filename
174
+ except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f" ❌ [{task_id}] Audio blocked: {bpe}"); st.error(f"Audio blocked {task_id}.", icon="πŸ”‡"); return None
175
+ except Exception as e: logger.exception(f" ❌ [{task_id}] Audio failed: {e}"); st.error(f"Audio failed {task_id}: {e}", icon="πŸ”Š"); return None
176
+
177
 
178
+ def generate_story_sequence_chrono(theme: str, num_scenes: int, num_timelines: int, divergence_prompt: str = "") -> Optional[ChronoWeaveResponse]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  """Generates branching story sequences using Gemini structured output and validates with Pydantic."""
180
+ st.info(f"πŸ“š Generating {num_timelines} timeline(s) x {num_scenes} scenes for: '{theme}'...")
181
  logger.info(f"Requesting story structure: Theme='{theme}', Timelines={num_timelines}, Scenes={num_scenes}")
 
 
182
  divergence_instruction = (
183
+ f"Introduce clear points of divergence between timelines, after the first scene if possible. "
184
+ f"Use hint if provided: '{divergence_prompt}'. "
185
+ f"State divergence reason clearly. **For timeline_id 0, use 'Initial path' or 'Baseline scenario'.**" # Explicit instruction for first timeline
186
  )
 
187
  prompt = f"""
188
+ Act as narrative designer. Create story based on theme: "{theme}".
 
 
189
  **Instructions:**
190
+ 1. Generate exactly **{num_timelines}** timelines.
191
+ 2. Each timeline exactly **{num_scenes}** scenes.
192
+ 3. **NO humans/humanoids**. Focus: animals, fantasy creatures, animated objects, nature.
193
+ 4. {divergence_instruction}
194
+ 5. Maintain consistent style: **'Simple, friendly kids animation, bright colors, rounded shapes'**, unless `timeline_visual_modifier` alters it.
195
+ 6. `audio_text`: single concise sentence (max 30 words).
196
+ 7. `image_prompt`: descriptive, concise (target 15-35 words MAX). Focus on scene elements. **AVOID repeating general style description**.
197
+ 8. `character_description`: VERY brief description of characters in scene prompt (name, features). Target < 20 words total.
198
+ **Output Format:** ONLY valid JSON object adhering to schema. No text before/after.
199
+ **JSON Schema:** ```json\n{json.dumps(ChronoWeaveResponse.model_json_schema(), indent=2)}\n```"""
 
 
 
 
 
 
 
 
200
  try:
201
+ response = client_standard.generate_content(contents=prompt, generation_config=genai.types.GenerationConfig(response_mime_type="application/json", temperature=0.7))
202
+ try: raw_data = json.loads(response.text)
203
+ except json.JSONDecodeError as json_err: logger.error(f"Failed JSON decode: {json_err}\nResponse:\n{response.text}"); st.error(f"🚨 Failed parse story: {json_err}", icon="πŸ“„"); st.text_area("Problem Response:", response.text, height=150); return None
204
+ except Exception as e: logger.error(f"Error processing text: {e}"); st.error(f"🚨 Error processing AI response: {e}", icon="πŸ“„"); return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  try:
206
  validated_data = ChronoWeaveResponse.model_validate(raw_data)
207
  logger.info("βœ… Story structure generated and validated successfully!")
208
  st.success("βœ… Story structure generated and validated!")
209
  return validated_data
210
+ except ValidationError as val_err: logger.error(f"JSON validation failed: {val_err}\nData:\n{json.dumps(raw_data, indent=2)}"); st.error(f"🚨 Generated structure invalid: {val_err}", icon="🧬"); st.json(raw_data); return None
211
+ except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f"Story gen blocked: {bpe}"); st.error("🚨 Story prompt blocked.", icon="🚫"); return None
212
+ except Exception as e: logger.exception("Error during story gen:"); st.error(f"🚨 Story gen error: {e}", icon="πŸ’₯"); return None
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
 
215
  def generate_image_imagen(prompt: str, aspect_ratio: str = "1:1", task_id: str = "IMG") -> Optional[Image.Image]:
216
+ """Generates an image using the dedicated image model handle."""
217
  logger.info(f"πŸ–ΌοΈ [{task_id}] Requesting image: '{prompt[:70]}...' (Aspect: {aspect_ratio})")
218
+ full_prompt = (f"Simple kids animation style, bright colors, rounded shapes. NO humans/humanoids. Aspect ratio {aspect_ratio}. Scene: {prompt}")
 
 
 
 
 
 
219
  try:
220
+ # Use the dedicated image_model handle <<<<<------ CORRECTED CALL
221
+ response = image_model.generate_content(
222
  full_prompt, generation_config=genai.types.GenerationConfig(candidate_count=1)
223
  )
 
224
  image_bytes, safety_ratings, block_reason, finish_reason = None, [], None, None
 
225
  if hasattr(response, 'candidates') and response.candidates:
226
  candidate = response.candidates[0]
227
  if hasattr(candidate, 'finish_reason'): finish_reason = getattr(candidate.finish_reason, 'name', str(candidate.finish_reason))
 
229
  part = candidate.content.parts[0]
230
  if hasattr(part, 'inline_data') and part.inline_data and hasattr(part.inline_data, 'data'): image_bytes = part.inline_data.data
231
  if hasattr(candidate, 'safety_ratings'): safety_ratings = candidate.safety_ratings
 
232
  if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
233
  if hasattr(response.prompt_feedback, 'block_reason') and response.prompt_feedback.block_reason.name != 'BLOCK_REASON_UNSPECIFIED': block_reason = response.prompt_feedback.block_reason.name
234
  if hasattr(response.prompt_feedback, 'safety_ratings'): safety_ratings.extend(response.prompt_feedback.safety_ratings)
235
 
236
  if image_bytes:
237
  try:
238
+ image = Image.open(BytesIO(image_bytes)); logger.info(f" βœ… [{task_id}] Image generated.")
 
239
  filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
240
+ if filtered_ratings: logger.warning(f" ⚠️ [{task_id}] Image flagged: {', '.join(filtered_ratings)}."); st.warning(f"Image {task_id} flagged: {', '.join(filtered_ratings)}", icon="⚠️")
 
 
241
  return image
242
+ except Exception as img_err: logger.error(f" ❌ [{task_id}] Img decode error: {img_err}"); st.warning(f"Decode image data {task_id} failed.", icon="πŸ–ΌοΈ"); return None
 
 
 
243
  else:
244
  fail_reason = "Unknown reason."
245
+ if block_reason: fail_reason = f"Blocked ({block_reason})."
246
+ elif finish_reason and finish_reason not in ['STOP', 'FINISH_REASON_UNSPECIFIED']: fail_reason = f"Finished early ({finish_reason})."
247
  else:
248
  filtered_ratings = [f"{r.category.name}: {r.probability.name}" for r in safety_ratings if hasattr(r,'probability') and r.probability.name != 'NEGLIGIBLE']
249
+ if filtered_ratings: fail_reason = f"Safety filters: {', '.join(filtered_ratings)}."
250
+ # Log full response only if reason remains unknown
251
+ if fail_reason == "Unknown reason.": logger.warning(f" ⚠️ [{task_id}] Full API response object: {response}") # Keep this debug log for now
 
 
 
252
  logger.warning(f" ⚠️ [{task_id}] No image data. Reason: {fail_reason} Prompt: '{prompt[:70]}...'")
253
+ st.warning(f"No image data {task_id}. Reason: {fail_reason}", icon="πŸ–ΌοΈ"); return None
254
+ except genai.types.generation_types.BlockedPromptException as bpe: logger.error(f" ❌ [{task_id}] Image blocked (exception): {bpe}"); st.error(f"Image blocked {task_id} (exception).", icon="🚫"); return None
255
+ except Exception as e: logger.exception(f" ❌ [{task_id}] Image gen failed: {e}"); st.error(f"Image gen failed {task_id}: {e}", icon="πŸ–ΌοΈ"); return None
 
 
 
 
 
 
 
 
256
 
257
  # --- Streamlit UI Elements ---
 
258
  st.sidebar.header("βš™οΈ Configuration")
259
  if GOOGLE_API_KEY: st.sidebar.success("Google API Key Loaded", icon="βœ…")
260
  else: st.sidebar.error("Google API Key Missing!", icon="🚨")
 
268
  generate_button = st.sidebar.button("✨ Generate ChronoWeave ✨", type="primary", disabled=(not GOOGLE_API_KEY), use_container_width=True)
269
  st.sidebar.markdown("---")
270
  st.sidebar.info("⏳ Generation can take several minutes.", icon="⏳")
271
+ st.sidebar.markdown(f"<small>Txt:{TEXT_MODEL_ID}, Img:{IMAGE_MODEL_ID}, Aud:{AUDIO_MODEL_ID}</small>", unsafe_allow_html=True)
272
 
273
  # --- Main Logic ---
274
  if generate_button:
275
+ if not theme: st.error("Please enter a story theme.", icon="πŸ‘ˆ")
 
276
  else:
277
+ run_id = str(uuid.uuid4()).split('-')[0]; temp_dir = os.path.join(TEMP_DIR_BASE, f"run_{run_id}")
278
+ try: os.makedirs(temp_dir, exist_ok=True); logger.info(f"Created temp dir: {temp_dir}")
279
+ except OSError as e: st.error(f"🚨 Failed create temp dir {temp_dir}: {e}", icon="πŸ“‚"); st.stop()
280
+ final_video_paths = {}; generation_errors = {}
 
 
 
 
 
 
 
281
 
282
  # --- 1. Generate Narrative Structure ---
283
  chrono_response: Optional[ChronoWeaveResponse] = None
284
+ with st.spinner("Generating narrative structure... πŸ€”"): chrono_response = generate_story_sequence_chrono(theme, num_scenes, num_timelines, divergence_prompt)
 
285
 
286
  if chrono_response:
287
  # --- 2. Process Each Timeline ---
288
+ overall_start_time = time.time(); all_timelines_successful = True
 
 
289
  with st.status("Generating assets and composing videos...", expanded=True) as status:
290
  for timeline_index, timeline in enumerate(chrono_response.timelines):
291
+ timeline_id, divergence, segments = timeline.timeline_id, timeline.divergence_reason, timeline.segments
292
+ timeline_label = f"Timeline {timeline_id}"; st.subheader(f"Processing {timeline_label}: {divergence}")
293
+ logger.info(f"--- Processing {timeline_label} (Idx: {timeline_index}) ---"); generation_errors[timeline_id] = []
294
+ temp_image_files, temp_audio_files, video_clips = {}, {}, []
295
+ timeline_start_time = time.time(); scene_success_count = 0
 
 
 
 
 
 
 
 
296
 
297
  for scene_index, segment in enumerate(segments):
298
+ scene_id = segment.scene_id; task_id = f"T{timeline_id}_S{scene_id}"
299
+ status.update(label=f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}...")
 
 
300
  st.markdown(f"--- **Scene {scene_id + 1} ({task_id})** ---")
301
+ logger.info(f"Processing {timeline_label}, Scene {scene_id + 1}/{len(segments)}...")
302
  scene_has_error = False
303
+ st.write(f" *Img Prompt:* {segment.image_prompt}" + (f" *(Mod: {segment.timeline_visual_modifier})*" if segment.timeline_visual_modifier else "")); st.write(f" *Audio Text:* {segment.audio_text}")
 
 
304
 
305
  # --- 2a. Image Generation ---
306
  generated_image: Optional[Image.Image] = None
 
309
  if segment.character_description: combined_prompt += f" Featuring: {segment.character_description}"
310
  if segment.timeline_visual_modifier: combined_prompt += f" Style hint: {segment.timeline_visual_modifier}."
311
  generated_image = generate_image_imagen(combined_prompt, aspect_ratio, task_id)
 
312
  if generated_image:
313
  image_path = os.path.join(temp_dir, f"{task_id}_image.png")
314
+ try: generated_image.save(image_path); temp_image_files[scene_id] = image_path; st.image(generated_image, width=180, caption=f"Scene {scene_id+1}")
315
+ except Exception as e: logger.error(f" ❌ [{task_id}] Img save error: {e}"); st.error(f"Save image {task_id} failed.", icon="πŸ’Ύ"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img save fail.")
316
+ else: scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Img gen fail."); continue
 
 
 
 
 
 
 
 
317
 
318
  # --- 2b. Audio Generation ---
319
  generated_audio_path: Optional[str] = None
320
+ if not scene_has_error:
321
  with st.spinner(f"[{task_id}] Generating audio... πŸ”Š"):
322
  audio_path_temp = os.path.join(temp_dir, f"{task_id}_audio.wav")
323
+ try: generated_audio_path = asyncio.run(generate_audio_live_async(segment.audio_text, audio_path_temp, audio_voice))
324
+ except RuntimeError as e: logger.error(f" ❌ [{task_id}] Asyncio error: {e}"); st.error(f"Asyncio audio error {task_id}: {e}", icon="⚑"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio async err.")
325
+ except Exception as e: logger.exception(f" ❌ [{task_id}] Audio error: {e}"); st.error(f"Audio error {task_id}: {e}", icon="πŸ’₯"); scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen err.")
 
 
 
 
 
 
 
 
326
  if generated_audio_path:
327
  temp_audio_files[scene_id] = generated_audio_path
328
  try:
329
  with open(generated_audio_path, 'rb') as ap: st.audio(ap.read(), format='audio/wav')
330
  except Exception as e: logger.warning(f" ⚠️ [{task_id}] Audio preview error: {e}")
331
+ else:
332
  scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Audio gen fail.")
 
333
  if scene_id in temp_image_files and os.path.exists(temp_image_files[scene_id]):
334
+ try: os.remove(temp_image_files[scene_id]); logger.info(f" πŸ—‘οΈ [{task_id}] Removed img due to audio fail."); del temp_image_files[scene_id]
335
+ except OSError as e: logger.warning(f" ⚠️ [{task_id}] Failed remove img after audio fail: {e}")
336
+ continue
337
 
338
  # --- 2c. Create Video Clip ---
339
  if not scene_has_error and scene_id in temp_image_files and scene_id in temp_audio_files:
340
+ st.write(f" 🎬 Creating clip S{scene_id+1}...")
341
  img_path, aud_path = temp_image_files[scene_id], temp_audio_files[scene_id]
342
  audio_clip_instance, image_clip_instance, composite_clip = None, None, None
343
  try:
344
  if not os.path.exists(img_path): raise FileNotFoundError(f"Img missing: {img_path}")
345
  if not os.path.exists(aud_path): raise FileNotFoundError(f"Aud missing: {aud_path}")
346
+ audio_clip_instance = AudioFileClip(aud_path); np_image = np.array(Image.open(img_path))
 
347
  image_clip_instance = ImageClip(np_image).set_duration(audio_clip_instance.duration)
348
  composite_clip = image_clip_instance.set_audio(audio_clip_instance)
349
+ video_clips.append(composite_clip); logger.info(f" βœ… [{task_id}] Clip created (Dur: {audio_clip_instance.duration:.2f}s).")
350
+ st.write(f" βœ… Clip created (Dur: {audio_clip_instance.duration:.2f}s)."); scene_success_count += 1
 
 
351
  except Exception as e:
352
+ logger.exception(f" ❌ [{task_id}] Failed clip creation: {e}"); st.error(f"Failed clip {task_id}: {e}", icon="🎬")
353
+ scene_has_error = True; generation_errors[timeline_id].append(f"S{scene_id+1}: Clip fail.")
354
+ if audio_clip_instance: audio_clip_instance.close();
 
 
355
  if image_clip_instance: image_clip_instance.close()
356
  try:
357
  if os.path.exists(img_path): os.remove(img_path)
358
  if os.path.exists(aud_path): os.remove(aud_path)
359
+ except OSError as e_rem: logger.warning(f" ⚠️ [{task_id}] Failed remove files after clip err: {e_rem}")
360
 
361
  # --- 2d. Assemble Timeline Video ---
362
  timeline_duration = time.time() - timeline_start_time
363
  if video_clips and scene_success_count == len(segments):
364
+ status.update(label=f"Composing video {timeline_label}...")
365
+ st.write(f"🎞️ Assembling video {timeline_label}..."); logger.info(f"🎞️ Assembling video {timeline_label}...")
366
+ output_filename = os.path.join(temp_dir, f"timeline_{timeline_id}_final.mp4"); final_timeline_video = None
 
 
 
367
  try:
368
  final_timeline_video = concatenate_videoclips(video_clips, method="compose")
369
  final_timeline_video.write_videofile(output_filename, fps=VIDEO_FPS, codec=VIDEO_CODEC, audio_codec=AUDIO_CODEC, logger=None)
370
+ final_video_paths[timeline_id] = output_filename; logger.info(f" βœ… [{timeline_label}] Video saved: {os.path.basename(output_filename)}")
371
+ st.success(f"βœ… Video {timeline_label} completed in {timeline_duration:.2f}s.")
 
372
  except Exception as e:
373
+ logger.exception(f" ❌ [{timeline_label}] Video assembly failed: {e}"); st.error(f"Assemble video {timeline_label} failed: {e}", icon="πŸ“Ό")
374
+ all_timelines_successful = False; generation_errors[timeline_id].append(f"T{timeline_id}: Assembly failed.")
 
375
  finally:
376
+ logger.debug(f"[{timeline_label}] Closing clips...");
377
  for i, clip in enumerate(video_clips):
378
  try:
379
  if clip:
380
  if clip.audio: clip.audio.close()
381
  clip.close()
382
+ except Exception as e_close: logger.warning(f" ⚠️ [{timeline_label}] Clip close err {i}: {e_close}")
383
  if final_timeline_video:
384
  try:
385
  if final_timeline_video.audio: final_timeline_video.audio.close()
386
  final_timeline_video.close()
387
+ except Exception as e_close_final: logger.warning(f" ⚠️ [{timeline_label}] Final vid close err: {e_close_final}")
388
+ elif not video_clips: logger.warning(f"[{timeline_label}] No clips. Skip assembly."); st.warning(f"No scenes for {timeline_label}. No video.", icon="🚫"); all_timelines_successful = False
389
+ else: error_count = len(segments) - scene_success_count; logger.warning(f"[{timeline_label}] {error_count} scene err(s). Skip assembly."); st.warning(f"{timeline_label}: {error_count} err(s). Video not assembled.", icon="⚠️"); all_timelines_successful = False
390
+ if generation_errors[timeline_id]: logger.error(f"Errors {timeline_label}: {generation_errors[timeline_id]}")
 
 
 
 
 
 
 
391
 
392
  # --- End of Timelines Loop ---
393
  overall_duration = time.time() - overall_start_time
394
+ if all_timelines_successful and final_video_paths: status_msg = f"Complete! ({len(final_video_paths)} videos in {overall_duration:.2f}s)"; status.update(label=status_msg, state="complete", expanded=False); logger.info(status_msg)
395
+ elif final_video_paths: status_msg = f"Partially Complete ({len(final_video_paths)} videos, errors). {overall_duration:.2f}s"; status.update(label=status_msg, state="warning", expanded=True); logger.warning(status_msg)
396
+ else: status_msg = f"Failed. No videos. {overall_duration:.2f}s"; status.update(label=status_msg, state="error", expanded=True); logger.error(status_msg)
 
397
 
398
  # --- 3. Display Results ---
399
  st.header("🎬 Generated Timelines")
400
  if final_video_paths:
401
+ sorted_timeline_ids = sorted(final_video_paths.keys()); num_cols = min(len(sorted_timeline_ids), 3); cols = st.columns(num_cols)
 
 
 
402
  for idx, timeline_id in enumerate(sorted_timeline_ids):
403
+ col = cols[idx % num_cols]; video_path = final_video_paths[timeline_id]
 
404
  timeline_data = next((t for t in chrono_response.timelines if t.timeline_id == timeline_id), None)
405
  reason = timeline_data.divergence_reason if timeline_data else "Unknown"
406
  with col:
407
  st.subheader(f"Timeline {timeline_id}"); st.caption(f"Divergence: {reason}")
408
  try:
409
  with open(video_path, 'rb') as vf: video_bytes = vf.read()
410
+ st.video(video_bytes); logger.info(f"Displaying T{timeline_id}")
 
411
  st.download_button(f"Download T{timeline_id}", video_bytes, f"timeline_{timeline_id}.mp4", "video/mp4", key=f"dl_{timeline_id}")
412
  if generation_errors.get(timeline_id):
413
+ with st.expander(f"⚠️ View {len(generation_errors[timeline_id])} Issues"): [st.warning(f"- {err}") for err in generation_errors[timeline_id]]
414
+ except FileNotFoundError: logger.error(f"Video missing: {video_path}"); st.error(f"Error: Video missing T{timeline_id}.", icon="🚨")
415
+ except Exception as e: logger.exception(f"Display error {video_path}: {e}"); st.error(f"Display error T{timeline_id}: {e}", icon="🚨")
 
416
  else:
417
  st.warning("No final videos were successfully generated.")
 
418
  all_errors = [msg for err_list in generation_errors.values() for msg in err_list]
419
  if all_errors:
420
+ st.subheader("Summary of Generation Issues");
421
  with st.expander("View All Errors", expanded=True):
422
  for tid, errors in generation_errors.items():
423
  if errors: st.error(f"T{tid}:"); [st.error(f" - {msg}") for msg in errors]
424
 
425
  # --- 4. Cleanup ---
426
  st.info(f"Attempting cleanup: {temp_dir}")
427
+ try: shutil.rmtree(temp_dir); logger.info(f"βœ… Temp dir removed: {temp_dir}"); st.success("βœ… Temp files cleaned.")
428
+ except Exception as e: logger.error(f"⚠️ Failed remove temp dir {temp_dir}: {e}"); st.warning(f"Could not remove temp files: {temp_dir}.", icon="⚠️")
429
+
430
+ elif not chrono_response: logger.error("Story gen/validation failed.")
431
+ else: st.error("Unexpected issue post-gen.", icon="πŸ›‘"); logger.error("Chrono_response truthy but invalid.")
432
+
433
+ else: st.info("Configure settings and click '✨ Generate ChronoWeave ✨' to start.")