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

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 

11 

12from PIL import Image 

13 

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 

21 

22# Local relative imports 

23sys.path.append("..") # noqa: E402 

24sys.path.append("../..") # noqa: E402 

25 

26from streamwise_job import StreamWiseJob 

27from streamwise_job import JobStatus 

28from streamwise_job import OutputMode 

29from streamwise_job import MAX_LOG_TEXT 

30 

31from resolutions import RESOLUTIONS 

32 

33from lmm_service_manager import LMMServiceManager 

34 

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 

41 

42from character import Character 

43 

44from video import FANTASYTALKING_FPS 

45from video import HUNYUANFRAMEPACK_FPS 

46from video import MAX_FT_DURATION_SECS 

47from video import VAE_T 

48 

49from gen_video_chunked import GenVideoChunked 

50 

51from file_utils import read_file_base64 

52from file_utils import save_base64_as_binary 

53from file_utils import base64_to_binary 

54 

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 

70 

71from console_utils import bytes_to_human 

72 

73from tts_utils import strip_audio_file_silence 

74 

75 

76MAX_IMG_LINE_CHARS = 50 # Max characters per line in the output image/video 

77 

78 

79class StreamCastJob(StreamWiseJob): 

80 """A job to generate a podcast with images, audio, and video.""" 

81 

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) 

93 

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) 

101 

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}") 

115 

116 audio_info = get_audio_file_info(audio_path) 

117 audio_duration = audio_info["duration_seconds"] 

118 

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"] 

128 

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 

134 

135 new_audio_duration = get_aligned_duration( 

136 stripped_audio_duration, 

137 fps=fps, 

138 vae=VAE_T) 

139 

140 if audio_duration == new_audio_duration: 

141 return audio_path, audio_duration # It was already aligned 

142 

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 

153 

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}'...") 

176 

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.") 

191 

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 

196 

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) 

249 

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) 

253 

254 self._log_video_info(f"[{scene_id}] Generated video", scene_video_binary) 

255 

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}") 

272 

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 

277 

278 video_frames = await get_video_frames(scene_video_binary) 

279 

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}.") 

291 

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).") 

297 

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) 

304 

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) 

312 

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.") 

315 

316 return video_audio_path 

317 

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) 

332 

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 

338 

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 ) 

368 

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']}.") 

381 

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) 

385 

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.") 

391 

392 self._log_video_info(f"[{scene_id}] Generated video", video_binary) 

393 

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() 

407 

408 self._log_video_info( 

409 f"[{scene_id}] Added debug text to video", 

410 video_binary) 

411 

412 return video_binary 

413 

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}.") 

424 

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}'.") 

440 

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()) 

448 

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)}.") 

453 

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 

478 

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}'.") 

489 

490 return image, character_images 

491 

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))}.") 

503 

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) 

508 

509 # Default prompts 

510 img_prompt = IMG_PROMPT 

511 video_prompt = VIDEO_PROMPT 

512 video_neg_prompt = VIDEO_NEG_PROMPT 

513 

514 await self.save_status(JobStatus.RUNNING) 

515 

516 # Configurations 

517 num_characters = self.get_config_int("num_characters") 

518 output_mode = self.get_config_output_mode() 

519 

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() 

538 

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" 

562 

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.") 

577 

578 await self.save_status(JobStatus.RUNNING) 

579 

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.") 

593 

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 

621 

622 await self.save_status(JobStatus.RUNNING) 

623 

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...") 

634 

635 if not scene_video_paths: 

636 raise ValueError("No scene videos generated. Cannot create final podcast video.") 

637 

638 await self.save_status(JobStatus.RUNNING) 

639 

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) 

647 

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) 

655 

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) 

659 

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}'")