ffmpeg-skill 2.0.0 → 2.2.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
@@ -203,7 +203,7 @@ These are the rules the skill file gives the agent and the code enforces.
203
203
  | Tool | What it does |
204
204
  |---|---|
205
205
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse |
206
- | `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio |
206
+ | `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` |
207
207
  | `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
208
208
  | `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
209
209
  | `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
@@ -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
@@ -21,7 +21,7 @@ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `chec
21
21
  5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs; do not chase them.
22
22
  6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
23
23
  7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); `--overwrite` is the one way to say "yes, replace it".
24
- 8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG; the full 4x3 sheet is for whole-clip layout jobs. Not finished until `Look:` names that PNG — a probe cannot see a caption on someone's face. Audio-only jobs write `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
24
+ 8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
25
25
  - **Mechanical (this skill's own job to verify and report):** the specified text/logo is at the specified position, subtitles appear at the specified timestamps, dimensions are even. Letterboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
26
26
  - **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, which belongs to the calling agent — say what you see in one line and let them judge it.
27
27
  With no vision capability, write `Look: PATH (pixels not inspected; agent has no image view)` — never claim a picture was inspected when it wasn't, without stalling for a capability that isn't there.
@@ -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
- | "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) |
139
+ | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` (TTS parts: `--list parts.txt`, `--on-missing skip`) |
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.2.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.2.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.2.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.
@@ -554,7 +554,16 @@ writes the resulting cut list exactly as it does today.
554
554
  ```
555
555
  join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
556
556
  [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
557
- ```
557
+ join.py --list parts.txt [--on-missing fail|skip] [...]
558
+ ```
559
+ `--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
560
+ file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
561
+ segments a TTS or render step wrote. Every input -- listed or given as arguments -- is checked
562
+ before anything runs: missing, empty (0 bytes) or unreadable as media. By default all of them
563
+ are named in one refusal (`kind: input`, `problems: [{index, path, reason}]`, nothing encoded);
564
+ `--on-missing skip` joins the usable ones and reports the rest under `skipped` (always present,
565
+ `[]` when nothing was). A list with no entries is refused as such, and a skip that leaves fewer
566
+ than two inputs is refused rather than "joining" one file.
558
567
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
559
568
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
560
569
  silent track generated for clips without audio), then chains `xfade` +
@@ -569,7 +578,21 @@ render.py --init project.json # starter file
569
578
  render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
570
579
  render.py project.json --cache DIR [--from STAGE] # reuse identical stages from a previous run
571
580
  render.py plan.json # execute a plan written by <tool> --plan plan.json
572
- ```
581
+ render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otio # the cut for an editor, nothing rendered
582
+ ```
583
+ `--export-timeline FILE` (2.1) writes the project's cut as an editor timeline instead of
584
+ rendering it: `.fcpxml` (FCPXML 1.10: Final Cut Pro, DaVinci Resolve), `.edl` (CMX 3600:
585
+ Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, others through
586
+ OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
587
+ `LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
588
+ timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
589
+ track; inline `chapters` are markers. Everything else in the project -- captions, graphics,
590
+ overlays, the silence cut, fit, audio processing, loudness, export -- is listed in the result's
591
+ `timeline.not_exported` and on stderr, never dropped silently. Media paths are absolute file
592
+ URLs to the sources, nothing is copied or encoded, and the existing-output rule applies
593
+ (`--overwrite` to replace). The timeline follows the project's numbers exactly; a render of
594
+ the same project can come out a few frames longer, because `join.py` offsets its crossfades
595
+ by each part's container duration, audio included.
573
596
  A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
574
597
  --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
575
598
  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":
@@ -450,7 +453,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
450
453
  elif name == "join":
451
454
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
452
455
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
453
- "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"}}
456
+ "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
457
+ "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"}}
454
458
  elif name == "audio":
455
459
  extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
456
460
  "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
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/join.py CHANGED
@@ -18,16 +18,25 @@ Examples:
18
18
  python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
19
19
  python3 join.py intro.wav talk.m4a outro.wav -o episode.flac # audio join, 0.5 s crossfade
20
20
  python3 join.py part1.wav part2.wav --transition none -o full.wav # butt join, sample rate of part1
