Coverage for tests/streamwise_app/test_streamdub.py: 100%

389 statements  

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

1#!/usr/bin/env python3 

2""" 

3Unit tests for StreamDub. 

4""" 

5 

6import base64 

7import json 

8import os 

9import sys 

10import pytest 

11 

12from dataclasses import asdict 

13from http import HTTPStatus 

14 

15from quart import Quart 

16 

17from unittest.mock import patch 

18from unittest.mock import MagicMock 

19from unittest.mock import AsyncMock 

20 

21# Add current path 

22sys.path.append(os.getcwd()) 

23 

24from tests.test_utils import temp_sys_path 

25from tests.torch_mock import TorchMock 

26from tests.streamwise_app.app_test_helpers import check_app_root 

27from tests.streamwise_app.app_test_helpers import check_health 

28from tests.streamwise_app.app_test_helpers import check_files 

29from tests.streamwise_app.app_test_helpers import check_unknown_route 

30from tests.streamwise_app.app_test_helpers import check_job_submit_page 

31from tests.streamwise_app.app_test_helpers import check_job_status_page 

32from tests.streamwise_app.app_test_helpers import check_api_job_status 

33from tests.streamwise_app.app_test_helpers import check_api_job_requests 

34 

35mock_torch = TorchMock() 

36 

37mock_modules = {} 

38mock_modules.update(mock_torch.get_sub_modules()) 

39 

40with patch.dict(sys.modules, mock_modules): 

41 with temp_sys_path("apps", "apps/streamdub"): 

42 from apps.streamdub.streamdub import StreamDubApp 

43 

44scene_mocks_base = { 

45 'scenedetect': MagicMock(), 

46 'scenedetect.detectors': MagicMock(), 

47 'scenedetect.stats_manager': MagicMock(), 

48} 

49 

50with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

51 with temp_sys_path("apps", "apps/streamdub"): 

52 from apps.streamdub.streamdub_job import StreamDubJob 

53 from apps.streamdub.streamdub_job import _is_empty_transcript 

54 

55 

56streamdub_app = StreamDubApp() 

57 

58 

59@pytest.fixture(name="test_app") 

60def _test_app() -> Quart: 

61 return streamdub_app.app 

62 

63 

64@pytest.mark.asyncio 

65async def test_app(test_app: Quart) -> None: 

66 """Check that GET / returns 200.""" 

67 await check_app_root(test_app, "StreamDub") 

68 

69 

70@pytest.mark.asyncio 

71async def test_health(test_app: Quart) -> None: 

72 """Check /health.""" 

73 await check_health(test_app) 

74 

75 

76@pytest.mark.asyncio 

77async def test_files(test_app: Quart) -> None: 

78 """Check /files endpoint.""" 

79 await check_files(test_app, "streamdub") 

80 

81 

82@pytest.mark.asyncio 

83async def test_unknown_route(test_app: Quart) -> None: 

84 """Check that an unknown route returns 404.""" 

85 await check_unknown_route(test_app) 

86 

87 

88@pytest.mark.asyncio 

89async def test_job_submit_page(test_app: Quart) -> None: 

90 """Check the web page for job submission.""" 

91 await check_job_submit_page(test_app) 

92 

93 

94@pytest.mark.asyncio 

95async def test_job_status_page(test_app: Quart) -> None: 

96 """Check the web page for job status.""" 

97 await check_job_status_page(test_app) 

98 

99 

100@pytest.mark.asyncio 

101async def test_api_job_status(test_app: Quart) -> None: 

102 """Check the API for job status (returns UNKNOWN for nonexistent jobs).""" 

103 await check_api_job_status(test_app) 

104 

105 

106@pytest.mark.asyncio 

107async def test_api_job_requests(test_app: Quart) -> None: 

108 """Check the API for job requests listing (returns empty for nonexistent jobs).""" 

109 await check_api_job_requests(test_app) 

110 

111 

112@pytest.mark.asyncio 

113async def test_submit_job(test_app: Quart) -> None: 

114 """Check the API for job requests.""" 

115 client = test_app.test_client() 

