feat(ffmpeg): plan multi-source EDL rendering (#478)

This commit is contained in:
Magnus Hedemark
2026-09-08 08:37:09 -04:00
committed by GitHub
parent 26c1832f65
commit f28c4b579a
7 changed files with 759 additions and 112 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ This skill provides a repeatable intake-to-acceptance workflow. It separates tec
| `scripts/fixtures/ffmpeg-8.1.2-inventories.json` | Small version-labeled parser fixture |
| `scripts/media-intake` | Read-only input inventory with bounded `ffprobe` metadata |
| `scripts/extract-review-frames` | Bounded timestamp frame extraction for human or vision review |
| `scripts/render-edl` | Validate an EDL and emit a non-executing FFmpeg command plan (review before multi-source rendering) |
| `scripts/render-edl` | Validate single- or multi-source EDLs and emit non-executing concat-filter or concat-demuxer plans |
| `scripts/audio-inspect` | Read-only audio metadata inspection with bounded probing |
| `scripts/media-verify` | Compare input/output probe documents against basic criteria |
| `scripts/editorial-workflow-example` | Generate synthetic audio/video and exercise the complete workflow with durable evidence |
+2
View File
@@ -97,6 +97,8 @@ Run `scripts/editorial-workflow-example` in a new or empty task-local directory
Run `scripts/generate-media-fixtures` when tests need deterministic non-personal media. Keep its generated binaries and manifest in the task workspace; commit the generator and assertions, not the outputs.
Use `scripts/render-edl` for a non-executing single- or multi-source plan. Default to decoded concat-filter assembly; select concat-demuxer stream copy only with matching probe-derived signatures and verified packet/keyframe boundaries. Unsupported transitions must remain explicit errors.
Copy a template into the task workspace and replace its placeholder/example values. Do not put private paths, media, transcripts, or review evidence in the public skill repository.
## Non-Negotiable Checks
+13
View File
@@ -118,6 +118,19 @@
"does not turn silence, clipping, subtitle presence, or sparse frames into editorial truth"
],
"case_set": "release"
},
{
"id": "multi-source-edl-plan",
"prompt": "Plan a montage from three reviewed ranges across two local files. Two ranges need frame-accurate cuts and the inputs have different dimensions and audio rates. Do not execute FFmpeg.",
"expected_output": "Validate explicit sources, streams, decimal-second ranges, destination order, duration math, and output normalization, then emit a concat-filter plan with correct reused input indexes, trim/atrim timestamp resets, deliberate video/audio normalization, and exactly-once output mapping. Reject stream-copy concat because probe-derived signatures do not match and distinguish the concat protocol as inapplicable.",
"assertions": [
"uses one input index per source and maps every event to the correct input",
"uses trim or atrim with timestamp reset before concat",
"normalizes declared video and audio properties before decoded concat",
"derives expected duration and rejects invalid or overlapping destination intervals",
"distinguishes concat filter, concat demuxer, and concat protocol without executing"
],
"case_set": "release"
}
]
}
@@ -43,6 +43,18 @@ For decoded segment assembly, use `trim`/`atrim`, reset segment timestamps with
For separate compatible files, the concat demuxer consumes an `ffconcat` list. Its `inpoint` and `outpoint` can include packets outside the requested interval because of inter-frame dependencies and packet boundaries; timestamps can also be adjusted globally. Review the decoded joins.
`scripts/render-edl` validates schema-v1 decimal-second EDLs and emits a plan without executing FFmpeg. Its default `concat-filter` strategy:
- assigns one input index per declared source and reuses that index across events;
- trims and resets timestamps for every selected video/audio segment;
- applies declared video/audio normalization before concat;
- maps each generated output label exactly once;
- derives duration from event ranges and checks the declared tolerance.
Use `--strategy concat-demuxer` only when every source carries the same probe-derived `compatibility_signature` and every event declares `boundary_precision: packet` with `keyframe_status: verified` or `not_applicable`. The plan returns an `edit.ffconcat` payload; write and review that file before execution. The helper never selects the concat protocol, because a structured EDL needs explicit file/range semantics rather than URL-style concatenation.
Transitions are deliberately rejected until a separately validated design supplies transition duration, handles, stream layout, and output-duration math. Source ranges may be reused or reordered; explicit destination ranges may not overlap.
Fast seek and stream copy may choose seek points or packets that do not correspond to an exact visual/audio edit boundary. Label keyframe/packet status as one of `verified`, `not_verified`, or `not_applicable`; never infer it from a round timestamp.
Record FFmpeg/ffprobe versions, complete generated command, mapping, codec settings, environment-sensitive capabilities, output digest, and acceptance report alongside the rendered artifact.
+473 -21
View File
@@ -1,25 +1,477 @@
#!/usr/bin/env python3
import argparse,json,math,sys
"""Validate an FFmpeg EDL and emit a deterministic, non-executing command plan."""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
from typing import Any
def main():
p=argparse.ArgumentParser(description=__doc__); p.add_argument("edl"); p.add_argument("--output",default="output.mp4"); a=p.parse_args()
try: d=json.loads(Path(a.edl).read_text())
except (OSError,json.JSONDecodeError) as e: print(json.dumps({"ok":False,"error":str(e)})); return 2
if not isinstance(d,dict) or d.get("schema_version")!=1: print(json.dumps({"ok":False,"error":"schema_version must be 1"})); return 2
events=d.get("events",[])
if not isinstance(events,list) or not events: print(json.dumps({"ok":False,"error":"events must be a non-empty array"})); return 2
sources=d.get("sources")
if not isinstance(sources,list) or len(sources)!=1 or len(events)!=1: print(json.dumps({"ok":False,"error":"render-edl supports exactly one source and one event"})); return 2
src={x.get("asset_id"):x for x in sources if isinstance(x,dict)}; out=[]; last=-1
for i,e in enumerate(events):
if not isinstance(e,dict) or e.get("asset_id") not in src: print(json.dumps({"ok":False,"error":f"invalid source at event {i}"})); return 2
x,y=e.get("in"),e.get("out"); dur=src[e["asset_id"]].get("duration")
if not all(isinstance(v,(int,float)) and not isinstance(v,bool) and math.isfinite(v) for v in (x,y)) or x<0 or y<=x or x<last or isinstance(dur,(int,float)) and y>dur: print(json.dumps({"ok":False,"error":f"invalid interval at event {i}"})); return 2
last=y; out.append({"asset_id":e["asset_id"],"in":x,"out":y,"action":e.get("action","keep")})
argv=["ffmpeg","-n"]
for e in out: argv += ["-ss",str(e["in"]),"-to",str(e["out"]),"-i",str(src[e["asset_id"]].get("source",e["asset_id"]))]
argv += ["-map","0:v:0?","-map","0:a:0?","-c","copy",a.output]
print(json.dumps({"ok":True,"executed":False,"events":out,"argv":argv,"output":a.output},indent=2)); return 0
if __name__=="__main__": sys.exit(main())
class EDLError(Exception):
def __init__(self, code: str, message: str, **details: Any) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details
def fail(code: str, message: str, **details: Any) -> None:
raise EDLError(code, message, **details)
def finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def number_text(value: int | float) -> str:
return f"{value:g}"
def load_edl(path: str) -> dict[str, Any]:
try:
document = json.loads(Path(path).read_text(encoding="utf-8"))
except OSError as exc:
fail("edl_read_failed", f"could not read EDL: {exc}")
except json.JSONDecodeError as exc:
fail("invalid_edl_json", f"EDL is not valid JSON: {exc}")
if not isinstance(document, dict):
fail("invalid_edl", "EDL root must be an object")
if document.get("schema_version") != 1:
fail("unsupported_schema", "schema_version must be 1")
if document.get("timebase", "seconds") != "seconds":
fail("unsupported_timebase", "timebase must be explicit decimal seconds")
return document
def validate_sources(
document: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
raw = document.get("sources")
if not isinstance(raw, list) or not raw:
fail("invalid_sources", "sources must be a non-empty array")
ordered: list[dict[str, Any]] = []
by_id: dict[str, dict[str, Any]] = {}
for index, source in enumerate(raw):
if not isinstance(source, dict):
fail("invalid_source", f"source {index} must be an object", source_index=index)
asset_id = source.get("asset_id")
locator = source.get("source")
duration = source.get("duration")
if not isinstance(asset_id, str) or not asset_id:
fail("invalid_source", f"source {index} needs a non-empty asset_id", source_index=index)
if asset_id in by_id:
fail("duplicate_source", f"duplicate asset_id: {asset_id}", asset_id=asset_id)
if not isinstance(locator, str) or not locator:
fail("invalid_source", f"source {asset_id} needs a source locator", asset_id=asset_id)
if duration is not None and (not finite_number(duration) or duration <= 0):
fail(
"invalid_source", f"source {asset_id} duration must be positive", asset_id=asset_id
)
normalized = dict(source)
normalized["input_index"] = len(ordered)
ordered.append(normalized)
by_id[asset_id] = normalized
return ordered, by_id
def validate_events(
document: dict[str, Any], sources: dict[str, dict[str, Any]]
) -> list[dict[str, Any]]:
raw = document.get("events")
if not isinstance(raw, list) or not raw:
fail("invalid_events", "events must be a non-empty array")
events: list[dict[str, Any]] = []
previous_destination_end = 0.0
for index, event in enumerate(raw):
if not isinstance(event, dict):
fail("invalid_event", f"event {index} must be an object", event_index=index)
asset_id = event.get("asset_id")
if asset_id not in sources:
fail("missing_source", f"event {index} references an unknown source", event_index=index)
start, end = event.get("in"), event.get("out")
if not finite_number(start) or not finite_number(end) or start < 0 or end <= start:
fail("invalid_interval", f"invalid interval at event {index}", event_index=index)
source_duration = sources[asset_id].get("duration")
if source_duration is not None and end > source_duration:
fail(
"interval_out_of_bounds",
f"event {index} ends after source duration",
event_index=index,
source_duration=source_duration,
)
stream_refs = event.get("stream_refs")
if (
not isinstance(stream_refs, list)
or not stream_refs
or not all(isinstance(item, str) and item for item in stream_refs)
):
fail(
"missing_stream_refs",
f"event {index} needs explicit stream_refs",
event_index=index,
)
input_index = sources[asset_id]["input_index"]
for stream_ref in stream_refs:
prefix = stream_ref.split(":", 1)[0]
if prefix.isdigit() and int(prefix) != input_index:
fail(
"stream_ref_input_mismatch",
f"event {index} stream reference does not match its source input index",
event_index=index,
stream_ref=stream_ref,
expected_input_index=input_index,
)
transition = event.get("treatment", {}).get("transition")
if transition not in (None, "none"):
fail(
"unsupported_transition",
f"event {index} requests unsupported transition {transition!r}",
event_index=index,
handles=event.get("handles"),
requirement="use a separately validated transition design with duration and handles",
)
event_duration = float(end - start)
destination_start = event.get("destination_start", previous_destination_end)
if not finite_number(destination_start) or destination_start < 0:
fail(
"invalid_destination",
f"event {index} has invalid destination_start",
event_index=index,
)
if destination_start < previous_destination_end - 1e-9:
fail(
"destination_overlap",
f"event {index} overlaps the previous destination interval",
event_index=index,
previous_end=previous_destination_end,
)
destination_end = float(destination_start + event_duration)
normalized = dict(event)
normalized.update(
{
"event_index": index,
"input_index": input_index,
"duration": event_duration,
"destination_start": float(destination_start),
"destination_end": destination_end,
}
)
events.append(normalized)
previous_destination_end = destination_end
return events
def expected_duration(events: list[dict[str, Any]]) -> float:
return sum(event["duration"] for event in events)
def validate_declared_duration(document: dict[str, Any], derived: float) -> float:
output = document.get("output", {})
if not isinstance(output, dict):
fail("invalid_output", "output must be an object")
declared = output.get("expected_duration")
tolerance = output.get("tolerance_seconds", 0.1)
if not finite_number(tolerance) or tolerance < 0:
fail("invalid_tolerance", "output.tolerance_seconds must be a non-negative number")
if declared is not None:
if not finite_number(declared) or declared < 0:
fail("invalid_expected_duration", "output.expected_duration must be non-negative")
if abs(declared - derived) > tolerance:
fail(
"duration_mismatch",
"declared expected duration does not match event duration math",
declared=declared,
derived=derived,
tolerance=tolerance,
)
return float(tolerance)
def requested_mapping(document: dict[str, Any]) -> list[str]:
output = document.get("output", {})
mapping = output.get("mapping", ["video", "audio"])
if (
not isinstance(mapping, list)
or not mapping
or any(item not in {"video", "audio"} for item in mapping)
):
fail("invalid_mapping", "output.mapping must contain video and/or audio")
if len(set(mapping)) != len(mapping):
fail("invalid_mapping", "output.mapping cannot contain duplicate stream types")
return mapping
def validate_event_mapping(events: list[dict[str, Any]], mapping: list[str]) -> None:
for event in events:
for kind in mapping:
marker = f":{kind[0]}:"
if not any(marker in stream_ref for stream_ref in event["stream_refs"]):
fail(
"missing_mapped_stream_ref",
f"event {event['event_index']} lacks an explicit {kind} stream reference",
event_index=event["event_index"],
stream_type=kind,
)
def codec_options(document: dict[str, Any], mapping: list[str]) -> list[str]:
output = document.get("output", {})
options: list[str] = []
if "video" in mapping:
video = output.get("video", {})
codec = video.get("codec", "mpeg4") if isinstance(video, dict) else "mpeg4"
options.extend(["-c:v", str(codec)])
if "audio" in mapping:
audio = output.get("audio", {})
codec = audio.get("codec", "pcm_s16le") if isinstance(audio, dict) else "pcm_s16le"
options.extend(["-c:a", str(codec)])
return options
def video_chain(label: str, output: dict[str, Any]) -> str:
settings = output.get("video", {})
if not isinstance(settings, dict):
fail("invalid_video_output", "output.video must be an object")
filters = [label]
width, height = settings.get("width"), settings.get("height")
if width is not None or height is not None:
if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
fail("invalid_video_output", "video width and height must be positive integers")
filters.append(f"scale={width}:{height}")
fps = settings.get("fps")
if fps is not None:
if not finite_number(fps) or fps <= 0:
fail("invalid_video_output", "video fps must be positive")
filters.append(f"fps={number_text(fps)}")
pixel_format = settings.get("pixel_format")
if pixel_format is not None:
if not isinstance(pixel_format, str) or not pixel_format:
fail("invalid_video_output", "video pixel_format must be a non-empty string")
filters.append(f"format={pixel_format}")
return ",".join(filters)
def audio_chain(label: str, output: dict[str, Any]) -> str:
settings = output.get("audio", {})
if not isinstance(settings, dict):
fail("invalid_audio_output", "output.audio must be an object")
filters = [label]
sample_rate = settings.get("sample_rate")
if sample_rate is not None:
if not isinstance(sample_rate, int) or sample_rate <= 0:
fail("invalid_audio_output", "audio sample_rate must be a positive integer")
filters.append(f"aresample={sample_rate}")
layout = settings.get("channel_layout")
if layout is not None:
if not isinstance(layout, str) or not layout:
fail("invalid_audio_output", "audio channel_layout must be a non-empty string")
filters.append(f"aformat=channel_layouts={layout}")
return ",".join(filters)
def concat_filter_plan(
document: dict[str, Any],
sources: list[dict[str, Any]],
events: list[dict[str, Any]],
output_path: str,
) -> dict[str, Any]:
mapping = requested_mapping(document)
validate_event_mapping(events, mapping)
output = document.get("output", {})
graph: list[str] = []
concat_inputs: list[str] = []
for index, event in enumerate(events):
input_index = event["input_index"]
start = number_text(event["in"])
end = number_text(event["out"])
if "video" in mapping:
label = f"[{input_index}:v:0]trim=start={start}:end={end},setpts=PTS-STARTPTS"
graph.append(f"{video_chain(label, output)}[v{index}]")
concat_inputs.append(f"[v{index}]")
if "audio" in mapping:
label = f"[{input_index}:a:0]atrim=start={start}:end={end},asetpts=PTS-STARTPTS"
graph.append(f"{audio_chain(label, output)}[a{index}]")
concat_inputs.append(f"[a{index}]")
concat = (
"".join(concat_inputs)
+ f"concat=n={len(events)}:v={int('video' in mapping)}:a={int('audio' in mapping)}"
)
output_labels: list[str] = []
if "video" in mapping:
output_labels.append("[vout]")
if "audio" in mapping:
output_labels.append("[aout]")
graph.append(concat + "".join(output_labels))
argv = ["ffmpeg", "-n"]
for source in sources:
argv.extend(["-i", source["source"]])
argv.extend(["-filter_complex", ";".join(graph)])
for label in output_labels:
argv.extend(["-map", label])
argv.extend(codec_options(document, mapping))
argv.append(output_path)
return {
"selected_mechanism": "concat_filter",
"argv": argv,
"filter_complex": ";".join(graph),
"auxiliary_files": [],
"mechanism_boundaries": {
"concat_demuxer": "not selected because decoded segment normalization is required",
"concat_protocol": "never selected for structured EDL assembly",
},
}
def signature(source: dict[str, Any]) -> dict[str, Any]:
value = source.get("compatibility_signature")
if not isinstance(value, dict) or not value:
fail(
"missing_compatibility_signature",
f"source {source['asset_id']} needs a probed compatibility_signature for concat_demuxer",
asset_id=source["asset_id"],
)
return value
def escape_ffconcat_path(path: str) -> str:
if "\n" in path or "\r" in path or "'" in path:
fail("unsafe_concat_path", "concat-demuxer source paths cannot contain quotes or newlines")
return path
def concat_demuxer_plan(
document: dict[str, Any],
sources: list[dict[str, Any]],
events: list[dict[str, Any]],
output_path: str,
) -> dict[str, Any]:
reference = signature(sources[0])
for source in sources[1:]:
if signature(source) != reference:
fail(
"incompatible_concat_sources",
"concat-demuxer sources have different probed compatibility signatures",
first_source=sources[0]["asset_id"],
incompatible_source=source["asset_id"],
)
lines = ["ffconcat version 1.0"]
for index, event in enumerate(events):
precision = event.get("boundary_precision")
keyframe_status = event.get("keyframe_status")
if precision != "packet" or keyframe_status not in {"verified", "not_applicable"}:
fail(
"unverified_stream_copy_boundary",
f"event {index} is not approved for packet-level stream copy",
event_index=index,
boundary_precision=precision,
keyframe_status=keyframe_status,
)
source = sources[event["input_index"]]
lines.extend(
[
f"file '{escape_ffconcat_path(source['source'])}'",
f"inpoint {number_text(event['in'])}",
f"outpoint {number_text(event['out'])}",
f"duration {number_text(event['duration'])}",
]
)
mapping = requested_mapping(document)
validate_event_mapping(events, mapping)
argv = [
"ffmpeg",
"-n",
"-f",
"concat",
"-safe",
"0",
"-i",
"<generated:edit.ffconcat>",
]
if "video" in mapping:
argv.extend(["-map", "0:v:0?"])
if "audio" in mapping:
argv.extend(["-map", "0:a:0?"])
argv.extend(["-c", "copy", output_path])
return {
"selected_mechanism": "concat_demuxer",
"argv": argv,
"filter_complex": None,
"auxiliary_files": [{"path": "edit.ffconcat", "content": "\n".join(lines) + "\n"}],
"mechanism_boundaries": {
"concat_filter": "not selected because probed signatures match and packet boundaries are approved",
"concat_protocol": "never selected for structured EDL assembly",
},
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("edl")
parser.add_argument("--output", default="output.mkv")
parser.add_argument(
"--strategy",
choices=("concat-filter", "concat-demuxer"),
default="concat-filter",
help="validated FFmpeg assembly mechanism",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
document = load_edl(args.edl)
sources, by_id = validate_sources(document)
events = validate_events(document, by_id)
derived_duration = expected_duration(events)
tolerance = validate_declared_duration(document, derived_duration)
plan = (
concat_demuxer_plan(document, sources, events, args.output)
if args.strategy == "concat-demuxer"
else concat_filter_plan(document, sources, events, args.output)
)
payload = {
"ok": True,
"executed": False,
"schema_version": 1,
"strategy": args.strategy,
"sources": [
{"asset_id": source["asset_id"], "input_index": source["input_index"]}
for source in sources
],
"events": [
{
"id": event.get("id", f"event-{event['event_index'] + 1}"),
"asset_id": event["asset_id"],
"input_index": event["input_index"],
"in": event["in"],
"out": event["out"],
"duration": event["duration"],
"destination_start": event["destination_start"],
"destination_end": event["destination_end"],
}
for event in events
],
"derived_duration": derived_duration,
"duration_tolerance": tolerance,
"output": args.output,
**plan,
}
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
except EDLError as exc:
print(
json.dumps(
{"ok": False, "error": {"code": exc.code, "message": exc.message, **exc.details}},
sort_keys=True,
)
)
return 2
if __name__ == "__main__":
sys.exit(main())
+233 -88
View File
@@ -35,116 +35,261 @@ def probe(*stream_types: str, duration: str = "5.0") -> dict[str, object]:
}
def test_render_edl_valid_plan_does_not_execute(tmp_path: Path) -> None:
source = tmp_path / "source.mp4"
source.write_bytes(b"")
output = tmp_path / "rendered.mp4"
edl = write_json(
tmp_path / "valid-edl.json",
{
"schema_version": 1,
"sources": [
{
"asset_id": "camera-a",
"source": str(source),
"duration": 10.0,
}
],
"events": [{"asset_id": "camera-a", "in": 1.25, "out": 3.5, "action": "keep"}],
def edl_document() -> dict[str, object]:
return {
"schema_version": 1,
"timebase": "seconds",
"sources": [
{"asset_id": "camera-a", "source": "camera-a.mkv", "duration": 4.0},
{"asset_id": "camera-b", "source": "camera-b.mkv", "duration": 4.0},
],
"events": [
{
"id": "event-a",
"asset_id": "camera-a",
"stream_refs": ["0:v:0", "0:a:0"],
"in": 1.0,
"out": 2.0,
},
{
"id": "event-b",
"asset_id": "camera-b",
"stream_refs": ["1:v:0", "1:a:0"],
"in": 0.5,
"out": 2.0,
},
],
"output": {
"mapping": ["video", "audio"],
"video": {"width": 320, "height": 180, "fps": 24, "pixel_format": "yuv420p"},
"audio": {"sample_rate": 48000, "channel_layout": "stereo"},
"expected_duration": 2.5,
"tolerance_seconds": 0.01,
},
)
}
def error_code(result: subprocess.CompletedProcess[str]) -> str:
return json.loads(result.stdout)["error"]["code"]
def test_render_edl_multi_source_concat_filter_plan_does_not_execute(tmp_path: Path) -> None:
output = tmp_path / "rendered.mkv"
edl = write_json(tmp_path / "multi-source.json", edl_document())
result = run_script("render-edl", str(edl), "--output", str(output))
assert result.returncode == 0, result.stderr
assert result.returncode == 0, result.stdout + result.stderr
report = json.loads(result.stdout)
assert report == {
"ok": True,
"executed": False,
"events": [{"asset_id": "camera-a", "in": 1.25, "out": 3.5, "action": "keep"}],
"argv": [
"ffmpeg",
"-n",
"-ss",
"1.25",
"-to",
"3.5",
"-i",
str(source),
"-map",
"0:v:0?",
"-map",
"0:a:0?",
"-c",
"copy",
str(output),
],
"output": str(output),
}
assert report["selected_mechanism"] == "concat_filter"
assert report["sources"] == [
{"asset_id": "camera-a", "input_index": 0},
{"asset_id": "camera-b", "input_index": 1},
]
assert [event["input_index"] for event in report["events"]] == [0, 1]
assert report["derived_duration"] == 2.5
assert "[0:v:0]trim=start=1:end=2" in report["filter_complex"]
assert "[1:a:0]atrim=start=0.5:end=2" in report["filter_complex"]
assert "concat=n=2:v=1:a=1[vout][aout]" in report["filter_complex"]
assert report["argv"].count("[vout]") == 1
assert report["argv"].count("[aout]") == 1
assert report["executed"] is False
assert not output.exists()
def test_render_edl_rejects_multi_source_plan(tmp_path: Path) -> None:
edl = write_json(
tmp_path / "multi-source-edl.json",
{
"schema_version": 1,
"sources": [
{"asset_id": "camera-a", "source": "camera-a.mp4", "duration": 2.0},
{"asset_id": "camera-b", "source": "camera-b.mp4", "duration": 2.0},
],
"events": [{"asset_id": "camera-a", "in": 0.0, "out": 1.0}],
def test_render_edl_concat_filter_plan_executes_against_synthetic_fixtures(tmp_path: Path) -> None:
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
import pytest
pytest.skip("ffmpeg and ffprobe are required for the rendered fixture check")
fixture_workspace = tmp_path / "fixtures"
fixture_result = run_script("generate-media-fixtures", str(fixture_workspace), "--json")
assert fixture_result.returncode == 0, fixture_result.stdout + fixture_result.stderr
output = tmp_path / "rendered.mkv"
document = {
"schema_version": 1,
"timebase": "seconds",
"sources": [
{
"asset_id": "red",
"source": str(fixture_workspace / "concat-red.mkv"),
"duration": 1.0,
},
{
"asset_id": "blue",
"source": str(fixture_workspace / "concat-blue.mkv"),
"duration": 1.0,
},
],
"events": [
{"asset_id": "red", "stream_refs": ["0:v:0", "0:a:0"], "in": 0.0, "out": 0.8},
{"asset_id": "blue", "stream_refs": ["1:v:0", "1:a:0"], "in": 0.1, "out": 0.9},
],
"output": {
"mapping": ["video", "audio"],
"video": {
"codec": "mpeg4",
"width": 160,
"height": 90,
"fps": 24,
"pixel_format": "yuv420p",
},
"audio": {"codec": "pcm_s16le", "sample_rate": 48000, "channel_layout": "mono"},
"expected_duration": 1.6,
"tolerance_seconds": 0.01,
},
}
edl = write_json(tmp_path / "executable-plan.json", document)
plan_result = run_script("render-edl", str(edl), "--output", str(output))
assert plan_result.returncode == 0, plan_result.stdout + plan_result.stderr
plan = json.loads(plan_result.stdout)
rendered = subprocess.run(plan["argv"], capture_output=True, text=True, check=False)
assert rendered.returncode == 0, rendered.stderr
assert output.exists()
probe_result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=codec_type",
"-of",
"json",
str(output),
],
capture_output=True,
text=True,
check=False,
)
result = run_script("render-edl", str(edl))
assert result.returncode == 2
assert json.loads(result.stdout) == {
"ok": False,
"error": "render-edl supports exactly one source and one event",
assert probe_result.returncode == 0, probe_result.stderr
assert {item["codec_type"] for item in json.loads(probe_result.stdout)["streams"]} == {
"video",
"audio",
}
def test_render_edl_rejects_multi_event_plan(tmp_path: Path) -> None:
edl = write_json(
tmp_path / "multi-event-edl.json",
{
"schema_version": 1,
"sources": [{"asset_id": "camera-a", "source": "camera-a.mp4", "duration": 3.0}],
"events": [
{"asset_id": "camera-a", "in": 0.0, "out": 1.0},
{"asset_id": "camera-a", "in": 1.0, "out": 2.0},
],
},
)
def test_render_edl_multi_event_same_source_reuses_input_index(tmp_path: Path) -> None:
document = edl_document()
document["sources"] = [document["sources"][0]]
document["events"][1]["asset_id"] = "camera-a"
document["events"][1]["stream_refs"] = ["0:v:0", "0:a:0"]
edl = write_json(tmp_path / "multi-event.json", document)
result = run_script("render-edl", str(edl))
assert result.returncode == 0
report = json.loads(result.stdout)
assert [event["input_index"] for event in report["events"]] == [0, 0]
assert report["argv"].count("-i") == 1
def test_render_edl_concat_demuxer_requires_matching_probed_signatures(tmp_path: Path) -> None:
document = edl_document()
signature = {"video": "mpeg4:320x180:24", "audio": "pcm_s16le:48000:stereo"}
for source in document["sources"]:
source["compatibility_signature"] = signature
for event in document["events"]:
event["boundary_precision"] = "packet"
event["keyframe_status"] = "verified"
edl = write_json(tmp_path / "copy.json", document)
result = run_script("render-edl", str(edl), "--strategy", "concat-demuxer")
assert result.returncode == 0, result.stdout
report = json.loads(result.stdout)
assert report["selected_mechanism"] == "concat_demuxer"
concat_text = report["auxiliary_files"][0]["content"]
assert "file 'camera-a.mkv'" in concat_text
assert "file 'camera-b.mkv'" in concat_text
assert "inpoint 1" in concat_text
assert report["argv"][-3:] == ["-c", "copy", "output.mkv"]
def test_render_edl_rejects_incompatible_concat_signatures(tmp_path: Path) -> None:
document = edl_document()
document["sources"][0]["compatibility_signature"] = {"fps": 24}
document["sources"][1]["compatibility_signature"] = {"fps": 25}
for event in document["events"]:
event["boundary_precision"] = "packet"
event["keyframe_status"] = "verified"
edl = write_json(tmp_path / "incompatible.json", document)
result = run_script("render-edl", str(edl), "--strategy", "concat-demuxer")
assert result.returncode == 2
assert json.loads(result.stdout) == {
"ok": False,
"error": "render-edl supports exactly one source and one event",
}
assert error_code(result) == "incompatible_concat_sources"
def test_render_edl_rejects_unverified_stream_copy_boundary(tmp_path: Path) -> None:
document = edl_document()
for source in document["sources"]:
source["compatibility_signature"] = {"fps": 24}
edl = write_json(tmp_path / "unverified.json", document)
result = run_script("render-edl", str(edl), "--strategy", "concat-demuxer")
assert result.returncode == 2
assert error_code(result) == "unverified_stream_copy_boundary"
def test_render_edl_rejects_transition_destination_overlap_and_bad_duration(tmp_path: Path) -> None:
transition = edl_document()
transition["events"][1]["treatment"] = {"transition": "xfade"}
transition_result = run_script(
"render-edl", str(write_json(tmp_path / "transition.json", transition))
)
assert error_code(transition_result) == "unsupported_transition"
overlap = edl_document()
overlap["events"][1]["destination_start"] = 0.5
overlap_result = run_script("render-edl", str(write_json(tmp_path / "overlap.json", overlap)))
assert error_code(overlap_result) == "destination_overlap"
duration = edl_document()
duration["output"]["expected_duration"] = 9.0
duration_result = run_script(
"render-edl", str(write_json(tmp_path / "duration.json", duration))
)
assert error_code(duration_result) == "duration_mismatch"
def test_render_edl_rejects_missing_source_stream_and_ambiguous_timebase(tmp_path: Path) -> None:
missing_source = edl_document()
missing_source["events"][0]["asset_id"] = "missing"
result = run_script("render-edl", str(write_json(tmp_path / "missing.json", missing_source)))
assert error_code(result) == "missing_source"
missing_stream = edl_document()
del missing_stream["events"][0]["stream_refs"]
result = run_script("render-edl", str(write_json(tmp_path / "stream.json", missing_stream)))
assert error_code(result) == "missing_stream_refs"
missing_audio = edl_document()
missing_audio["events"][0]["stream_refs"] = ["0:v:0"]
result = run_script("render-edl", str(write_json(tmp_path / "audio.json", missing_audio)))
assert error_code(result) == "missing_mapped_stream_ref"
mismatched_input = edl_document()
mismatched_input["events"][1]["stream_refs"] = ["0:v:0", "0:a:0"]
result = run_script("render-edl", str(write_json(tmp_path / "mismatch.json", mismatched_input)))
assert error_code(result) == "stream_ref_input_mismatch"
timebase = edl_document()
timebase["timebase"] = "frames"
result = run_script("render-edl", str(write_json(tmp_path / "timebase.json", timebase)))
assert error_code(result) == "unsupported_timebase"
def test_render_edl_rejects_invalid_interval(tmp_path: Path) -> None:
edl = write_json(
tmp_path / "invalid-edl.json",
{
"schema_version": 1,
"sources": [{"asset_id": "camera-a", "duration": 2.0}],
"events": [{"asset_id": "camera-a", "in": 1.0, "out": 3.0}],
},
)
result = run_script("render-edl", str(edl))
document = edl_document()
document["events"][0]["out"] = 7.0
result = run_script("render-edl", str(write_json(tmp_path / "invalid.json", document)))
assert result.returncode == 2
assert json.loads(result.stdout) == {
"ok": False,
"error": "invalid interval at event 0",
}
assert error_code(result) == "interval_out_of_bounds"
def test_audio_inspect_reports_missing_ffprobe(tmp_path: Path) -> None:
+25 -2
View File
@@ -1,7 +1,17 @@
{
"schema_version": 1,
"timebase": "seconds",
"sources": [{"asset_id": "asset-001", "duration": 120.0}],
"sources": [
{
"asset_id": "asset-001",
"source": "input-a.mkv",
"duration": 120.0,
"compatibility_signature": {
"video": "h264:1920x1080:yuv420p:30000/1001",
"audio": "aac:48000:stereo"
}
}
],
"events": [
{
"id": "event-001",
@@ -13,10 +23,23 @@
"reason": "<editorial reason>",
"evidence": [{"type": "transcript", "locator": "00:10-00:25", "confidence": 0.8}],
"boundary_precision": "not_verified",
"keyframe_status": "not_verified",
"treatment": {"transition": null, "audio_fade": true},
"review_status": "needs_review",
"verification": []
}
],
"output": {"mapping": ["video", "audio"], "expected_duration": 15.0, "tolerance_seconds": 0.1}
"output": {
"mapping": ["video", "audio"],
"video": {
"codec": "libx264",
"width": 1920,
"height": 1080,
"fps": 29.97,
"pixel_format": "yuv420p"
},
"audio": {"codec": "aac", "sample_rate": 48000, "channel_layout": "stereo"},
"expected_duration": 15.0,
"tolerance_seconds": 0.1
}
}