fixtures.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. """Portable fixtures for the imported movie regression suites."""
  2. import json
  3. import importlib.util
  4. import importlib.machinery
  5. import shutil
  6. import subprocess
  7. import types
  8. import sys
  9. from pathlib import Path
  10. TIMEOUT_SECONDS = 900
  11. def missing_executables(*names: str) -> list[str]:
  12. """Return executable names that cannot be resolved on PATH."""
  13. return [name for name in names if shutil.which(name) is None]
  14. def output_text(result: subprocess.CompletedProcess[bytes]) -> str:
  15. """Decode a captured command's combined output as UTF-8 evidence."""
  16. return (result.stdout + result.stderr).decode("utf-8", errors="replace")
  17. def run_tool(
  18. name: str,
  19. args: list[str],
  20. *,
  21. cwd: Path,
  22. env: dict[str, str] | None = None,
  23. ) -> subprocess.CompletedProcess[bytes]:
  24. """Invoke one extensionless movie tool through its PEP 723 environment."""
  25. uv = shutil.which("uv")
  26. if uv is None:
  27. raise RuntimeError("uv is required for movie tool tests")
  28. script = (
  29. Path(__file__).resolve().parents[2]
  30. / "skills/proving-it-works-with-a-movie/scripts"
  31. / name
  32. )
  33. return subprocess.run(
  34. [uv, "run", "--script", str(script), *args],
  35. cwd=cwd,
  36. env=env,
  37. capture_output=True,
  38. timeout=TIMEOUT_SECONDS,
  39. )
  40. def load_script(name: str) -> types.ModuleType:
  41. """Load an extensionless movie tool as a test module."""
  42. script = (
  43. Path(__file__).resolve().parents[2]
  44. / "skills/proving-it-works-with-a-movie/scripts"
  45. / name
  46. )
  47. if not script.exists():
  48. script = script.with_suffix(".py")
  49. sys.path.insert(0, str(script.parent))
  50. loader = importlib.machinery.SourceFileLoader(f"movie_tool_{name}", str(script))
  51. spec = importlib.util.spec_from_loader(loader.name, loader)
  52. if spec is None or spec.loader is None:
  53. raise ImportError(f"cannot load movie tool {name!r}")
  54. module = importlib.util.module_from_spec(spec)
  55. try:
  56. spec.loader.exec_module(module)
  57. finally:
  58. sys.path.pop(0)
  59. return module
  60. def duration(path: Path) -> float:
  61. """Measure a media file's container duration with ffprobe."""
  62. ffprobe = shutil.which("ffprobe")
  63. if ffprobe is None:
  64. raise RuntimeError("ffprobe is required for movie tool tests")
  65. result = subprocess.run(
  66. [
  67. ffprobe,
  68. "-v",
  69. "error",
  70. "-show_entries",
  71. "format=duration",
  72. "-of",
  73. "csv=p=0",
  74. str(path),
  75. ],
  76. capture_output=True,
  77. timeout=TIMEOUT_SECONDS,
  78. )
  79. if result.returncode != 0:
  80. raise RuntimeError(f"ffprobe failed for {path}: {output_text(result)}")
  81. return float(result.stdout.decode("utf-8").strip())
  82. def _run_ffmpeg(args: list[str], *, cwd: Path) -> None:
  83. ffmpeg = shutil.which("ffmpeg")
  84. if ffmpeg is None:
  85. raise RuntimeError("ffmpeg is required for movie tool tests")
  86. result = subprocess.run(
  87. [ffmpeg, "-nostdin", "-y", "-v", "error", *args],
  88. cwd=cwd,
  89. capture_output=True,
  90. timeout=TIMEOUT_SECONDS,
  91. )
  92. if result.returncode != 0:
  93. raise RuntimeError(f"ffmpeg fixture generation failed: {output_text(result)}")
  94. def assembly_fixture(work: Path) -> Path:
  95. """Write the imported image/frames/synthetic-timing-audio assembly case."""
  96. shots = work / "shots"
  97. shots.mkdir(parents=True)
  98. for index in range(1, 5):
  99. _run_ffmpeg(
  100. [
  101. "-f",
  102. "lavfi",
  103. "-i",
  104. f"color=c=0x{index}0{index}0{index}0:size=320x180:d=0.1",
  105. "-frames:v",
  106. "1",
  107. str(shots / f"s0{index}.png"),
  108. ],
  109. cwd=work,
  110. )
  111. narration = work / "narration"
  112. narration.mkdir()
  113. wav = narration / "body.wav"
  114. _run_ffmpeg(
  115. [
  116. "-f",
  117. "lavfi",
  118. "-i",
  119. "sine=frequency=300:duration=6",
  120. str(wav),
  121. ],
  122. cwd=work,
  123. )
  124. manifest = [
  125. {
  126. "id": "body",
  127. "text": "one two three four five six seven eight nine ten",
  128. "wav": "body.wav",
  129. "duration": duration(wav),
  130. }
  131. ]
  132. (narration / "manifest.json").write_text(
  133. json.dumps(manifest), encoding="utf-8"
  134. )
  135. scenes = work / "scenes.yaml"
  136. scenes.write_text(
  137. """resolution: { width: 640, height: 360 }
  138. fps: 30
  139. scenes:
  140. - id: opener
  141. kind: image
  142. src: shots/s01.png
  143. duration: 2
  144. - id: body
  145. kind: frames
  146. src: shots
  147. rate: 1.0
  148. narration: one two three four five six seven eight nine ten
  149. """,
  150. encoding="utf-8",
  151. )
  152. return scenes
  153. def checker_fixture(work: Path) -> dict[str, Path]:
  154. """Create the four imported checker movies and their subtitle sidecars."""
  155. front_loaded = work / "front-loaded.mp4"
  156. _run_ffmpeg(
  157. [
  158. "-f",
  159. "lavfi",
  160. "-i",
  161. "testsrc2=size=320x240:rate=10:d=2",
  162. "-f",
  163. "lavfi",
  164. "-i",
  165. "color=c=navy:size=320x240:rate=10:d=20",
  166. "-f",
  167. "lavfi",
  168. "-i",
  169. "sine=frequency=300:duration=22",
  170. "-filter_complex",
  171. "[0:v][1:v]concat=n=2:v=1:a=0[v]",
  172. "-map",
  173. "[v]",
  174. "-map",
  175. "2:a",
  176. "-c:v",
  177. "libx264",
  178. "-pix_fmt",
  179. "yuv420p",
  180. "-c:a",
  181. "aac",
  182. "-shortest",
  183. str(front_loaded),
  184. ],
  185. cwd=work,
  186. )
  187. paced = work / "paced.mp4"
  188. _run_ffmpeg(
  189. [
  190. "-f",
  191. "lavfi",
  192. "-i",
  193. "testsrc2=size=320x240:rate=10:d=22",
  194. "-f",
  195. "lavfi",
  196. "-i",
  197. "sine=frequency=300:duration=22",
  198. "-c:v",
  199. "libx264",
  200. "-pix_fmt",
  201. "yuv420p",
  202. "-c:a",
  203. "aac",
  204. "-shortest",
  205. str(paced),
  206. ],
  207. cwd=work,
  208. )
  209. still = work / "still.mp4"
  210. _run_ffmpeg(
  211. [
  212. "-f",
  213. "lavfi",
  214. "-i",
  215. "color=c=navy:size=320x240:rate=10:d=12",
  216. "-f",
  217. "lavfi",
  218. "-i",
  219. "sine=frequency=300:duration=12",
  220. "-c:v",
  221. "libx264",
  222. "-pix_fmt",
  223. "yuv420p",
  224. "-c:a",
  225. "aac",
  226. "-shortest",
  227. str(still),
  228. ],
  229. cwd=work,
  230. )
  231. silent = work / "silent.mp4"
  232. _run_ffmpeg(
  233. [
  234. "-f",
  235. "lavfi",
  236. "-i",
  237. "testsrc2=size=320x240:rate=10:d=12",
  238. "-c:v",
  239. "libx264",
  240. "-pix_fmt",
  241. "yuv420p",
  242. str(silent),
  243. ],
  244. cwd=work,
  245. )
  246. subtitles = """1
  247. 00:00:00,000 --> 00:00:07,000
  248. A narrated movie needs subtitles:
  249. plenty of people watch muted.
  250. 2
  251. 00:00:07,000 --> 00:00:14,000
  252. The checker treats their absence
  253. as a defect, not a nicety.
  254. 3
  255. 00:00:14,000 --> 00:00:21,500
  256. And it notices when they stop
  257. before the narration does.
  258. """
  259. (work / "paced.srt").write_text(subtitles, encoding="utf-8")
  260. (work / "short.srt").write_text(
  261. "\n".join(subtitles.splitlines()[:8]) + "\n", encoding="utf-8"
  262. )
  263. short = work / "short.mp4"
  264. shutil.copyfile(paced, short)
  265. return {
  266. "front-loaded": front_loaded,
  267. "paced": paced,
  268. "still": still,
  269. "silent": silent,
  270. "short": short,
  271. }