116 

117 response = await client.post("/api/job", json={"video_base64": "AAAA"}) 

118 assert response.status_code == HTTPStatus.BAD_REQUEST 

119 response_json = await response.get_json() 

120 assert "error" in response_json 

121 assert response_json["error"] == "Service manager not initialized" 

122 

123 # Mock the service manager 

124 streamdub_app.service_manager = MagicMock() 

125 streamdub_app.service_manager.get_service_url = MagicMock( 

126 return_value="http://mock_service_url:1234" 

127 ) 

128 

129 response = await client.post("/api/job", json={"video_base64": "AAAA"}) 

130 # assert response.status_code == HTTPStatus.OK 

131 assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR 

132 response_json = await response.get_json() 

133 assert "error" in response_json 

134 # assert "job_id" in response_json 

135 # assert response_json["status"] == "success" 

136 

137 

138@pytest.mark.asyncio 

139async def test_streamdub_job_no_video() -> None: 

140 """StreamDubJob.gen_dub with missing video raises ValueError.""" 

141 job = StreamDubJob( 

142 job_id="test_no_video", 

143 service_manager=MagicMock(), 

144 ) 

145 with pytest.raises(ValueError, match="Missing 'video_base64'"): 

146 await job.gen_dub(video_base64=None) 

147 

148 

149@pytest.mark.asyncio 

150async def test_streamdub_job_generate_no_video() -> None: 

151 """StreamDubJob.generate with missing video_base64 raises ValueError.""" 

152 job = StreamDubJob( 

153 job_id="test_generate_no_video", 

154 service_manager=MagicMock(), 

155 ) 

156 with pytest.raises(ValueError, match="Missing 'video_base64'"): 

157 await job.generate(job_config={}) 

158 

159 

160@pytest.mark.asyncio 

161async def test_streamdub_job_detect_scenes_missing_file() -> None: 

162 """StreamDubJob.detect_scenes raises FileNotFoundError when video file is absent.""" 

163 job = StreamDubJob( 

164 job_id="test_detect_no_file", 

165 service_manager=MagicMock(), 

166 ) 

167 with pytest.raises(FileNotFoundError): 

168 await job.detect_scenes() 

169 

170 

171@pytest.mark.asyncio 

172async def test_streamdub_job_gen_dub_no_scenes() -> None: 

173 """gen_dub with a valid video but no detected scenes raises ValueError.""" 

174 job = StreamDubJob( 

175 job_id="test_gen_dub_no_scenes", 

176 service_manager=MagicMock(), 

177 ) 

178 # "AAAA" is valid base64; scenedetect mocks return empty scene list 

179 with pytest.raises(ValueError, match="No scenes detected"): 

180 await job.gen_dub(video_base64="AAAA") 

181 

182 

183@pytest.mark.asyncio 

184async def test_gen_dub_writes_scenes_json() -> None: 

185 """gen_dub must write scenes.json after chunking audio so the UI can display scenes.""" 

186 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

187 with temp_sys_path("apps", "apps/streamdub"): 

188 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

189 from apps.scene import SceneSegment 

190 

191 service_manager = AsyncMock() 

192 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

193 

194 job_id = "test_scenes_json" 

195 job = _StreamDubJob(job_id=job_id, service_manager=service_manager) 

196 

197 # Build two fake scenes (audio_path not yet set — chunk_audio_into_scenes sets it) 

198 fake_scenes = [ 

199 SceneSegment(scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0), 

200 SceneSegment(scene_id=1, start_frame=30, end_frame=60, start_sec=1.0, end_sec=2.0), 

201 ] 

202 

203 # Patch detect_scenes and chunk_audio_into_scenes so we control the scene list 

204 async def fake_detect_scenes(**kwargs: object) -> list: 

205 return fake_scenes 

206 

207 async def fake_chunk_audio(**kwargs: object) -> list: 

208 # Simulate chunking setting audio_path on each scene (mirrors real behaviour) 

209 for scene in fake_scenes: 

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

211 return [] 

212 

213 with patch.object(job, "detect_scenes", side_effect=fake_detect_scenes), \ 

