Coverage for apps/streamedit/streamedit_job.py: 77%

202 statements  

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

1""" 

2StreamEdit job to generate an edited video. 

3""" 

4import sys 

5import json 

6import aiofiles 

7import aiofiles.os 

8 

9from typing import override 

10from typing import Any 

11from typing import Dict 

12from typing import List 

13from typing import Optional 

14 

15from dataclasses import asdict 

16 

17from scenedetect import open_video 

18from scenedetect import SceneManager 

19from scenedetect.detectors import ContentDetector 

20from scenedetect.stats_manager import StatsManager 

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 

28 

29from lmm_service_manager import LMMServiceManager 

30 

31from scene import SceneSegment 

32 

33from console_utils import bytes_to_human 

34 

35from file_utils import save_base64_as_binary 

36from file_utils import read_file_bytes 

37from file_utils import read_file_base64 

38from media_utils import chunk_video_binary 

39from media_utils import chunk_audio_base64 

40from media_utils import get_video_frames 

41from media_utils import extract_audio_from_video 

42from media_utils import concatenate_videos 

43 

44from tts_utils import get_audio_chunks_by_silences 

45 

46from video import MAX_FT_DURATION_SECS 

47from video import FANTASYTALKING_FPS 

48from video import get_num_video_frames_from_duration 

49 

50from edit_prompts import build_edit_prompt 

51from edit_prompts import EDIT_PROMPT 

52 

53 

54class StreamEditJob(StreamWiseJob): 

55 """A job to generate an edited 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 "streamedit", 

65 job_id, 

66 service_manager, 

67 config) 

68 self.scenes: List[SceneSegment] = [] # Populated by detect_scenes() during gen_edit() 

69 

70 @override 

71 async def generate( 

72 self, 

73 job_config: Dict[str, Any], 

74 ) -> None: 

75 video_base64 = job_config.get("video_base64", None) 

76 assert video_base64 is not None 

77 await self.gen_edit(video_base64) 

78 

79 def _save_frame_from_video(self, video_path: str, frame_path: str, frame_num: int = 0) -> bool: 

80 """Extract a single frame from a video file and save it as a JPEG. 

81 

82 Returns True on success, False if the frame could not be read. 

83 """ 

84 import cv2 # noqa: PLC0415 - deferred to avoid module-level dependency 

85 cap = cv2.VideoCapture(video_path) 

86 cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num) 

87 ok, frame = cap.read() 

88 cap.release() 

89 if ok: 

90 cv2.imwrite(frame_path, frame) 

91 return ok 

92 

93 async def extract_scene_frames(self) -> None: 

94 """Extract one representative frame per scene from the input video and save as PNG. 

95 

96 Populates ``scene.frame_image_paths`` for each scene so the WebUI can 

97 display a thumbnail alongside the scene details. 

98 """ 

99 video_path = f"{self.job_path}/video.mp4" 

100 for scene in self.scenes: 

101 frame_file_name = f"scene_{scene.scene_id:03d}_frame.png" 

102 frame_path = f"{self.job_path}/{frame_file_name}" 

103 ok = self._save_frame_from_video(video_path, frame_path, scene.start_frame) 

104 if ok: 

105 scene.add_image_path(frame_file_name) 

106 else: 

107 self.logger.warning( 

108 f"[{scene.scene_id}] Failed to extract frame at position {scene.start_frame}.") 

109 

110 async def _save_video_as_output(self, video_path: str) -> None: 

111 """Copy a video file to the job output path.""" 

112 out_path = f"{self.job_path}/{self.job_id}.mp4" 

113 video_binary = await read_file_bytes(video_path) 

114 async with aiofiles.open(out_path, "wb") as file: 

115 await file.write(video_binary) 

116 self.logger.info(f"Video ({bytes_to_human(len(video_binary))}) saved to '{out_path}'.") 

117 

118 async def gen_edit( 

119 self, 

120 video_base64: str, 

121 ) -> None: 

122 """ 

123 Generate an edited video given an input video. 

