test_narration_contract.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import base64
  2. import io
  3. import json
  4. import subprocess
  5. import sys
  6. import tempfile
  7. import unittest
  8. from contextlib import redirect_stderr, redirect_stdout
  9. from pathlib import Path
  10. from unittest.mock import patch
  11. import fixtures
  12. class ChatResponseContract(unittest.TestCase):
  13. def test_malformed_transcripts_cannot_publish_candidates_in_any_asr_mode(self):
  14. cases = ({}, {"transcript": None}, {"transcript": 42},
  15. {"transcript": ["Two", "words"]}, {"transcript": {}},
  16. {"transcript": False}, {"transcript": ""},
  17. {"transcript": " "}, {"transcript": "...!?"})
  18. for response in cases:
  19. for mode in ("off", "auto", "on"):
  20. with self.subTest(response=response, mode=mode), tempfile.TemporaryDirectory() as temp:
  21. module = fixtures.load_script("narrate")
  22. root = Path(temp)
  23. scenes, output = root / "scenes.json", root / "narration"
  24. scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Two words"}]}))
  25. sentinels = []
  26. def post(*args, **kwargs):
  27. sentinel = f"NOT MEDIA: response {len(sentinels) + 1}".encode()
  28. sentinels.append(sentinel)
  29. audio = dict(response, data=base64.b64encode(sentinel).decode())
  30. return {"choices": [{"message": {"audio": audio}}]}
  31. argv = ["narrate", str(scenes), str(output), "--engine", "openai-chat", "--verify", mode]
  32. with patch.object(sys, "argv", argv), \
  33. patch.object(module.shutil, "which", return_value="fake-ffprobe"), \
  34. patch.object(module, "openai_key", return_value="fake-key"), \
  35. patch.object(module, "post", post), \
  36. patch.object(module, "duration", return_value=1.25), \
  37. patch.object(module, "transcribe_local", side_effect=AssertionError("invalid transcript reached ASR")), \
  38. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  39. try:
  40. result = module.main()
  41. except Exception as error:
  42. result = error
  43. self.assertEqual(result, 1)
  44. self.assertEqual(json.loads((output / "manifest.json").read_text()), [])
  45. self.assertEqual(len(sentinels), 2)
  46. self.assertEqual({path.read_bytes() for path in output.glob(".clip.attempt-*.wav")}, set(sentinels))
  47. self.assertFalse((output / "clip.wav").exists())
  48. def test_valid_chat_and_cached_reuse_obey_independent_asr_modes(self):
  49. for mode in ("off", "auto", "on"):
  50. for heard in (None, "Two words"):
  51. with self.subTest(mode=mode, heard=heard), tempfile.TemporaryDirectory() as temp:
  52. module = fixtures.load_script("narrate")
  53. root = Path(temp)
  54. scenes, output = root / "scenes.json", root / "narration"
  55. scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Two words"}]}))
  56. sentinel = b"NOT MEDIA: accepted response"
  57. response = {"choices": [{"message": {"audio": {
  58. "data": base64.b64encode(sentinel).decode(), "transcript": "Two words",
  59. }}}]}
  60. argv = ["narrate", str(scenes), str(output), "--engine", "openai-chat", "--verify", "off"]
  61. with patch.object(sys, "argv", argv), \
  62. patch.object(module.shutil, "which", return_value="fake-ffprobe"), \
  63. patch.object(module, "openai_key", return_value="fake-key"), \
  64. patch.object(module, "post", return_value=response) as post, \
  65. patch.object(module, "duration", return_value=1.25), \
  66. patch.object(module, "transcribe_local", return_value=heard) as asr, \
  67. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  68. self.assertEqual(module.main(), 0)
  69. argv[-1] = mode
  70. expected = 1 if mode == "on" and heard is None else 0
  71. self.assertEqual(module.main(), expected)
  72. self.assertEqual(post.call_count, 1, "accepted cache must not synthesize again")
  73. self.assertEqual(asr.call_count, int(mode != "off"))
  74. self.assertEqual(bool(json.loads((output / "manifest.json").read_text())), expected == 0)
  75. self.assertEqual((output / "clip.wav").read_bytes(), sentinel)
  76. class NarrationPublicationContract(unittest.TestCase):
  77. def setUp(self):
  78. self.module = fixtures.load_script("narrate")
  79. self.directory = tempfile.TemporaryDirectory()
  80. self.addCleanup(self.directory.cleanup)
  81. self.root = Path(self.directory.name)
  82. self.scenes = self.root / "scenes.yaml"
  83. self.output = self.root / "narration"
  84. def run_narrate(self, scenes, synthesize, *, verify="off", expected=0, engine="piper",
  85. extra_options=(), transcript=None, asr_calls=None):
  86. self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
  87. argv = ["narrate", str(self.scenes), str(self.output), "--engine", engine,
  88. "--verify", verify, *extra_options]
  89. stderr = io.StringIO()
  90. def transcribe(wav, model):
  91. if asr_calls is not None:
  92. asr_calls.append((wav, model))
  93. return transcript
  94. with patch.object(sys, "argv", argv), \
  95. patch.object(self.module.shutil, "which", return_value="ffprobe"), \
  96. patch.object(self.module, "openai_key", return_value="key" if engine.startswith("openai") else None), \
  97. patch.object(self.module, "say_piper", side_effect=synthesize), \
  98. patch.object(self.module, "say_openai_chat",
  99. side_effect=lambda key, text, wav, voice: synthesize(text, wav, voice)), \
  100. patch.object(self.module, "duration", return_value=1.25), \
  101. patch.object(self.module, "transcribe_local", side_effect=transcribe), \
  102. redirect_stdout(io.StringIO()), redirect_stderr(stderr):
  103. self.assertEqual(self.module.main(), expected, stderr.getvalue())
  104. manifest = self.output / "manifest.json"
  105. return json.loads(manifest.read_text(encoding="utf-8")) if manifest.exists() else []
  106. def test_acceptance_is_withdrawn_before_replacing_accepted_bytes(self):
  107. old_wav = self.output / "first.wav"
  108. self.output.mkdir()
  109. old_wav.write_bytes(b"accepted bytes")
  110. synthesis = {"engine": "piper", "voice": self.module.PIPER_VOICE,
  111. "model": self.module.PIPER_VOICE}
  112. (self.output / "manifest.json").write_text(json.dumps([{
  113. "id": "first", "text": "Old words", "wav": "first.wav",
  114. "duration": 1.0, "synthesis": synthesis,
  115. }]), encoding="utf-8")
  116. def synthesize(text, wav, voice):
  117. published = json.loads((self.output / "manifest.json").read_text())
  118. self.assertNotIn("first", [entry["id"] for entry in published])
  119. wav.write_bytes(b"replacement bytes")
  120. manifest = self.run_narrate(
  121. [{"id": "first", "narration": "New words"}], synthesize
  122. )
  123. self.assertEqual(manifest[0]["text"], "New words")
  124. self.assertEqual(old_wav.read_bytes(), b"replacement bytes")
  125. def test_rejected_and_exceptional_takes_are_not_accepted_on_a_rerun(self):
  126. attempts = []
  127. def synthesize(text, wav, voice):
  128. attempts.append((text, wav))
  129. wav.write_bytes(f"{text}:{len(attempts)}".encode())
  130. if text == "Reject this":
  131. return "invented words that do not match this script at all"
  132. if text == "Raise here":
  133. raise RuntimeError("synthesis interrupted")
  134. scenes = [{"id": "reject", "narration": "Reject this"},
  135. {"id": "raise", "narration": "Raise here"}]
  136. def accepted(text, wav, voice):
  137. wav.write_bytes(b"accepted")
  138. return text
  139. self.run_narrate(scenes, accepted, engine="openai-chat")
  140. first = self.run_narrate(scenes, synthesize, engine="openai-chat", expected=1, extra_options=("--force",))
  141. attempts_after_rejection = len(attempts)
  142. second = self.run_narrate(scenes, synthesize, engine="openai-chat", expected=1)
  143. self.assertEqual(first, [])
  144. self.assertEqual(second, [])
  145. self.assertGreater(len(attempts), attempts_after_rejection)
  146. rejected_paths = [path for text, path in attempts if text == "Reject this"]
  147. self.assertEqual(len({path.name for path in rejected_paths}), len(rejected_paths))
  148. self.assertTrue(all(path.exists() for path in rejected_paths))
  149. self.assertEqual(len({path.read_bytes() for path in rejected_paths}), len(rejected_paths))
  150. def test_duration_failure_leaves_only_prior_accepted_scenes_published(self):
  151. scenes = [{"id": "accepted", "narration": "Accepted words"},
  152. {"id": "broken", "narration": "Broken words"}]
  153. def synthesize(text, wav, voice):
  154. wav.write_bytes(text.encode())
  155. self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
  156. argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper",
  157. "--verify", "off"]
  158. durations = iter((1.0, RuntimeError("ffprobe failed")))
  159. with patch.object(sys, "argv", argv), \
  160. patch.object(self.module.shutil, "which", return_value="ffprobe"), \
  161. patch.object(self.module, "openai_key", return_value=None), \
  162. patch.object(self.module, "say_piper", side_effect=synthesize), \
  163. patch.object(self.module, "duration", side_effect=durations), \
  164. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  165. self.assertEqual(self.module.main(), 1)
  166. manifest = json.loads((self.output / "manifest.json").read_text())
  167. self.assertEqual([entry["id"] for entry in manifest], ["accepted"])
  168. def test_duration_failures_keep_distinct_attempt_bytes_across_reruns(self):
  169. scenes = [{"id": "clip", "narration": "Measure this clip"}]
  170. def synthesize(text, wav, voice):
  171. wav.write_bytes(f"attempt {len(list(self.output.glob('.clip.attempt-*.wav'))) + 1}".encode())
  172. for expected_attempts in (1, 2):
  173. self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
  174. argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper", "--verify", "off"]
  175. with patch.object(sys, "argv", argv), \
  176. patch.object(self.module.shutil, "which", return_value="ffprobe"), \
  177. patch.object(self.module, "openai_key", return_value=None), \
  178. patch.object(self.module, "say_piper", side_effect=synthesize), \
  179. patch.object(self.module, "duration", side_effect=RuntimeError("ffprobe failed")), \
  180. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  181. self.assertEqual(self.module.main(), 1)
  182. attempts = sorted(self.output.glob(".clip.attempt-*.wav"))
  183. self.assertEqual(len(attempts), expected_attempts)
  184. self.assertEqual(len({path.read_bytes() for path in attempts}), expected_attempts)
  185. self.assertFalse((self.output / "clip.wav").exists())
  186. def test_two_accepted_scenes_are_published_together(self):
  187. def synthesize(text, wav, voice):
  188. wav.write_bytes(text.encode())
  189. manifest = self.run_narrate([
  190. {"id": "first", "narration": "First accepted scene"},
  191. {"id": "second", "narration": "Second accepted scene"},
  192. ], synthesize)
  193. self.assertEqual([entry["id"] for entry in manifest], ["first", "second"])
  194. def test_strict_unavailable_verification_withdraws_cached_acceptance(self):
  195. def synthesize(text, wav, voice):
  196. wav.write_bytes(b"accepted")
  197. self.run_narrate([{"id": "clip", "narration": "Read these words"}], synthesize)
  198. manifest = self.run_narrate(
  199. [{"id": "clip", "narration": "Read these words"}], synthesize,
  200. verify="on", expected=1,
  201. )
  202. self.assertEqual(manifest, [])
  203. def test_unsupported_asr_comparison_obeys_auto_on_and_off_modes(self):
  204. def synthesize(text, wav, voice):
  205. wav.write_bytes(b"clip")
  206. scenes = [{"id": "clip", "narration": "\u77ed\u6587"}]
  207. for mode, expected in (("auto", 0), ("on", 1), ("off", 0)):
  208. with self.subTest(mode=mode):
  209. asr_calls = []
  210. manifest = self.run_narrate(scenes, synthesize, verify=mode,
  211. expected=expected, transcript="\u77ed\u6587",
  212. asr_calls=asr_calls)
  213. self.assertEqual(bool(manifest), expected == 0)
  214. self.assertEqual(bool(asr_calls), mode != "off")
  215. def test_missing_ffprobe_stops_before_synthesis(self):
  216. self.scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Words"}]}),
  217. encoding="utf-8")
  218. with patch.object(sys, "argv", ["narrate", str(self.scenes), str(self.output)]), \
  219. patch.object(self.module.shutil, "which", return_value=None), \
  220. patch.object(self.module, "say_piper", side_effect=AssertionError("synthesized")), \
  221. redirect_stderr(io.StringIO()):
  222. with self.assertRaises(SystemExit):
  223. self.module.main()
  224. def test_fresh_unsupported_chat_transcript_is_rejected_before_synthesis(self):
  225. called = []
  226. def synthesize(*args):
  227. called.append(args)
  228. manifest = self.run_narrate(
  229. [{"id": "clip", "narration": "\U00020000\U00020001"}], synthesize,
  230. engine="openai-chat", expected=1,
  231. )
  232. self.assertEqual(manifest, [])
  233. self.assertEqual(called, [])
  234. def test_cached_unsupported_chat_transcript_withdraws_acceptance_without_asr(self):
  235. self.output.mkdir()
  236. (self.output / "clip.wav").write_bytes(b"cached bytes")
  237. synthesis = {"engine": "openai-chat", "voice": "nova",
  238. "model": self.module.OPENAI_CHAT_MODEL}
  239. (self.output / "manifest.json").write_text(json.dumps([{
  240. "id": "clip", "text": "\u77ed\u6587", "wav": "clip.wav", "duration": 1.0,
  241. "synthesis": synthesis,
  242. }]), encoding="utf-8")
  243. self.scenes.write_text(json.dumps({"scenes": [
  244. {"id": "clip", "narration": "\u77ed\u6587"}
  245. ]}), encoding="utf-8")
  246. for mode in ("off", "auto"):
  247. with self.subTest(mode=mode):
  248. (self.output / "manifest.json").write_text(json.dumps([{
  249. "id": "clip", "text": "\u77ed\u6587", "wav": "clip.wav", "duration": 1.0,
  250. "synthesis": synthesis,
  251. }]), encoding="utf-8")
  252. argv = ["narrate", str(self.scenes), str(self.output), "--engine", "openai-chat",
  253. "--verify", mode]
  254. with patch.object(sys, "argv", argv), \
  255. patch.object(self.module.shutil, "which", return_value="ffprobe"), \
  256. patch.object(self.module, "openai_key", return_value="key"), \
  257. patch.object(self.module, "say_openai_chat", side_effect=AssertionError("cached")), \
  258. patch.object(self.module, "transcribe_local", side_effect=AssertionError("ASR")), \
  259. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  260. self.assertEqual(self.module.main(), 1)
  261. self.assertEqual(json.loads((self.output / "manifest.json").read_text()), [])
  262. def test_empty_chat_or_asr_speech_is_rejected(self):
  263. def empty_chat_synthesis(text, wav, voice):
  264. wav.write_bytes(b"audio")
  265. return ""
  266. empty_chat = self.run_narrate(
  267. [{"id": "clip", "narration": "Two words"}],
  268. empty_chat_synthesis, engine="openai-chat", expected=1,
  269. )
  270. self.assertEqual(empty_chat, [])
  271. def synthesize(text, wav, voice):
  272. wav.write_bytes(b"audio")
  273. self.scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Two words"}]}),
  274. encoding="utf-8")
  275. argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper", "--verify", "auto"]
  276. with patch.object(sys, "argv", argv), \
  277. patch.object(self.module.shutil, "which", return_value="ffprobe"), \
  278. patch.object(self.module, "openai_key", return_value=None), \
  279. patch.object(self.module, "say_piper", side_effect=synthesize), \
  280. patch.object(self.module, "transcribe_local", return_value=""), \
  281. patch.object(self.module, "duration", return_value=1.0), \
  282. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  283. self.assertEqual(self.module.main(), 1)
  284. self.assertEqual(json.loads((self.output / "manifest.json").read_text()), [])
  285. def test_cached_nested_wav_keeps_its_manifest_path_after_reverification(self):
  286. nested = self.output / "takes" / "clip.wav"
  287. nested.parent.mkdir(parents=True)
  288. nested.write_bytes(b"accepted")
  289. synthesis = {"engine": "piper", "voice": self.module.PIPER_VOICE,
  290. "model": self.module.PIPER_VOICE}
  291. (self.output / "manifest.json").write_text(json.dumps([{
  292. "id": "clip", "text": "Nested clip", "wav": "takes/clip.wav", "duration": 1.0,
  293. "synthesis": synthesis,
  294. }]), encoding="utf-8")
  295. manifest = self.run_narrate(
  296. [{"id": "clip", "narration": "Nested clip"}],
  297. lambda *args: (_ for _ in ()).throw(AssertionError("cached")), verify="on",
  298. transcript="Nested clip",
  299. )
  300. self.assertEqual(manifest[0]["wav"], "takes/clip.wav")
  301. def test_cached_strict_verification_withdraws_before_interrupt(self):
  302. def synthesize(text, wav, voice):
  303. wav.write_bytes(b"accepted")
  304. self.run_narrate([{"id": "clip", "narration": "Interrupt safely"}], synthesize)
  305. self.scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Interrupt safely"}]}),
  306. encoding="utf-8")
  307. argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper", "--verify", "on"]
  308. with patch.object(sys, "argv", argv), \
  309. patch.object(self.module.shutil, "which", return_value="ffprobe"), \
  310. patch.object(self.module, "openai_key", return_value=None), \
  311. patch.object(self.module, "say_piper", side_effect=AssertionError("cached")), \
  312. patch.object(self.module, "transcribe_local", side_effect=KeyboardInterrupt), \
  313. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  314. with self.assertRaises(KeyboardInterrupt):
  315. self.module.main()
  316. self.assertEqual(json.loads((self.output / "manifest.json").read_text()), [])
  317. class NarrationComparisonContract(unittest.TestCase):
  318. def setUp(self):
  319. self.module = fixtures.load_script("narrate")
  320. def test_comparison_marks_scripts_requiring_segmentation_or_no_words_unavailable(self):
  321. for script, heard in (
  322. ("你好世界", "こんにちは世界"),
  323. ("短文", "短文"),
  324. ("mixed 日本語 words", "mixed 日本語 words"),
  325. ("\U00020000\U00020001", "\U00020002\U00020003"),
  326. ("*** !!!", "*** !!!"),
  327. ):
  328. with self.subTest(script=script):
  329. self.assertIsNone(self.module.structural_drift(script, heard))
  330. def test_comparison_accepts_multiline_accented_latin_and_spaced_cyrillic(self):
  331. for script, heard in (
  332. ("Caf\u00e9\nna\u00efve", "CAF\u00c9 na\u00efve"),
  333. ("\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440", "\u043f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440"),
  334. ("\uc548\ub155 \uc138\uacc4", "\uc548\ub155 \uc138\uacc4"),
  335. ):
  336. with self.subTest(script=script):
  337. self.assertEqual(self.module.structural_drift(script, heard), (0.0, 0))
  338. def test_drift_check_reports_unavailable_comparison_as_nonzero(self):
  339. with tempfile.TemporaryDirectory() as directory:
  340. root = Path(directory)
  341. script = root / "script.txt"
  342. heard = root / "heard.txt"
  343. script.write_text("\u77ed\u6587", encoding="utf-8")
  344. heard.write_text("\u77ed\u6587", encoding="utf-8")
  345. output = io.StringIO()
  346. with patch.object(sys, "argv", ["narrate", "--drift-check", str(script), str(heard)]), \
  347. redirect_stdout(output):
  348. self.assertEqual(self.module.main(), 1)
  349. self.assertIn("comparison unavailable", output.getvalue())
  350. class AssemblyNarrationContract(unittest.TestCase):
  351. def setUp(self):
  352. self.module = fixtures.load_script("assemble")
  353. self.directory = tempfile.TemporaryDirectory()
  354. self.addCleanup(self.directory.cleanup)
  355. self.root = Path(self.directory.name)
  356. self.scenes = self.root / "scenes.yaml"
  357. self.narration = self.root / "narration"
  358. self.narration.mkdir()
  359. def assemble(self, scenes, *, manifest=None, run_side_effect=None):
  360. self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
  361. if manifest is not None:
  362. (self.narration / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
  363. calls = []
  364. def run(command, **kwargs):
  365. calls.append(command)
  366. if run_side_effect is not None:
  367. return run_side_effect(command)
  368. return subprocess.CompletedProcess(command, 0, "1.0", "")
  369. argv = ["assemble", str(self.scenes), str(self.root / "out.mp4"),
  370. "--narration", str(self.narration), "--work", str(self.root / "work")]
  371. with patch.object(sys, "argv", argv), \
  372. patch.object(self.module.shutil, "which", return_value="tool"), \
  373. patch.object(self.module, "find_browser", return_value=None), \
  374. patch.object(self.module, "run", side_effect=run), \
  375. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  376. return self.module.main(), calls
  377. def test_required_narration_contract_fails_before_encoding(self):
  378. with self.assertRaises(SystemExit):
  379. self.assemble([{"id": "spoken", "kind": "image", "src": "still.png",
  380. "narration": "Expected words"}],
  381. run_side_effect=lambda command: (_ for _ in ()).throw(
  382. AssertionError("encoding started")
  383. ))
  384. def test_missing_entry_wav_or_matching_text_fails_before_encoding(self):
  385. (self.root / "still.png").write_bytes(b"image sentinel")
  386. cases = (
  387. ([], "missing entry"),
  388. ([{"id": "spoken", "text": "Expected words", "wav": "missing.wav"}], "missing WAV"),
  389. ([{"id": "spoken", "text": "Changed words", "wav": "accepted.wav"}], "changed text"),
  390. )
  391. for manifest, label in cases:
  392. with self.subTest(label=label):
  393. (self.narration / "manifest.json").unlink(missing_ok=True)
  394. with self.assertRaises(SystemExit):
  395. self.assemble([{"id": "spoken", "kind": "image", "src": "still.png",
  396. "narration": "Expected words"}], manifest=manifest,
  397. run_side_effect=lambda command: (_ for _ in ()).throw(
  398. AssertionError("encoding started")
  399. ))
  400. def test_manifest_wav_name_is_authoritative_and_removed_narration_ignores_leftover_wav(self):
  401. selected = self.narration / "accepted-name.wav"
  402. selected.write_bytes(b"accepted")
  403. leftover = self.narration / "silent.wav"
  404. leftover.write_bytes(b"leftover")
  405. (self.root / "still.png").write_bytes(b"image sentinel")
  406. manifest = [{"id": "spoken", "text": "Expected words",
  407. "wav": selected.name, "duration": 1.0, "synthesis": {}}]
  408. status, calls = self.assemble([
  409. {"id": "spoken", "kind": "image", "src": "still.png", "narration": "Expected words"},
  410. {"id": "silent", "kind": "image", "src": "still.png"},
  411. ], manifest=manifest)
  412. self.assertEqual(status, 0)
  413. encoded = [call for call in calls if call and call[0] == "ffmpeg"]
  414. self.assertTrue(any(str(selected) in call for call in encoded))
  415. self.assertFalse(any(str(leftover) in call for call in encoded))
  416. offsets = json.loads((self.root / "work" / "offsets.json").read_text())
  417. self.assertEqual(set(offsets), {"spoken"})
  418. def test_2560x1080_movie_uses_source_audio_and_silent_source_gets_anullsrc(self):
  419. source = self.root / "wide-2560x1080.mp4"
  420. source.write_bytes(b"source")
  421. status, calls = self.assemble(
  422. [{"id": "movie", "kind": "movie", "src": source.name,
  423. "narration": "Ignore this", "height": 800}],
  424. run_side_effect=lambda command: subprocess.CompletedProcess(
  425. command, 0,
  426. json.dumps({"streams": [{"codec_type": "video", "width": 2560,
  427. "height": 1080}]}), ""
  428. ) if "-show_streams" in command else subprocess.CompletedProcess(command, 0, "1.0", ""),
  429. )
  430. self.assertEqual(status, 0)
  431. movie_encode = next(call for call in calls if call and call[0] == "ffmpeg")
  432. self.assertIn("anullsrc=r=44100:cl=stereo", movie_encode)
  433. self.assertIn("0:v:0", movie_encode)
  434. self.assertIn("1:a:0", movie_encode)
  435. self.assertIn("scale=1920:800:force_original_aspect_ratio=decrease",
  436. movie_encode[movie_encode.index("-vf") + 1])
  437. offsets = json.loads((self.root / "work" / "offsets.json").read_text())
  438. self.assertEqual(offsets, {})
  439. def test_movie_with_audio_maps_its_source_audio(self):
  440. source = self.root / "source-with-audio.mp4"
  441. source.write_bytes(b"source")
  442. status, calls = self.assemble(
  443. [{"id": "movie", "kind": "movie", "src": source.name}],
  444. run_side_effect=lambda command: subprocess.CompletedProcess(
  445. command, 0,
  446. json.dumps({"streams": [{"codec_type": "video"}, {"codec_type": "audio"}]}), ""
  447. ) if "-show_streams" in command else subprocess.CompletedProcess(command, 0, "1.0", ""),
  448. )
  449. self.assertEqual(status, 0)
  450. movie_encode = next(call for call in calls if call and call[0] == "ffmpeg")
  451. self.assertNotIn("anullsrc=r=44100:cl=stereo", movie_encode)
  452. self.assertEqual(movie_encode[movie_encode.index("-map") + 1], "0:v:0")
  453. self.assertEqual(movie_encode[movie_encode.index("-map", movie_encode.index("-map") + 1) + 1], "0:a:0")
  454. def test_movie_geometry_fits_width_and_requested_inner_height_before_padding(self):
  455. self.assertEqual(self.module.movie_geometry(1920, 1080, 800), {
  456. "scale": (1920, 800),
  457. "pad": (1920, 1080),
  458. })
  459. class PercentPathContract(unittest.TestCase):
  460. def test_sequence_pattern_escapes_only_directory_percents(self):
  461. module = fixtures.load_script("media_paths")
  462. pattern = module.sequence_pattern(Path("folder%name") / "frames", "frame-%08d.png")
  463. self.assertEqual(pattern, "folder%%name/frames/frame-%08d.png")
  464. def test_checker_sampling_escapes_only_output_directory_percents(self):
  465. module = fixtures.load_script("check-movie")
  466. with tempfile.TemporaryDirectory() as directory:
  467. work = Path(directory) / "proof%take"
  468. work.mkdir()
  469. command = []
  470. def run(argv, **kwargs):
  471. command.extend(argv)
  472. return subprocess.CompletedProcess(argv, 0, "", "")
  473. with patch.object(module.subprocess, "run", side_effect=run), redirect_stdout(io.StringIO()):
  474. with self.assertRaises(SystemExit):
  475. module.sample_picture(Path("movie.mp4"), work)
  476. output = command[-1]
  477. self.assertIn("proof%%take", output)
  478. self.assertTrue(output.endswith("s%05d.png"))
  479. if __name__ == "__main__":
  480. unittest.main()