214 patch.object(job, "chunk_audio_into_scenes", side_effect=fake_chunk_audio), \ 

215 patch.object(job, "gen_dub_scene", side_effect=ValueError("stop")): 

216 try: 

217 await job.gen_dub(video_base64="AAAA") 

218 except (ValueError, Exception): 

219 pass # We only care that scenes.json was written before gen_dub_scene is called 

220 

221 scenes_json_path = os.path.join(job.job_path, "scenes.json") 

222 assert os.path.exists(scenes_json_path), "scenes.json must be written after chunk_audio_into_scenes" 

223 with open(scenes_json_path) as f: 

224 data = json.load(f) 

225 assert len(data) == 2 

226 assert data[0]["scene_id"] == 0 

227 assert data[0]["audio_path"] == "scene_000.wav" 

228 assert data[1]["scene_id"] == 1 

229 assert data[1]["audio_path"] == "scene_001.wav" 

230 

231 await job.close() 

232 

233 

234@pytest.mark.asyncio 

235async def test_gen_dub_scene_uses_voice_cloning() -> None: 

236 """gen_dub_scene must call gen_audio with voice_sample when original scene audio is available.""" 

237 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

238 with temp_sys_path("apps", "apps/streamdub"): 

239 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

240 from apps.scene import SceneSegment 

241 

242 service_manager = AsyncMock() 

243 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

244 

245 job_id = "test_voice_clone" 

246 job = _StreamDubJob(job_id=job_id, service_manager=service_manager, config={"add_subtitles": False}) 

247 

248 # Write a dummy original scene audio file that the job should read and forward 

249 original_audio_content = b"RIFF....WAVEfmt " # minimal dummy WAV bytes 

250 original_audio_b64 = base64.b64encode(original_audio_content).decode() 

251 scene_audio_path = os.path.join(job.job_path, "scene_000.wav") 

252 os.makedirs(job.job_path, exist_ok=True) 

253 with open(scene_audio_path, "wb") as f: 

254 f.write(original_audio_content) 

255 

256 # Create a scene with transcript already set (skip transcription / translation) 

257 fake_scene = SceneSegment( 

258 scene_id=0, 

259 start_frame=0, 

260 end_frame=30, 

261 start_sec=0.0, 

262 end_sec=1.0, 

263 ) 

264 fake_scene.audio_path = "scene_000.wav" 

265 fake_scene.transcript = "Hola mundo" # pre-translated text 

266 

267 dubbed_audio_b64 = base64.b64encode(b"dubbed_audio").decode() 

268 dubbed_video_binary = b"dubbed_video" 

269 

270 gen_audio_mock = AsyncMock(return_value=dubbed_audio_b64) 

271 gen_video_mock = AsyncMock(return_value=dubbed_video_binary) 

272 

273 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value="Hello world")), \ 

274 patch.object(job, "translate_scene", new=AsyncMock(return_value="Hola mundo")), \ 

275 patch.object(job.gen, "gen_audio", gen_audio_mock), \ 

276 patch.object(job, "gen_video_lip_synced", gen_video_mock): 

277 await job.gen_dub_scene(fake_scene, lang_code="e") 

278 

279 # Verify that gen_audio was called with voice_sample set to the original audio base64 

280 gen_audio_mock.assert_called_once() 

281 call_kwargs = gen_audio_mock.call_args.kwargs 

282 assert call_kwargs.get("voice_sample") == original_audio_b64, ( 

283 "voice_sample must equal the base64-encoded original scene audio" 

284 ) 

285 assert call_kwargs.get("text") == "Hola mundo", ( 

286 "gen_audio must receive the translated text" 

287 ) 

288 

289 await job.close() 

290 

291 

292@pytest.mark.asyncio 

293async def test_gen_dub_scene_falls_back_when_no_audio() -> None: 

294 """gen_dub_scene calls gen_audio without voice_sample when the original scene audio is missing.""" 

295 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

296 with temp_sys_path("apps", "apps/streamdub"): 

297 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

298 from apps.scene import SceneSegment 

299 