124 """ 

125 async with self.job_status_handler(): 

126 if not video_base64: 

127 self.logger.error("Video is required.") 

128 await self.save_status(JobStatus.FAILED) 

129 raise ValueError("Missing 'video_base64' in request") 

130 self.logger.info(f"Generating edit for video with {bytes_to_human(len(video_base64))}.") 

131 

132 # Save video locally for processing and debugging 

133 self.logger.info(f"Saving input video with {bytes_to_human(len(video_base64))}.") 

134 video_path = f"{self.job_path}/video.mp4" 

135 await save_base64_as_binary(video_path, video_base64) 

136 

137 await self.save_status(JobStatus.RUNNING) 

138 

139 # Detect scenes first (this validates the video and may take time) 

140 self.scenes = await self.detect_scenes(video_path) 

141 self.logger.info(f"Detected {len(self.scenes)} scenes.") 

142 

143 await self.save_status(JobStatus.RUNNING) 

144 

145 # Build edit prompt from user instructions 

146 edit_instructions = self.get_config_str("edit_instructions") 

147 edit_prompt = build_edit_prompt(edit_instructions) 

148 

149 # If no scenes detected, fall back to returning the original video unchanged 

150 if not self.scenes: 

151 self.logger.warning("No scenes detected. Saving original video as output.") 

152 await self._save_video_as_output(video_path) 

153 return 

154 

155 # Chunk audio into per-scene files 

156 await self.chunk_audio_into_scenes() 

157 

158 # Extract a thumbnail frame per scene for the WebUI 

159 await self.extract_scene_frames() 

160 

161 # Write scenes debug file (audio_path and frame_image_paths are now populated) 

162 scenes_path = f"{self.job_path}/scenes.json" 

163 async with aiofiles.open(scenes_path, "w") as scene_file: 

164 scenes_dict_list = [asdict(scene) for scene in self.scenes] 

165 await scene_file.write(json.dumps(scenes_dict_list, indent=2)) 

166 

167 await self.save_status(JobStatus.RUNNING) 

168 

169 # Edit each scene 

170 scene_video_paths: List[Optional[str]] = [] 

171 for scene in self.scenes: 

172 try: 

173 scene_path = await self.gen_edit_scene(scene, edit_prompt) 

174 scene_video_paths.append(scene_path) 

175 self.logger.info(f"[{scene.scene_id}] Edited scene saved to '{scene_path}'.") 

176 # Extract a thumbnail from the edited scene for the WebUI 

177 edit_frame_path = f"{self.job_path}/scene_{scene.scene_id:03d}_edit_frame.png" 

178 if not self._save_frame_from_video(scene_path, edit_frame_path): 

179 self.logger.warning(f"[{scene.scene_id}] Could not extract edit frame.") 

180 except Exception as ex: 

181 self.logger.error(f"[{scene.scene_id}] Error editing scene: {ex}") 

182 scene_video_paths.append(None) 

183 

184 await self.save_status(JobStatus.RUNNING) 

185 

186 # Combine the edited scenes into a final video 

187 valid_paths = [p for p in scene_video_paths if p] 

188 if not valid_paths: 

189 # No scenes could be edited – fall back to the original video 

190 self.logger.warning("No edited scenes produced. Saving original video as output.") 

191 await self._save_video_as_output(video_path) 

192 return 

193 

194 self.logger.info(f"Combining {len(valid_paths)} edited scenes into final video...") 

195 scene_binaries: List[bytes] = [] 

196 for path in valid_paths: 

197 scene_binary = await read_file_bytes(path) 

198 scene_binaries.append(scene_binary) 

199 

200 video_binary = await concatenate_videos(scene_binaries) 

201 if not video_binary: 

202 raise ValueError("Cannot concatenate edited scenes into final video.") 

203 

204 out_path = f"{self.job_path}/{self.job_id}.mp4" 

205 async with aiofiles.open(out_path, "wb") as file: 

206 await file.write(video_binary) 

207 

208 self.logger.info(f"Generated edited video with {bytes_to_human(len(video_binary))} at '{out_path}'.") 

209 

210 async def detect_scenes( 

211 self, 

212 video_path: str, 

213 threshold: float = 27.0, 

214 min_scene_len: int = 15, 

215 ) -> List[SceneSegment]: 

216 """ 

