Coverage for apps/streamcast/streamcast_job.py: 93%
342 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
1"""
2StreamCast job to generate a video podcast.
3It coordinates the execution of the different models.
4"""
5import os
6import sys
7import time
8import asyncio
9import json
10import aiofiles
12from PIL import Image
14from typing import override
15from typing import List
16from typing import Dict
17from typing import Optional
18from typing import Any
19from typing import Tuple
20from typing import cast
22# Local relative imports
23sys.path.append("..") # noqa: E402
24sys.path.append("../..") # noqa: E402
26from streamwise_job import StreamWiseJob
27from streamwise_job import JobStatus
28from streamwise_job import OutputMode
29from streamwise_job import MAX_LOG_TEXT
31from resolutions import RESOLUTIONS
33from lmm_service_manager import LMMServiceManager
35from podcast_prompts import IMG_PROMPT_BASE
36from podcast_prompts import IMG_PROMPT
37from podcast_prompts import IMG_NEG_PROMPT
38from podcast_prompts import IMG_ZOOM_PROMPT
39from podcast_prompts import VIDEO_PROMPT
40from podcast_prompts import VIDEO_NEG_PROMPT
42from character import Character
44from video import FANTASYTALKING_FPS
45from video import HUNYUANFRAMEPACK_FPS
46from video import MAX_FT_DURATION_SECS
47from video import VAE_T
49from gen_video_chunked import GenVideoChunked
51from file_utils import read_file_base64
52from file_utils import save_base64_as_binary
53from file_utils import base64_to_binary
55from media_utils import add_text_to_frame
56from media_utils import get_video_with_text
57from media_utils import get_font_size
58from media_utils import get_video_frames
59from media_utils import get_video_duration
60from media_utils import get_audio_duration
61from media_utils import get_video_fps
62from media_utils import get_video_file_info
63from media_utils import get_aligned_duration
64from media_utils import get_audio_file_info
65from media_utils import fit_audio_to_duration
66from media_utils import save_video_frames
67from media_utils import save_video_audio
68from media_utils import concatenate_videos
69from media_utils import split_text_lines
71from console_utils import bytes_to_human
73from tts_utils import strip_audio_file_silence
76MAX_IMG_LINE_CHARS = 50 # Max characters per line in the output image/video
79class StreamCastJob(StreamWiseJob):
80 """A job to generate a podcast with images, audio, and video."""
82 def __init__(
83 self,
84 job_id: str,
85 service_manager: LMMServiceManager,
86 config: Dict[str, Any] = {},
87 ) -> None:
88 super().__init__(
89 "streamcast",
90 job_id,
91 service_manager,
92 config)
94 @override
95 async def generate(
96 self,
97 job_config: Dict[str, Any],
98 ) -> None:
99 pdf_base64: Optional[str] = job_config.get("pdf_base64")
100 await self.gen_podcast(pdf_base64)
102 async def align_audio(
103 self,
104 audio_path: str,
105 ) -> Tuple[str, float]:
106 """
107 Strip end silence and extend audio duration to align with the video constraints:
108 1+4n frames for VAE (e.g., 4 for Fantasy Talking)
109 Frames per second (e.g., 23 FPS for Fantasy Talking)
110 """
111 if not os.path.exists(audio_path):
112 raise FileNotFoundError(f"Audio file not found: {audio_path}")
113 if not audio_path.lower().endswith(".wav"):
114 raise ValueError(f"Audio file must be a WAV file: {audio_path}")
116 audio_info = get_audio_file_info(audio_path)
117 audio_duration = audio_info["duration_seconds"]
119 audio_path_trimmed = audio_path.rstrip(".wav")
120 stripped_audio_path = f"{audio_path_trimmed}_stripped.wav"
121 strip_audio_file_silence(
122 audio_path,
123 output_path=stripped_audio_path,
124 strip_start=True, # False to give more natural start
125 strip_end=True)
126 stripped_audio_info = get_audio_file_info(stripped_audio_path)
127 stripped_audio_duration = stripped_audio_info["duration_seconds"]
129 # Choose the right FPS alignment
130 output_mode = self.get_config_output_mode()
131 fps = FANTASYTALKING_FPS # For video+audio synced use Fantasy Talking FPS
132 if output_mode is not OutputMode.VIDEO_AUDIO_SYNCED:
133 fps = HUNYUANFRAMEPACK_FPS # Audio and unsynced use FramePack FPS
135 new_audio_duration = get_aligned_duration(
136 stripped_audio_duration,
137 fps=fps,
138 vae=VAE_T)
140 if audio_duration == new_audio_duration:
141 return audio_path, audio_duration # It was already aligned
143 aligned_audio_path = f"{audio_path_trimmed}_aligned.wav"
144 aligned_audio_path = fit_audio_to_duration(
145 stripped_audio_path,
146 new_audio_duration,
147 aligned_audio_path)
148 num_frames = new_audio_duration * fps
149 self.logger.info(
150 f"Aligned audio with {audio_duration:.3f} to {new_audio_duration:.3f} "
151 f"using {fps} FPS and {VAE_T} VAE for {num_frames:.1f} frames.")
152 return aligned_audio_path, new_audio_duration
154 async def gen_scene(
155 self,
156 scene_id: int,
157 character: Character,
158 text: str,
159 video_prompt: str = VIDEO_PROMPT,
160 video_neg_prompt: str = VIDEO_NEG_PROMPT,
161 ) -> str:
162 """
163 Generate a scene with audio and video starting with image at high resolution.
164 1. Generate video+audio at medium resolution with Fantasy Talking (single or by chunks).
165 3. Upscale video+audio to high resolution.
166 """
167 t0 = time.time()
168 width, height = self.width, self.height
169 image = character.image
170 if image:
171 width, height = image.size
172 log_text = text[:MAX_LOG_TEXT] + "..." if len(text) > MAX_LOG_TEXT else text
173 self.logger.info(
174 f"[{scene_id}] Generating scene for character {character.name} "
175 f"with image {width}x{height} and text '{log_text}'...")
177 # Generate audio (kokoro)
178 audio_base64 = await self.gen.gen_audio(
179 text,
180 voice=character.voice,
181 speed=character.speech_speed,
182 task_id=f"{scene_id:03d}",
183 deadline=self.get_scene_deadline(scene_id))
184 if audio_base64 is None:
185 raise ValueError(f"Cannot generate audio for scene {scene_id} with text '{log_text}'")
186 audio_duration = get_audio_duration(audio_base64)
187 audio_path = f"{self.job_path}/{scene_id:03d}.wav"
188 await save_base64_as_binary(audio_path, audio_base64)
189 self.logger.info(
190 f"[{scene_id}] Generated audio with {bytes_to_human(len(audio_base64))} and {audio_duration:.3f} seconds.")
192 aligned_audio_path, aligned_audio_duration = await self.align_audio(audio_path)
193 if audio_duration != aligned_audio_duration:
194 audio_path = aligned_audio_path
195 audio_duration = aligned_audio_duration
197 # Generate base video
198 output_mode = self.get_config_output_mode()
199 if output_mode is OutputMode.AUDIO_ONLY:
200 # Generate video with text slides
201 frame_text = f"{character.name}:\n" + "\n".join(split_text_lines(text, MAX_IMG_LINE_CHARS))
202 font_size = get_font_size(width, height)
203 font_color = self.characters.get_color(character.name)
204 scene_video_binary = await get_video_with_text(
205 width, height,
206 frame_text,
207 duration_seconds=audio_duration,
208 fps=HUNYUANFRAMEPACK_FPS,
209 font_size=font_size,
210 font_color=font_color)
211 elif audio_duration > MAX_FT_DURATION_SECS and output_mode == OutputMode.VIDEO_AUDIO_SYNCED:
212 # Generate video synced with the audio (multiple sub-shots)
213 self.logger.info(
214 f"[{scene_id}] Scene is too long ({audio_duration:.3f}>{MAX_FT_DURATION_SECS:.3f} seconds), "
215 "split into sub-shots.")
216 gen_video_chunked = GenVideoChunked(
217 video_id=scene_id,
218 gen=self.gen,
219 job_path=self.job_path,
220 logger=self.logger)
221 scene_video_binary = await gen_video_chunked.gen_video_chunked(
222 audio_path=audio_path,
223 image=self.image,
224 prompt=video_prompt,
225 neg_prompt=video_neg_prompt,
226 width=width,
227 height=height,
228 num_steps=self.get_num_steps(),
229 upscaling=self.get_config_bool("upscaling"),
230 debug=self.get_config_bool("debug_image"),
231 deadline=self.get_scene_deadline(scene_id),
232 )
233 """
234 scene_video_binary = await self.gen_scene_chunks(
235 scene_id,
236 audio_path,
237 image,
238 video_prompt=video_prompt,
239 video_neg_prompt=video_neg_prompt)
240 """
241 else:
242 # Generate video synced with the audio
243 scene_video_binary = await self.gen_scene_single(
244 scene_id,
245 audio_path,
246 image,
247 video_prompt=video_prompt,
248 video_neg_prompt=video_neg_prompt)
250 video_path = f"{self.job_path}/{scene_id:03d}_pre.mp4"
251 async with aiofiles.open(video_path, "wb") as file:
252 await file.write(scene_video_binary)
254 self._log_video_info(f"[{scene_id}] Generated video", scene_video_binary)
256 # Upscale video (without the audio) to high resolution
257 video_file_info = get_video_file_info(scene_video_binary)
258 video_info = video_file_info["video"]
259 src_width, src_height = video_info["width"], video_info["height"]
260 width, height = self.width, self.height
261 if src_width < width or src_height < height:
262 self.logger.info(
263 f"[{scene_id}] Upscaling video from {src_width}x{src_height} to {width}x{height}...")
264 scene_video_upscaled_binary = await self.gen.gen_video_upscale(
265 video_binary=scene_video_binary,
266 width=width,
267 height=height,
268 task_id=f"{scene_id:03d}_upscale",
269 deadline=self.get_scene_deadline(scene_id))
270 if scene_video_upscaled_binary is None:
271 raise ValueError(f"Cannot upscale video for scene {scene_id}")
273 self._log_video_info(
274 f"[{scene_id}] Upscaled video with {src_width}x{src_height} to video ",
275 scene_video_upscaled_binary)
276 scene_video_binary = scene_video_upscaled_binary
278 video_frames = await get_video_frames(scene_video_binary)
280 # Check video vs audio duration
281 video_file_info = get_video_file_info(scene_video_binary)
282 video_info = video_file_info["video"]
283 _video_fps = video_info.get("fps")
284 video_fps: float = _video_fps if _video_fps is not None else FANTASYTALKING_FPS
285 _video_duration = video_info.get("duration_seconds")
286 video_duration: float = _video_duration if _video_duration is not None else 0.0
287 video_num_frames = video_info["num_frames"]
288 if len(video_frames) != video_num_frames:
289 self.logger.warning(
290 f"[{scene_id}] Video frames mismatch: {len(video_frames)} != {video_num_frames}.")
292 if audio_duration != video_duration:
293 self.logger.warning(
294 f"[{scene_id}] Audio duration ({audio_duration:.3f} seconds) is different than video "
295 f"({video_duration:.3f} seconds "
296 f"= {video_num_frames} / {video_fps} FPS).")
298 # Add subtitles
299 if self.get_config_bool("add_subtitles") and output_mode is not OutputMode.AUDIO_ONLY:
300 # TODO it does not support multiple lines yet
301 # frame_text = f"{character.name}:\n" + "\n".join(split_text_lines(text, MAX_IMG_LINE_CHARS))
302 frame_text = f"{character.name}: {text}"
303 video_frames = await self._overlay_subtitles_on_frames(scene_video_binary, frame_text)
305 # Add audio back to the video
306 video_audio_path = f"{self.job_path}/{scene_id:03d}.mp4"
307 video_audio_path = await save_video_audio(
308 video_content=video_frames,
309 audio_path=audio_path,
310 out_video_path=video_audio_path,
311 fps=video_fps)
313 self._log_video_info(f"[{scene_id}] Generated scene", video_audio_path)
314 self.logger.info(f"[{scene_id}] Generated scene in {time.time() - t0:.3f} seconds.")
316 return video_audio_path
318 async def gen_scene_single(
319 self,
320 scene_id: int,
321 audio_path: str,
322 image: Image.Image,
323 video_prompt: str = VIDEO_PROMPT,
324 video_neg_prompt: str = VIDEO_NEG_PROMPT,
325 ) -> bytes:
326 """
327 Generate video+audio in a single shot at medium resolution with Fantasy Talking.
328 Returns video synced with the audio in base64.
329 """
330 audio_base64 = await read_file_base64(audio_path)
331 audio_duration = get_audio_duration(audio_base64)
333 width, height = self.width, self.height
334 if self.get_config_bool("upscaling"):
335 # width, height = RESOLUTIONS[self.aspect_ratio]["medium"]
336 width = self.width // 2
337 height = self.height // 2
339 output_mode = self.get_config_output_mode()
340 num_steps = self.get_num_steps()
341 if output_mode == OutputMode.VIDEO_AUDIO_SYNCED:
342 self.logger.info(f"[{scene_id}] Generating video+audio with {audio_duration:.3f} seconds...")
343 video_binary = await self.gen.gen_video_audio_from_img(
344 img=image,
345 audio_base64=audio_base64,
346 prompt=video_prompt,
347 neg_prompt=video_neg_prompt,
348 width=width,
349 height=height,
350 steps=num_steps,
351 task_id=f"{scene_id:03d}",
352 deadline=self.get_scene_deadline(scene_id),
353 )
354 else:
355 self.logger.info(f"[{scene_id}] Generating video with {audio_duration:.3f} seconds...")
356 video_binary = await self.gen.gen_video(
357 img=image,
358 prompt=video_prompt,
359 neg_prompt=video_neg_prompt,
360 width=width,
361 height=height,
362 video_seconds=audio_duration, # Because of rounding, this may produce more frames than asked
363 steps=num_steps,
364 task_id=f"{scene_id:03d}",
365 wait_request=True,
366 deadline=self.get_scene_deadline(scene_id),
367 )
369 # Mismatch because we use ffmpeg for get_video_frames()
370 video_frames = await get_video_frames(video_binary)
371 video_num_frames = len(video_frames)
372 video_file_info = get_video_file_info(video_binary)
373 video_info = video_file_info["video"]
374 _video_fps = video_info.get("fps")
375 video_fps: float = _video_fps if _video_fps is not None else FANTASYTALKING_FPS
376 _video_duration = video_info.get("duration_seconds")
377 video_duration: float = _video_duration if _video_duration is not None else 0.0
378 if video_num_frames != video_info["num_frames"]:
379 self.logger.warning(
380 f"[{scene_id}] Video frames mismatch: {video_num_frames} != {video_info['num_frames']}.")
382 video_path = f"{self.job_path}/{scene_id:03d}_{width}x{height}_single.mp4"
383 async with aiofiles.open(video_path, "wb") as file:
384 await file.write(video_binary)
386 # Sanity check for one frame or more
387 if abs(video_duration - audio_duration) >= 1.0 / video_fps:
388 self.logger.warning(
389 f"[{scene_id}] Generated video duration mismatch: "
390 f"{video_duration:.3f} != {audio_duration:.3f} seconds.")
392 self._log_video_info(f"[{scene_id}] Generated video", video_binary)
394 if self.get_config_bool("debug_image"):
395 frame_text = f"{scene_id:03d}"
396 video_frames = [
397 cast(Image.Image, add_text_to_frame(frame, text=frame_text, position="top-left"))
398 for frame in video_frames
399 ]
400 video_path = f"{self.job_path}/{scene_id:03d}_{width}x{height}_single_debug.mp4"
401 await save_video_frames(
402 video_frames=video_frames,
403 fps=video_fps,
404 out_video_path=video_path)
405 async with aiofiles.open(video_path, "rb") as file:
406 video_binary = await file.read()
408 self._log_video_info(
409 f"[{scene_id}] Added debug text to video",
410 video_binary)
412 return video_binary
414 async def gen_images(
415 self,
416 img_prompt: str,
417 use_image_edit: bool = True,
418 ) -> Tuple[Image.Image, List[Image.Image]]:
419 """
420 Generate main image and character images.
421 """
422 width, height = RESOLUTIONS[self.aspect_ratio]["high"]
423 self.logger.info(f"Generating image with size {width}x{height} and prompt: {img_prompt}.")
425 # Generate the main image
426 img_neg_prompt = IMG_NEG_PROMPT
427 image = await self.gen.gen_image(
428 img_prompt,
429 neg_prompt=img_neg_prompt,
430 width=width,
431 height=height,
432 # TODO steps=25,
433 task_id="main_image",
434 deadline=self.get_submission_time())
435 if image is not None:
436 width, height = image.size
437 image_path = f"{self.job_path}/main_image.png"
438 image.save(image_path)
439 self.logger.info(f"Image with {width}x{height} pixels saved to '{image_path}'.")
441 # Get the sub-images from the main image
442 num_characters = len(self.characters)
443 character_images = await self.gen.gen_extract_characters(
444 image,
445 num_characters,
446 task_id="extract_characters",
447 deadline=self.get_submission_time())
449 if not character_images:
450 raise ValueError("No characters extracted from the image.")
451 if len(character_images) != num_characters:
452 raise ValueError(f"Expected {num_characters} characters, but got {len(character_images)}.")
454 self.logger.info(f"Editing {len(character_images)} characters from the image.")
455 for character_ix, character_image in enumerate(character_images):
456 if use_image_edit:
457 img_zoom_prompt = IMG_ZOOM_PROMPT
458 character = self.characters.get_by_index(character_ix)
459 if character and character.description:
460 img_zoom_prompt += f"\nThe character is a {character.gender} "
461 img_zoom_prompt += f"and their description is {character.description}."
462 position = self.characters.get_position(character.name)
463 if position == "left":
464 img_zoom_prompt += "\nThe character is looking to the right."
465 elif position == "right":
466 img_zoom_prompt += "\nThe character is looking to the left."
467 elif position == "center":
468 img_zoom_prompt += "\nThe character is looking forward."
469 character_image = await self.gen.gen_edit_image(
470 character_image,
471 prompt=img_zoom_prompt,
472 neg_prompt=img_neg_prompt,
473 width=width,
474 height=height,
475 task_id=f"character_{character_ix:03d}",
476 deadline=self.get_submission_time()) # TODO deadlines for each image
477 character_images[character_ix] = character_image
479 self.logger.info(f"Extracted {len(character_images)} characters from the image.")
480 for character_ix, character_image in enumerate(character_images):
481 if character_ix >= len(self.characters):
482 self.logger.warning(f"Character {character_ix} not found in {self.characters}.")
483 character = self.characters.get_by_index(character_ix)
484 character.image = character_image
485 character_image_path = f"{self.job_path}/character_{character_ix + 1:03d}.png"
486 character_image.save(character_image_path)
487 width, height = character_image.size
488 self.logger.info(f"Character {character_ix + 1} {width}x{height} saved to '{character_image_path}'.")
490 return image, character_images
492 async def gen_podcast(
493 self,
494 pdf_base64: Optional[str],
495 ) -> None:
496 """Generate a podcast."""
497 async with self.job_status_handler():
498 if not pdf_base64:
499 self.logger.error("Document is required.")
500 await self.save_status(JobStatus.FAILED)
501 raise ValueError("Missing 'pdf_base64' in request")
502 self.logger.info(f"Generating podcast transcript for document with {bytes_to_human(len(pdf_base64))}.")
504 self.logger.info(f"Document base64 with {bytes_to_human(len(pdf_base64))}.")
505 async with aiofiles.open(f"{self.job_path}/document.pdf", "wb") as file:
506 pdf_binary = base64_to_binary(pdf_base64)
507 await file.write(pdf_binary)
509 # Default prompts
510 img_prompt = IMG_PROMPT
511 video_prompt = VIDEO_PROMPT
512 video_neg_prompt = VIDEO_NEG_PROMPT
514 await self.save_status(JobStatus.RUNNING)
516 # Configurations
517 num_characters = self.get_config_int("num_characters")
518 output_mode = self.get_config_output_mode()
520 # Podcast transcript
521 future_images: Optional[asyncio.Task] = None
522 scene_id = 0
523 async with aiofiles.open(f"{self.job_path}/podcast_transcript.jsonl", "wb") as file:
524 async for line_json in self.gen.gen_podcast_transcript(
525 task_id="transcript",
526 pdf_base64=pdf_base64,
527 num_characters=num_characters,
528 style_prompt=self.get_config_str("style_prompt"),
529 scene_prompt=self.get_config_str("scene_prompt"),
530 custom_prompt=self.get_config_str("custom_prompt"),
531 max_tokens=self.get_config_int("max_tokens"),
532 max_dialogues=self.get_config_int("max_dialogues"),
533 max_words_per_dialogue=self.get_config_int("max_words_per_dialogue"),
534 ):
535 line = (json.dumps(line_json) + "\n").encode("utf-8")
536 await file.write(line)
537 await file.flush()
539 line_type = line_json.get("type", "")
540 if line_type == "image":
541 img_prompt = line_json.get("content", None)
542 img_prompt += "\n" + IMG_PROMPT_BASE
543 if num_characters == 1:
544 img_prompt += f"\nThe image shows {num_characters} character. "
545 elif num_characters > 1:
546 img_prompt += f"\nThe image shows {num_characters} characters. "
547 self.logger.info(f"Image prompt: {img_prompt}")
548 elif line_type == "character":
549 character_name = line_json.get("name", "Unknown")
550 character_gender = line_json.get("gender", "Unknown")
551 character_description = line_json.get("description", "")
552 character = Character(
553 name=character_name,
554 gender=character_gender,
555 description=character_description)
556 if "speech_speed" in self.config:
557 character.speech_speed = float(self.config["speech_speed"])
558 self.characters[character_name] = character
559 self.logger.info(f"Character: {character_name}, {character_gender}, {character_description}")
560 img_prompt += f"{character_name} is a {character_gender} "
561 img_prompt += f"and the description is {character_description}\n"
563 if len(self.characters) == num_characters and output_mode is not OutputMode.AUDIO_ONLY:
564 # Trigger async image generation
565 future_images = asyncio.create_task(self.gen_images(
566 img_prompt=img_prompt,
567 use_image_edit=self.get_config_bool("edit_image"),
568 ))
569 elif line_type == "dialogue":
570 character_name = line_json.get("character", "Unknown")
571 dialogue_content = line_json.get("content", "")
572 self.logger.info(f"Scene {scene_id}: [{character_name}] {dialogue_content}")
573 # TODO we could trigger the task for gen_scene()
574 self.transcript_scenes.append(line_json)
575 scene_id += 1
576 self.logger.info("Transcript generated.")
578 await self.save_status(JobStatus.RUNNING)
580 # Generate images
581 img_main: Optional[Image.Image] = None
582 if future_images is None and output_mode is not OutputMode.AUDIO_ONLY:
583 future_images = asyncio.create_task(self.gen_images(
584 img_prompt=img_prompt,
585 use_image_edit=self.get_config_bool("edit_image"),
586 ))
587 if output_mode is OutputMode.AUDIO_ONLY:
588 self.logger.debug("Audio only mode, skipping image generation.")
589 else:
590 assert future_images is not None, "future_images must be set for non-AUDIO_ONLY mode"
591 img_main, img_characters = await future_images
592 self.logger.info(f"Main and {len(img_characters)} character images generated.")
594 # Generate each scene
595 scene_video_tasks: Dict[int, asyncio.Task] = {}
596 for scene_id, scene_json in enumerate(self.transcript_scenes):
597 character_name = scene_json.get("character", "Unknown")
598 if character_name in self.characters:
599 character = self.characters[character_name]
600 else:
601 self.logger.warning(f"Character '{character_name}' not found in characters. Using default.")
602 character = Character(name=character, gender="Unknown")
603 if character.image is None and output_mode is not OutputMode.AUDIO_ONLY:
604 self.logger.warning(f"Character '{character_name}' has no image. Using main image as fallback.")
605 character.image = img_main # Use the main image as a fallback
606 character_position = self.characters.get_position(character_name)
607 character_video_prompt = video_prompt % (character.gender, character_position)
608 if "content" not in scene_json:
609 self.logger.warning(f"[{scene_id}] No content for scene. Skipping...")
610 else:
611 scene_text = scene_json["content"]
612 scene_video_task = asyncio.create_task(
613 self.gen_scene(
614 scene_id=scene_id,
615 character=character,
616 text=scene_text,
617 video_prompt=character_video_prompt,
618 video_neg_prompt=video_neg_prompt
619 ))
620 scene_video_tasks[scene_id] = scene_video_task
622 await self.save_status(JobStatus.RUNNING)
624 scene_video_paths: List[str] = []
625 results = await asyncio.gather(*scene_video_tasks.values(), return_exceptions=True)
626 for scene_id, result in enumerate(results):
627 if isinstance(result, Exception):
628 self._handle_scene_exception(scene_id, result)
629 elif isinstance(result, str):
630 scene_video_paths.append(result)
631 self.logger.info(f"[{scene_id}] Video+audio saved to '{result}'.")
632 else:
633 self.logger.warning(f"[{scene_id}] No video generated. Skipping...")
635 if not scene_video_paths:
636 raise ValueError("No scene videos generated. Cannot create final podcast video.")
638 await self.save_status(JobStatus.RUNNING)
640 # Concatenate all scene videos into a final podcast video
641 self.logger.info(f"Concatenating {len(scene_video_paths)} scenes into final podcast video...")
642 scene_binaries: List[bytes] = []
643 for scene_video_path in scene_video_paths:
644 async with aiofiles.open(scene_video_path, "rb") as file:
645 scene_binary = await file.read()
646 scene_binaries.append(scene_binary)
648 video_binary = await concatenate_videos(
649 scene_binaries,
650 fast_copy=False) # TODO move to True once we fix the durations
651 if not video_binary:
652 raise ValueError("Cannot concatenate scenes into final podcast video.")
653 video_duration = await get_video_duration(video_binary)
654 video_fps = get_video_fps(video_binary)
656 video_path = f"{self.job_path}/{self.job_id}.mp4"
657 async with aiofiles.open(video_path, "wb") as file:
658 await file.write(video_binary)
660 self.logger.info(
661 f"Generated podcast video with {video_duration:.3f} seconds, "
662 f"{video_fps} FPS, "
663 f"{bytes_to_human(len(video_binary))}, and "
664 f"'{video_path}'")