300 service_manager = AsyncMock() 

301 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

302 

303 job = _StreamDubJob(job_id="test_fallback", service_manager=service_manager, config={"add_subtitles": False}) 

304 

305 # Do NOT write a scene audio file — simulate missing audio 

306 fake_scene = SceneSegment( 

307 scene_id=0, 

308 start_frame=0, 

309 end_frame=30, 

310 start_sec=0.0, 

311 end_sec=1.0, 

312 ) 

313 fake_scene.audio_path = "scene_000.wav" 

314 fake_scene.transcript = "Hola mundo" 

315 

316 dubbed_audio_b64 = base64.b64encode(b"dubbed_audio").decode() 

317 dubbed_video_binary = b"dubbed_video" 

318 

319 gen_audio_mock = AsyncMock(return_value=dubbed_audio_b64) 

320 gen_video_mock = AsyncMock(return_value=dubbed_video_binary) 

321 

322 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value="Hello world")), \ 

323 patch.object(job, "translate_scene", new=AsyncMock(return_value="Hola mundo")), \ 

324 patch.object(job.gen, "gen_audio", gen_audio_mock), \ 

325 patch.object(job, "gen_video_lip_synced", gen_video_mock): 

326 await job.gen_dub_scene(fake_scene, lang_code="e") 

327 

328 # gen_audio should be called without voice_sample (no audio file → standard TTS) 

329 gen_audio_mock.assert_called_once() 

330 assert gen_audio_mock.call_args.kwargs.get("voice_sample") is None 

331 

332 await job.close() 

333 

334 

335@pytest.mark.asyncio 

336async def test_gen_dub_scene_updates_scenes_json() -> None: 

337 """gen_dub_scene must update scenes.json with transcript and translation 

338 so the UI stops showing '⏳ Transcribing…' / '⏳ Translating…' during processing.""" 

339 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

340 with temp_sys_path("apps", "apps/streamdub"): 

341 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

342 from apps.scene import SceneSegment 

343 

344 service_manager = AsyncMock() 

345 job = _StreamDubJob(job_id="test_scenes_json_update", service_manager=service_manager) 

346 os.makedirs(job.job_path, exist_ok=True) 

347 

348 scene = SceneSegment( 

349 scene_id=2, start_frame=60, end_frame=90, 

350 start_sec=2.0, end_sec=3.0, 

351 audio_path="scene_002.wav", 

352 ) 

353 job.scenes = [scene] 

354 

355 # Write initial scenes.json without transcript/translation (mirrors gen_dub behaviour) 

356 scenes_path = os.path.join(job.job_path, "scenes.json") 

357 with open(scenes_path, "w") as scenes_file: 

358 json.dump([asdict(scene)], scenes_file) 

359 

360 with open(os.path.join(job.job_path, "scene_002.wav"), "wb") as audio_file: 

361 audio_file.write(b"\x00" * 16) 

362 

363 original_transcript = "Hello, how are you?" 

364 translated_text = "Hola, ¿cómo estás?" 

365 

366 # Capture scenes.json state when translate_scene is called — transcript must already be present 

367 scenes_json_at_translation_time: list = [] 

368 

369 async def fake_transcribe(s: object) -> str: 

370 return original_transcript 

371 

372 async def fake_translate(s: object, **kwargs: object) -> str: 

373 with open(scenes_path) as f: 

374 scenes_json_at_translation_time.extend(json.load(f)) 

375 return translated_text 

376 

377 async def fake_lip_sync(s: object) -> bytes: 

378 return b"video_bytes" 

379 

380 with patch.object(job, "transcribe_audio", side_effect=fake_transcribe), \ 

381 patch.object(job, "translate_scene", side_effect=fake_translate), \ 

382 patch.object(job, "save_status", new=AsyncMock()), \ 

383 patch.object(job, "gen_video_lip_synced", side_effect=fake_lip_sync), \ 

384 patch.object(job, "_add_subtitles_to_video", new=AsyncMock(return_value=b"video_bytes")), \ 

385 patch.object(job, "get_submission_time", return_value=0.0): 

