ffmpeg-skill 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -257,7 +257,7 @@ These are the rules the skill file gives the agent and the code enforces.
257
257
 
258
258
  | Tool | What it does |
259
259
  |---|---|
260
- | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table) |
260
+ | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table); `--export-timeline edit.fcpxml\|.edl\|.otio` (2.1) writes the cut as an editor timeline for Final Cut, DaVinci Resolve or Premiere instead of rendering it — clips, speed, dissolves, the music bed and chapter markers carry over, and `not_exported` names what stays behind |
261
261
  | `batch.py` | Apply a step recipe or a project to a folder with a content-hash cache; `--watch`; `--jobs N` processes several files at once under one shared `--timeout` |
262
262
  | `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list; `--switch energy` auto-cuts to whichever camera is loudest/talking instead of a hand-built list, with `--min-shot` (minimum shot length) and `--edl` (write the cut list) |
263
263
  | `verify.py` | Run the toolchain on real device files and report PASS / FAIL per step |
package/SKILL.md CHANGED
@@ -117,8 +117,8 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
117
117
  | "pull the audio out", "give me the sound as WAV" | `audio.py input.mp4 -o input.wav` (an audio extension drops the picture; `--audio-stream 1` picks a track) |
118
118
  | "compress the voice", "limit the peaks", "gate the room noise" | `audio.py input.mp4 --compress --comp-threshold -20 --comp-ratio 4` / `--limit --limit-ceiling -1` / `--gate --gate-threshold -45` |
119
119
  | "the audio drifts out of sync over the hour" | `sync.py camera.mp4 recorder.wav --fix-drift --replace-audio` |
120
- | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; give an image or colour, nothing is fetched |
121
- | "make this a TikTok / Reel / Short / YouTube / X / LinkedIn / podcast" | `render.py --template tiktok\|reels\|shorts\|youtube-shorts\|youtube\|x\|linkedin\|facebook\|podcast input.mp4 [--cues cues.txt] [--title "..."]` — frame, captions, loudness, export and check in one command (`--list-templates`, `--write-project` to edit first) |
120
+ | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; nothing is fetched |
121
+ | "make this a TikTok / Reel / Short / YouTube / X / LinkedIn / podcast" | `render.py --template tiktok\|reels\|shorts\|youtube-shorts\|youtube\|x\|linkedin\|facebook\|podcast input.mp4 [--cues cues.txt] [--title "..."]` — frame, captions, loudness, export, check in one command (`--list-templates`; `--write-project` to edit first) |
122
122
  | "post it everywhere", "one edit for every platform" | `render.py --template all input.mp4 --cues cues.txt` (or a comma list) → one file per destination plus `<name>_pack.md` (`report.py --pack` renders the HTML) |
123
123
  | "export for YouTube / Reels / X", "a ProRes master" | `export.py input.mp4 --preset youtube\|reels\|tiktok\|shorts\|linkedin\|facebook\|x\|prores\|h265` (`--normalize` hits the loudness spec in the same call; `youtube-hdr` keeps HDR, `youtube-av1` writes AV1) |
124
124
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
@@ -133,12 +133,13 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
133
133
  | "waveform/spectrum video for this track" | `waveform.py podcast.wav -o waveform.mp4` |
134
134
  | "add black at the start" | `pad.py clip.mp4 --start 1.5` |
135
135
  | "cut to the product shot 0:12-0:16", "B-roll over this bit" | `broll.py talk.mp4 --insert product.mp4 --at 12 --end 16` (repeat `--insert/--at`; `--audio b\|mix`) |
136
- | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; a `render.py` project spells it `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts, titled `Chapter N`, renameable |
136
+ | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; in a project: `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts (`Chapter N`) |
137
137
  | "set the title / artist / comment" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
138
138
  | "put these videos in a 4x2 grid" | `grid.py t1.mp4 ... t8.mp4 --cols 4 --rows 2` |
139
139
  | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
140
- | "several changes to the same edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
141
- | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages come from the cache (`--from STAGE` starts there) |
140
+ | "several changes to one edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
141
+ | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages reused (`--from STAGE` starts there) |
142
+ | "open it in Premiere / Resolve / Final Cut" | `render.py project.json --export-timeline edit.fcpxml\|.edl\|.otio` — the cut as a timeline, nothing rendered; read `not_exported` |
142
143
  | "do this to every file in the folder", "use all the cores" | `batch.py FOLDER --recipe batch.json --jobs auto` (steps or a render project; cached) |
143
144
  | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` (manual) or `--switch energy` (auto-cuts to the loudest camera, `--min-shot`, `--edl`) |
