Spaces:
Running
Running
Update app.py
Browse files
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
|
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
|
96 |
client_standard = genai.GenerativeModel(TEXT_MODEL_ID)
|
97 |
logger.info(f"Initialized standard GenerativeModel for {TEXT_MODEL_ID}.")
|
98 |
|
99 |
-
#
|
100 |
-
#
|
101 |
-
#
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
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 |
-
|
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 |
-
|
168 |
-
|
|
|
|
|
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)
|
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'
|
182 |
config = {
|
183 |
"response_modalities": ["AUDIO"],
|
184 |
-
"audio_config": {
|
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" #
|
188 |
}
|
189 |
}
|
190 |
|
191 |
-
#
|
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 |
-
#
|
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.
|
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 |
-
#
|
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 |
-
#
|
|
|
354 |
)
|
355 |
|
356 |
# Check for valid response and image data
|
357 |
-
|
358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 =
|
370 |
-
|
|
|
|
|
|
|
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 |
-
#
|
401 |
# logger.debug(f"Full Imagen response object: {response}")
|
402 |
return None
|
403 |
|
404 |
except genai.types.generation_types.BlockedPromptException as bpe:
|
405 |
-
|
|
|
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
|
418 |
if GOOGLE_API_KEY:
|
419 |
st.sidebar.success("Google API Key Loaded", icon="β
")
|
420 |
else:
|
421 |
-
|
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
|
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}
|
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
|
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
|
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
|
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 |
-
#
|
535 |
-
continue
|
536 |
|
537 |
# --- 2b. Audio Generation ---
|
538 |
generated_audio_path: Optional[str] = None
|
539 |
-
if not scene_has_error:
|
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()
|
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
|
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 |
-
|
593 |
-
# Use numpy array for ImageClip to avoid potential PIL issues with moviepy versions
|
594 |
np_image = np.array(Image.open(img_path))
|
595 |
-
|
596 |
|
597 |
-
|
598 |
-
composite_clip
|
599 |
-
|
600 |
-
|
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 |
-
#
|
610 |
-
if
|
611 |
-
if
|
|
|
|
|
|
|
612 |
|
613 |
# --- End of Scene Loop ---
|
614 |
|
615 |
# --- 2d. Assemble Timeline Video ---
|
616 |
timeline_duration = time.time() - timeline_start_time
|
617 |
-
|
|
|
|
|
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 |
-
#
|
645 |
-
logger.debug(f"[{timeline_label}] Closing {len(video_clips)}
|
646 |
-
for clip in video_clips:
|
647 |
try:
|
648 |
-
clip
|
649 |
-
|
650 |
-
|
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 |
-
|
666 |
-
|
|
|
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"
|
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)
|
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 |
-
|
|
|
|
|
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 |
-
|
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
|
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 |
-
|
744 |
-
|
|
|
|
|
|
|
|
|
745 |
|
746 |
# --- 4. Cleanup ---
|
747 |
-
st.info(f"
|
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 |
-
#
|
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 |
|