386 job.gen = MagicMock() 

387 job.gen.gen_audio = AsyncMock(return_value="AAAA") 

388 job.gen.stop = AsyncMock() 

389 await job.gen_dub_scene(scene, lang_code="e") 

390 

391 # scenes.json must contain the transcript by the time translation is requested 

392 assert len(scenes_json_at_translation_time) == 1 

393 assert scenes_json_at_translation_time[0]["transcript"] == original_transcript, \ 

394 "scenes.json must be updated with transcript before translation begins" 

395 

396 # scenes.json must contain the translation after gen_dub_scene completes 

397 with open(scenes_path) as f: 

398 final_scenes = json.load(f) 

399 assert final_scenes[0]["transcript"] == original_transcript 

400 assert final_scenes[0]["translation"] == translated_text, \ 

401 "scenes.json must be updated with translation after gen_dub_scene completes" 

402 

403 await job.close() 

404 

405 

406def test_scene_segment_has_translation_field() -> None: 

407 """SceneSegment must have a 'translation' field separate from 'transcript'.""" 

408 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

409 with temp_sys_path("apps", "apps/streamdub"): 

410 from apps.scene import SceneSegment 

411 

412 scene = SceneSegment(scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0) 

413 scene.transcript = "Hello, how are you?" 

414 scene.translation = "Hola, ¿cómo estás?" 

415 

416 d = asdict(scene) 

417 assert d["transcript"] == "Hello, how are you?" 

418 assert d["translation"] == "Hola, ¿cómo estás?" 

419 # Changing translation must not affect transcript 

420 scene.translation = "Bonjour" 

421 assert scene.transcript == "Hello, how are you?" 

422 

423 

424@pytest.mark.asyncio 

425async def test_gen_dub_scene_stores_translation_separately() -> None: 

426 """gen_dub_scene must store original transcript in scene.transcript and 

427 the translation in scene.translation (not overwrite transcript).""" 

428 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

429 with temp_sys_path("apps", "apps/streamdub"): 

430 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

431 from apps.scene import SceneSegment 

432 

433 service_manager = AsyncMock() 

434 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

435 

436 job = _StreamDubJob(job_id="test_translation_field", service_manager=service_manager, 

437 config={"add_subtitles": False}) 

438 

439 # Pre-create the job directory so transcript file writes succeed 

440 os.makedirs(job.job_path, exist_ok=True) 

441 

442 scene = SceneSegment( 

443 scene_id=0, start_frame=0, end_frame=30, 

444 start_sec=0.0, end_sec=1.0, 

445 audio_path="scene_000.wav", 

446 ) 

447 

448 # Create a dummy audio file so voice cloning has a sample to read 

449 with open(os.path.join(job.job_path, "scene_000.wav"), "wb") as f: 

450 f.write(b"\x00" * 16) 

451 

452 original_transcript = "Hello, how are you?" 

453 translated_text = "Hola, ¿cómo estás?" 

454 

455 async def fake_transcribe(s: object) -> str: 

456 return original_transcript 

457 

458 async def fake_translate(s: object, **kwargs: object) -> str: 

459 return translated_text 

460 

461 async def fake_lip_sync(s: object) -> bytes: 

462 return b"video_bytes" 

463 

464 with patch.object(job, "transcribe_audio", side_effect=fake_transcribe), \ 

465 patch.object(job, "translate_scene", side_effect=fake_translate), \ 

466 patch.object(job, "save_status", new=AsyncMock()), \ 

467 patch.object(job, "gen_video_lip_synced", side_effect=fake_lip_sync), \ 

468 patch.object(job, "get_submission_time", return_value=0.0): 

469 job.gen = MagicMock() 

470 job.gen.gen_audio = AsyncMock(return_value="AAAA") 

471 job.gen.stop = AsyncMock() 

472 result = await job.gen_dub_scene(scene, lang_code="e") 

473 

474 # Transcript must remain the original transcription 

475 assert scene.transcript == original_transcript, \ 

476 "scene.transcript must hold the original transcription, not the translation" 

477 # Translation must be stored in the dedicated field 