144
145
  | "what's in this file", "how long is it" | `probe.py input.mp4` |
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.0.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.1.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.0.0", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.1.0", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -469,6 +469,7 @@ Per-tool keys added in 1.17, all additive:
469
469
  | `snap` | `cut.py --snap beats`, `render.py` | `{mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}`. `grid` is `"supported"`: points are moved only onto grid points a measured onset marks, never onto the regular grid's continuation through a silent passage. `moved` has exactly one row per in/out point given (`from`, `to`, `delta`, `snapped`, `beat_index`) — a point is never added or dropped, and `to` is always either a measured beat or the caller's own value |
470
470
  | `filler`, `removed_seconds_total` | `silence.py --filler` | `{lang, source, engine, words, removed, removed_count, removed_seconds, removed_words, word_timings, list, warnings}`. The existing `removed_seconds` is unchanged in name and meaning — the seconds of *silence* removed, which is what it has always held — and `removed_seconds_total` is the additive sibling covering silence plus filler |
471
471
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
472
+ | `timeline` | `render.py --export-timeline FILE` | `{format, rate, duration, frames, clips, transition, transition_frames, music, markers, notes, not_exported}`: the written timeline's own numbers (`rate` is exact, `"30000/1001"` for 29.97) and what the project asked for that an editor timeline cannot carry. Nothing is encoded (`commands: []`); `verified` means the file was written and reads back as its format (`verification` step `parse`) -- not that an editor opened it |
472
473
  | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
473
474
 
474
475
  Per-tool keys added in 1.17.1, all additive:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -16,7 +16,10 @@ flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
16
16
  deliverables. Since 2.0 `hdr: true` (and its 1.9 twin `hdr_signal`) is the narrow
17
17
  fact — a real PQ / HLG / Dolby Vision transfer; `bt2020_or_hdr` also counts a
