Coverage for apps/streamlecture/streamlecture_job.py: 94%

153 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1""" 

2StreamLecture job to generate a lecture video. 

3""" 

4import asyncio 

5import json 

6import sys 

7import aiofiles 

8 

9from PIL import Image 

10 

11from typing import override 

12from typing import Any 

13from typing import Dict 

14from typing import List 

15from typing import Optional 

16 

17from lecture_prompts import IMG_PROMPT 

18from lecture_prompts import IMG_NEG_PROMPT 

19from lecture_prompts import VIDEO_PROMPT 

20from lecture_prompts import VIDEO_NEG_PROMPT 

21from lecture_prompts import LECTURE_STYLE_PROMPT 

22from lecture_prompts import LECTURE_CUSTOM_PROMPT 

23 

24# Local relative imports 

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

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

27 

28from streamwise_job import StreamWiseJob 

29from streamwise_job import JobStatus 

30from streamwise_job import OutputMode 

31from streamwise_job import MAX_LOG_TEXT 

32 

33from lmm_service_manager import LMMServiceManager 

34 

35from gen_video_chunked import GenVideoChunked 

36 

37from console_utils import bytes_to_human 

38 

39from file_utils import base64_to_binary 

40from file_utils import save_base64_as_binary 

41 

42from pdf_utils import parse_pdf 

43 

44from media_utils import get_audio_duration 

45from media_utils import get_video_frames 

46from media_utils import get_video_file_info 

47from media_utils import save_video_audio 

48from media_utils import concatenate_videos 

49 

50from video import MAX_FT_DURATION_SECS 

51from video import FANTASYTALKING_FPS 

52 

53 

54class StreamLectureJob(StreamWiseJob): 

55 """A job to generate a lecture video.""" 

56 

57 def __init__( 

58 self, 

59 job_id: str, 

60 service_manager: LMMServiceManager, 

61 config: Dict[str, Any] = {}, 

62 ) -> None: 

63 super().__init__( 

64 "streamlecture", 

65 job_id, 

66 service_manager, 

67 config) 

68 self.image: Optional[Image.Image] = None 

69 

70 @override 

71 async def generate( 

72 self, 

73 job_config: Dict[str, Any], 

74 ) -> None: 

75 pdf_base64: Optional[str] = job_config.get("pdf_base64") 

76 await self.gen_lecture(pdf_base64) 

77 

78 async def gen_scene( 

79 self, 

80 scene_id: int, 

81 text: str, 

82 ) -> str: 

83 """ 

84 Generate a scene with TTS audio and video from the classroom image. 

85 Returns the path to the saved scene video. 

86 """ 

87 log_text = text[:MAX_LOG_TEXT] + "..." if len(text) > MAX_LOG_TEXT else text 

88 self.logger.info(f"[{scene_id}] Generating scene with text '{log_text}'.") 

89 

90 # Generate TTS audio 

91 voice = "af_heart" 

92 speed = self.get_config_float("speech_speed", 1.1) 

93 audio_base64 = await self.gen.gen_audio( 

94 text, 

95 voice=voice, 

96 speed=speed, 

97 task_id=f"{scene_id:03d}", 

98 deadline=self.get_scene_deadline(scene_id), 

99 ) 

100 if not audio_base64: 

101 raise ValueError(f"Cannot generate audio for scene {scene_id}") 

102 audio_duration = get_audio_duration(audio_base64) 

103 audio_path = f"{self.job_path}/{scene_id:03d}.wav" 

104 await save_base64_as_binary(audio_path, audio_base64) 

105 self.logger.info( 

106 f"[{scene_id}] Generated audio with {bytes_to_human(len(audio_base64))} " 

107 f"and {audio_duration:.3f} seconds.") 

108 

109 if self.image is None: 

110 raise ValueError(f"[{scene_id}] Classroom image not available for video generation.") 

111 

112 output_mode = self.get_config_output_mode() 

113 num_steps = self.get_num_steps() 

114 width, height = self.image.size 

115 

116 # Generate video from the classroom image + audio 

117 if output_mode is OutputMode.AUDIO_ONLY: 

118 # Audio-only: no video generation needed, handled at concatenation 

119 return audio_path 

120 

121 if audio_duration < MAX_FT_DURATION_SECS and output_mode == OutputMode.VIDEO_AUDIO_SYNCED: 