478 assert scene.translation == translated_text, \ 

479 "scene.translation must hold the translated text" 

480 # gen_audio must have been called with the translation text, not the original 

481 job.gen.gen_audio.assert_called_once() 

482 call_kwargs = job.gen.gen_audio.call_args 

483 tts_text = call_kwargs.kwargs["text"] 

484 assert tts_text == translated_text, \ 

485 "gen_audio must receive the translation, not the original transcript" 

486 assert result == b"video_bytes" 

487 

488 await job.close() 

489 

490 

491@pytest.mark.asyncio 

492async def test_gen_dub_scene_empty_translation_uses_original_video() -> None: 

493 """gen_dub_scene must use original video (no lip sync) when translation is empty.""" 

494 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

495 with temp_sys_path("apps", "apps/streamdub"): 

496 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

497 from apps.scene import SceneSegment 

498 

499 service_manager = AsyncMock() 

500 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

501 

502 job = _StreamDubJob( 

503 job_id="test_empty_translation", 

504 service_manager=service_manager, 

505 config={"add_subtitles": False}, 

506 ) 

507 os.makedirs(job.job_path, exist_ok=True) 

508 

509 fake_scene = SceneSegment( 

510 scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0, 

511 audio_path="scene_000.wav", 

512 ) 

513 

514 original_video = b"original_video_bytes" 

515 get_video_mock = AsyncMock(return_value=original_video) 

516 gen_audio_mock = AsyncMock() 

517 gen_video_mock = AsyncMock() 

518 

519 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value="Hello")), \ 

520 patch.object(job, "translate_scene", new=AsyncMock(return_value="")), \ 

521 patch.object(job, "get_video_scene", get_video_mock), \ 

522 patch.object(job.gen, "gen_audio", gen_audio_mock), \ 

523 patch.object(job, "gen_video_lip_synced", gen_video_mock): 

524 result = await job.gen_dub_scene(fake_scene, lang_code="e") 

525 

526 # Original video must be returned; no audio generation or lip sync should happen 

527 get_video_mock.assert_called_once() 

528 gen_audio_mock.assert_not_called() 

529 gen_video_mock.assert_not_called() 

530 assert result == original_video 

531 

532 await job.close() 

533 

534 

535@pytest.mark.asyncio 

536async def test_gen_dub_scene_adds_subtitles() -> None: 

537 """gen_dub_scene must call _add_subtitles_to_video when add_subtitles is True (default).""" 

538 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

539 with temp_sys_path("apps", "apps/streamdub"): 

540 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

541 from apps.scene import SceneSegment 

542 

543 service_manager = AsyncMock() 

544 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

545 

546 # Default config: add_subtitles defaults to True 

547 job = _StreamDubJob(job_id="test_subtitles", service_manager=service_manager) 

548 os.makedirs(job.job_path, exist_ok=True) 

549 

550 # Write a dummy audio file for voice cloning 

551 with open(os.path.join(job.job_path, "scene_000.wav"), "wb") as f: 

552 f.write(b"\x00" * 16) 

553 

554 fake_scene = SceneSegment( 

555 scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0, 

556 audio_path="scene_000.wav", 

557 ) 

558 

559 dubbed_video = b"dubbed_video_bytes" 

560 subtitled_video = b"subtitled_video_bytes" 

561 subtitles_mock = AsyncMock(return_value=subtitled_video) 

562 

563 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value="Hello")), \ 

564 patch.object(job, "translate_scene", new=AsyncMock(return_value="Hola")), \ 

565 patch.object(job.gen, "gen_audio", AsyncMock(return_value=base64.b64encode(b"audio").decode())), \ 

566 patch.object(job, "gen_video_lip_synced", AsyncMock(return_value=dubbed_video)), \ 

567 patch.object(job, "_add_subtitles_to_video", subtitles_mock): 

568 result = await job.gen_dub_scene(fake_scene, lang_code="e") 

569 

570 # _add_subtitles_to_video must be called with the scene and dubbed video 

571 subtitles_mock.assert_called_once() 

572 call_args = subtitles_mock.call_args 

