|
|
@@ -1,9 +1,149 @@
|
|
|
import json
|
|
|
+import os
|
|
|
+import shutil
|
|
|
+import subprocess
|
|
|
+import sys
|
|
|
import tempfile
|
|
|
import unittest
|
|
|
from pathlib import Path
|
|
|
|
|
|
import fixtures
|
|
|
+from PIL import Image
|
|
|
+
|
|
|
+
|
|
|
+def run_ffmpeg(args: list[str], *, cwd: Path) -> subprocess.CompletedProcess[bytes]:
|
|
|
+ ffmpeg = shutil.which("ffmpeg")
|
|
|
+ if ffmpeg is None:
|
|
|
+ raise RuntimeError("ffmpeg is required for assembly fixtures")
|
|
|
+ result = subprocess.run(
|
|
|
+ [ffmpeg, "-nostdin", "-y", "-v", "error", *args],
|
|
|
+ cwd=cwd,
|
|
|
+ capture_output=True,
|
|
|
+ timeout=fixtures.TIMEOUT_SECONDS,
|
|
|
+ )
|
|
|
+ if result.returncode != 0:
|
|
|
+ raise RuntimeError(
|
|
|
+ f"ffmpeg fixture generation failed: {fixtures.output_text(result)}"
|
|
|
+ )
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def make_tone(path: Path, duration: float, frequency: int, *, cwd: Path) -> None:
|
|
|
+ run_ffmpeg(
|
|
|
+ [
|
|
|
+ "-f",
|
|
|
+ "lavfi",
|
|
|
+ "-i",
|
|
|
+ f"sine=frequency={frequency}:sample_rate=8000:duration={duration}",
|
|
|
+ "-c:a",
|
|
|
+ "pcm_s16le",
|
|
|
+ str(path),
|
|
|
+ ],
|
|
|
+ cwd=cwd,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def make_movie(path: Path, duration: float, frequency: int, *, cwd: Path) -> None:
|
|
|
+ run_ffmpeg(
|
|
|
+ [
|
|
|
+ "-f",
|
|
|
+ "lavfi",
|
|
|
+ "-i",
|
|
|
+ f"color=c=green:size=160x90:rate=10:duration={duration}",
|
|
|
+ "-f",
|
|
|
+ "lavfi",
|
|
|
+ "-i",
|
|
|
+ f"sine=frequency={frequency}:sample_rate=8000:duration={duration}",
|
|
|
+ "-c:v",
|
|
|
+ "libx264",
|
|
|
+ "-pix_fmt",
|
|
|
+ "yuv420p",
|
|
|
+ "-c:a",
|
|
|
+ "aac",
|
|
|
+ "-shortest",
|
|
|
+ str(path),
|
|
|
+ ],
|
|
|
+ cwd=cwd,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def decoded_pixel(path: Path, timestamp: float, *, cwd: Path) -> tuple[int, int, int]:
|
|
|
+ result = run_ffmpeg(
|
|
|
+ [
|
|
|
+ "-ss",
|
|
|
+ str(timestamp),
|
|
|
+ "-i",
|
|
|
+ str(path),
|
|
|
+ "-frames:v",
|
|
|
+ "1",
|
|
|
+ "-vf",
|
|
|
+ "scale=1:1",
|
|
|
+ "-f",
|
|
|
+ "rawvideo",
|
|
|
+ "-pix_fmt",
|
|
|
+ "rgb24",
|
|
|
+ "-",
|
|
|
+ ],
|
|
|
+ cwd=cwd,
|
|
|
+ )
|
|
|
+ if len(result.stdout) < 3:
|
|
|
+ raise AssertionError(f"no decoded pixel from {path}")
|
|
|
+ return tuple(result.stdout[:3])
|
|
|
+
|
|
|
+
|
|
|
+def decoded_frequency(path: Path, *, cwd: Path) -> float:
|
|
|
+ sample_rate = 8000
|
|
|
+ result = run_ffmpeg(
|
|
|
+ [
|
|
|
+ "-ss",
|
|
|
+ "0.1",
|
|
|
+ "-t",
|
|
|
+ "0.5",
|
|
|
+ "-i",
|
|
|
+ str(path),
|
|
|
+ "-map",
|
|
|
+ "0:a:0",
|
|
|
+ "-f",
|
|
|
+ "s16le",
|
|
|
+ "-acodec",
|
|
|
+ "pcm_s16le",
|
|
|
+ "-ac",
|
|
|
+ "1",
|
|
|
+ "-ar",
|
|
|
+ str(sample_rate),
|
|
|
+ "-",
|
|
|
+ ],
|
|
|
+ cwd=cwd,
|
|
|
+ )
|
|
|
+ samples = [
|
|
|
+ int.from_bytes(result.stdout[index:index + 2], "little", signed=True)
|
|
|
+ for index in range(0, len(result.stdout) - 1, 2)
|
|
|
+ ]
|
|
|
+ nonzero = [sample for sample in samples if sample]
|
|
|
+ crossings = sum(
|
|
|
+ (left < 0 <= right) or (left > 0 >= right)
|
|
|
+ for left, right in zip(nonzero, nonzero[1:])
|
|
|
+ )
|
|
|
+ seconds = len(samples) / sample_rate
|
|
|
+ return crossings / (2 * seconds)
|
|
|
+
|
|
|
+
|
|
|
+def available_browser() -> str | None:
|
|
|
+ candidates = [
|
|
|
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
|
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
|
+ "chromium",
|
|
|
+ "chromium-browser",
|
|
|
+ "google-chrome",
|
|
|
+ "google-chrome-stable",
|
|
|
+ ]
|
|
|
+ for candidate in candidates:
|
|
|
+ if Path(candidate).is_file():
|
|
|
+ return candidate
|
|
|
+ found = shutil.which(candidate)
|
|
|
+ if found:
|
|
|
+ return found
|
|
|
+ return None
|
|
|
|
|
|
|
|
|
class AssemblyRegression(unittest.TestCase):
|
|
|
@@ -57,6 +197,221 @@ class AssemblyRegression(unittest.TestCase):
|
|
|
delta=0.001,
|
|
|
)
|
|
|
|
|
|
+ def test_image_frames_and_movie_paths_timing_order_and_cleanup(self):
|
|
|
+ missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
|
|
|
+ if missing:
|
|
|
+ self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
|
|
|
+
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
+ root = Path(directory)
|
|
|
+ launch = root / "launch"
|
|
|
+ launch.mkdir()
|
|
|
+ project = root / "movie O'Brien λ & [take]"
|
|
|
+ assets = project / "assets"
|
|
|
+ frames = assets / "frames"
|
|
|
+ subtitles = assets / "subtitles" / "nested"
|
|
|
+ narration = project / "narration"
|
|
|
+ frames.mkdir(parents=True)
|
|
|
+ subtitles.mkdir(parents=True)
|
|
|
+ narration.mkdir()
|
|
|
+
|
|
|
+ still = assets / "still image.png"
|
|
|
+ Image.new("RGB", (160, 90), (230, 230, 230)).save(still)
|
|
|
+ Image.new("RGB", (160, 90), (255, 0, 0)).save(frames / "a.png")
|
|
|
+ Image.new("RGB", (160, 90), (0, 0, 255)).save(frames / "b.png")
|
|
|
+ source_pngs = {
|
|
|
+ path: path.read_bytes() for path in [still, *sorted(frames.glob("*.png"))]
|
|
|
+ }
|
|
|
+
|
|
|
+ source_movie = assets / "source movie.mp4"
|
|
|
+ make_movie(source_movie, 0.8, 440, cwd=launch)
|
|
|
+ make_tone(narration / "image.wav", 0.8, 660, cwd=launch)
|
|
|
+ make_tone(narration / "frames.wav", 1.4, 770, cwd=launch)
|
|
|
+ make_tone(narration / "movie.wav", 1.6, 880, cwd=launch)
|
|
|
+
|
|
|
+ nested_srt = subtitles / "captions.srt"
|
|
|
+ nested_srt.write_bytes(
|
|
|
+ b"1\r\n00:00:00,000 --> 00:00:00,500\r\nPortable paths\r\n"
|
|
|
+ )
|
|
|
+ scenes = project / "scenes.yaml"
|
|
|
+ yaml_text = f"""resolution: {{ width: 160, height: 90 }}
|
|
|
+fps: 10
|
|
|
+scenes:
|
|
|
+ - id: image
|
|
|
+ kind: image
|
|
|
+ src: assets/still image.png
|
|
|
+ duration: 0.3
|
|
|
+ - id: frames
|
|
|
+ kind: frames
|
|
|
+ src: {frames.resolve().as_posix()}
|
|
|
+ rate: 2.0
|
|
|
+ - id: movie
|
|
|
+ kind: movie
|
|
|
+ src: assets/source movie.mp4
|
|
|
+"""
|
|
|
+ scenes.write_bytes(yaml_text.replace("\n", "\r\n").encode("utf-8"))
|
|
|
+
|
|
|
+ work = root / "generated outside launch"
|
|
|
+ output = project / "assembled output.mp4"
|
|
|
+ result = fixtures.run_tool(
|
|
|
+ "assemble",
|
|
|
+ [
|
|
|
+ os.path.relpath(scenes, launch),
|
|
|
+ os.path.relpath(output, launch),
|
|
|
+ "--narration",
|
|
|
+ str(narration.resolve()),
|
|
|
+ "--work",
|
|
|
+ str(work.resolve()),
|
|
|
+ ],
|
|
|
+ cwd=launch,
|
|
|
+ )
|
|
|
+ self.assertEqual(result.returncode, 0, fixtures.output_text(result))
|
|
|
+
|
|
|
+ image_duration = fixtures.duration(work / "image.mp4")
|
|
|
+ frames_duration = fixtures.duration(work / "frames.mp4")
|
|
|
+ movie_duration = fixtures.duration(work / "movie.mp4")
|
|
|
+ self.assertAlmostEqual(
|
|
|
+ image_duration,
|
|
|
+ max(fixtures.duration(narration / "image.wav"), 0.3),
|
|
|
+ delta=0.25,
|
|
|
+ )
|
|
|
+ self.assertAlmostEqual(
|
|
|
+ frames_duration,
|
|
|
+ max(fixtures.duration(narration / "frames.wav"), 1.0),
|
|
|
+ delta=0.25,
|
|
|
+ )
|
|
|
+ self.assertAlmostEqual(
|
|
|
+ movie_duration,
|
|
|
+ fixtures.duration(source_movie),
|
|
|
+ delta=0.25,
|
|
|
+ )
|
|
|
+ self.assertLess(movie_duration, fixtures.duration(narration / "movie.wav") - 0.4)
|
|
|
+ self.assertAlmostEqual(
|
|
|
+ decoded_frequency(work / "movie.mp4", cwd=launch),
|
|
|
+ 440,
|
|
|
+ delta=15,
|
|
|
+ )
|
|
|
+
|
|
|
+ offsets = json.loads(
|
|
|
+ (work / "offsets.json").read_text(encoding="utf-8")
|
|
|
+ )
|
|
|
+ self.assertAlmostEqual(offsets["image"], 0.0, delta=0.001)
|
|
|
+ self.assertAlmostEqual(offsets["frames"], image_duration, delta=0.001)
|
|
|
+ self.assertNotIn("movie", offsets)
|
|
|
+
|
|
|
+ first = decoded_pixel(work / "frames.mp4", 0.2, cwd=launch)
|
|
|
+ second = decoded_pixel(work / "frames.mp4", 0.7, cwd=launch)
|
|
|
+ self.assertGreater(first[0], first[2] + 100)
|
|
|
+ self.assertGreater(second[2], second[0] + 100)
|
|
|
+ self.assertEqual(
|
|
|
+ {path: path.read_bytes() for path in source_pngs},
|
|
|
+ source_pngs,
|
|
|
+ )
|
|
|
+ self.assertEqual(list(work.glob("frames-frames-*")), [])
|
|
|
+
|
|
|
+ subtitled = project / "subtitled output.mp4"
|
|
|
+ subtitle_result = fixtures.run_tool(
|
|
|
+ "burn-subtitles",
|
|
|
+ [
|
|
|
+ str(output.resolve()),
|
|
|
+ os.path.relpath(nested_srt, launch),
|
|
|
+ str(subtitled.resolve()),
|
|
|
+ "--soft",
|
|
|
+ ],
|
|
|
+ cwd=launch,
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ subtitle_result.returncode,
|
|
|
+ 0,
|
|
|
+ fixtures.output_text(subtitle_result),
|
|
|
+ )
|
|
|
+ self.assertTrue(subtitled.is_file())
|
|
|
+
|
|
|
+ def test_card_uses_longer_narration_duration(self):
|
|
|
+ if sys.platform == "win32":
|
|
|
+ self.skipTest(
|
|
|
+ "native Windows card completion is pending Task5 browser discovery/file-URI work"
|
|
|
+ )
|
|
|
+ missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
|
|
|
+ if missing:
|
|
|
+ self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
|
|
|
+ browser = available_browser()
|
|
|
+ if browser is None:
|
|
|
+ self.skipTest("required browser unavailable for card assembly")
|
|
|
+
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
+ root = Path(directory)
|
|
|
+ narration = root / "narration"
|
|
|
+ narration.mkdir()
|
|
|
+ make_tone(narration / "card.wav", 0.8, 550, cwd=root)
|
|
|
+ scenes = root / "scenes.yaml"
|
|
|
+ scenes.write_bytes(
|
|
|
+ b"resolution: { width: 160, height: 90 }\r\n"
|
|
|
+ b"fps: 10\r\n"
|
|
|
+ b"scenes:\r\n"
|
|
|
+ b" - id: card\r\n"
|
|
|
+ b" kind: card\r\n"
|
|
|
+ b" title: Portable\r\n"
|
|
|
+ b" subtitle: paths\r\n"
|
|
|
+ b" duration: 0.3\r\n"
|
|
|
+ )
|
|
|
+ work = root / "work"
|
|
|
+ result = fixtures.run_tool(
|
|
|
+ "assemble",
|
|
|
+ [
|
|
|
+ str(scenes),
|
|
|
+ str(root / "out.mp4"),
|
|
|
+ "--work",
|
|
|
+ str(work),
|
|
|
+ "--browser",
|
|
|
+ browser,
|
|
|
+ ],
|
|
|
+ cwd=root,
|
|
|
+ )
|
|
|
+ self.assertEqual(result.returncode, 0, fixtures.output_text(result))
|
|
|
+ self.assertAlmostEqual(
|
|
|
+ fixtures.duration(work / "card.mp4"),
|
|
|
+ max(fixtures.duration(narration / "card.wav"), 0.3),
|
|
|
+ delta=0.25,
|
|
|
+ )
|
|
|
+ offsets = json.loads(
|
|
|
+ (work / "offsets.json").read_text(encoding="utf-8")
|
|
|
+ )
|
|
|
+ self.assertAlmostEqual(offsets["card"], 0.0, delta=0.001)
|
|
|
+
|
|
|
+ def test_frame_sources_survive_failed_assembly_cleanup(self):
|
|
|
+ missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
|
|
|
+ if missing:
|
|
|
+ self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
|
|
|
+
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
+ root = Path(directory)
|
|
|
+ source = root / "movie O'Brien λ & [take]" / "frames"
|
|
|
+ source.mkdir(parents=True)
|
|
|
+ bad_frame = source / "a.png"
|
|
|
+ bad_frame.write_bytes(b"not a PNG")
|
|
|
+ scenes = root / "scenes.yaml"
|
|
|
+ scenes.write_text(
|
|
|
+ f"""resolution: {{ width: 160, height: 90 }}
|
|
|
+fps: 10
|
|
|
+scenes:
|
|
|
+ - id: broken
|
|
|
+ kind: frames
|
|
|
+ src: {source.resolve().as_posix()}
|
|
|
+ rate: 1.0
|
|
|
+""",
|
|
|
+ encoding="utf-8",
|
|
|
+ )
|
|
|
+ work = root / "work"
|
|
|
+ result = fixtures.run_tool(
|
|
|
+ "assemble",
|
|
|
+ [str(scenes), str(root / "out.mp4"), "--work", str(work)],
|
|
|
+ cwd=root,
|
|
|
+ )
|
|
|
+ self.assertNotEqual(result.returncode, 0)
|
|
|
+ self.assertEqual(bad_frame.read_bytes(), b"not a PNG")
|
|
|
+ self.assertEqual(list(work.glob("frames-broken-*")), [])
|
|
|
+
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
unittest.main()
|