test_subtitle_contract.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. """Text and mocked-boundary contracts; this module never processes media."""
  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. def milliseconds(timestamp):
  13. hours, minutes, rest = timestamp.split(":")
  14. seconds, millis = rest.split(",")
  15. return ((int(hours) * 60 + int(minutes)) * 60 + int(seconds)) * 1000 + int(millis)
  16. def read_cues(path):
  17. cues = []
  18. for block in path.read_text(encoding="utf-8").strip().split("\n\n"):
  19. if block:
  20. _, timing, text = block.split("\n", 2)
  21. start, end = map(milliseconds, timing.split(" --> "))
  22. cues.append((start, end, " ".join(text.split())))
  23. return cues
  24. class SubtitleTimingContract(unittest.TestCase):
  25. def setUp(self):
  26. self.module = fixtures.load_script("make-subtitles")
  27. temporary = tempfile.TemporaryDirectory()
  28. self.addCleanup(temporary.cleanup)
  29. self.root = Path(temporary.name)
  30. self.output = self.root / "captions.srt"
  31. def subtitles(self, entries, *options):
  32. manifest = self.root / "manifest.json"
  33. manifest.write_text(json.dumps(entries), encoding="utf-8")
  34. stdout = io.StringIO()
  35. with patch.object(sys, "argv", ["make-subtitles", str(manifest), str(self.output), *options]), \
  36. redirect_stdout(stdout):
  37. self.assertEqual(self.module.main(), 0)
  38. return read_cues(self.output), stdout.getvalue()
  39. def assert_scene(self, cues, start, end, text):
  40. self.assertTrue(cues)
  41. self.assertEqual(cues[0][0], start)
  42. self.assertEqual(cues[-1][1], end)
  43. previous = start
  44. for a, b, _ in cues:
  45. self.assertEqual(a, previous)
  46. self.assertLess(a, b)
  47. self.assertLessEqual(b, end)
  48. previous = b
  49. self.assertEqual(" ".join(cue[2] for cue in cues).split(), text.split())
  50. def test_five_chunks_fit_half_second_before_next_scene(self):
  51. text = "one two six ten red"
  52. cues, report = self.subtitles([
  53. {"id": "short", "duration": 0.5, "text": text},
  54. {"id": "next", "duration": 1, "text": "next"},
  55. ], "--max-chars", "3")
  56. self.assert_scene(cues[:-1], 0, 500, text)
  57. self.assertEqual(cues[-1], (500, 1500, "next"))
  58. self.assertIn("ends at 00:00:01,500", report)
  59. def test_one_word_covers_twelve_second_scene(self):
  60. cues, report = self.subtitles([{"id": "held", "duration": 12, "text": "Held"}])
  61. self.assert_scene(cues, 0, 12000, "Held")
  62. self.assertIn("ends at 00:00:12,000", report)
  63. def test_mixed_chunks_get_proportional_time(self):
  64. cues, _ = self.subtitles([{"id": "mix", "duration": 1, "text": "a bbbbbbbbb"}], "--max-chars", "9")
  65. self.assertEqual(cues, [(0, 100, "a"), (100, 1000, "bbbbbbbbb")])
  66. def test_max_seconds_guides_splitting_without_losing_tail_or_words(self):
  67. text = "one two six ten red cat dog fox"
  68. cues, _ = self.subtitles([{"id": "long", "duration": 12, "text": text}], "--max-secs", "3")
  69. self.assert_scene(cues, 0, 12000, text)
  70. self.assertGreater(len(cues), 1)
  71. self.assertTrue(all(b - a <= 3000 for a, b, _ in cues))
  72. def test_max_seconds_refines_unequal_chunks_against_allocated_time(self):
  73. text = "ab cde f ghi"
  74. cues, report = self.subtitles([
  75. {"id": "unequal", "duration": 6, "text": text},
  76. ], "--max-secs", "3")
  77. self.assert_scene(cues, 0, 6000, text)
  78. self.assertTrue(all(b - a <= 3000 for a, b, _ in cues), cues)
  79. self.assertIn("ends at 00:00:06,000", report)
  80. def test_chunks_coalesce_to_fit_representable_milliseconds(self):
  81. text = "one two six ten red"
  82. cues, report = self.subtitles([{"id": "tiny", "duration": 0.002, "text": text}], "--max-chars", "3")
  83. self.assert_scene(cues, 0, 2, text)
  84. self.assertLessEqual(len(cues), 2)
  85. self.assertIn("ends at 00:00:00,002", report)
  86. def test_unrepresentable_max_seconds_preserves_positive_cues_and_all_words(self):
  87. text = "one two six ten red"
  88. cues, _ = self.subtitles([
  89. {"id": "tiny", "duration": 0.002, "text": text},
  90. ], "--max-secs", "0.0001")
  91. self.assert_scene(cues, 0, 2, text)
  92. self.assertLessEqual(len(cues), 2)
  93. def test_submillisecond_scene_can_use_its_rounded_interval(self):
  94. cues, _ = self.subtitles([{"id": "tiny", "duration": 0.0008, "text": "one two"}])
  95. self.assert_scene(cues, 0, 1, "one two")
  96. def test_invalid_or_unrepresentable_duration_fails_before_writing_srt(self):
  97. for duration in (0, -1, 0.0001, float("nan"), float("inf"), "invalid"):
  98. with self.subTest(duration=duration), redirect_stderr(io.StringIO()):
  99. with self.assertRaises(SystemExit) as caught:
  100. self.subtitles([{"id": "invalid", "duration": duration, "text": "words"}])
  101. self.assertNotEqual(caught.exception.code, 0)
  102. self.assertFalse(self.output.exists())
  103. def test_invalid_readability_limits_fail_clearly(self):
  104. for option, value in (("--max-chars", "0"), ("--max-secs", "0"),
  105. ("--max-secs", "nan"), ("--max-secs", "inf")):
  106. with self.subTest(option=option, value=value), redirect_stderr(io.StringIO()):
  107. with self.assertRaises(SystemExit) as caught:
  108. self.subtitles([{"id": "scene", "duration": 1, "text": "words"}], option, value)
  109. self.assertNotEqual(caught.exception.code, 0)
  110. def test_manual_offset_uses_rounded_scene_boundaries(self):
  111. cues, report = self.subtitles([{"id": "shifted", "duration": 0.5004, "text": "one two six"}], "--offsets", "shifted=2.1254", "--max-chars", "3")
  112. self.assert_scene(cues, 2125, 2626, "one two six")
  113. self.assertIn("ends at 00:00:02,626", report)
  114. def test_empty_cut_and_unknown_offset_keys_do_not_introduce_cues(self):
  115. for mapping in ({}, {"unknown": 2}):
  116. with self.subTest(mapping=mapping):
  117. offsets = self.root / "offsets.json"
  118. offsets.write_text(json.dumps(mapping), encoding="utf-8")
  119. cues, report = self.subtitles([{"id": "excluded", "duration": 1, "text": "excluded"}], "--offsets-json", str(offsets), "--offsets", "excluded=5")
  120. self.assertEqual(cues, [])
  121. self.assertIn("0 cues, ends at 00:00:00,000", report)
  122. class SubtitleTrackContract(unittest.TestCase):
  123. def test_supplied_track_replaces_existing_subtitles_with_optional_audio(self):
  124. module = fixtures.load_script("burn-subtitles")
  125. for soft in (True, False):
  126. for source_audio in (True, False):
  127. with self.subTest(soft=soft, source_audio=source_audio), tempfile.TemporaryDirectory() as directory:
  128. root = Path(directory)
  129. movie, subs = root / "movie.mp4", root / "new.srt"
  130. movie.write_bytes(b"source token")
  131. subs.write_text("1\n00:00:00,000 --> 00:00:01,000\nNew caption\n", encoding="utf-8")
  132. selections = []
  133. source = {"v:0": "source video", "s:0": "old caption"}
  134. if source_audio:
  135. source["a:0"] = "source audio"
  136. inputs = [source, {"s:0": "new caption"}]
  137. def encode(command, **kwargs):
  138. if "-vf" in command:
  139. return False
  140. maps = [command[i + 1] for i, arg in enumerate(command) if arg == "-map"]
  141. self.assertEqual(maps, ["0:v:0", "0:a?", "1:s:0"])
  142. for spec in maps:
  143. index, kind = spec.rstrip("?").split(":", 1)
  144. selected = [value for stream, value in inputs[int(index)].items() if stream == kind or stream.startswith(kind + ":")]
  145. if not spec.endswith("?"):
  146. self.assertTrue(selected)
  147. selections.extend(selected)
  148. return True
  149. stdout, stderr = io.StringIO(), io.StringIO()
  150. argv = ["burn-subtitles", str(movie), str(subs), str(root / "out.mp4")]
  151. if soft:
  152. argv.append("--soft")
  153. with patch.object(sys, "argv", argv), \
  154. patch.object(module.shutil, "which", return_value="mock-ffmpeg"), \
  155. patch.object(module, "has_libass", return_value=True), \
  156. patch.object(module, "run", side_effect=encode), \
  157. redirect_stdout(stdout), \
  158. redirect_stderr(stderr):
  159. self.assertEqual(module.main(), 0)
  160. self.assertEqual(selections, ["source video", *(["source audio"] if source_audio else []), "new caption"])
  161. self.assertNotIn("no libass", stdout.getvalue())
  162. self.assertEqual("burn failed" in stderr.getvalue(), not soft)
  163. class SubtitleParserContract(unittest.TestCase):
  164. def setUp(self):
  165. self.module = fixtures.load_script("check-movie")
  166. def test_literal_arrow_in_caption_is_not_a_timing_line(self):
  167. self.assertEqual(self.module.subtitle_end("1\n00:00:00,000 --> 00:00:01,250\nFollow source --> destination.\n"), 1.25)
  168. def test_timestamp_shaped_caption_cannot_extend_coverage(self):
  169. text = "1\n00:00:00,000 --> 00:00:01,250\n00:00:00,000 --> 00:59:00,000\n"
  170. self.assertEqual(self.module.subtitle_end(text), 1.25)
  171. def test_malformed_actual_timing_is_rejected(self):
  172. for timing in ("00:00:00,000 --> invalid", "not a timing line", "00:00:00,000 --> 00:99:00,000"):
  173. with self.subTest(timing=timing), self.assertRaises(ValueError):
  174. self.module.subtitle_end(f"1\n{timing}\ncaption\n")
  175. def test_empty_subtitles_have_no_end(self):
  176. self.assertIsNone(self.module.subtitle_end("\n \n"))
  177. def test_multiple_cues_keep_existing_latest_end_policy(self):
  178. text = "1\n00:00:00,000 --> 00:00:10,250\nFirst\n\n2\n00:00:05,000 --> 00:00:06,000\nSecond\n"
  179. self.assertEqual(self.module.subtitle_end(text), 10.25)
  180. class SubtitleHandoffContract(unittest.TestCase):
  181. def test_rerun_removed_opening_narration_keeps_evidence_and_retimes_remaining_caption(self):
  182. narrate = fixtures.load_script("narrate")
  183. assemble = fixtures.load_script("assemble")
  184. subtitles = fixtures.load_script("make-subtitles")
  185. with tempfile.TemporaryDirectory() as directory:
  186. root = Path(directory)
  187. scenes_file, narration = root / "scenes.yaml", root / "narration"
  188. work, movie, srt = root / "segments", root / "movie.mp4", root / "movie.srt"
  189. (root / "still.png").write_bytes(b"still token")
  190. scenes = [
  191. {"id": "opening", "kind": "image", "src": "still.png", "duration": 2, "narration": "Opening words"},
  192. {"id": "body", "kind": "image", "src": "still.png", "duration": 12, "narration": "Body"},
  193. ]
  194. encoded_inputs = []
  195. def synthesize(text, wav, voice):
  196. wav.write_bytes(text.encode("utf-8"))
  197. def measure(path):
  198. if path.name.startswith(".opening") or path.name == "opening.wav":
  199. return 1.0
  200. return 12.0
  201. def media_command(command, **kwargs):
  202. if command[0] == "ffprobe":
  203. path = Path(command[-1])
  204. seconds = {"opening.mp4": 2.375, "body.mp4": 12.0, "movie.mp4": 14.375}.get(path.name)
  205. if seconds is None:
  206. seconds = measure(path)
  207. return subprocess.CompletedProcess(command, 0, str(seconds), "")
  208. self.assertEqual(command[0], "ffmpeg")
  209. encoded_inputs.append([command[i + 1] for i, item in enumerate(command) if item == "-i"])
  210. Path(command[-1]).write_bytes(b"encoded token")
  211. return subprocess.CompletedProcess(command, 0, "", "")
  212. for rerun in (False, True):
  213. if rerun:
  214. del scenes[0]["narration"]
  215. scenes_file.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
  216. with patch.object(sys, "argv", ["narrate", str(scenes_file), str(narration), "--engine", "piper", "--verify", "off"]), \
  217. patch.object(narrate.shutil, "which", return_value="mock-tool"), \
  218. patch.object(narrate, "openai_key", return_value=None), \
  219. patch.object(narrate, "say_piper", side_effect=synthesize), \
  220. patch.object(narrate, "duration", side_effect=measure), \
  221. redirect_stdout(io.StringIO()), \
  222. redirect_stderr(io.StringIO()):
  223. self.assertEqual(narrate.main(), 0)
  224. manifest = json.loads((narration / "manifest.json").read_text(encoding="utf-8"))
  225. self.assertEqual([entry["id"] for entry in manifest], ["body"] if rerun else ["opening", "body"])
  226. self.assertEqual(manifest[-1]["text"], "Body")
  227. self.assertEqual(manifest[-1]["duration"], 12)
  228. self.assertEqual(manifest[-1]["wav"], "body.wav")
  229. self.assertEqual((narration / "opening.wav").read_bytes(), b"Opening words")
  230. encoded_inputs.clear()
  231. with patch.object(sys, "argv", ["assemble", str(scenes_file), str(movie), "--narration", str(narration), "--work", str(work)]), \
  232. patch.object(assemble.shutil, "which", return_value="mock-tool"), \
  233. patch.object(assemble, "find_browser", return_value=None), \
  234. patch.object(assemble, "run", side_effect=media_command), \
  235. redirect_stdout(io.StringIO()):
  236. self.assertEqual(assemble.main(), 0)
  237. offsets = json.loads((work / "offsets.json").read_text(encoding="utf-8"))
  238. self.assertEqual(offsets, {"body": 2.375} if rerun else {"opening": 0.0, "body": 2.375})
  239. self.assertEqual(str(narration / "opening.wav") in encoded_inputs[0], not rerun)
  240. self.assertIn(str(narration / "body.wav"), encoded_inputs[1])
  241. with patch.object(sys, "argv", ["make-subtitles", str(narration / "manifest.json"), str(srt), "--offsets-json", str(work / "offsets.json")]), \
  242. redirect_stdout(io.StringIO()):
  243. self.assertEqual(subtitles.main(), 0)
  244. expected = [(2375, 14375, "Body")]
  245. if not rerun:
  246. expected.insert(0, (0, 1000, "Opening words"))
  247. self.assertEqual(read_cues(srt), expected)
  248. if __name__ == "__main__":
  249. unittest.main()