122 scene_video_binary = await self.gen.gen_video_audio_from_img( 

123 img=self.image, 

124 audio_base64=audio_base64, 

125 prompt=VIDEO_PROMPT, 

126 neg_prompt=VIDEO_NEG_PROMPT, 

127 width=width, 

128 height=height, 

129 steps=num_steps, 

130 task_id=f"{scene_id:03d}", 

131 deadline=self.get_scene_deadline(scene_id), 

132 ) 

133 elif audio_duration >= MAX_FT_DURATION_SECS and output_mode == OutputMode.VIDEO_AUDIO_SYNCED: 

134 gen_video_chunked = GenVideoChunked( 

135 video_id=scene_id, 

136 gen=self.gen, 

137 job_path=self.job_path, 

138 logger=self.logger) 

139 scene_video_binary = await gen_video_chunked.gen_video_chunked( 

140 audio_path=audio_path, 

141 image=self.image, 

142 prompt=VIDEO_PROMPT, 

143 neg_prompt=VIDEO_NEG_PROMPT, 

144 width=width, 

145 height=height, 

146 num_steps=num_steps, 

147 upscaling=self.get_config_bool("upscaling"), 

148 debug=self.get_config_bool("debug_image"), 

149 deadline=self.get_scene_deadline(scene_id), 

150 ) 

151 else: 

152 # VIDEO_AUDIO_UNSYNCED: generate video and merge audio separately 

153 scene_video_binary = await self.gen.gen_video( 

154 img=self.image, 

155 prompt=VIDEO_PROMPT, 

156 neg_prompt=VIDEO_NEG_PROMPT, 

157 width=width, 

158 height=height, 

159 video_seconds=audio_duration, 

160 steps=num_steps, 

161 task_id=f"{scene_id:03d}", 

162 wait_request=True, 

163 deadline=self.get_scene_deadline(scene_id), 

164 ) 

165 

166 video_frames = await get_video_frames(scene_video_binary) 

167 video_file_info = get_video_file_info(scene_video_binary) 

168 video_info = video_file_info.get("video", {}) 

169 _video_fps = video_info.get("fps") 

170 video_fps: float = _video_fps if _video_fps is not None else FANTASYTALKING_FPS 

171 

172 video_audio_path = f"{self.job_path}/{scene_id:03d}.mp4" 

173 video_audio_path = await save_video_audio( 

174 video_content=video_frames, 

175 audio_path=audio_path, 

176 out_video_path=video_audio_path, 

177 fps=video_fps) 

178 

179 self.logger.info(f"[{scene_id}] Scene saved to '{video_audio_path}'.") 

180 return video_audio_path 

181 

182 async def gen_lecture( 

183 self, 

184 pdf_base64: Optional[str], 

185 ) -> None: 

186 """ 

187 Generate a lecture video from a PDF. 

188 """ 

189 async with self.job_status_handler(): 

190 if not pdf_base64: 

191 self.logger.error("Document is required.") 

192 await self.save_status(JobStatus.FAILED) 

193 raise ValueError("Missing 'pdf_base64' in request") 

194 self.logger.info(f"Generating lecture video for document with {bytes_to_human(len(pdf_base64))}.") 

195 

196 # Save PDF for processing and debugging 

197 self.logger.info(f"Document base64 with {bytes_to_human(len(pdf_base64))}.") 

198 pdf_path = f"{self.job_path}/document.pdf" 

199 async with aiofiles.open(pdf_path, "wb") as file: 

200 pdf_binary = base64_to_binary(pdf_base64) 

201 await file.write(pdf_binary) 

202 

203 # Extract the materials from the PDF 

204 pdf_text, pdf_images = parse_pdf(pdf_path) 

205 num_pages = len(pdf_text) 

206 self.logger.info(f"Extracted {num_pages} pages and {len(pdf_images)} images from PDF.") 

207 

208 # Save extracted text for debugging 

209 txt_path = f"{self.job_path}/document.txt" 

210 async with aiofiles.open(txt_path, "w", encoding="utf-8") as txt_file: 

211 for page_ix, page_text in enumerate(pdf_text): 

212 await txt_file.write(f"--- Page {page_ix + 1} ---\n{page_text}\n") 

213 

214 # Generate the main classroom image in parallel with transcript generation 

215 image_task: asyncio.Task[Optional[Image.Image]] = asyncio.create_task( 

216 self.gen.gen_image( 

217 prompt=IMG_PROMPT, 

218 neg_prompt=IMG_NEG_PROMPT, 

219 width=self.width, 

220 height=self.height, 

221 task_id="main_image", 

222 deadline=self.get_submission_time(), 

223 ) 

224 ) 

