mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
feat(ffmpeg): plan multi-source EDL rendering (#478)
This commit is contained in:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user