573 assert call_args.args[0] is fake_scene 

574 assert call_args.args[1] == dubbed_video 

575 assert result == subtitled_video 

576 

577 await job.close() 

578 

579 

580@pytest.mark.asyncio 

581async def test_gen_dub_scene_voice_cloning_disabled() -> None: 

582 """gen_dub_scene uses gen_audio when voice_cloning is disabled in config.""" 

583 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

584 with temp_sys_path("apps", "apps/streamdub"): 

585 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

586 from apps.scene import SceneSegment 

587 

588 service_manager = AsyncMock() 

589 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

590 

591 job = _StreamDubJob( 

592 job_id="test_no_clone", 

593 service_manager=service_manager, 

594 config={"add_subtitles": False, "voice_cloning": False}, 

595 ) 

596 os.makedirs(job.job_path, exist_ok=True) 

597 

598 # Write a scene audio file — voice cloning is disabled so it must NOT be used 

599 with open(os.path.join(job.job_path, "scene_000.wav"), "wb") as f: 

600 f.write(b"\x00" * 16) 

601 

602 fake_scene = SceneSegment( 

603 scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0, 

604 audio_path="scene_000.wav", 

605 ) 

606 

607 dubbed_audio_b64 = base64.b64encode(b"dubbed_audio").decode() 

608 gen_audio_mock = AsyncMock(return_value=dubbed_audio_b64) 

609 

610 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value="Hello")), \ 

611 patch.object(job, "translate_scene", new=AsyncMock(return_value="Hola")), \ 

612 patch.object(job.gen, "gen_audio", gen_audio_mock), \ 

613 patch.object(job, "gen_video_lip_synced", AsyncMock(return_value=b"video")): 

614 await job.gen_dub_scene(fake_scene, lang_code="e") 

615 

616 # gen_audio must be called without voice_sample (voice cloning disabled → standard TTS) 

617 gen_audio_mock.assert_called_once() 

618 assert gen_audio_mock.call_args.kwargs.get("voice_sample") is None 

619 

620 await job.close() 

621 

622 

623@pytest.mark.asyncio 

624async def test_gen_dub_scene_voice_cloning_falls_back_on_error() -> None: 

625 """gen_dub_scene falls back to standard TTS (no voice_sample) when voice cloning fails.""" 

626 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

627 with temp_sys_path("apps", "apps/streamdub"): 

628 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

629 from apps.scene import SceneSegment 

630 

631 service_manager = AsyncMock() 

632 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

633 

634 job = _StreamDubJob( 

635 job_id="test_clone_fallback", 

636 service_manager=service_manager, 

637 config={"add_subtitles": False}, 

638 ) 

639 os.makedirs(job.job_path, exist_ok=True) 

640 

641 # Write a scene audio file so voice cloning is attempted 

642 with open(os.path.join(job.job_path, "scene_000.wav"), "wb") as f: 

643 f.write(b"\x00" * 16) 

644 

645 fake_scene = SceneSegment( 

646 scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0, 

647 audio_path="scene_000.wav", 

648 ) 

649 

650 fallback_audio_b64 = base64.b64encode(b"fallback_audio").decode() 

651 

652 # First call (with voice_sample → VibeVoice) raises; second call (no voice_sample → kokoro) succeeds 

653 call_count = 0 

654 

655 async def gen_audio_side_effect(**kwargs: object) -> str: 

656 nonlocal call_count 

657 call_count += 1 

658 if call_count == 1: 

659 raise RuntimeError("VibeVoice unavailable") 

660 return fallback_audio_b64 

661 

662 gen_audio_mock = AsyncMock(side_effect=gen_audio_side_effect) 

663 

664 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value="Hello")), \ 

665 patch.object(job, "translate_scene", new=AsyncMock(return_value="Hola")), \ 

666 patch.object(job.gen, "gen_audio", gen_audio_mock), \ 

667 patch.object(job, "gen_video_lip_synced", AsyncMock(return_value=b"video")): 

668 await job.gen_dub_scene(fake_scene, lang_code="e") 

669 

