test_subtitles.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import tempfile
  2. import json
  3. import sys
  4. import io
  5. from contextlib import redirect_stderr, redirect_stdout
  6. import unittest
  7. from pathlib import Path
  8. from unittest.mock import patch
  9. import fixtures
  10. class SubtitlePathRegression(unittest.TestCase):
  11. def test_hard_burn_runs_from_safe_directory_with_absolute_media(self):
  12. module = fixtures.load_script("burn-subtitles")
  13. with tempfile.TemporaryDirectory() as directory:
  14. root = Path(directory) / "movie O'Brien λ"
  15. root.mkdir()
  16. movie, subs, output = root / "in.mp4", root / "nested" / "captions.srt", root / "out.mp4"
  17. subs.parent.mkdir()
  18. movie.write_bytes(b"movie")
  19. subs.write_bytes("\ufeff1\r\n00:00:00,000 --> 00:00:01,000\r\nλ\r\n".encode("utf-8"))
  20. calls = []
  21. def fake_run(cmd, *, cwd=None):
  22. calls.append((cmd, cwd))
  23. if cwd is not None:
  24. self.assertEqual((cwd / "captions.srt").read_bytes(), subs.read_bytes())
  25. return True
  26. stdout, stderr = io.StringIO(), io.StringIO()
  27. with patch.object(sys, "argv", ["burn-subtitles", str(movie), str(subs), str(output)]), patch.object(module.shutil, "which", return_value="ffmpeg"), patch.object(module, "has_libass", return_value=True), patch.object(module, "run", side_effect=fake_run), redirect_stdout(stdout), redirect_stderr(stderr):
  28. self.assertEqual(module.main(), 0)
  29. self.assertIn("burned into the picture", stdout.getvalue())
  30. command, cwd = calls[0]
  31. self.assertEqual(cwd.name.startswith("movie-subtitles-"), True)
  32. self.assertIn(str(movie.resolve()), command)
  33. self.assertIn(str(output.resolve()), command)
  34. self.assertTrue(any(value.startswith("subtitles=filename=captions.srt") for value in command))
  35. self.assertFalse(cwd.exists())
  36. def test_burn_failure_is_reported_separately_from_missing_libass(self):
  37. module = fixtures.load_script("burn-subtitles")
  38. with tempfile.TemporaryDirectory() as directory:
  39. root = Path(directory)
  40. movie, subs, output = root / "in.mp4", root / "captions.srt", root / "out.mp4"
  41. movie.write_bytes(b"movie")
  42. subs.write_text("1\n00:00:00,000 --> 00:00:01,000\ncaption\n", encoding="utf-8")
  43. stdout, stderr = io.StringIO(), io.StringIO()
  44. with patch.object(sys, "argv", ["burn-subtitles", str(movie), str(subs), str(output)]), patch.object(module.shutil, "which", return_value="ffmpeg"), patch.object(module, "has_libass", return_value=True), patch.object(module, "run", return_value=False), redirect_stdout(stdout), redirect_stderr(stderr):
  45. self.assertEqual(module.main(), 1)
  46. self.assertIn("burn failed", stderr.getvalue())
  47. class SubtitleOffsetRegression(unittest.TestCase):
  48. def subtitles(self, *manual, offsets=None):
  49. module = fixtures.load_script("make-subtitles")
  50. with tempfile.TemporaryDirectory() as directory:
  51. root = Path(directory)
  52. manifest, output = root / "manifest.json", root / "captions.srt"
  53. manifest.write_text(json.dumps([
  54. {"id": scene, "text": scene, "duration": 1.0}
  55. for scene in ("intro", "body", "end")
  56. ]), encoding="utf-8")
  57. argv = ["make-subtitles", str(manifest), str(output)]
  58. if offsets is not None:
  59. path = root / "offsets.json"
  60. path.write_text(json.dumps(offsets), encoding="utf-8")
  61. argv += ["--offsets-json", str(path)]
  62. if manual:
  63. argv += ["--offsets", *manual]
  64. with patch.object(sys, "argv", argv), redirect_stdout(io.StringIO()):
  65. self.assertEqual(module.main(), 0)
  66. cues = []
  67. for block in output.read_text(encoding="utf-8").strip().split("\n\n"):
  68. if not block:
  69. continue
  70. _, timing, text = block.split("\n", 2)
  71. times = []
  72. for timestamp in timing.split(" --> "):
  73. h, m, s = timestamp.replace(",", ".").split(":")
  74. times.append(int(h) * 3600 + int(m) * 60 + float(s))
  75. cues.append((*times, text))
  76. return cues
  77. def test_default_scenes_run_back_to_back(self):
  78. self.assertEqual(self.subtitles(),
  79. [(0, 1, "intro"), (1, 2, "body"), (2, 3, "end")])
  80. def test_partial_manual_offsets_preserve_other_scenes(self):
  81. self.assertEqual(self.subtitles("intro=2"),
  82. [(2, 3, "intro"), (3, 4, "body"), (4, 5, "end")])
  83. self.assertEqual(self.subtitles("body=4"),
  84. [(0, 1, "intro"), (4, 5, "body"), (5, 6, "end")])
  85. def test_assembly_offsets_select_scenes_in_the_cut(self):
  86. self.assertEqual(self.subtitles(offsets={"intro": 2, "end": 8}),
  87. [(2, 3, "intro"), (8, 9, "end")])
  88. def test_manual_offsets_change_timing_without_changing_cut_membership(self):
  89. self.assertEqual(self.subtitles("intro=3", "body=5", offsets={"intro": 2, "end": 8}),
  90. [(3, 4, "intro"), (8, 9, "end")])
  91. def test_cut_without_narrated_scenes_has_no_cues(self):
  92. for offsets in ({}, {"silent": 2}):
  93. with self.subTest(offsets=offsets):
  94. self.assertEqual(self.subtitles(offsets=offsets), [])
  95. class SubtitleIntegrationRegression(unittest.TestCase):
  96. def test_bom_manifest_and_offsets_write_utf8_under_legacy_console(self):
  97. import json
  98. import os
  99. with tempfile.TemporaryDirectory() as directory:
  100. root = Path(directory)
  101. manifest, offsets, out = root / "manifest.json", root / "offsets.json", root / "λ.srt"
  102. manifest.write_bytes(('\ufeff' + json.dumps([{"id": "clip", "text": "Unicode λ café", "duration": 1}], ensure_ascii=False) + '\r\n').encode('utf-8'))
  103. offsets.write_text('{"clip": 2}', encoding="utf-8-sig")
  104. env = dict(os.environ, PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
  105. result = fixtures.run_tool("make-subtitles", [str(manifest), str(out), "--offsets-json", str(offsets)], cwd=root, env=env)
  106. self.assertEqual(result.returncode, 0, fixtures.output_text(result))
  107. text = out.read_text(encoding="utf-8")
  108. self.assertIn("Unicode λ café", text)
  109. self.assertIn("00:00:02,000 --> 00:00:03,000", text)
  110. self.assertFalse(out.read_bytes().startswith(b'\xef\xbb\xbf'))
  111. def test_hard_subtitles_are_pixels_in_nested_special_path(self):
  112. import subprocess
  113. missing = fixtures.missing_executables("uv", "ffmpeg")
  114. if missing:
  115. self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
  116. module = fixtures.load_script("burn-subtitles")
  117. if not module.has_libass():
  118. self.skipTest("libass FFmpeg is required for hard subtitle pixels")
  119. with tempfile.TemporaryDirectory() as directory:
  120. root = Path(directory) / "O'Brien λ # %"
  121. root.mkdir()
  122. movie, subs, output = root / "in.mp4", root / "nested" / "O'Brien.srt", root / "out.mp4"
  123. subs.parent.mkdir()
  124. subs.write_bytes("\ufeff1\r\n00:00:00,000 --> 00:00:01,000\r\nVisible caption\r\n".encode("utf-8"))
  125. fixtures._run_ffmpeg(["-f", "lavfi", "-i", "color=c=black:s=640x360:d=1", "-c:v", "libx264", str(movie)], cwd=root)
  126. result = fixtures.run_tool("burn-subtitles", [str(movie), str(subs), str(output)], cwd=Path(directory))
  127. self.assertEqual(result.returncode, 0, fixtures.output_text(result))
  128. self.assertIn("burned into the picture", fixtures.output_text(result))
  129. frame = subprocess.run(["ffmpeg", "-v", "error", "-ss", "0.5", "-i", str(output), "-frames:v", "1", "-pix_fmt", "gray", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout
  130. self.assertGreater(sum(value > 180 for value in frame), 50)
  131. def test_burn_failure_fallback_does_not_claim_missing_libass(self):
  132. module = fixtures.load_script("burn-subtitles")
  133. for libass in (True, False):
  134. with self.subTest(libass=libass), tempfile.TemporaryDirectory() as directory:
  135. root = Path(directory)
  136. movie, subs = root / "in.mp4", root / "in.srt"
  137. movie.touch(); subs.touch()
  138. stdout, stderr = io.StringIO(), io.StringIO()
  139. with patch.object(sys, "argv", ["burn-subtitles", str(movie), str(subs), str(root / "out.mp4")]), patch.object(module.shutil, "which", return_value="ffmpeg"), patch.object(module, "has_libass", return_value=libass), patch.object(module, "run", side_effect=[False, True] if libass else [True]), redirect_stdout(stdout), redirect_stderr(stderr):
  140. self.assertEqual(module.main(), 0)
  141. self.assertEqual("no libass" in stdout.getvalue(), not libass)
  142. self.assertEqual("burn failed" in stderr.getvalue(), libass)
  143. if __name__ == "__main__":
  144. unittest.main()