18
18
  wide-gamut SDR file (1.x's `hdr`), and `hdr_format` names that in-between case
19
- (`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR input.
19
+ (`BT.2020 SDR`). "Not HDR" is not "ready for a BT.709 deliverable": a BT.2020 SDR file
20
+ still carries wide-gamut primaries, so an SDR-only client or platform expecting BT.709 needs
21
+ `color.py --to-sdr` first -- `export.py` only retags BT.709, it does not convert (eval 24 h1).
22
+ `export.py` platform presets are SDR and warn on HDR input.
20
23
  iPhone `.mov` files also carry timecode/metadata tracks; scripts map only the
21
24
  first audio track, so extra tracks are dropped on re-encode. Keep ProRes masters
22
25
  at source colour: `export.py --preset prores` does not retag.
@@ -569,7 +569,21 @@ render.py --init project.json # starter file
569
569
  render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
570
570
  render.py project.json --cache DIR [--from STAGE] # reuse identical stages from a previous run
571
571
  render.py plan.json # execute a plan written by <tool> --plan plan.json
572
- ```
572
+ render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otio # the cut for an editor, nothing rendered
573
+ ```
574
+ `--export-timeline FILE` (2.1) writes the project's cut as an editor timeline instead of
575
+ rendering it: `.fcpxml` (FCPXML 1.10: Final Cut Pro, DaVinci Resolve), `.edl` (CMX 3600:
576
+ Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, others through
577
+ OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
578
+ `LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
579
+ timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
580
+ track; inline `chapters` are markers. Everything else in the project -- captions, graphics,
581
+ overlays, the silence cut, fit, audio processing, loudness, export -- is listed in the result's
582
+ `timeline.not_exported` and on stderr, never dropped silently. Media paths are absolute file
583
+ URLs to the sources, nothing is copied or encoded, and the existing-output rule applies
584
+ (`--overwrite` to replace). The timeline follows the project's numbers exactly; a render of
585
+ the same project can come out a few frames longer, because `join.py` offsets its crossfades
586
+ by each part's container duration, audio included.
573
587
  A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
574
588
  --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
575
589
  head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
@@ -0,0 +1,379 @@
1
+ """render.py project -> an editor's timeline (EDL, FCPXML, OTIO).
2
+
3
+ `render.py project.json --export-timeline edit.fcpxml|.edl|.otio` hands a rough cut to a person
4
+ who finishes it in Premiere, DaVinci Resolve or Final Cut. It is a translation, not a render:
5
+ nothing is encoded, and every decision in the file is one the project already made.
6
+
7
+ The timeline has to match what render.py itself would deliver, or the editor opens a different
8
+ cut than the agent showed. join.py's crossfade overlaps neighbouring clips by the transition
9
+ duration `d` (xfade: the last `d` s of A blend with the first `d` s of B, so the whole is `d`
10
+ shorter). Editors model a dissolve centred on a cut, using media beyond each clip's visible
11
+ range ("handles"), so each side of a dissolve is trimmed by `d/2` and the dissolve spans `d/2`
12
+ either side of the new cut: the same frames blend over the same span, and the total length is
13
+ the rendered one.
14
+
15
+ Only what an editor timeline can carry is exported: clips (with in/out and speed), the
16
+ transition between them, a music bed on its own audio track, and chapters as markers.
17
+ Everything else in the project -- captions, graphics, overlays, the silence cut, fit/crop,
18
+ audio processing, loudness, the export preset -- is listed under `not_exported`, never silently
19
+ dropped.
20
+ """
21
+ import json
22
+ import os
23
+ from fractions import Fraction
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+ from xml.sax.saxutils import escape as _xml_escape
27
+
28
+ FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
29
+ # Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
30
+ _NTSC = {23.976: Fraction(24000, 1001), 29.97: Fraction(30000, 1001), 59.94: Fraction(60000, 1001),
31
+ 47.952: Fraction(48000, 1001), 119.88: Fraction(120000, 1001)}
32
+ # project keys an editor timeline cannot carry, with what the caller should know about each
33
+ _NOT_EXPORTED = {
34
+ "silence": "the silence cut (render.py measures it at render time; the timeline keeps the clips whole)",
35
+ "captions": "captions (burn them with render.py, or import the .srt into the editor)",
36
+ "graphics": "graphics (titles, lower thirds, social templates)",
37
+ "overlays": "overlays (logos and text)",
38
+ "fit": "fit / reframe to the delivery aspect (set the sequence frame in the editor)",
39
+ "audiogram": "the audiogram picture",
40
+ "loudness": "loudness normalisation",
41
+ "export": "the export preset (the editor's own export decides the delivery)",
42
+ "check": "the platform check",
43
+ "brand": "brand styles",
44
+ "snap": "beat snapping (the timeline uses the project's in/out as written, before any snap)",
45
+ }
46
+ _AUDIO_NOT_EXPORTED = ("replace", "voice", "denoise", "duck", "duck_amount", "duck_threshold", "duck_attack",
47
+ "duck_release", "gain", "stereo", "mono", "downmix", "stereo_widen", "effects",
48
+ "effects_volume", "stems", "music_volume", "fade_in", "fade_out", "music_fade_out",
49
+ "music_loop")
50
+
51
+
52
+ class TimelineError(ValueError):
53
+ """A project that cannot be expressed as a timeline (a caller-facing `kind: input`)."""
54
+
55
+
56
+ def exact_rate(fps: float) -> Fraction:
57
+ for approx, exact in _NTSC.items():
58
+ if abs(fps - approx) < 0.01:
59
+ return exact
60
+ frac = Fraction(fps).limit_denominator(1001)
61
+ if frac <= 0:
62
+ raise TimelineError(f"frame rate {fps!r} is not usable for a timeline")
63
+ return frac
64
+
65
+
66
+ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
67
+ """The editor-neutral timeline: every time in frames at `rate`.
68
+
69
+ `probes` maps each resolved clip/music path to its probe(); `rel` resolves a project path
70
+ the way render.py does. Raises TimelineError for what an editor timeline cannot represent."""
71
+ clips_in = proj.get("clips") or []
72
+ if not clips_in:
73
+ raise TimelineError("project.clips is empty")
74
+ frame = proj.get("frame") or {}
75
+ first = probes[rel(clips_in[0]["src"])]
76
+ fps = frame.get("fps") or (first.get("video") or {}).get("fps")
77
+ if not fps:
78
+ raise TimelineError("no frame rate: the first clip has no video stream and project.frame.fps is not set")
79
+ rate = exact_rate(float(fps))
80
+ width = int(frame.get("width") or (first.get("video") or {}).get("width") or 1920)
81
+ height = int(frame.get("height") or (first.get("video") or {}).get("height") or 1080)
82
+
83
+ def fr(seconds: float) -> int:
84
+ return int(round(Fraction(seconds).limit_denominator(1000000) * rate))
85
+
86
+ trans = proj.get("transition") or {}
87
+ t_type = str(trans.get("type", "fade")) if len(clips_in) > 1 else "none"
88
+ d_frames = 0 if t_type == "none" else fr(float(trans.get("duration", 0.5)))
89
+ notes: List[str] = []
90
+ if d_frames and t_type != "fade":
91
+ notes.append(f'transition "{t_type}" is exported as a cross dissolve: editors do not share xfade\'s other patterns')
92
+
93
+ clips: List[Dict[str, Any]] = []
94
+ for i, c in enumerate(clips_in):
95
+ src = rel(c["src"])
96
+ meta = probes[src]
97
+ dur = float(meta.get("duration") or 0.0)
98
+ start = float(c.get("in") or 0.0)
99
+ end = float(c["out"]) if c.get("out") is not None else dur
100
+ if end <= start:
101
+ raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
102
+ speed = float(c.get("speed") or 1.0)
103
+ if not speed > 0:
104
+ raise TimelineError(f"clip {i}: speed must be positive, got {c.get('speed')!r}")
105
+ src_in, src_out = fr(start), fr(end)
106
+ clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
107
+ "src_in": src_in, "src_out": src_out, "speed": speed,
108
+ "length": int(round((src_out - src_in) / speed)),
109
+ "media_frames": fr(dur), "has_video": bool(meta.get("video")),
110
+ "has_audio": bool(meta.get("audio")),
111
+ "width": (meta.get("video") or {}).get("width"), "height": (meta.get("video") or {}).get("height")})
112
+
113
+ # Centre each dissolve on the cut: trim d/2 off the outgoing tail and d/2 off the incoming head.
114
+ half_a, half_b = d_frames // 2, d_frames - d_frames // 2
115
+ for i, c in enumerate(clips):
116
+ c["trim_head"] = half_b if (i > 0 and d_frames) else 0
117
+ c["trim_tail"] = half_a if (i < len(clips) - 1 and d_frames) else 0
118
+ visible = c["length"] - c["trim_head"] - c["trim_tail"]
119
+ if visible <= 0:
120
+ raise TimelineError(f"clip {c['index']} ({c['name']}) is shorter than its transitions")
121
+ c["visible"] = visible
122
+ pos = 0
123
+ for c in clips:
124
+ c["record_in"] = pos
125
+ pos += c["visible"]
126
+ total = pos
127
+
128
+ music = None
129
+ audio = proj.get("audio") or {}
130
+ if audio.get("music"):
131
+ msrc = rel(audio["music"])
132
+ mmeta = probes[msrc]
133
+ mlen = min(fr(float(mmeta.get("duration") or 0.0)), total)
134
+ if mlen > 0:
135
+ music = {"src": os.path.abspath(msrc), "name": os.path.basename(msrc), "length": mlen,
136
+ "media_frames": fr(float(mmeta.get("duration") or 0.0))}
137
+ if audio.get("music_loop"):
138
+ notes.append("music_loop is not exported: the bed plays once, up to the timeline's length")
139
+
140
+ markers: List[Dict[str, Any]] = []
141
+ chapters = proj.get("chapters")
142
+ if isinstance(chapters, list):
143
+ for ch in chapters:
144
+ at = fr(float(ch.get("at", 0)))
145
+ if 0 <= at < total:
146
+ markers.append({"at": at, "title": str(ch.get("title") or "")})
147
+ elif chapters:
148
+ notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
149
+
150
+ not_exported = [text for key, text in _NOT_EXPORTED.items() if proj.get(key)]
151
+ dropped_audio = sorted(k for k in _AUDIO_NOT_EXPORTED if audio.get(k) not in (None, False, "", 0) and k != "music")
152
+ if dropped_audio:
153
+ not_exported.append("audio processing: " + ", ".join(dropped_audio))
154
+ return {"name": Path(str(proj.get("output") or "timeline")).stem, "rate": rate, "width": width, "height": height,
155
+ "clips": clips, "transition": t_type if d_frames else "none", "transition_frames": d_frames,
156
+ "music": music, "markers": markers, "total": total, "notes": notes, "not_exported": not_exported}
157
+
158
+
159
+ # ---------------------------------------------------------------------------------------- EDL
160
+ def _tc(frames: int, fps_int: int) -> str:
161
+ h, rem = divmod(frames, fps_int * 3600)
162
+ m, rem = divmod(rem, fps_int * 60)
163
+ s, f = divmod(rem, fps_int)
164
+ return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
165
+
166
+
167
+ def to_edl(tl: Dict[str, Any]) -> str:
168
+ """CMX 3600, non-drop timecode at the nominal integer rate, record starting at 01:00:00:00.
169
+ Every event is reel AX (the convention for file-based media) with the file named in
170
+ FROM CLIP NAME / SOURCE FILE comments, which Premiere and Resolve both relink from. A speed
171
+ change is an M2 line. A dissolve is the standard pair: a zero-length cut on the outgoing
172
+ source where its event ended, then a D event that brings the incoming clip in over `d`
173
+ frames starting at the incoming clip's own in point -- so each event's record range runs
174
+ from where the previous one stopped to where the next dissolve begins."""
175
+ fps_int = int(round(float(tl["rate"])))
176
+ hour = fps_int * 3600
177
+ clips = tl["clips"]
178
+ d = tl["transition_frames"]
179
+ # record range of each event: starts `trim_head` before its visible start (the dissolve
180
+ # window), ends where the next event starts
181
+ starts = [c["record_in"] - c["trim_head"] for c in clips]
182
+ ends = starts[1:] + [tl["total"]]
183
+ lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
184
+ n = 0
185
+ prev_src_end = 0
186
+ for i, c in enumerate(clips):
187
+ n += 1
188
+ chan = "B" if c["has_audio"] and c["has_video"] else ("V" if c["has_video"] else "A")
189
+ rec_in, rec_out = hour + starts[i], hour + ends[i]
190
+ s_in = c["src_in"]
191
+ s_out = s_in + int(round((ends[i] - starts[i]) * c["speed"]))
192
+ if i > 0 and d:
193
+ lines.append(f"{n:03d} AX {chan:<5} C {_tc(prev_src_end, fps_int)} {_tc(prev_src_end, fps_int)} "
194
+ f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
195
+ lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
196
+ f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
197
+ lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
198
+ lines.append(f"* TO CLIP NAME: {c['name']}")
199
+ else:
200
+ lines.append(f"{n:03d} AX {chan:<5} C {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
201
+ f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
202
+ lines.append(f"* FROM CLIP NAME: {c['name']}")
203
+ lines.append(f"* SOURCE FILE: {c['src']}")
204
+ if abs(c["speed"] - 1.0) > 1e-6:
205
+ lines.append(f"M2 AX {fps_int * c['speed']:05.1f} {_tc(s_in, fps_int)}")
206
+ lines.append("")
207
+ prev_src_end = s_out
208
+ if tl["music"]:
209
+ n += 1
210
+ m = tl["music"]
211
+ lines.append(f"{n:03d} AX A2 C {_tc(0, fps_int)} {_tc(m['length'], fps_int)} "
212
+ f"{_tc(hour, fps_int)} {_tc(hour + m['length'], fps_int)}")
213
+ lines.append(f"* FROM CLIP NAME: {m['name']}")
214
+ lines.append(f"* SOURCE FILE: {m['src']}")
215
+ lines.append("")
216
+ for mk in tl["markers"]:
217
+ lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {mk['title']}")
218
+ return "\n".join(lines).rstrip("\n") + "\n"
219
+
220
+
221
+ # ---------------------------------------------------------------------------------------- FCPXML
222
+ def _t(frames: int, rate: Fraction) -> str:
223
+ """A rational FCPXML time: frames at `rate`, as `N/Ds` (or `Ns` when whole)."""
224
+ value = Fraction(frames) / rate
225
+ return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
226
+
227
+
228
+ def _url(path: str) -> str:
229
+ # pathlib builds the percent-encoded file:// URL itself (and the drive-letter form on
230
+ # Windows): no network-library import in scripts/, not even for quoting (see the no-fetch test)
231
+ return Path(os.path.abspath(path)).as_uri()
232
+
233
+
234
+ def to_fcpxml(tl: Dict[str, Any]) -> str:
235
+ """FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
236
+ asset-clips with cross dissolves between them, the music bed as a connected clip on lane -1
237
+ of the first clip, chapters as chapter-markers. Speed is a linear timeMap on the clip."""
238
+ rate = tl["rate"]
239
+ q = lambda s: _xml_escape(str(s), {'"': "&quot;"}) # noqa: E731
240
+ fd = _t(1, rate)
241
+ res = [f' <format id="r1" name="FFVideoFormat{tl["height"]}p" frameDuration="{fd}" width="{tl["width"]}" height="{tl["height"]}"/>']
242
+ ids: Dict[str, str] = {}
243
+
244
+ def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool) -> str:
245
+ if path in ids:
246
+ return ids[path]
247
+ rid = f"r{len(ids) + 2}"
248
+ ids[path] = rid
249
+ res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
250
+ f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
251
+ + (' format="r1"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
252
+ f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
253
+ return rid
254
+
255
+ spine: List[str] = []
256
+ d = tl["transition_frames"]
257
+ markers = list(tl["markers"])
258
+ for i, c in enumerate(tl["clips"]):
259
+ rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"])
260
+ if i > 0 and d:
261
+ spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
262
+ f'duration="{_t(d, rate)}"/>')
263
+ src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
264
+ inner: List[str] = []
265
+ if abs(c["speed"] - 1.0) > 1e-6:
266
+ # timeMap maps clip-local time to source time; two linear points give a constant speed
267
+ inner.append(" <timeMap>")
268
+ inner.append(f' <timept time="0s" value="{_t(src_start, rate)}" interp="linear"/>')
269
+ inner.append(f' <timept time="{_t(c["visible"], rate)}" '
270
+ f'value="{_t(src_start + int(round(c["visible"] * c["speed"])), rate)}" interp="linear"/>')
271
+ inner.append(" </timeMap>")
272
+ start_attr = "0s"
273
+ else:
274
+ start_attr = _t(src_start, rate)
275
+ local0 = src_start if abs(c["speed"] - 1.0) <= 1e-6 else 0
276
+ for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
277
+ inner.append(f' <chapter-marker start="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
278
+ f'duration="{fd}" value="{q(mk["title"])}"/>')
279
+ if i == 0 and tl["music"]:
280
+ m = tl["music"]
281
+ mid = asset(m["src"], m["name"], m["media_frames"], False, True)
282
+ inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_t(local0, rate)}" name="{q(m["name"])}" '
283
+ f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
284
+ head = (f' <asset-clip ref="{rid}" offset="{_t(c["record_in"], rate)}" name="{q(c["name"])}" '
285
+ f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="r1" tcFormat="NDF"')
286
+ spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
287
+ out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
288
+ " <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
289
+ f' <project name="{q(tl["name"])}">',
290
+ f' <sequence format="r1" duration="{_t(tl["total"], rate)}" tcStart="0s" tcFormat="NDF">',
291
+ " <spine>", *spine, " </spine>", " </sequence>", " </project>", " </event>",
292
+ " </library>", "</fcpxml>"]
293
+ return "\n".join(out) + "\n"
294
+
295
+
296
+ # ---------------------------------------------------------------------------------------- OTIO
297
+ def _rt(frames: int, rate: Fraction) -> Dict[str, Any]:
298
+ return {"OTIO_SCHEMA": "RationalTime.1", "rate": float(rate), "value": float(frames)}
299
+
300
+
301
+ def _range(start: int, dur: int, rate: Fraction) -> Dict[str, Any]:
302
+ return {"OTIO_SCHEMA": "TimeRange.1", "start_time": _rt(start, rate), "duration": _rt(dur, rate)}
303
+
304
+
305
+ def to_otio(tl: Dict[str, Any]) -> str:
306
+ """OpenTimelineIO JSON (Resolve imports it natively; Premiere/Avid through OTIO's adapters):
307
+ a video track of clips and SMPTE dissolves, a music track, markers on the stack. Times are in
308
+ timeline frames; a speed change is a LinearTimeWarp effect over the clip's source range."""
309
+ rate = tl["rate"]
310
+ d = tl["transition_frames"]
311
+ video: List[Dict[str, Any]] = []
312
+ for i, c in enumerate(tl["clips"]):
313
+ if i > 0 and d:
314
+ video.append({"OTIO_SCHEMA": "Transition.1", "name": "Cross Dissolve", "metadata": {},
315
+ "transition_type": "SMPTE_Dissolve",
316
+ # OTIO: in_offset is the part before the cut, out_offset the part after
317
+ "in_offset": _rt(c["trim_head"], rate), "out_offset": _rt(tl["clips"][i - 1]["trim_tail"], rate)})
318
+ src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
319
+ clip = {"OTIO_SCHEMA": "Clip.2", "name": c["name"], "metadata": {}, "enabled": True,
320
+ # OTIO's own convention: source_range starts at the media's in point and lasts the
321
+ # clip's length on the timeline; a LinearTimeWarp says how fast the media runs
322
+ # through it (core OTIO does not rescale durations by effects).
323
+ "source_range": _range(src_start, c["visible"], rate),
324
+ "effects": [], "markers": [],
325
+ "media_references": {"DEFAULT_MEDIA": {
326
+ "OTIO_SCHEMA": "ExternalReference.1", "name": c["name"], "metadata": {},
327
+ "target_url": _url(c["src"]), "available_range": _range(0, c["media_frames"], rate),
328
+ "available_image_bounds": None}},
329
+ "active_media_reference_key": "DEFAULT_MEDIA"}
330
+ if abs(c["speed"] - 1.0) > 1e-6:
331
+ clip["effects"].append({"OTIO_SCHEMA": "LinearTimeWarp.1", "name": "speed", "metadata": {},
332
+ "effect_name": "LinearTimeWarp", "time_scalar": c["speed"]})
333
+ video.append(clip)
334
+ tracks = [{"OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "metadata": {}, "enabled": True,
335
+ "source_range": None, "effects": [], "markers": [], "children": video}]
336
+ if tl["music"]:
337
+ m = tl["music"]
338
+ tracks.append({"OTIO_SCHEMA": "Track.1", "name": "Music", "kind": "Audio", "metadata": {}, "enabled": True,
339
+ "source_range": None, "effects": [], "markers": [], "children": [{
340
+ "OTIO_SCHEMA": "Clip.2", "name": m["name"], "metadata": {}, "enabled": True,
341
+ "source_range": _range(0, m["length"], rate), "effects": [], "markers": [],
342
+ "media_references": {"DEFAULT_MEDIA": {
343
+ "OTIO_SCHEMA": "ExternalReference.1", "name": m["name"], "metadata": {},
344
+ "target_url": _url(m["src"]), "available_range": _range(0, m["media_frames"], rate),
345
+ "available_image_bounds": None}},
346
+ "active_media_reference_key": "DEFAULT_MEDIA"}]})
347
+ markers = [{"OTIO_SCHEMA": "Marker.2", "name": mk["title"], "metadata": {}, "color": "RED", "comment": "",
348
+ "marked_range": _range(mk["at"], 0, rate)} for mk in tl["markers"]]
349
+ doc = {"OTIO_SCHEMA": "Timeline.1", "name": tl["name"], "metadata": {}, "global_start_time": _rt(0, rate),
350
+ "tracks": {"OTIO_SCHEMA": "Stack.1", "name": "tracks", "metadata": {}, "enabled": True,
351
+ "source_range": None, "effects": [], "markers": markers, "children": tracks}}
352
+ return json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
353
+
354
+
355
+ WRITERS = {"edl": to_edl, "fcpxml": to_fcpxml, "otio": to_otio}
356
+
357
+
358
+ def parses(path: str, fmt: str) -> bool:
359
+ """The verification step: the written file reads back as what it claims to be."""
360
+ try:
361
+ text = Path(path).read_text(encoding="utf-8")
362
+ if fmt == "fcpxml":
363
+ import xml.etree.ElementTree as ET
364
+ return ET.fromstring(text.encode("utf-8")).tag == "fcpxml"
365
+ if fmt == "otio":
366
+ return json.loads(text).get("OTIO_SCHEMA") == "Timeline.1"
367
+ return text.startswith("TITLE:") and any(line[:3].isdigit() for line in text.splitlines())
368
+ except (OSError, ValueError, SyntaxError):
369
+ return False
370
+
371
+
372
+ def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]:
373
+ """The part of the timeline a caller reads back in render.py's --json document."""
374
+ rate = tl["rate"]
375
+ return {"format": fmt, "rate": f"{rate.numerator}/{rate.denominator}" if rate.denominator != 1 else str(rate.numerator),
376
+ "duration": round(float(Fraction(tl["total"]) / rate), 3), "frames": tl["total"],
377
+ "clips": len(tl["clips"]), "transition": tl["transition"],
378
+ "transition_frames": tl["transition_frames"], "music": bool(tl["music"]),
379
+ "markers": len(tl["markers"]), "notes": tl["notes"], "not_exported": tl["not_exported"]}
@@ -206,7 +206,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
206
206
  required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"},