670 # gen_audio called twice: first with voice_sample (→ VibeVoice, fails), then without (→ kokoro) 

671 assert gen_audio_mock.call_count == 2 

672 first_call_kwargs = gen_audio_mock.call_args_list[0].kwargs 

673 second_call_kwargs = gen_audio_mock.call_args_list[1].kwargs 

674 assert first_call_kwargs.get("voice_sample") is not None, "first call must include voice_sample" 

675 assert second_call_kwargs.get("voice_sample") is None, "fallback call must NOT include voice_sample" 

676 

677 await job.close() 

678 

679 

680# --------------------------------------------------------------------------- 

681# _is_empty_transcript unit tests 

682# --------------------------------------------------------------------------- 

683 

684@pytest.mark.parametrize("text", [ 

685 "", 

686 "-", 

687 "- -", 

688 "♪ ♪", 

689 " ", 

690 "---", 

691 "♪", 

692 " - - ", 

693]) 

694def test_is_empty_transcript_true(text: str) -> None: 

695 """_is_empty_transcript must return True for texts with no word characters.""" 

696 assert _is_empty_transcript(text), f"Expected True for {text!r}" 

697 

698 

699@pytest.mark.parametrize("text", [ 

700 "Hello", 

701 "Hello world", 

702 "- hello -", 

703 "♪ La la la ♪", 

704 "1", 

705 "ok", 

706]) 

707def test_is_empty_transcript_false(text: str) -> None: 

708 """_is_empty_transcript must return False when the text contains word characters.""" 

709 assert not _is_empty_transcript(text), f"Expected False for {text!r}" 

710 

711 

712# --------------------------------------------------------------------------- 

713# gen_dub_scene skips dubbing for empty-like transcripts 

714# --------------------------------------------------------------------------- 

715 

716@pytest.mark.asyncio 

717@pytest.mark.parametrize("transcript", [ 

718 "-", 

719 "- -", 

720 "♪ ♪", 

721 " ", 

722]) 

723async def test_gen_dub_scene_skips_empty_like_transcript(transcript: str) -> None: 

724 """gen_dub_scene must skip translation and use original video for non-word transcripts.""" 

725 with patch.dict(sys.modules, {**mock_modules, **scene_mocks_base}): 

726 with temp_sys_path("apps", "apps/streamdub"): 

727 from apps.streamdub.streamdub_job import StreamDubJob as _StreamDubJob 

728 from apps.scene import SceneSegment 

729 

730 service_manager = AsyncMock() 

731 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

732 

733 job = _StreamDubJob( 

734 job_id=f"test_empty_transcript_{hash(transcript) & 0xFFFF}", 

735 service_manager=service_manager, 

736 config={"add_subtitles": False}, 

737 ) 

738 os.makedirs(job.job_path, exist_ok=True) 

739 

740 fake_scene = SceneSegment( 

741 scene_id=0, start_frame=0, end_frame=30, start_sec=0.0, end_sec=1.0, 

742 audio_path="scene_000.wav", 

743 ) 

744 

745 original_video = b"original_video_bytes" 

746 get_video_mock = AsyncMock(return_value=original_video) 

747 translate_mock = AsyncMock() 

748 gen_audio_mock = AsyncMock() 

749 gen_video_mock = AsyncMock() 

750 

751 with patch.object(job, "transcribe_audio", new=AsyncMock(return_value=transcript)), \ 

752 patch.object(job, "translate_scene", translate_mock), \ 

753 patch.object(job, "get_video_scene", get_video_mock), \ 

754 patch.object(job.gen, "gen_audio", gen_audio_mock), \ 

755 patch.object(job, "gen_video_lip_synced", gen_video_mock): 

756 result = await job.gen_dub_scene(fake_scene, lang_code="e") 

757 

758 # Must return original video without calling translation, audio gen, or lip sync 

759 get_video_mock.assert_called_once() 

760 translate_mock.assert_not_called() 

761 gen_audio_mock.assert_not_called() 

762 gen_video_mock.assert_not_called() 

763 assert result == original_video 

764 

765 await job.close()