217 Return list of scenes for a video. 

218 TODO Move to a library later. 

219 """ 

220 if not await aiofiles.os.path.exists(video_path): 

221 raise FileNotFoundError(f"Video file not found: {video_path}") 

222 video = open_video(video_path) 

223 

224 stats_manager = StatsManager() 

225 scene_manager = SceneManager(stats_manager) 

226 content_detector = ContentDetector( 

227 threshold=threshold, 

228 min_scene_len=min_scene_len) 

229 scene_manager.add_detector(content_detector) 

230 

231 scene_manager.detect_scenes(video) 

232 scene_list = scene_manager.get_scene_list() 

233 

234 scenes = [] 

235 for scene_id, (start_tc, end_tc) in enumerate(scene_list): 

236 scene = SceneSegment( 

237 scene_id, 

238 start_tc.get_frames(), end_tc.get_frames(), 

239 start_tc.get_seconds(), end_tc.get_seconds() 

240 ) 

241 scenes.append(scene) 

242 return scenes 

243 

244 async def chunk_audio_into_scenes(self) -> List[str]: 

245 """ 

246 Chunk the audio of the video into per-scene WAV files. 

247 Sets scene.audio_path on each SceneSegment to the relative filename 

248 (e.g. scene_000.wav) and saves the file to the job directory. 

249 Returns a list of base64-encoded audio chunks, one per scene. 

250 """ 

251 chunks = [] 

252 try: 

253 video_path = f"{self.job_path}/video.mp4" 

254 audio_path = f"{self.job_path}/audio.wav" 

255 audio_path = await extract_audio_from_video(video_path, audio_path) 

256 audio_base64 = await read_file_base64(audio_path) 

257 self.logger.info(f"Extracted audio with {bytes_to_human(len(audio_base64))}.") 

258 

259 for scene in self.scenes: 

260 scene_audio_base64 = chunk_audio_base64( 

261 audio_base64=audio_base64, 

262 start_seconds=scene.start_sec, 

263 end_seconds=scene.end_sec) 

264 scene_audio_path = f"{self.job_path}/scene_{scene.scene_id:03d}.wav" 

265 await save_base64_as_binary(scene_audio_path, scene_audio_base64) 

266 scene.audio_path = f"scene_{scene.scene_id:03d}.wav" 

267 chunks.append(scene_audio_base64) 

268 except Exception as ex: 

269 self.logger.error(f"Error during audio chunking: {ex} [{type(ex)}]") 

270 return chunks 

271 

272 async def gen_edit_scene( 

273 self, 

274 scene: SceneSegment, 

275 edit_prompt: str = EDIT_PROMPT, 

276 ) -> str: 

277 """ 

278 Generate edited version of a scene and save it to disk. 

279 Returns the path to the saved file. 

280 """ 

281 input_video_path = f"{self.job_path}/video.mp4" 

282 input_video_binary = await read_file_bytes(input_video_path) 

283 

284 scene_binary = chunk_video_binary( 

285 video_binary=input_video_binary, 

286 start_seconds=scene.start_sec, 

287 end_seconds=scene.end_sec, 

288 ) 

289 

290 scene_audio_path = f"{self.job_path}/{scene.audio_path}" 

291 scene_audio_base64 = await read_file_base64(scene_audio_path) 

292 

293 scene_duration = scene.end_sec - scene.start_sec 

294 if scene_duration > MAX_FT_DURATION_SECS: 

295 scene_edit_binary = await self._gen_edit_scene_chunked( 

296 scene=scene, 

297 scene_binary=scene_binary, 

298 scene_audio_path=scene_audio_path, 

299 scene_audio_base64=scene_audio_base64, 

300 edit_prompt=edit_prompt, 

301 ) 

302 else: 

303 scene_video_frames = await get_video_frames(scene_binary) 

304 scene_edit_binary = await self.gen.gen_video_audio_from_video( 

305 video=scene_video_frames, 

306 audio_base64=scene_audio_base64, 

307 prompt=edit_prompt, 

308 task_id=f"{scene.scene_id:03d}", 

309 deadline=self.get_submission_time() + scene.start_sec, 

310 ) 

311 

312 scene_path = f"{self.job_path}/scene_{scene.scene_id:03d}_edit.mp4" 

313 async with aiofiles.open(scene_path, "wb") as file: 

314 await file.write(scene_edit_binary) 

315 return scene_path 

316 

317 async def _gen_edit_scene_chunked( 

318 self, 

319 scene: SceneSegment, 

320 scene_binary: bytes, 

321 scene_audio_path: str, 

322 scene_audio_base64: str, 

323 edit_prompt: str, 

324 ) -> bytes: 

325 """ 

