mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
454 lines
15 KiB
Python
Executable File
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())
|