21
+ python3 join.py --list parts.txt --transition none -o voice.wav # the segments a TTS step wrote, in order
22
+ python3 join.py --list parts.txt --on-missing skip -o voice.wav # join what is there, report the rest
23
+
24
+ Every input is checked before anything is joined: a missing, empty or unreadable segment is
25
+ refused with every problem named at once (kind input), never discovered one run at a time.
26
+ --on-missing skip joins the usable segments instead and lists the others under `skipped`.
21
27
  """
22
28
  import argparse
29
+ import os
23
30
  import sys
24
- from typing import List
31
+ from typing import Any, Dict, List
25
32
 
26
- from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS, fmt_secs
33
+ from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
27
34
 
28
35
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
29
36
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
30
37
  LAYOUTS = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}
38
+ # inputs --on-missing skip dropped, reported in the success document (empty when nothing was)
39
+ STATE_SKIPPED: List[Dict[str, Any]] = []
31
40
 
32
41
 
33
42
  def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
@@ -77,14 +86,74 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
77
86
  die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
78
87
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
79
88
  + ("crossfade" if d else "butt join") + ")")
80
- emit(output, mode="audio", clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
89
+ emit(output, mode="audio", skipped=list(STATE_SKIPPED), clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
81
90
  sample_rate=rate, channels=channels, video=False)
82
91
  return 0
83
92
 
84
93
 
94
+ def read_list(path: str) -> List[str]:
95
+ """--list FILE: one segment per line, in order, relative to the list file's own folder.
96
+ Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
97
+ (`file 'part 01.wav'`) is accepted, so a list written for `ffmpeg -f concat` works as is."""
98
+ try:
99
+ text = open(path, encoding="utf-8").read()
100
+ except OSError as exc:
101
+ die(f"cannot read --list {path}: {exc}")
102
+ base = os.path.dirname(os.path.abspath(path))
103
+ entries: List[str] = []
104
+ for line in text.splitlines():
105
+ line = line.strip()
106
+ if not line or line.startswith("#"):
107
+ continue
108
+ if line.startswith("file "):
109
+ line = line[5:].strip()
110
+ if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"":
111
+ line = line[1:-1].replace("'\\''", "'")
112
+ entries.append(line if os.path.isabs(line) else os.path.join(base, line))
113
+ if not entries:
114
+ # the failure this exists for: an upstream step produced nothing, and the join must
115
+ # say so plainly rather than hand ffmpeg an empty concat and die somewhere later
116
+ die(f"--list {path} names no segments (empty, or only blank lines and comments)",
117
+ hint="check the step that writes the list: it produced no files")
118
+ return entries
119
+
120
+
121
+ def preflight(paths: List[str]) -> List[Dict[str, Any]]:
122
+ """Every problem with every input, found before anything runs: missing, empty (0 bytes,
123
+ the usual trace of a TTS or download step that failed after creating its file), or not
124
+ readable as media. One ffprobe per file; nothing is decoded. Under --dry-run a file that
125
+ does not exist yet is not a problem: in a planned pipeline it is an earlier step's output."""
126
+ ffprobe = require_tool("ffprobe")
127
+ problems: List[Dict[str, Any]] = []
128
+ for i, p in enumerate(paths):
129
+ if not os.path.exists(p):
130
+ if STATE.dry_run:
131
+ continue # a dry-run pipeline plans on outputs earlier steps have not written yet
132
+ problems.append({"index": i, "path": p, "reason": "missing"})
133
+ continue
134
+ if os.path.isdir(p):
135
+ problems.append({"index": i, "path": p, "reason": "a directory, not a file"})
136
+ continue
137
+ if os.path.getsize(p) == 0:
138
+ problems.append({"index": i, "path": p, "reason": "empty (0 bytes)"})
139
+ continue
140
+ proc = run([ffprobe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", p],
141
+ quiet=True, check=False)
142
+ kinds = {line.strip() for line in (proc.stdout or "").splitlines() if line.strip()}
143
+ if proc.returncode != 0 or not kinds & {"audio", "video"}:
144
+ first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
145
+ problems.append({"index": i, "path": p, "reason": f"unreadable: {first}"})
146
+ return problems
147
+
148
+
85
149
  def main() -> int:
86
150
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
87
- ap.add_argument("inputs", nargs="+", help="two or more clips in order")
151
+ ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
152
+ ap.add_argument("--list", metavar="FILE", help="read the clips from FILE, one per line in order (paths relative to FILE; "
153
+ "ffmpeg concat lines `file 'x.wav'` also accepted); an empty list is refused")
154
+ ap.add_argument("--on-missing", choices=["fail", "skip"], default="fail",
155
+ help="a missing, empty or unreadable input: fail (default; every problem named at once) or skip "
156
+ "(join the rest; each skipped input is reported under `skipped`)")
88
157
  ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
89
158
  ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
90
159
  ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
@@ -104,8 +173,29 @@ def main() -> int:
104
173
  if args.fps is not None and args.fps <= 0:
105
174
  die(f"--fps must be positive, got {args.fps:g}")
106
175
 
176
+ if args.list:
177
+ if args.inputs:
178
+ die("give the clips either as arguments or with --list, not both")
179
+ args.inputs = read_list(args.list)
180
+ if not args.inputs:
181
+ die("give at least two clips (or --list FILE)")
182
+ skipped: List[Dict[str, Any]] = []
183
+ problems = preflight(args.inputs)
184
+ if problems and args.on_missing == "fail":
185
+ listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in problems)
186
+ die(f"{len(problems)} of {len(args.inputs)} inputs cannot be joined: {listing}",
187
+ hint="fix or regenerate them, or pass --on-missing skip to join the rest", problems=problems)
188
+ if problems:
189
+ bad = {p["index"] for p in problems}
190
+ skipped = problems
191
+ args.inputs = [p for i, p in enumerate(args.inputs) if i not in bad]
192
+ for p in problems:
193
+ info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
194
+ STATE_SKIPPED[:] = skipped
107
195
  if len(args.inputs) < 2:
108
- die("give at least two clips")
196
+ die("give at least two clips" if not skipped else
197
+ f"only {len(args.inputs)} usable input(s) left after skipping {len(skipped)}; nothing to join",
198
+ skipped=skipped)
109
199
  validate_color(args.pad_color, "--pad-color")
110
200
  metas = [probe(p) for p in args.inputs]
111
201
  if all(not m.get("video") for m in metas):
@@ -206,7 +296,7 @@ def main() -> int:
206
296
  expected = sum(durs) - d * (n - 1)
207
297
  r = probe(output, role="output")
208
298
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
209
- emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
299
+ emit(output, mode="video", skipped=list(STATE_SKIPPED), clips=n, transition=args.transition, expected_duration=round(expected, 3),
210
300
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
211
301
  return 0
212
302
 
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")