225 

226 await self.save_status(JobStatus.RUNNING) 

227 

228 # Generate the lecture transcript using the podcast transcript service (1 professor character) 

229 output_mode = self.get_config_output_mode() 

230 scene_texts: List[Dict] = [] 

231 async with aiofiles.open(f"{self.job_path}/lecture_transcript.jsonl", "wb") as file: 

232 async for line_json in self.gen.gen_podcast_transcript( 

233 task_id="transcript", 

234 pdf_base64=pdf_base64, 

235 num_characters=1, 

236 style_prompt=LECTURE_STYLE_PROMPT, 

237 custom_prompt=LECTURE_CUSTOM_PROMPT, 

238 max_tokens=self.get_config_int("max_tokens"), 

239 max_dialogues=self.get_config_int("max_dialogues"), 

240 max_words_per_dialogue=self.get_config_int("max_words_per_dialogue"), 

241 ): 

242 line = (json.dumps(line_json) + "\n").encode("utf-8") 

243 await file.write(line) 

244 await file.flush() 

245 

246 line_type = line_json.get("type", "") 

247 if line_type == "dialogue": 

248 log_text = line_json.get("content", "")[:MAX_LOG_TEXT] 

249 self.logger.info(f"Scene {len(scene_texts)}: {log_text}") 

250 scene_texts.append(line_json) 

251 self.logger.info(f"Lecture transcript generated with {len(scene_texts)} scenes.") 

252 

253 await self.save_status(JobStatus.RUNNING) 

254 

255 # Wait for classroom image 

256 self.image = await image_task 

257 if self.image is not None: 

258 width, height = self.image.size 

259 image_path = f"{self.job_path}/main_image.png" 

260 self.image.save(image_path) 

261 self.logger.info(f"Classroom image {width}x{height} saved to '{image_path}'.") 

262 elif output_mode is not OutputMode.AUDIO_ONLY: 

263 raise ValueError("Cannot generate classroom image for lecture video.") 

264 

265 # Generate each scene concurrently 

266 scene_video_tasks: Dict[int, asyncio.Task] = {} 

267 for scene_id, scene_json in enumerate(scene_texts): 

268 scene_text = scene_json.get("content", "") 

269 if not scene_text: 

270 self.logger.warning(f"[{scene_id}] Empty scene text. Skipping.") 

271 continue 

272 scene_task = asyncio.create_task( 

273 self.gen_scene(scene_id=scene_id, text=scene_text) 

274 ) 

275 scene_video_tasks[scene_id] = scene_task 

276 

277 await self.save_status(JobStatus.RUNNING) 

278 

279 # Collect scene results 

280 scene_video_paths: List[str] = [] 

281 results = await asyncio.gather(*scene_video_tasks.values(), return_exceptions=True) 

282 for scene_id, result in enumerate(results): 

283 if isinstance(result, Exception): 

284 self._handle_scene_exception(scene_id, result) 

285 elif isinstance(result, str): 

286 scene_video_paths.append(result) 

287 self.logger.info(f"[{scene_id}] Scene saved to '{result}'.") 

288 else: 

289 self.logger.warning(f"[{scene_id}] No video generated. Skipping.") 

290 

291 if not scene_video_paths: 

292 raise ValueError("No scene videos generated. Cannot create final lecture video.") 

293 

294 await self.save_status(JobStatus.RUNNING) 

295 

296 # Concatenate all scene videos into a final lecture video 

297 self.logger.info(f"Concatenating {len(scene_video_paths)} scenes into final lecture video...") 

298 scene_binaries: List[bytes] = [] 

299 for scene_video_path in scene_video_paths: 

300 async with aiofiles.open(scene_video_path, "rb") as vfile: 

301 scene_binaries.append(await vfile.read()) 

302 

303 video_binary = await concatenate_videos(scene_binaries, fast_copy=False) 

304 if not video_binary: 

305 raise ValueError("Cannot concatenate scenes into final lecture video.") 

306 

307 video_path = f"{self.job_path}/{self.job_id}.mp4" 

308 async with aiofiles.open(video_path, "wb") as vfile: 

309 await vfile.write(video_binary) 

310 

311 self.logger.info(f"Generated lecture video with {bytes_to_human(len(video_binary))} at '{video_path}'.")