326 Generate edited scene in sub-chunks when the scene exceeds MAX_FT_DURATION_SECS. 

327 Splits the scene audio by silences, generates each sub-chunk independently, 

328 then concatenates the results. 

329 """ 

330 scene_duration = scene.end_sec - scene.start_sec 

331 self.logger.info( 

332 "[%d] Scene too long (%.3f > %.3f s), using chunked generation.", 

333 scene.scene_id, scene_duration, MAX_FT_DURATION_SECS) 

334 

335 audio_splits = get_audio_chunks_by_silences( 

336 scene_audio_path, 

337 max_duration_seconds=MAX_FT_DURATION_SECS, 

338 chunk_alignment_seconds=1.0 / FANTASYTALKING_FPS, 

339 ) 

340 

341 if not audio_splits: 

342 raise ValueError(f"[{scene.scene_id}] No audio splits produced for long scene.") 

343 

344 self.logger.info( 

345 "[%d] Split into %d sub-chunks.", scene.scene_id, len(audio_splits)) 

346 

347 sub_binaries: List[bytes] = [] 

348 for sub_idx, (sub_start_s, sub_end_s) in enumerate(audio_splits): 

349 sub_duration = sub_end_s - sub_start_s 

350 self.logger.info( 

351 "[%d.%d] Sub-chunk %.3f-%.3f s (%.3f s).", 

352 scene.scene_id, sub_idx, sub_start_s, sub_end_s, sub_duration) 

353 

354 sub_scene_binary = chunk_video_binary( 

355 video_binary=scene_binary, 

356 start_seconds=sub_start_s, 

357 end_seconds=sub_end_s, 

358 ) 

359 sub_scene_frames = await get_video_frames(sub_scene_binary) 

360 

361 if not sub_scene_frames: 

362 self.logger.warning( 

363 "[%d.%d] No frames for sub-chunk, skipping.", scene.scene_id, sub_idx) 

364 continue 

365 

366 # Align frame count to what FantasyTalking expects for the audio duration 

367 expected_frames = get_num_video_frames_from_duration(sub_duration) 

368 if len(sub_scene_frames) < expected_frames: 

369 sub_scene_frames += [sub_scene_frames[-1]] * (expected_frames - len(sub_scene_frames)) 

370 elif len(sub_scene_frames) > expected_frames: 

371 sub_scene_frames = sub_scene_frames[:expected_frames] 

372 

373 sub_audio_base64 = chunk_audio_base64( 

374 audio_base64=scene_audio_base64, 

375 start_seconds=sub_start_s, 

376 end_seconds=sub_end_s, 

377 ) 

378 

379 sub_edit_binary = await self.gen.gen_video_audio_from_video( 

380 video=sub_scene_frames, 

381 audio_base64=sub_audio_base64, 

382 prompt=edit_prompt, 

383 task_id=f"{scene.scene_id:03d}_{sub_idx:03d}", 

384 deadline=self.get_submission_time() + scene.start_sec + sub_start_s, 

385 ) 

386 sub_binaries.append(sub_edit_binary) 

387 

388 if not sub_binaries: 

389 raise ValueError(f"[{scene.scene_id}] All sub-chunks failed for long scene.") 

390 

391 scene_edit_binary = await concatenate_videos(sub_binaries) 

392 if not scene_edit_binary: 

393 raise ValueError(f"[{scene.scene_id}] Cannot concatenate sub-chunk results.") 

394 return scene_edit_binary