Files
magnus919_agent-skills/ffmpeg/scripts/generate-media-fixtures
T

723 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate a bounded synthetic FFmpeg fixture battery and evidence manifest."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
class FixtureError(Exception):
"""A controlled fixture-generation failure."""
def resolve_tool(name: str) -> str:
expanded = os.path.expanduser(name)
if os.path.sep in expanded:
candidate = Path(expanded)
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate.resolve())
else:
resolved = shutil.which(name)
if resolved:
return resolved
raise FixtureError(f"required executable not found: {name}")
def execute(argv: list[str], timeout: float) -> subprocess.CompletedProcess[str]:
try:
result = subprocess.run(
argv,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise FixtureError(f"command could not complete: {argv[0]}: {exc}") from exc
if result.returncode:
detail = result.stderr.strip()[-2000:] or result.stdout.strip()[-2000:]
raise FixtureError(f"command failed ({result.returncode}): {' '.join(argv)}\n{detail}")
return result
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return f"sha256:{digest.hexdigest()}"
def probe(ffprobe: str, path: Path, timeout: float) -> dict[str, Any]:
result = execute(
[
ffprobe,
"-v",
"error",
"-show_format",
"-show_streams",
"-show_chapters",
"-of",
"json",
str(path),
],
timeout,
)
try:
document = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise FixtureError(f"invalid ffprobe JSON for {path.name}: {exc}") from exc
if not isinstance(document, dict):
raise FixtureError(f"ffprobe JSON root for {path.name} must be an object")
return document
def stream(document: dict[str, Any], kind: str) -> dict[str, Any] | None:
streams = document.get("streams", [])
if not isinstance(streams, list):
return None
return next(
(item for item in streams if isinstance(item, dict) and item.get("codec_type") == kind),
None,
)
def fixture_record(
path: Path,
role: str,
generator: list[str] | str,
probe_document: dict[str, Any] | None,
expected: list[str],
limitations: list[str],
) -> dict[str, Any]:
return {
"id": path.stem,
"path": path.name,
"role": role,
"sha256": sha256(path),
"generator": generator,
"probe": probe_document,
"expected_properties": expected,
"limitations": limitations,
}
def add_media_fixture(
records: list[dict[str, Any]],
ffprobe: str,
path: Path,
role: str,
generator: list[str],
expected: list[str],
limitations: list[str],
timeout: float,
) -> dict[str, Any]:
document = probe(ffprobe, path, timeout)
record = fixture_record(path, role, generator, document, expected, limitations)
records.append(record)
return document
def create_workspace(path: Path) -> Path:
workspace = path.expanduser().resolve()
if workspace.exists() and any(workspace.iterdir()):
raise FixtureError(f"workspace must be absent or empty: {workspace}")
workspace.mkdir(parents=True, exist_ok=True)
return workspace
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("workspace", help="new or empty directory for fixture artifacts")
parser.add_argument("--ffmpeg", default="ffmpeg")
parser.add_argument("--ffprobe", default="ffprobe")
parser.add_argument("--timeout", type=float, default=30.0, help="per-command timeout (1-300)")
parser.add_argument("--json", action="store_true", help="emit compact summary JSON")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if not 1 <= args.timeout <= 300:
raise FixtureError("--timeout must be between 1 and 300 seconds")
workspace = create_workspace(Path(args.workspace))
ffmpeg = resolve_tool(args.ffmpeg)
ffprobe = resolve_tool(args.ffprobe)
version = execute([ffmpeg, "-version"], args.timeout).stdout.splitlines()[0]
probe_version = execute([ffprobe, "-version"], args.timeout).stdout.splitlines()[0]
commands: list[list[str]] = []
fixtures: list[dict[str, Any]] = []
def generate(path: Path, arguments: list[str]) -> list[str]:
command = [ffmpeg, "-v", "error", "-n", *arguments, str(path)]
execute(command, args.timeout)
commands.append(command)
return command
base = workspace / "gop-source.mkv"
base_command = generate(
base,
[
"-f",
"lavfi",
"-i",
"testsrc2=size=320x180:rate=24:duration=3",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000:duration=3",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"mpeg4",
"-g",
"48",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
"-shortest",
],
)
base_probe = add_media_fixture(
fixtures,
ffprobe,
base,
"non-keyframe-cut-source",
base_command,
["3 second CFR source", "24 fps video", "48 kHz audio", "two-second GOP"],
["MPEG-4 keyframe placement is build-specific and must be probed"],
args.timeout,
)
copy_cut = workspace / "stream-copy-cut.mkv"
copy_command = generate(
copy_cut,
["-ss", "0.5", "-i", str(base), "-t", "1", "-map", "0", "-c", "copy"],
)
copy_probe = add_media_fixture(
fixtures,
ffprobe,
copy_cut,
"packet-boundary-copy-cut",
copy_command,
["stream-copy cut requested at a non-GOP boundary"],
["packet/keyframe behavior is evidence, not frame-accurate intent"],
args.timeout,
)
accurate_cut = workspace / "decoded-accurate-cut.mkv"
accurate_command = generate(
accurate_cut,
[
"-i",
str(base),
"-ss",
"0.5",
"-t",
"1",
"-map",
"0:v:0",
"-map",
"0:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
],
)
accurate_probe = add_media_fixture(
fixtures,
ffprobe,
accurate_cut,
"decoded-accurate-cut",
accurate_command,
["decoded one-second cut requested at 0.5 seconds"],
["container duration remains subject to time-base rounding"],
args.timeout,
)
vfr = workspace / "vfr-video.mkv"
vfr_command = generate(
vfr,
[
"-f",
"lavfi",
"-i",
"testsrc2=size=160x90:rate=30:duration=2",
"-vf",
"select=not(mod(n\\,2))+not(mod(n\\,5))",
"-fps_mode",
"vfr",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-an",
],
)
add_media_fixture(
fixtures,
ffprobe,
vfr,
"variable-frame-cadence",
vfr_command,
["irregularly selected frames with original timestamps"],
["container-reported average rate is not a complete cadence proof"],
args.timeout,
)
compatible: list[Path] = []
for color, frequency in (("red", 550), ("blue", 660)):
path = workspace / f"concat-{color}.mkv"
command = generate(
path,
[
"-f",
"lavfi",
"-i",
f"color=c={color}:size=160x90:rate=24:duration=1",
"-f",
"lavfi",
"-i",
f"sine=frequency={frequency}:sample_rate=48000:duration=1",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
"-shortest",
],
)
add_media_fixture(
fixtures,
ffprobe,
path,
"concat-compatible-input",
command,
["160x90 at 24 fps", "48 kHz mono PCM audio"],
["compatibility applies only to the generated pair and recorded build"],
args.timeout,
)
compatible.append(path)
concat_list = workspace / "compatible.ffconcat"
concat_list.write_text(
"ffconcat version 1.0\n" + "".join(f"file '{path.name}'\n" for path in compatible),
encoding="utf-8",
)
fixtures.append(
fixture_record(
concat_list,
"concat-demuxer-list",
"generated from concat-red.mkv and concat-blue.mkv",
None,
["two explicitly ordered compatible inputs"],
["relative entries assume the media files remain beside this list"],
)
)
concat_output = workspace / "concat-compatible-output.mkv"
concat_command = generate(
concat_output,
["-f", "concat", "-safe", "0", "-i", str(concat_list), "-c", "copy"],
)
concat_probe = add_media_fixture(
fixtures,
ffprobe,
concat_output,
"concat-compatible-success",
concat_command,
["both compatible inputs joined with concat demuxer and stream copy"],
["success does not generalize to other containers or streams"],
args.timeout,
)
incompatible = workspace / "concat-incompatible.mkv"
incompatible_command = generate(
incompatible,
[
"-f",
"lavfi",
"-i",
"testsrc2=size=176x144:rate=25:duration=1",
"-f",
"lavfi",
"-i",
"sine=frequency=770:sample_rate=44100:duration=1",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
"-ac",
"2",
"-shortest",
],
)
incompatible_probe = add_media_fixture(
fixtures,
ffprobe,
incompatible,
"concat-incompatible-input",
incompatible_command,
["176x144 at 25 fps", "44.1 kHz stereo PCM audio"],
["intentionally differs from the compatible pair"],
args.timeout,
)
drift = workspace / "audio-duration-drift.mkv"
drift_command = generate(
drift,
[
"-f",
"lavfi",
"-i",
"testsrc2=size=160x90:rate=24:duration=2",
"-itsoffset",
"0.25",
"-f",
"lavfi",
"-i",
"sine=frequency=880:sample_rate=48000:duration=2.1",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
],
)
add_media_fixture(
fixtures,
ffprobe,
drift,
"audio-offset-and-duration-drift-candidate",
drift_command,
["audio starts 0.25 seconds after video", "audio source duration exceeds video source"],
["probe timestamps identify a candidate; sync requires declared-point review"],
args.timeout,
)
audio = workspace / "audio-analysis.wav"
audio_command = generate(
audio,
[
"-f",
"lavfi",
"-i",
"aevalsrc=if(between(t\\,1\\,2)\\,0\\,if(between(t\\,3\\,3.5)\\,1.2*sin(2*PI*440*t)\\,0.4*sin(2*PI*440*t))):s=48000:d=4",
"-c:a",
"pcm_s16le",
],
)
add_media_fixture(
fixtures,
ffprobe,
audio,
"audio-silence-and-peak-candidates",
audio_command,
["one-second digital silence", "normal and over-range generated amplitude regions"],
["threshold events are candidates; listening and editorial approval remain separate"],
args.timeout,
)
faded_audio = workspace / "audio-faded.wav"
fade_command = generate(
faded_audio,
[
"-i",
str(audio),
"-af",
"afade=t=in:d=0.1,afade=t=out:st=3.9:d=0.1",
"-c:a",
"pcm_s16le",
],
)
add_media_fixture(
fixtures,
ffprobe,
faded_audio,
"audio-fade-output",
fade_command,
["100 ms fade-in and fade-out applied to the synthetic analysis source"],
["mechanical fade presence does not establish editorial suitability"],
args.timeout,
)
speech_like = workspace / "speech-like-audio.wav"
speech_like_command = generate(
speech_like,
[
"-f",
"lavfi",
"-i",
"aevalsrc=if(between(t\\,1\\,1.7)\\,0\\,0.35*sin(2*PI*(180+40*sin(2*PI*3*t))*t)):s=48000:d=3",
"-c:a",
"pcm_s16le",
],
)
add_media_fixture(
fixtures,
ffprobe,
speech_like,
"synthetic-speech-like-analysis-source",
speech_like_command,
["frequency-modulated voiced-like tone", "declared 0.7 second silence interval"],
["synthetic modulation is not human speech and cannot validate intelligibility"],
args.timeout,
)
subtitle_text = workspace / "captions.srt"
subtitle_text.write_text(
"1\n00:00:00,200 --> 00:00:00,800\nSynthetic caption\n",
encoding="utf-8",
)
fixtures.append(
fixture_record(
subtitle_text,
"subtitle-source-text",
"written by generate-media-fixtures",
None,
["single non-personal caption cue"],
["SRT timing does not prove rendered legibility"],
)
)
subtitle_media = workspace / "subtitle-stream.mkv"
subtitle_command = generate(
subtitle_media,
[
"-f",
"lavfi",
"-i",
"color=c=black:size=160x90:rate=24:duration=1",
"-f",
"lavfi",
"-i",
"sine=frequency=330:sample_rate=48000:duration=1",
"-f",
"srt",
"-i",
str(subtitle_text),
"-map",
"0:v:0",
"-map",
"1:a:0",
"-map",
"2:s:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
"-c:s",
"srt",
"-shortest",
],
)
add_media_fixture(
fixtures,
ffprobe,
subtitle_media,
"subtitle-stream-survival",
subtitle_command,
["video, audio, and subtitle streams are explicitly mapped"],
["embedded subtitle presence does not prove player rendering"],
args.timeout,
)
filter_inventory = execute([ffmpeg, "-hide_banner", "-filters"], args.timeout).stdout
subtitle_filter_available = any(
line.split()[1:2] == ["subtitles"]
for line in filter_inventory.splitlines()
if len(line.split()) >= 2
)
subtitle_burn_in: dict[str, Any]
if subtitle_filter_available:
burned = workspace / "subtitle-burned.mkv"
escaped_subtitle = (
str(subtitle_text).replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")
)
burned_command = generate(
burned,
[
"-i",
str(subtitle_media),
"-vf",
f"subtitles=filename='{escaped_subtitle}'",
"-map",
"0:v:0",
"-map",
"0:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"copy",
],
)
burned_probe = add_media_fixture(
fixtures,
ffprobe,
burned,
"subtitle-burn-in-output",
burned_command,
["caption pixels rendered into video", "no subtitle stream mapped to output"],
["visual legibility still requires frame or player review"],
args.timeout,
)
subtitle_burn_in = {
"status": "EXERCISED",
"output": burned.name,
"subtitle_stream_present": stream(burned_probe, "subtitle") is not None,
}
else:
subtitle_burn_in = {
"status": "UNAVAILABLE",
"reason": "local FFmpeg build does not report the subtitles filter",
}
frames: list[str] = []
for index, timestamp in enumerate((0.4, 0.5, 0.6), start=1):
frame = workspace / f"boundary-{index}.png"
command = generate(
frame,
["-ss", str(timestamp), "-i", str(base), "-frames:v", "1"],
)
fixtures.append(
fixture_record(
frame,
"bounded-boundary-frame",
command,
None,
[f"one frame requested at {timestamp:.1f} seconds"],
["the image establishes only its sampled timestamp"],
)
)
frames.append(frame.name)
base_video = stream(base_probe, "video") or {}
base_audio = stream(base_probe, "audio") or {}
incompatible_video = stream(incompatible_probe, "video") or {}
incompatible_audio = stream(incompatible_probe, "audio") or {}
concat_comparison = {
"compatible_pair": {
"verdict": "PASS",
"method": "concat demuxer with stream copy",
"output_probe": "concat-compatible-output.mkv",
"stream_types": [
item.get("codec_type")
for item in concat_probe.get("streams", [])
if isinstance(item, dict)
],
},
"incompatible_candidate": {
"verdict": "REJECTED_BEFORE_CONCAT",
"differences": {
"video_dimensions": [
[base_video.get("width"), base_video.get("height")],
[incompatible_video.get("width"), incompatible_video.get("height")],
],
"video_rate": [
base_video.get("avg_frame_rate"),
incompatible_video.get("avg_frame_rate"),
],
"audio_sample_rate": [
base_audio.get("sample_rate"),
incompatible_audio.get("sample_rate"),
],
"audio_channels": [
base_audio.get("channels"),
incompatible_audio.get("channels"),
],
},
"reason": "stream parameters differ; extension equality is not compatibility evidence",
},
}
manifest = {
"schema_version": 1,
"fixture_set": "ffmpeg-synthetic-media-v1",
"tool_context": {"ffmpeg": version, "ffprobe": probe_version},
"portable_assertions": [
"generators use lavfi or committed non-personal text",
"every media fixture is probed after generation",
"stream-copy and decoded cuts retain separate evidence",
"incompatible concat input is rejected from observed stream differences",
"visual samples cover only three declared timestamps",
],
"environment_observations": {
"copy_cut_probe": copy_probe,
"decoded_cut_probe": accurate_probe,
},
"concat": concat_comparison,
"review_packet": {
"source": base.name,
"timestamps_seconds": [0.4, 0.5, 0.6],
"frames": frames,
"coverage": "three samples around one proposed boundary; no whole-video claim",
},
"subtitle_burn_in": subtitle_burn_in,
"fixtures": fixtures,
"commands": commands,
"known_limitations": [
"FFmpeg filters and codec behavior vary by recorded build",
"no listening or semantic visual review is automated",
"no downstream player or service is exercised",
"generated media is task-local and must not be committed",
],
}
manifest_path = workspace / "fixture-manifest.json"
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
summary = {
"ok": True,
"workspace": str(workspace),
"fixture_set": manifest["fixture_set"],
"fixture_count": len(fixtures),
"manifest": manifest_path.name,
"concat_incompatible_verdict": concat_comparison["incompatible_candidate"]["verdict"],
}
print(
json.dumps(summary, sort_keys=True, separators=(",", ":"))
if args.json
else json.dumps(summary, indent=2, sort_keys=True)
)
return 0
except FixtureError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, sort_keys=True))
return 2
if __name__ == "__main__":
raise SystemExit(main())