test_assembly.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import json
  2. import os
  3. import shutil
  4. import subprocess
  5. import sys
  6. import tempfile
  7. import unittest
  8. from pathlib import Path
  9. import fixtures
  10. from PIL import Image
  11. def run_ffmpeg(args: list[str], *, cwd: Path) -> subprocess.CompletedProcess[bytes]:
  12. ffmpeg = shutil.which("ffmpeg")
  13. if ffmpeg is None:
  14. raise RuntimeError("ffmpeg is required for assembly fixtures")
  15. result = subprocess.run(
  16. [ffmpeg, "-nostdin", "-y", "-v", "error", *args],
  17. cwd=cwd,
  18. capture_output=True,
  19. timeout=fixtures.TIMEOUT_SECONDS,
  20. )
  21. if result.returncode != 0:
  22. raise RuntimeError(
  23. f"ffmpeg fixture generation failed: {fixtures.output_text(result)}"
  24. )
  25. return result
  26. def make_tone(path: Path, duration: float, frequency: int, *, cwd: Path) -> None:
  27. run_ffmpeg(
  28. [
  29. "-f",
  30. "lavfi",
  31. "-i",
  32. f"sine=frequency={frequency}:sample_rate=8000:duration={duration}",
  33. "-c:a",
  34. "pcm_s16le",
  35. str(path),
  36. ],
  37. cwd=cwd,
  38. )
  39. def make_movie(path: Path, duration: float, frequency: int, *, cwd: Path) -> None:
  40. run_ffmpeg(
  41. [
  42. "-f",
  43. "lavfi",
  44. "-i",
  45. f"color=c=green:size=160x90:rate=10:duration={duration}",
  46. "-f",
  47. "lavfi",
  48. "-i",
  49. f"sine=frequency={frequency}:sample_rate=8000:duration={duration}",
  50. "-c:v",
  51. "libx264",
  52. "-pix_fmt",
  53. "yuv420p",
  54. "-c:a",
  55. "aac",
  56. "-shortest",
  57. str(path),
  58. ],
  59. cwd=cwd,
  60. )
  61. def decoded_pixel(path: Path, timestamp: float, *, cwd: Path) -> tuple[int, int, int]:
  62. result = run_ffmpeg(
  63. [
  64. "-ss",
  65. str(timestamp),
  66. "-i",
  67. str(path),
  68. "-frames:v",
  69. "1",
  70. "-vf",
  71. "scale=1:1",
  72. "-f",
  73. "rawvideo",
  74. "-pix_fmt",
  75. "rgb24",
  76. "-",
  77. ],
  78. cwd=cwd,
  79. )
  80. if len(result.stdout) < 3:
  81. raise AssertionError(f"no decoded pixel from {path}")
  82. return tuple(result.stdout[:3])
  83. def decoded_frequency(path: Path, *, cwd: Path) -> float:
  84. sample_rate = 8000
  85. result = run_ffmpeg(
  86. [
  87. "-ss",
  88. "0.1",
  89. "-t",
  90. "0.5",
  91. "-i",
  92. str(path),
  93. "-map",
  94. "0:a:0",
  95. "-f",
  96. "s16le",
  97. "-acodec",
  98. "pcm_s16le",
  99. "-ac",
  100. "1",
  101. "-ar",
  102. str(sample_rate),
  103. "-",
  104. ],
  105. cwd=cwd,
  106. )
  107. samples = [
  108. int.from_bytes(result.stdout[index:index + 2], "little", signed=True)
  109. for index in range(0, len(result.stdout) - 1, 2)
  110. ]
  111. nonzero = [sample for sample in samples if sample]
  112. crossings = sum(
  113. (left < 0 <= right) or (left > 0 >= right)
  114. for left, right in zip(nonzero, nonzero[1:])
  115. )
  116. seconds = len(samples) / sample_rate
  117. return crossings / (2 * seconds)
  118. def available_browser() -> str | None:
  119. return fixtures.load_script("browser_tools").find_browser(os.environ.get("MOVIE_BROWSER"))
  120. class AssemblyRegression(unittest.TestCase):
  121. def test_narration_padding_and_offsets(self):
  122. missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
  123. if missing:
  124. self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
  125. with tempfile.TemporaryDirectory() as directory:
  126. work = Path(directory)
  127. scenes = fixtures.assembly_fixture(work)
  128. result = fixtures.run_tool(
  129. "assemble", [str(scenes), str(work / "out.mp4")], cwd=work
  130. )
  131. self.assertEqual(result.returncode, 0, fixtures.output_text(result))
  132. self.assertAlmostEqual(
  133. fixtures.duration(work / "out.mp4"), 8, delta=0.4
  134. )
  135. offsets = json.loads(
  136. (work / "segments/offsets.json").read_text(encoding="utf-8")
  137. )
  138. self.assertAlmostEqual(offsets["body"], 2, delta=0.3)
  139. subtitles = fixtures.run_tool(
  140. "make-subtitles",
  141. [
  142. str(work / "narration/manifest.json"),
  143. str(work / "out.srt"),
  144. "--offsets-json",
  145. str(work / "segments/offsets.json"),
  146. ],
  147. cwd=work,
  148. )
  149. self.assertEqual(
  150. subtitles.returncode, 0, fixtures.output_text(subtitles)
  151. )
  152. srt = (work / "out.srt").read_text(encoding="utf-8")
  153. timing_line = next(line for line in srt.splitlines() if "-->" in line)
  154. start = timing_line.partition("-->")[0].strip()
  155. hours, minutes, seconds_millis = start.split(":")
  156. seconds, millis = seconds_millis.split(",")
  157. first_cue_start = (
  158. int(hours) * 3600
  159. + int(minutes) * 60
  160. + int(seconds)
  161. + int(millis) / 1000
  162. )
  163. self.assertAlmostEqual(
  164. first_cue_start,
  165. offsets["body"],
  166. delta=0.001,
  167. )
  168. def test_image_frames_and_movie_paths_timing_order_and_cleanup(self):
  169. missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
  170. if missing:
  171. self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
  172. with tempfile.TemporaryDirectory() as directory:
  173. root = Path(directory)
  174. launch = root / "launch"
  175. launch.mkdir()
  176. project = root / "movie O'Brien λ & [take]"
  177. assets = project / "assets"
  178. frames = assets / "frames"
  179. subtitles = assets / "subtitles" / "nested"
  180. narration = project / "narration"
  181. frames.mkdir(parents=True)
  182. subtitles.mkdir(parents=True)
  183. narration.mkdir()
  184. still = assets / "still image.png"
  185. Image.new("RGB", (160, 90), (230, 230, 230)).save(still)
  186. Image.new("RGB", (160, 90), (255, 0, 0)).save(frames / "a.png")
  187. Image.new("RGB", (160, 90), (0, 0, 255)).save(frames / "b.png")
  188. source_pngs = {
  189. path: path.read_bytes() for path in [still, *sorted(frames.glob("*.png"))]
  190. }
  191. source_movie = assets / "source movie.mp4"
  192. make_movie(source_movie, 0.8, 440, cwd=launch)
  193. make_tone(narration / "image.wav", 0.8, 660, cwd=launch)
  194. make_tone(narration / "frames.wav", 1.4, 770, cwd=launch)
  195. make_tone(narration / "movie.wav", 1.6, 880, cwd=launch)
  196. (narration / "manifest.json").write_text(json.dumps([
  197. {"id": "image", "text": "Image narration", "wav": "image.wav", "duration": 0.8},
  198. {"id": "frames", "text": "Frames narration", "wav": "frames.wav", "duration": 1.4},
  199. ]), encoding="utf-8")
  200. nested_srt = subtitles / "captions.srt"
  201. nested_srt.write_bytes(
  202. b"1\r\n00:00:00,000 --> 00:00:00,500\r\nPortable paths\r\n"
  203. )
  204. scenes = project / "scenes.yaml"
  205. yaml_text = f"""resolution: {{ width: 160, height: 90 }}
  206. fps: 10
  207. scenes:
  208. - id: image
  209. kind: image
  210. src: assets/still image.png
  211. duration: 0.3
  212. narration: Image narration
  213. - id: frames
  214. kind: frames
  215. src: {frames.resolve().as_posix()}
  216. rate: 2.0
  217. narration: Frames narration
  218. - id: movie
  219. kind: movie
  220. src: assets/source movie.mp4
  221. """
  222. scenes.write_bytes(yaml_text.replace("\n", "\r\n").encode("utf-8"))
  223. work = root / "generated outside launch"
  224. output = project / "assembled output.mp4"
  225. result = fixtures.run_tool(
  226. "assemble",
  227. [
  228. os.path.relpath(scenes, launch),
  229. os.path.relpath(output, launch),
  230. "--narration",
  231. str(narration.resolve()),
  232. "--work",
  233. str(work.resolve()),
  234. ],
  235. cwd=launch,
  236. )
  237. self.assertEqual(result.returncode, 0, fixtures.output_text(result))
  238. image_duration = fixtures.duration(work / "image.mp4")
  239. frames_duration = fixtures.duration(work / "frames.mp4")
  240. movie_duration = fixtures.duration(work / "movie.mp4")
  241. self.assertAlmostEqual(
  242. image_duration,
  243. max(fixtures.duration(narration / "image.wav"), 0.3),
  244. delta=0.25,
  245. )
  246. self.assertAlmostEqual(
  247. frames_duration,
  248. max(fixtures.duration(narration / "frames.wav"), 1.0),
  249. delta=0.25,
  250. )
  251. self.assertAlmostEqual(
  252. movie_duration,
  253. fixtures.duration(source_movie),
  254. delta=0.25,
  255. )
  256. self.assertLess(movie_duration, fixtures.duration(narration / "movie.wav") - 0.4)
  257. self.assertAlmostEqual(
  258. decoded_frequency(work / "movie.mp4", cwd=launch),
  259. 440,
  260. delta=15,
  261. )
  262. offsets = json.loads(
  263. (work / "offsets.json").read_text(encoding="utf-8")
  264. )
  265. self.assertAlmostEqual(offsets["image"], 0.0, delta=0.001)
  266. self.assertAlmostEqual(offsets["frames"], image_duration, delta=0.001)
  267. self.assertNotIn("movie", offsets)
  268. first = decoded_pixel(work / "frames.mp4", 0.2, cwd=launch)
  269. second = decoded_pixel(work / "frames.mp4", 0.7, cwd=launch)
  270. self.assertGreater(first[0], first[2] + 100)
  271. self.assertGreater(second[2], second[0] + 100)
  272. self.assertEqual(
  273. {path: path.read_bytes() for path in source_pngs},
  274. source_pngs,
  275. )
  276. self.assertEqual(list(work.glob("frames-frames-*")), [])
  277. subtitled = project / "subtitled output.mp4"
  278. subtitle_result = fixtures.run_tool(
  279. "burn-subtitles",
  280. [
  281. str(output.resolve()),
  282. os.path.relpath(nested_srt, launch),
  283. str(subtitled.resolve()),
  284. "--soft",
  285. ],
  286. cwd=launch,
  287. )
  288. self.assertEqual(
  289. subtitle_result.returncode,
  290. 0,
  291. fixtures.output_text(subtitle_result),
  292. )
  293. self.assertTrue(subtitled.is_file())
  294. def test_card_uses_longer_narration_duration(self):
  295. missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
  296. if missing:
  297. self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
  298. browser = available_browser()
  299. if browser is None:
  300. self.skipTest("required browser unavailable for card assembly")
  301. with tempfile.TemporaryDirectory() as directory:
  302. root = Path(directory)
  303. narration = root / "narration"
  304. narration.mkdir()
  305. make_tone(narration / "card.wav", 0.8, 550, cwd=root)
  306. (narration / "manifest.json").write_text(json.dumps([
  307. {"id": "card", "text": "Card narration", "wav": "card.wav", "duration": 0.8}
  308. ]), encoding="utf-8")
  309. scenes = root / "scenes.yaml"
  310. scenes.write_bytes(
  311. b"\xef\xbb\xbfresolution: { width: 160, height: 90 }\r\n"
  312. b"fps: 10\r\n"
  313. b"scenes:\r\n"
  314. b" - id: card\r\n"
  315. b" kind: card\r\n"
  316. b" title: Portable\r\n"
  317. b" subtitle: paths\r\n"
  318. b" duration: 0.3\r\n"
  319. b" narration: Card narration\r\n"
  320. )
  321. work = root / "work"
  322. result = fixtures.run_tool(
  323. "assemble",
  324. [
  325. str(scenes),
  326. str(root / "out.mp4"),
  327. "--work",
  328. str(work),
  329. "--browser",
  330. browser,
  331. ],
  332. cwd=root,
  333. )
  334. self.assertEqual(result.returncode, 0, fixtures.output_text(result))
  335. self.assertAlmostEqual(
  336. fixtures.duration(work / "card.mp4"),
  337. max(fixtures.duration(narration / "card.wav"), 0.3),
  338. delta=0.25,
  339. )
  340. offsets = json.loads(
  341. (work / "offsets.json").read_text(encoding="utf-8")
  342. )
  343. self.assertAlmostEqual(offsets["card"], 0.0, delta=0.001)
  344. def test_frame_sources_survive_failed_assembly_cleanup(self):
  345. missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
  346. if missing:
  347. self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
  348. with tempfile.TemporaryDirectory() as directory:
  349. root = Path(directory)
  350. source = root / "movie O'Brien λ & [take]" / "frames"
  351. source.mkdir(parents=True)
  352. bad_frame = source / "a.png"
  353. bad_frame.write_bytes(b"not a PNG")
  354. scenes = root / "scenes.yaml"
  355. scenes.write_text(
  356. f"""resolution: {{ width: 160, height: 90 }}
  357. fps: 10
  358. scenes:
  359. - id: broken
  360. kind: frames
  361. src: {source.resolve().as_posix()}
  362. rate: 1.0
  363. """,
  364. encoding="utf-8",
  365. )
  366. work = root / "work"
  367. result = fixtures.run_tool(
  368. "assemble",
  369. [str(scenes), str(root / "out.mp4"), "--work", str(work)],
  370. cwd=root,
  371. )
  372. self.assertNotEqual(result.returncode, 0)
  373. self.assertEqual(bad_frame.read_bytes(), b"not a PNG")
  374. self.assertEqual(list(work.glob("frames-broken-*")), [])
  375. if __name__ == "__main__":
  376. unittest.main()