Spaces:
Running
Running
Update app.py
Browse files
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"
|
54 |
# Audio Model Config
|
55 |
-
|
56 |
-
|
57 |
-
AUDIO_SAMPLING_RATE = 24000 # Standard for TTS models like Google's
|
58 |
# Image Model Config
|
59 |
-
IMAGE_MODEL_ID = "imagen-3" #
|
60 |
DEFAULT_ASPECT_RATIO = "1:1"
|
61 |
# Video Config
|
62 |
VIDEO_FPS = 24
|
63 |
-
VIDEO_CODEC = "libx264"
|
64 |
-
AUDIO_CODEC = "aac"
|
65 |
# File Management
|
66 |
-
TEMP_DIR_BASE = ".chrono_temp"
|
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
|
91 |
|
|
|
92 |
live_model = genai.GenerativeModel(AUDIO_MODEL_ID)
|
93 |
-
logger.info(f"Initialized
|
|
|
|
|
|
|
|
|
94 |
|
95 |
except AttributeError as ae:
|
96 |
logger.exception("AttributeError during Google AI Client Initialization.")
|
97 |
-
st.error(f"π¨ Initialization Error: {ae}. Ensure
|
98 |
st.stop()
|
99 |
except Exception as e:
|
100 |
-
|
101 |
-
|
|
|
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)
|
109 |
audio_text: str = Field(..., min_length=5, max_length=150)
|
110 |
-
character_description: str = Field(..., max_length=250)
|
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 |
-
|
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}
|
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
|
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
|
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 |
-
|
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 |
-
|
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
|
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,
|
211 |
-
f"
|
212 |
-
f"
|
213 |
)
|
214 |
-
|
215 |
prompt = f"""
|
216 |
-
Act as
|
217 |
-
Create a story based on the core theme: "{theme}".
|
218 |
-
|
219 |
**Instructions:**
|
220 |
-
1.
|
221 |
-
2.
|
222 |
-
3.
|
223 |
-
4.
|
224 |
-
5.
|
225 |
-
6.
|
226 |
-
7.
|
227 |
-
8.
|
228 |
-
|
229 |
-
**
|
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 |
-
|
241 |
-
|
242 |
-
|
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 |
-
|
267 |
-
|
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
|
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 |
-
|
|
|
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 (
|
327 |
-
elif finish_reason and finish_reason not in ['STOP', 'FINISH_REASON_UNSPECIFIED']: fail_reason = f"Finished early (
|
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
|
331 |
-
|
332 |
-
|
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
|
338 |
-
|
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>
|
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 =
|
373 |
-
|
374 |
-
|
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 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
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 |
-
|
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(
|
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 |
-
|
433 |
-
|
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:
|
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 |
-
|
450 |
-
except
|
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:
|
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
|
469 |
-
except OSError as e: logger.warning(f" β οΈ [{task_id}] Failed remove
|
470 |
-
continue
|
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
|
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 |
-
|
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 |
-
|
491 |
-
|
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
|
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 |
-
|
504 |
-
|
505 |
-
|
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 |
-
|
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}]
|
517 |
-
|
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}]
|
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}]
|
532 |
-
elif not video_clips:
|
533 |
-
|
534 |
-
|
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 |
-
|
546 |
-
|
547 |
-
|
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 |
-
|
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 |
-
|
572 |
-
except
|
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 |
-
|
588 |
-
|
589 |
-
|
590 |
-
|
591 |
-
|
592 |
-
|
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.")
|
|
|
|
|
|
|
|
|
|
|
|