#!/usr/bin/env python3 """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 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", "", ] 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())