Files

454 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run a bounded synthetic FFmpeg workflow from intake through acceptance."""
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 WorkflowError(Exception):
"""A controlled workflow failure with an actionable message."""
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 WorkflowError(f"required executable not found: {name}")
def run(argv: list[str], *, timeout: float = 30.0) -> 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 WorkflowError(f"command could not complete: {argv[0]}: {exc}") from exc
if result.returncode:
detail = result.stderr.strip()[-2000:] or result.stdout.strip()[-2000:]
raise WorkflowError(f"command failed ({result.returncode}): {' '.join(argv)}\n{detail}")
return result
def write_json(path: Path, value: object) -> None:
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
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, media: Path) -> tuple[list[str], dict[str, Any]]:
command = [
ffprobe,
"-v",
"error",
"-show_format",
"-show_streams",
"-show_chapters",
"-of",
"json",
str(media),
]
result = run(command)
try:
document = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise WorkflowError(f"ffprobe returned invalid JSON for {media.name}: {exc}") from exc
if not isinstance(document, dict):
raise WorkflowError(f"ffprobe JSON root for {media.name} must be an object")
return command, document
def first_line(command: list[str]) -> str:
output = run(command).stdout.splitlines()
if not output:
raise WorkflowError(f"version command returned no output: {command[0]}")
return output[0]
def duration(document: dict[str, Any]) -> float | None:
raw = document.get("format", {}).get("duration")
try:
return float(raw)
except (TypeError, ValueError):
return None
def stream_types(document: dict[str, Any]) -> list[str]:
streams = document.get("streams", [])
if not isinstance(streams, list):
return []
return [
stream.get("codec_type")
for stream in streams
if isinstance(stream, dict) and isinstance(stream.get("codec_type"), str)
]
def prepare_workspace(path: Path) -> Path:
resolved = path.expanduser().resolve()
if resolved.exists() and any(resolved.iterdir()):
raise WorkflowError(f"workspace must be absent or empty: {resolved}")
resolved.mkdir(parents=True, exist_ok=True)
return resolved
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("workspace", help="new or empty directory for generated artifacts")
parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable")
parser.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable")
parser.add_argument(
"--duration", type=float, default=3.0, help="synthetic source duration (1-30 seconds)"
)
parser.add_argument(
"--timeout", type=float, default=30.0, help="per-command timeout (1-300 seconds)"
)
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.0 <= args.duration <= 30.0:
raise WorkflowError("--duration must be between 1 and 30 seconds")
if not 1.0 <= args.timeout <= 300.0:
raise WorkflowError("--timeout must be between 1 and 300 seconds")
workspace = prepare_workspace(Path(args.workspace))
ffmpeg = resolve_tool(args.ffmpeg)
ffprobe = resolve_tool(args.ffprobe)
source = workspace / "synthetic-source.mkv"
output = workspace / "edited-output.mkv"
start = round(args.duration * 0.2, 3)
end = round(args.duration * 0.8, 3)
expected_duration = round(end - start, 3)
commands: list[list[str]] = []
ffmpeg_version = first_line([ffmpeg, "-version"])
ffprobe_version = first_line([ffprobe, "-version"])
generate = [
ffmpeg,
"-v",
"error",
"-n",
"-f",
"lavfi",
"-i",
f"testsrc2=size=320x180:rate=24:duration={args.duration:g}",
"-f",
"lavfi",
"-i",
f"sine=frequency=880:sample_rate=48000:duration={args.duration:g}",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
"-shortest",
str(source),
]
run(generate, timeout=args.timeout)
commands.append(generate)
source_probe_command, source_probe = probe(ffprobe, source)
commands.append(source_probe_command)
write_json(workspace / "source-probe.json", source_probe)
required_types = {"video", "audio"}
if not required_types.issubset(stream_types(source_probe)):
raise WorkflowError("synthetic source does not contain both video and audio")
source_digest = sha256(source)
intake = {
"schema_version": 1,
"workflow_id": "synthetic-editorial-example",
"assets": [
{
"id": "asset-synthetic-001",
"source": source.name,
"source_digest": source_digest,
"authorization": "generated synthetic media",
"probe": {
"command": source_probe_command,
"result_path": "source-probe.json",
},
}
],
"tool_context": {
"ffmpeg": ffmpeg_version,
"ffprobe": ffprobe_version,
},
"output_contract": {
"container": "matroska",
"required_stream_types": ["video", "audio"],
"expected_duration_seconds": expected_duration,
"duration_tolerance_seconds": 0.15,
"downstream_consumer": None,
},
"preservation": {"originals_untouched": True, "overwrite_policy": "refuse"},
"privacy_boundary": "synthetic, non-personal fixture only",
"assumptions": [],
}
write_json(workspace / "intake-manifest.json", intake)
evidence = {
"schema_version": 1,
"workflow_id": "synthetic-editorial-example",
"asset_id": "asset-synthetic-001",
"observations": [
{
"class": "observed_artifact",
"claim": "source probe reports video and audio streams",
"locator": "source-probe.json",
"coverage": "container and stream metadata only",
}
],
"unverified": [
"semantic visual interpretation",
"listening quality",
"downstream consumer compatibility",
],
}
write_json(workspace / "evidence-packet.json", evidence)
edl = {
"schema_version": 1,
"workflow_id": "synthetic-editorial-example",
"timebase": "seconds",
"sources": [
{
"asset_id": "asset-synthetic-001",
"source": source.name,
"duration": duration(source_probe),
"digest": source_digest,
}
],
"events": [
{
"id": "event-001",
"action": "keep",
"asset_id": "asset-synthetic-001",
"stream_refs": ["0:v:0", "0:a:0"],
"in": start,
"out": end,
"reason": "exercise a deterministic bounded trim",
"evidence": ["evidence-packet.json#observations/0"],
"boundary_precision": "decoded",
"review_status": "approved_for_synthetic_example",
}
],
"output": {
"mapping": ["video", "audio"],
"expected_duration": expected_duration,
"tolerance_seconds": 0.15,
},
}
write_json(workspace / "edit-decision-list.json", edl)
render = [
ffmpeg,
"-v",
"error",
"-n",
"-i",
str(source),
"-ss",
str(start),
"-t",
str(expected_duration),
"-map",
"0:v:0",
"-map",
"0:a:0",
"-c:v",
"mpeg4",
"-q:v",
"5",
"-c:a",
"pcm_s16le",
str(output),
]
run(render, timeout=args.timeout)
commands.append(render)
output_probe_command, output_probe = probe(ffprobe, output)
commands.append(output_probe_command)
write_json(workspace / "output-probe.json", output_probe)
decode = [
ffmpeg,
"-v",
"error",
"-i",
str(output),
"-map",
"0:v:0",
"-map",
"0:a:0",
"-f",
"null",
"-",
]
run(decode, timeout=args.timeout)
commands.append(decode)
frame_paths: list[str] = []
for index, timestamp in enumerate((0.1, max(0.1, expected_duration - 0.1)), start=1):
frame = workspace / f"review-frame-{index}.png"
extract = [
ffmpeg,
"-v",
"error",
"-ss",
str(timestamp),
"-i",
str(output),
"-frames:v",
"1",
"-n",
str(frame),
]
run(extract, timeout=args.timeout)
commands.append(extract)
frame_paths.append(frame.name)
audio_check = [
ffmpeg,
"-v",
"error",
"-i",
str(output),
"-map",
"0:a:0",
"-af",
"astats=metadata=1:reset=1",
"-f",
"null",
"-",
]
run(audio_check, timeout=args.timeout)
commands.append(audio_check)
actual_duration = duration(output_probe)
checks = [
{
"criterion": "output_decodes",
"boundary": "component",
"verdict": "PASS",
"evidence": "decode command completed",
},
{
"criterion": "required_streams",
"boundary": "integration",
"verdict": "PASS"
if required_types.issubset(stream_types(output_probe))
else "FAIL",
"evidence": "output-probe.json",
},
{
"criterion": "duration",
"boundary": "integration",
"verdict": (
"PASS"
if actual_duration is not None
and abs(actual_duration - expected_duration) <= 0.15
else "FAIL"
),
"expected": expected_duration,
"observed": actual_duration,
"tolerance": 0.15,
"evidence": "output-probe.json",
},
{
"criterion": "bounded_visual_samples_created",
"boundary": "content-sampling",
"verdict": "PASS",
"evidence": frame_paths,
"limitation": "samples establish only the sampled timestamps",
},
{
"criterion": "audio_processing_path",
"boundary": "signal",
"verdict": "PASS",
"evidence": "bounded astats command completed",
"limitation": "no listening review was performed",
},
{
"criterion": "downstream_consumer",
"boundary": "downstream",
"verdict": "UNVERIFIED",
"evidence": None,
},
]
failures = [check for check in checks if check["verdict"] == "FAIL"]
acceptance = {
"schema_version": 1,
"workflow_id": "synthetic-editorial-example",
"asset_id": "asset-synthetic-001",
"event_ids": ["event-001"],
"output": output.name,
"output_digest": sha256(output),
"render_command": render,
"tool_context": intake["tool_context"],
"criteria": checks,
"overall_verdict": "FAIL" if failures else "PASS_WITH_UNVERIFIED_BOUNDARIES",
"unverified_boundaries": [
"semantic visual review",
"listening review",
"downstream consumer compatibility",
],
}
write_json(workspace / "acceptance-report.json", acceptance)
write_json(workspace / "command-log.json", {"commands": commands})
report = {
"ok": not failures,
"workflow_id": "synthetic-editorial-example",
"workspace": str(workspace),
"artifacts": sorted(path.name for path in workspace.iterdir()),
"overall_verdict": acceptance["overall_verdict"],
"unverified_boundaries": acceptance["unverified_boundaries"],
}
if args.json:
print(json.dumps(report, sort_keys=True, separators=(",", ":")))
else:
print(json.dumps(report, indent=2, sort_keys=True))
return 0 if report["ok"] else 1
except WorkflowError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, sort_keys=True))
return 2
if __name__ == "__main__":
raise SystemExit(main())