207
207
  {"capability": "filter:drawbox", "when": "--safe NAME (the platform's occluded zones)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
208
208
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
209
- "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)"],
209
+ "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)", "editor timeline (.fcpxml / .edl / .otio) instead of a render (--export-timeline)"],
210
210
  required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
211
211
  video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
212
212
  "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
@@ -426,7 +426,10 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
426
426
  extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
427
427
  "plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
428
428
  "tool": {"type": "string", "description": "plan execution: the tool the plan ran"},
429
- "tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"}}
429
+ "tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
430
+ "timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
431
+ "transition_frames, music, markers, notes, and not_exported (what the project asks for that an "
432
+ "editor timeline cannot carry)"}}
430
433
  elif name == "verify":
431
434
  extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
432
435
  elif name == "batch":
package/scripts/export.py CHANGED
@@ -148,7 +148,10 @@ def main() -> int:
148
148
  f"{meta['video'].get('color_transfer') or 'untagged'})",
149
149
  hint="use --preset youtube for an SDR delivery; there is no way to invent HDR range from an SDR master")
150
150
  if meta["video"].get("bt2020_or_hdr") and args.preset not in ("prores", "copy", "youtube-hdr"):
151
- notes.append("source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
151
+ # 2.0: `hdr` is a real PQ/HLG/DV signal; bt2020_or_hdr also covers BT.2020 SDR, which is
152
+ # not HDR but still needs converting before BT.709 tags describe it (eval 24 h1)
153
+ kind = "HDR" if meta["video"].get("hdr") else "wide-gamut SDR, not HDR"
154
+ notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
152
155
  info("warning: " + notes[-1])
153
156
  has_audio = bool(meta.get("audio"))
154
157
  output = args.output or default_output(args.input, args.preset, p["ext"])
package/scripts/render.py CHANGED
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
64
64
 
65
65
  from export import PRESETS, PLATFORM_OF
66
66
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
67
- from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, fingerprint, PLAN_VERSION, ffmpeg_version
67
+ from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
68
68
  import subprocess
69
69
  from _contract import CONTRACT_VERSION
70
70
  from batch import file_key
@@ -718,6 +718,44 @@ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
718
718
  info(f"frame: {preset['w']}x{preset['h']} from the {export['preset']} export preset (captions and overlays are sized for delivery)")
719
719
 
720
720
 
721
+ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
722
+ """--export-timeline: the project's cut as an editor timeline, nothing rendered (_common.timeline)."""
723
+ from _common import timeline as tlmod
724
+ fmt = tlmod.FORMATS.get(Path(dest).suffix.lower())
725
+ if not fmt:
726
+ die(f"--export-timeline {dest}: unknown timeline format", kind="input",
727
+ hint="name the file .fcpxml, .edl or .otio")
728
+ paths = [rel(c["src"]) for c in proj.get("clips") or [] if c.get("src")]
729
+ if (proj.get("audio") or {}).get("music"):
730
+ paths.append(rel(proj["audio"]["music"]))
731
+ probes: Dict[str, Dict[str, Any]] = {}
732
+ for path in paths:
733
+ if not os.path.exists(path):
734
+ die(f"timeline source not found: {path}")
735
+ probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
736
+ try:
737
+ tl = tlmod.build(proj, probes, rel)
738
+ except tlmod.TimelineError as exc:
739
+ die(f"--export-timeline: {exc}", kind="input")
740
+ text = tlmod.WRITERS[fmt](tl)
741
+ report = tlmod.summary(tl, fmt)
742
+ _check_output_path(["ffmpeg", dest])
743
+ _check_existing_output(["ffmpeg", dest])
744
+ verification: List[Dict[str, Any]] = []
745
+ if not STATE.dry_run:
746
+ try:
747
+ Path(dest).write_text(text, encoding="utf-8")
748
+ except OSError as exc:
749
+ die(f"cannot write {dest}: {exc}", kind="output")
750
+ verification.append({"step": "parse", "ok": tlmod.parses(dest, fmt)})
751
+ for line in report["not_exported"]:
752
+ info(f"not exported: {line}")
753
+ info(("[dry-run] would write " if STATE.dry_run else "wrote ") +
754
+ f"{dest} ({fmt}, {report['clips']} clips, {report['duration']}s at {report['rate']} fps)")
755
+ emit(dest, timeline=report, verification=verification)
756
+ return 0
757
+
758
+
721
759
  def main() -> int:
722
760
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
723
761
  ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
@@ -732,6 +770,10 @@ def main() -> int:
732
770
  choices=list(STAGE_ORDER),
733
771
  help="start at this stage, taking every earlier one from --cache; refuses if "
734
772
  "one of them is not there")
773
+ ap.add_argument("--export-timeline", metavar="FILE",
774
+ help="write the project's cut as an editor timeline instead of rendering it: .fcpxml (Final Cut, "
775
+ "Resolve), .edl (CMX 3600: Premiere, Resolve, Avid) or .otio (OpenTimelineIO); clips, speed, "
776
+ "transitions, the music bed and chapter markers carry over, everything else is listed as not exported")
735
777
  ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
736
778
  tpl = ap.add_argument_group("delivery templates (one command per destination)")
737
779
  tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
@@ -810,6 +852,9 @@ def main() -> int:
810
852
  if isinstance(proj.get("chapters"), str) and not os.path.exists(rel(proj["chapters"])):
811
853
  die(f"chapters file not found: {rel(proj['chapters'])}")
812
854
 
855
+ if args.export_timeline:
856
+ return export_timeline(proj, rel, args.export_timeline)
857
+
813
858
  clips = proj.get("clips") or []
814
859
  if not clips:
815
860
  die("project.clips is empty")