ffmpeg-skill 2.2.1 → 2.2.3

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/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.2.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.2.3`) | 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.2.1", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.2.3", "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"},
@@ -492,6 +492,12 @@ Per-tool keys added in 1.18.0, all additive:
492
492
  | `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
493
493
  | `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
494
494
 
495
+ Per-tool keys added after 2.2.2, all additive:
496
+
497
+ | key | tool | what it holds |
498
+ |---|---|---|
499
+ | `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
500
+
495
501
  `check.py` also gains an informational `subtitles` row on **every** platform:
496
502
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
497
503
  does not (or when there are none). Like `channels` and `chapters` it is never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
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",
@@ -563,11 +563,21 @@ before anything runs: missing, empty (0 bytes) or unreadable as media. By defaul
563
563
  are named in one refusal (`kind: input`, `problems: [{index, path, reason}]`, nothing encoded);
564
564
  `--on-missing skip` joins the usable ones and reports the rest under `skipped` (always present,
565
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.
566
+ than two inputs is refused rather than "joining" one file. Under `--dry-run` an input that does
567
+ not exist yet is an earlier step's output, with either `--on-missing`: it stays in the plan and
568
+ is named under `pending: [{index, path}]` and in `notes` (never `skipped`). Its extension stands
569
+ in for its streams (any audio extension the skill reads, `.aiff` and `.caf` included: no
570
+ picture), so a pending `.mp4` next to measured audio is refused as the mix a real run would
571
+ refuse; `expected_duration` is null while any input is pending, and `notes` names the planned
572
+ frame, rate or xfade offsets that rest on a pending input's unmeasured placeholder.
567
573
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
568
574
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
569
575
  silent track generated for clips without audio), then chains `xfade` +
570
- `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
576
+ `acrossfade`. With a transition each clip has one length for picture and sound
577
+ -- the video stream's, or the audio's when it runs more than a frame longer --
578
+ and the shorter stream is padded (silence / last frame held), so every clip's
579
+ sound stays with its picture. Output length = sum of clip lengths −
580
+ transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
571
581
  longer than 2 × the transition. Use `--transition none` for a plain cut.
572
582
  A subtitle/data track in the source is not carried into the retimed/concatenated
573
583
  output; the result says so with `dropped_non_av_streams: true`.
@@ -586,13 +596,20 @@ Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, othe
586
596
  OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
587
597
  `LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
588
598
  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.
599
+ track; inline `chapters` are markers. Times take the render's own grammar (`"0:05"`,
600
+ `hh:mm:ss:ff` at the source's fps), and the sequence is the project's `frame` sized by
601
+ fit.py's rule (`{aspect 16:9, width 1920}` is 1920x1080; an aspect alone takes the export
602
+ preset's size when the preset has that aspect; an aspect fit.py refuses, such as `16/9`, is
603
+ refused unless the project's own `fit.aspect` replaces it in the render). A clip of another
604
+ aspect gets its spatial conform in FCPXML (`adjust-conform`: `fill` for `frame.fit: crop`,
605
+ `fit` for pad); EDL and OTIO cannot state one. Everything else in the project -- captions,
606
+ graphics, overlays, the silence cut, fit, a reframe the format cannot state (and `blur`'s
607
+ background), audio processing, loudness, export -- is listed in the result's
608
+ `timeline.not_exported` and on stderr, never dropped silently. Media paths are absolute file URLs to the sources, nothing is
609
+ copied or encoded, and the existing-output rule applies (`--overwrite` to replace). The
610
+ timeline follows the project's numbers exactly; a render of the same project can come out a
611
+ few frames longer, because `join.py` offsets its crossfades by each part's container duration,
612
+ audio included.
596
613
  A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
597
614
  --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
598
615
  head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
@@ -69,10 +69,10 @@ from _common.emit import (
69
69
  from _common.probe import (
70
70
  analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
71
71
  _fraction, keyframes_near, SCORE_RE, SIL_RE, decode_gray_frames,
72
- measured_level_dbfs, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
72
+ measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
73
73
  )
74
74
  from _common.decision import (
75
- aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
75
+ aac_args, add_pad_fill_args, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
76
76
  description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
77
77
  frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
78
78
  filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
@@ -82,7 +82,7 @@ from _common.decision import (
82
82
  BEAT_Z_FLOOR, BEAT_Z_SPAN,
83
83
  _onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
84
84
  brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
85
- fmt_secs, fmt_smpte_time, fmt_srt_time, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
85
+ fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
86
86
  signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
87
87
  )
88
88
  from _common.asr import (
@@ -201,7 +201,7 @@ __all__ = [
201
201
  "fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
202
202
  "is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
203
203
  "keyframes_near", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
204
- "load_brand", "measured_level_dbfs", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
204
+ "load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
205
205
  "_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
206
206
  "place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
207
207
  "print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
  import json
10
10
  import math
11
11
  import os
12
+ import re
12
13
  import argparse
14
+ from fractions import Fraction
13
15
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional, Sequence
16
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
15
17
  from _common.color import bt709_tag_args, _sdr_bt709
16
18
  from _common.emit import die
17
19
  from _common.runner import CODECS, STATE, ffmpeg_encoders
@@ -47,6 +49,60 @@ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
47
49
  parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
48
50
 
49
51
 
52
+ def aspect_ratio(value: Any) -> Optional[Fraction]:
53
+ """An aspect written W:H as an exact ratio, or None. fit.py --aspect and --export-timeline's
54
+ sequence frame read an aspect through this one function, so the export refuses `16/9` and
55
+ `2.39:1` where the render hands them to fit.py, which refuses them too (the timeline once
56
+ took them and wrote a sequence for a project whose render then failed in fit.py). Each side
57
+ is read with int(), as fit.py read it in 2.2.1, so a patch release refuses nothing fit.py
58
+ took: `+16:9` and `1_6:9` still parse, and `0:9` is a zero ratio, which frame_size() treats
59
+ as no aspect (2.2.1 kept the source frame). render.py's frame_from_preset() keeps 2.2.1's
60
+ looser match: it only picks the export preset's size."""
61
+ parts = str(value).split(":")
62
+ if len(parts) != 2:
63
+ return None
64
+ try:
65
+ return Fraction(int(parts[0]), int(parts[1]))
66
+ except (ValueError, ZeroDivisionError):
67
+ return None
68
+
69
+
70
+ def _even(n: float) -> int:
71
+ v = int(round(n))
72
+ return v if v % 2 == 0 else v + 1
73
+
74
+
75
+ def frame_size(ratio: Optional[Fraction], width: Optional[int], height: Optional[int],
76
+ src_w: int, src_h: int) -> Tuple[int, int]:
77
+ """The frame fit.py sizes for --aspect `ratio` (None: the source's own) and --width/--height,
78
+ from a src_w x src_h picture -- and so the sequence frame render.py --export-timeline writes
79
+ for the same project frame. Both sides given are used directly; one side takes the other from
80
+ the aspect. With neither, the canvas takes the new aspect without exceeding the source's
81
+ resolution in either dimension: a narrower/taller target than the source (e.g. 9:16 from a
82
+ 16:9 source) must be bounded by the source's HEIGHT, not its width -- bounding by width there
83
+ multiplies the height by src_ratio/ratio (a 1920x1080 source asked for 9:16 used to come out
84
+ 1920x3414, a ~3.16x upscale in both fit=pad and fit=crop, entirely unrequested). Every side is
85
+ rounded to even."""
86
+ src_ratio = Fraction(src_w, src_h) if src_h else None
87
+ if ratio is None:
88
+ ratio = src_ratio
89
+ if width and height:
90
+ return _even(width), _even(height)
91
+ if width:
92
+ out_w = _even(width)
93
+ return out_w, (_even(out_w / ratio) if ratio else width)
94
+ if height:
95
+ out_h = _even(height)
96
+ return (_even(out_h * ratio) if ratio else height), out_h
97
+ if ratio and src_ratio:
98
+ if ratio <= src_ratio:
99
+ out_h = _even(src_h)
100
+ return _even(out_h * ratio), out_h
101
+ out_w = _even(src_w)
102
+ return out_w, _even(out_w / ratio)
103
+ return _even(src_w), _even(src_h)
104
+
105
+
50
106
  # x264 preset names mapped onto SVT-AV1's 0-13 speed scale (lower = slower / better)
51
107
  SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
52
108
 
@@ -73,8 +73,11 @@ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool
73
73
  return env
74
74
 
75
75
 
76
+ # the audio members of MEDIA_EXT: every audio file this skill reads, a wider set than the audio
77
+ # outputs it writes (AUDIO_CODECS / is_audio_output() -- no .aif, .aiff, .caf or .wma there)
78
+ AUDIO_MEDIA_EXT = {".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma"}
76
79
  MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
77
- ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
80
+ ".png", ".jpg", ".jpeg", ".webp"} | AUDIO_MEDIA_EXT
78
81
 
79
82
 
80
83
  def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start: float = 0.0,
@@ -280,6 +283,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
280
283
  "color_range": video.get("color_range"),
281
284
  "rotation": rotation,
282
285
  "nb_frames": _to_int(video.get("nb_frames")),
286
+ # the stream's own length (a container's also counts audio priming and a longer track)
287
+ "duration": _to_float(video.get("duration")),
283
288
  "bitrate": _to_int(video.get("bit_rate")),
284
289
  }
285
290
  if audio:
@@ -289,6 +294,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
289
294
  "channel_layout": audio.get("channel_layout"),
290
295
  "sample_rate": _to_int(audio.get("sample_rate")),
291
296
  "bitrate": _to_int(audio.get("bit_rate")),
297
+ "duration": _to_float(audio.get("duration")),
292
298
  }
293
299
  # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
294
300
  out["audio_streams"] = [{
@@ -13,18 +13,23 @@ either side of the new cut: the same frames blend over the same span, and the to
13
13
  the rendered one.
14
14
 
15
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.
16
+ transition between them, a music bed on its own audio track, and chapters as markers, on a
17
+ sequence of the project's frame (sized by the rule fit.py renders it with). FCPXML also states
18
+ how a clip of another aspect fills that frame (`adjust-conform`); EDL and OTIO cannot.
19
+ Everything else in the project -- captions, graphics, overlays, the silence cut, fit/crop, a
20
+ reframe the format cannot state, audio processing, loudness, the export preset -- is listed
21
+ under `not_exported`, never silently dropped.
20
22
  """
21
23
  import json
24
+ import math
22
25
  import os
23
26
  from fractions import Fraction
24
27
  from pathlib import Path
25
28
  from typing import Any, Dict, List, Optional, Tuple
26
29
  from xml.sax.saxutils import escape as _xml_escape
27
30
 
31
+ from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
32
+
28
33
  FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
29
34
  # Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
30
35
  _NTSC = {23.976: Fraction(24000, 1001), 29.97: Fraction(30000, 1001), 59.94: Fraction(60000, 1001),
@@ -43,6 +48,9 @@ _NOT_EXPORTED = {
43
48
  "brand": "brand styles",
44
49
  "snap": "beat snapping (the timeline uses the project's in/out as written, before any snap)",
45
50
  }
51
+ # frame.fit -> the FCPXML adjust-conform type that fills the sequence the same way. blur has no
52
+ # conform of its own: the picture is fitted whole, and its blurred background is not exported.
53
+ _CONFORM = {"pad": "fit", "crop": "fill", "blur": "fit"}
46
54
  _AUDIO_NOT_EXPORTED = ("replace", "voice", "denoise", "duck", "duck_amount", "duck_threshold", "duck_attack",
47
55
  "duck_release", "gain", "stereo", "mono", "downmix", "stereo_widen", "effects",
48
56
  "effects_volume", "stems", "music_volume", "fade_in", "fade_out", "music_fade_out",
@@ -63,6 +71,43 @@ def exact_rate(fps: float) -> Fraction:
63
71
  return frac
64
72
 
65
73
 
74
+ def _number(value: Any, what: str) -> float:
75
+ """A numeric project value (a rate, a size, a speed, a transition length) as a float, or a
76
+ TimelineError naming it -- never float()'s traceback."""
77
+ try:
78
+ number = float(value)
79
+ except (TypeError, ValueError):
80
+ number = math.nan
81
+ if not math.isfinite(number):
82
+ raise TimelineError(f"{what}: {value!r} is not a number")
83
+ return number
84
+
85
+
86
+ def _seconds(value: Any, what: str, fps: Optional[float]) -> float:
87
+ """A project time read with the grammar render.py's own stages read it (time_arg()): seconds,
88
+ mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at `fps` or its own @fps. A value it refuses is a
89
+ TimelineError, so `--json` gets a `kind: input` document, not a traceback and empty stdout."""
90
+ try:
91
+ seconds = parse_time(str(value), fps)
92
+ except MissingFpsError as exc:
93
+ raise TimelineError(f"{what}: {exc}")
94
+ except ValueError as exc:
95
+ raise TimelineError(f"{what}: {exc} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the "
96
+ "source's fps or with an explicit @fps suffix)")
97
+ if not math.isfinite(seconds):
98
+ raise TimelineError(f"{what}: {value!r} is not a time")
99
+ return seconds
100
+
101
+
102
+ def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
103
+ """A source's displayed width x height (rotation metadata swaps them, as fit.py reads it)."""
104
+ video = meta.get("video") or {}
105
+ w, h = int(video.get("width") or 0), int(video.get("height") or 0)
106
+ if not (w and h):
107
+ return None
108
+ return (h, w) if video.get("rotation") in (90, -90, 270, -270) else (w, h)
109
+
110
+
66
111
  def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
67
112
  """The editor-neutral timeline: every time in frames at `rate`.
68
113
 
@@ -71,44 +116,76 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
71
116
  clips_in = proj.get("clips") or []
72
117
  if not clips_in:
73
118
  raise TimelineError("project.clips is empty")
119
+ for i, c in enumerate(clips_in):
120
+ if not isinstance(c, dict) or not c.get("src"):
121
+ raise TimelineError(f"clips[{i}]: no src")
74
122
  frame = proj.get("frame") or {}
75
123
  first = probes[rel(clips_in[0]["src"])]
76
- fps = frame.get("fps") or (first.get("video") or {}).get("fps")
124
+ frame_fps = _number(frame["fps"], "frame.fps") if frame.get("fps") else None
125
+ fps = frame_fps or (first.get("video") or {}).get("fps")
77
126
  if not fps:
78
127
  raise TimelineError("no frame rate: the first clip has no video stream and project.frame.fps is not set")
79
128
  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)
129
+ # The sequence is the project's frame sized by fit.py's rule (render.py has already filled an
130
+ # aspect-only frame from the export preset), so {aspect 16:9, width 1920} is 1920x1080
131
+ # whatever the source's height -- the size the render's fit stage delivers. No frame at all
132
+ # is the first clip's own picture, which is what the render keeps.
133
+ aspect = frame.get("aspect")
134
+ ratio = aspect_ratio(aspect) if aspect else None
135
+ if aspect and ratio is None:
136
+ raise TimelineError(f"frame.aspect {aspect!r}: use W:H with whole numbers, like 16:9 (fit.py refuses it too)")
137
+ want_w, want_h = (int(_number(frame[k], f"frame.{k}")) if frame.get(k) else None for k in ("width", "height"))
138
+ if (want_w or 0) < 0 or (want_h or 0) < 0:
139
+ raise TimelineError(f"frame width/height must be positive, got {frame.get('width')!r} x {frame.get('height')!r}")
140
+ src_w, src_h = _picture(first) or (1920, 1080)
141
+ if ratio is None and not want_w and not want_h:
142
+ width, height = src_w, src_h
143
+ else:
144
+ width, height = frame_size(ratio, want_w, want_h, src_w, src_h)
145
+ fit_mode = str(frame.get("fit") or "pad")
146
+ if fit_mode not in _CONFORM:
147
+ raise TimelineError(f"frame.fit {frame.get('fit')!r}: use pad, crop or blur (fit.py's --fit)")
82
148
 
83
149
  def fr(seconds: float) -> int:
84
150
  return int(round(Fraction(seconds).limit_denominator(1000000) * rate))
85
151
 
86
152
  trans = proj.get("transition") or {}
87
153
  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)))
154
+ d_frames = 0 if t_type == "none" else fr(_number(trans.get("duration", 0.5), "transition.duration"))
89
155
  notes: List[str] = []
90
156
  if d_frames and t_type != "fade":
91
157
  notes.append(f'transition "{t_type}" is exported as a cross dissolve: editors do not share xfade\'s other patterns')
92
158
 
93
159
  clips: List[Dict[str, Any]] = []
160
+ reframed: List[str] = []
94
161
  for i, c in enumerate(clips_in):
95
162
  src = rel(c["src"])
96
163
  meta = probes[src]
97
164
  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
165
+ # in/out are on the source's own clock, as cut.py reads them: hh:mm:ss:ff at the
166
+ # source's fps, never frame.fps. A clip with no picture has no fps, and cut.py takes no
167
+ # other, so its hh:mm:ss:ff needs an @fps suffix here as in the render.
168
+ clip_fps = (meta.get("video") or {}).get("fps")
169
+ start = _seconds(c["in"], f"clips[{i}].in", clip_fps) if c.get("in") is not None else 0.0
170
+ if start < 0:
171
+ raise TimelineError(f"clips[{i}].in must not be negative, got {c['in']!r}")
172
+ end = _seconds(c["out"], f"clips[{i}].out", clip_fps) if c.get("out") is not None else dur
100
173
  if end <= start:
101
174
  raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
102
- speed = float(c.get("speed") or 1.0)
175
+ speed = _number(c.get("speed") or 1.0, f"clips[{i}].speed")
103
176
  if not speed > 0:
104
177
  raise TimelineError(f"clip {i}: speed must be positive, got {c.get('speed')!r}")
178
+ picture = _picture(meta)
179
+ reframe = bool(picture and abs(Fraction(*picture) - Fraction(width, height)) > Fraction(1, 100))
180
+ if reframe:
181
+ reframed.append(os.path.basename(src))
105
182
  src_in, src_out = fr(start), fr(end)
106
183
  clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
107
184
  "src_in": src_in, "src_out": src_out, "speed": speed,
108
185
  "length": int(round((src_out - src_in) / speed)),
109
186
  "media_frames": fr(dur), "has_video": bool(meta.get("video")),
110
187
  "has_audio": bool(meta.get("audio")),
111
- "width": (meta.get("video") or {}).get("width"), "height": (meta.get("video") or {}).get("height")})
188
+ "picture": picture, "fps": clip_fps, "reframed": reframe})
112
189
 
113
190
  # Centre each dissolve on the cut: trim d/2 off the outgoing tail and d/2 off the incoming head.
114
191
  half_a, half_b = d_frames // 2, d_frames - d_frames // 2
@@ -140,22 +217,44 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
140
217
  markers: List[Dict[str, Any]] = []
141
218
  chapters = proj.get("chapters")
142
219
  if isinstance(chapters, list):
143
- for ch in chapters:
144
- at = fr(float(ch.get("at", 0)))
220
+ for j, ch in enumerate(chapters):
221
+ # read as render.py's chapters stage reads it (metadata.py): a chapter list carries
222
+ # no fps, so hh:mm:ss:ff needs its @fps there and here alike
223
+ at = fr(_seconds(ch.get("at", 0), f"chapters[{j}].at", None))
145
224
  if 0 <= at < total:
146
225
  markers.append({"at": at, "title": str(ch.get("title") or "")})
147
226
  elif chapters:
148
227
  notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
149
228
 
229
+ # A clip of another aspect than the sequence is a reframe: whether the file can state it
230
+ # depends on the format, so summary() adds it to not_exported per format (_reframe_note).
150
231
  not_exported = [text for key, text in _NOT_EXPORTED.items() if proj.get(key)]
151
232
  dropped_audio = sorted(k for k in _AUDIO_NOT_EXPORTED if audio.get(k) not in (None, False, "", 0) and k != "music")
152
233
  if dropped_audio:
153
234
  not_exported.append("audio processing: " + ", ".join(dropped_audio))
154
235
  return {"name": Path(str(proj.get("output") or "timeline")).stem, "rate": rate, "width": width, "height": height,
236
+ "fit": fit_mode, "reframed": list(dict.fromkeys(reframed)),
155
237
  "clips": clips, "transition": t_type if d_frames else "none", "transition_frames": d_frames,
156
238
  "music": music, "markers": markers, "total": total, "notes": notes, "not_exported": not_exported}
157
239
 
158
240
 
241
+ def _reframe_note(tl: Dict[str, Any], fmt: str) -> List[str]:
242
+ """What `not_exported` says about the clips whose picture has another aspect than the
243
+ sequence. FCPXML states each one's spatial conform (adjust-conform: fit for frame.fit pad,
244
+ fill for crop), so there only blur's blurred background is missing; EDL and OTIO have no
245
+ field for a conform at all, so the editor's own default decides and the caller is told."""
246
+ if not tl["reframed"]:
247
+ return []
248
+ names = ", ".join(tl["reframed"])
249
+ if fmt == "fcpxml":
250
+ if tl["fit"] != "blur":
251
+ return []
252
+ return [f"frame.fit blur's blurred background behind {names}: the FCPXML fits the picture into "
253
+ f"{tl['width']}x{tl['height']} (adjust-conform fit), leaving bars where the render blurs"]
254
+ return [f"the reframe to {tl['width']}x{tl['height']} (frame.fit {tl['fit']}) of {names}: "
255
+ "set the clips' spatial conform in the editor (fit = pad, fill = crop)"]
256
+
257
+
159
258
  # ---------------------------------------------------------------------------------------- EDL
160
259
  def _tc(frames: int, fps_int: int) -> str:
161
260
  h, rem = divmod(frames, fps_int * 3600)
@@ -234,21 +333,40 @@ def _url(path: str) -> str:
234
333
  def to_fcpxml(tl: Dict[str, Any]) -> str:
235
334
  """FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
236
335
  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."""
336
+ of the first clip, chapters as chapter-markers. Speed is a linear timeMap on the clip. The
337
+ sequence has its own format (r1); each video asset has its source's, and a clip of another
338
+ aspect states its spatial conform (adjust-conform) for frame.fit."""
238
339
  rate = tl["rate"]
239
340
  q = lambda s: _xml_escape(str(s), {'"': "&quot;"}) # noqa: E731
240
341
  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:
342
+ res: List[str] = []
343
+ ids: Dict[Any, str] = {} # a format's (width, height, frameDuration) or an asset's path -> its id
344
+
345
+ def fmt(width: int, height: int, frame_duration: str) -> str:
346
+ key = (width, height, frame_duration)
347
+ if key not in ids:
348
+ ids[key] = f"r{len(ids) + 1}"
349
+ res.append(f' <format id="{ids[key]}" name="FFVideoFormat{height}p" frameDuration="{frame_duration}" '
350
+ f'width="{width}" height="{height}"/>')
351
+ return ids[key]
352
+
353
+ seq_fmt = fmt(tl["width"], tl["height"], fd) # r1: the sequence's frame
354
+
355
+ def clip_fmt(c: Dict[str, Any]) -> str:
356
+ # An asset describes its own media, not the sequence: with every asset on r1, a 320x180
357
+ # file under a 1080x1920 frame claimed to be 1080x1920, and its conform had nothing to do.
358
+ if not (c["has_video"] and c["picture"]):
359
+ return seq_fmt
360
+ return fmt(c["picture"][0], c["picture"][1], _t(1, exact_rate(float(c["fps"]))) if c["fps"] else fd)
361
+
362
+ def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = "") -> str:
245
363
  if path in ids:
246
364
  return ids[path]
247
- rid = f"r{len(ids) + 2}"
365
+ rid = f"r{len(ids) + 1}"
248
366
  ids[path] = rid
249
367
  res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
250
368
  f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
251
- + (' format="r1"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
369
+ + (f' format="{format_id}"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
252
370
  f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
253
371
  return rid
254
372
 
@@ -256,7 +374,8 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
256
374
  d = tl["transition_frames"]
257
375
  markers = list(tl["markers"])
258
376
  for i, c in enumerate(tl["clips"]):
259
- rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"])
377
+ cid = clip_fmt(c)
378
+ rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid)
260
379
  if i > 0 and d:
261
380
  spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
262
381
  f'duration="{_t(d, rate)}"/>')
@@ -273,21 +392,28 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
273
392
  else:
274
393
  start_attr = _t(src_start, rate)
275
394
  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"])}"/>')
395
+ # the FCPXML 1.10 DTD's order inside an asset-clip: timeMap, then the adjust-* elements
396
+ # (adjust-conform), then anchored items (the music bed), then markers. Anything out of
397
+ # that order does not validate, and Final Cut validates what it imports against that DTD.
398
+ if c["reframed"]:
399
+ # the DTD reads a missing adjust-conform as fit, so crop has to say fill; fit is
400
+ # written too, so the file states the project's choice rather than a default
401
+ inner.append(f' <adjust-conform type="{_CONFORM[tl["fit"]]}"/>')
279
402
  if i == 0 and tl["music"]:
280
403
  m = tl["music"]
281
404
  mid = asset(m["src"], m["name"], m["media_frames"], False, True)
282
405
  inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_t(local0, rate)}" name="{q(m["name"])}" '
283
406
  f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
407
+ for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
408
+ inner.append(f' <chapter-marker start="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
409
+ f'duration="{fd}" value="{q(mk["title"])}"/>')
284
410
  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"')
411
+ f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}" tcFormat="NDF"')
286
412
  spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
287
413
  out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
288
414
  " <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
289
415
  f' <project name="{q(tl["name"])}">',
290
- f' <sequence format="r1" duration="{_t(tl["total"], rate)}" tcStart="0s" tcFormat="NDF">',
416
+ f' <sequence format="{seq_fmt}" duration="{_t(tl["total"], rate)}" tcStart="0s" tcFormat="NDF">',
291
417
  " <spine>", *spine, " </spine>", " </sequence>", " </project>", " </event>",
292
418
  " </library>", "</fcpxml>"]
293
419
  return "\n".join(out) + "\n"
@@ -376,4 +502,5 @@ def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]:
376
502
  "duration": round(float(Fraction(tl["total"]) / rate), 3), "frames": tl["total"],
377
503
  "clips": len(tl["clips"]), "transition": tl["transition"],
378
504
  "transition_frames": tl["transition_frames"], "music": bool(tl["music"]),
379
- "markers": len(tl["markers"]), "notes": tl["notes"], "not_exported": tl["not_exported"]}
505
+ "markers": len(tl["markers"]), "notes": tl["notes"],
506
+ "not_exported": tl["not_exported"] + _reframe_note(tl, fmt)}
@@ -443,6 +443,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
443
443
  extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
444
444
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
445
445
  "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
446
+ elif name == "color":
447
+ extra = {"dropped_non_av_streams": {"type": "boolean"},
448
+ "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
449
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
450
+ "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
446
451
  elif name == "cut":
447
452
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
448
453
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
@@ -451,10 +456,13 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
451
456
  "reencoded": {"type": "boolean"},
452
457
  "lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"}}
453
458
  elif name == "join":
454
- extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
459
+ extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
460
+ "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
455
461
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
456
462
  "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)"}}
463
+ "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
464
+ "pending": {"type": "array", "description": "--dry-run: inputs that do not exist yet, planned on as an earlier step's output and never also under skipped: [{index, path}] ([] otherwise)"},
465
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--dry-run with pending inputs: which ones, what a real run does if one is still missing (including a skip that leaves fewer than two inputs), whether the mode came from the extensions, and which planned numbers are the unmeasured stub's placeholders (a pending first clip's frame and rate, xfade offsets after a pending clip)"}}
458
466
  elif name == "audio":
459
467
  extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
460
468
  "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
package/scripts/color.py CHANGED
@@ -169,12 +169,36 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
169
169
  return ",".join(chain)
170
170
 
171
171
 
172
+ def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
173
+ """Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
174
+ true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
175
+ its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
176
+ white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
177
+ as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
178
+ v = meta["video"]
179
+ trc = v.get("color_transfer") or "bt709"
180
+ space = v.get("color_space") or "bt2020nc"
181
+ return ",".join([
182
+ f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
183
+ "format=gbrpf32le",
184
+ "zscale=p=bt709",
185
+ "zscale=t=bt709:m=bt709:r=tv",
186
+ "format=yuv420p",
187
+ ])
188
+
189
+
190
+ def is_bt2020_sdr(v: dict) -> bool:
191
+ """BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
192
+ return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
193
+ and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
194
+
195
+
172
196
  def main() -> int:
173
197
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
174
198
  ap.add_argument("input")
175
199
  ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
176
200
  mode = ap.add_mutually_exclusive_group(required=True)
177
- mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
201
+ mode.add_argument("--to-sdr", action="store_true", help="convert to SDR BT.709: tone-map HDR (PQ/HLG/Dolby Vision); BT.2020 primaries on an SDR transfer get a gamut conversion only, no tone map")
178
202
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
179
203
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
180
204
  mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
@@ -183,7 +207,7 @@ def main() -> int:
183
207
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
184
208
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
185
209
  ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
186
- ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
210
+ ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
187
211
  ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
188
212
  ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
189
213
  ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
@@ -278,10 +302,21 @@ def main() -> int:
278
302
  return 0
279
303
 
280
304
  measurements = None
305
+ sdr_extra: dict = {}
281
306
  if args.to_sdr:
282
307
  if not v.get("bt2020_or_hdr") and not args.force:
283
308
  die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
284
- vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
309
+ if is_bt2020_sdr(v):
310
+ vf = bt2020_sdr_to_bt709_chain(meta)
311
+ sdr_path = "gamut"
312
+ note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
313
+ f"without a tone map (--tonemap/--peak/--desat do not apply)")
314
+ else:
315
+ vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
316
+ sdr_path = "tonemap"
317
+ note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
318
+ info(f"note: {note}")
319
+ sdr_extra = {"sdr_path": sdr_path, "notes": [note]}
285
320
  output = args.output or default_output(args.input, "sdr")
286
321
  tag = "sdr"
287
322
  elif args.correct:
@@ -323,7 +358,7 @@ def main() -> int:
323
358
  r = probe(output, role="output")
324
359
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
325
360
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
326
- extra = {"dropped_non_av_streams": dropped_streams}
361
+ extra = {"dropped_non_av_streams": dropped_streams, **sdr_extra}
327
362
  if measurements is not None:
328
363
  measurements["output"] = analyze_levels(output)
329
364
  extra["measurements"] = measurements
package/scripts/fit.py CHANGED
@@ -39,20 +39,16 @@ import sys
39
39
  from fractions import Fraction
40
40
  from typing import List
41
41
 
42
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs
42
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size
43
43
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
44
- ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
45
44
 
46
45
 
47
46
  def parse_aspect(value: str) -> Fraction:
48
- if value in ASPECT_PRESETS:
49
- return ASPECT_PRESETS[value]
50
- try:
51
- w, h = value.split(":")
52
- return Fraction(int(w), int(h))
53
- except (ValueError, ZeroDivisionError):
47
+ # the one aspect grammar, shared with render.py's frame and --export-timeline's sequence
48
+ ratio = aspect_ratio(value)
49
+ if ratio is None:
54
50
  die(f"bad aspect '{value}', use W:H like 16:9")
55
- return Fraction(1) # unreachable
51
+ return ratio
56
52
 
57
53
 
58
54
  def atempo_chain(factor: float) -> str:
@@ -69,11 +65,6 @@ def atempo_chain(factor: float) -> str:
69
65
  return ",".join(parts)
70
66
 
71
67
 
72
- def even(n: float) -> int:
73
- v = int(round(n))
74
- return v if v % 2 == 0 else v + 1
75
-
76
-
77
68
  def main() -> int:
78
69
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
79
70
  ap.add_argument("input")
@@ -192,31 +183,8 @@ def main() -> int:
192
183
 
193
184
  # ---- aspect / size
194
185
  if args.aspect or args.width or args.height:
195
- src_ratio = Fraction(sw, sh) if sh else None
196
- ratio = parse_aspect(args.aspect) if args.aspect else src_ratio
197
- if args.width and args.height:
198
- out_w, out_h = even(args.width), even(args.height)
199
- elif args.width:
200
- out_w = even(args.width)
201
- out_h = even(out_w / ratio) if ratio else args.width
202
- elif args.height:
203
- out_h = even(args.height)
204
- out_w = even(out_h * ratio) if ratio else args.height
205
- elif ratio and src_ratio:
206
- # No explicit --width/--height: size the canvas to the new aspect without exceeding
207
- # the source's own resolution in either dimension. A narrower/taller target than the
208
- # source (e.g. 9:16 from a 16:9 source) must be bounded by the source's HEIGHT, not
209
- # its width -- bounding by width there multiplies the height by src_ratio/ratio (a
210
- # 1920x1080 source asked for 9:16 used to come out 1920x3414, a ~3.16x upscale in
211
- # both fit=pad and fit=crop, entirely unrequested).
212
- if ratio <= src_ratio:
213
- out_h = even(sh)
214
- out_w = even(out_h * ratio)
215
- else:
216
- out_w = even(sw)
217
- out_h = even(out_w / ratio)
218
- else:
219
- out_w, out_h = even(sw), even(sh)
186
+ # the one sizing rule, shared with render.py --export-timeline's sequence frame
187
+ out_w, out_h = frame_size(parse_aspect(args.aspect) if args.aspect else None, args.width, args.height, sw, sh)
220
188
  if args.fit == "crop":
221
189
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
222
190
  vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
package/scripts/join.py CHANGED
@@ -24,19 +24,62 @@ Examples:
24
24
  Every input is checked before anything is joined: a missing, empty or unreadable segment is
25
25
  refused with every problem named at once (kind input), never discovered one run at a time.
26
26
  --on-missing skip joins the usable segments instead and lists the others under `skipped`.
27
+ Under --dry-run a segment that does not exist yet is an earlier step's output: it is planned
28
+ on and named under `pending` (never `skipped`), whichever --on-missing was given, and its
29
+ extension stands in for what it will hold (an audio extension: no picture).
27
30
  """
28
31
  import argparse
29
32
  import os
30
33
  import sys
31
- from typing import Any, Dict, List
34
+ from typing import Any, Dict, List, Optional, Tuple
32
35
 
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
36
+ from _common import STATE, AUDIO_MEDIA_EXT, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
34
37
 
35
38
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
36
39
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
37
40
  LAYOUTS = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}
38
41
  # inputs --on-missing skip dropped, reported in the success document (empty when nothing was)
39
42
  STATE_SKIPPED: List[Dict[str, Any]] = []
43
+ # --dry-run: inputs that do not exist yet, planned on as an earlier step's output
44
+ STATE_PENDING: List[Dict[str, Any]] = []
45
+ STATE_NOTES: List[str] = []
46
+
47
+
48
+ def reported() -> Dict[str, Any]:
49
+ """The input bookkeeping both success documents carry: `skipped` is what the join left out,
50
+ `pending` what a dry run planned on without it existing; `notes` says the same in words."""
51
+ extra: Dict[str, Any] = {"skipped": list(STATE_SKIPPED), "pending": list(STATE_PENDING)}
52
+ if STATE_NOTES:
53
+ extra["notes"] = list(STATE_NOTES)
54
+ return extra
55
+
56
+
57
+ def unpending_length(durs: List[float], d: float) -> Optional[float]:
58
+ """The joined length the clip durations add up to, or None while an input is pending: its
59
+ dry-run stub says 0 s, which would count the clip as nothing and still subtract a transition
60
+ for it (a negative length when nothing exists yet)."""
61
+ if STATE_PENDING:
62
+ return None
63
+ return round(sum(durs) - d * (len(durs) - 1), 3)
64
+
65
+
66
+ def clip_length(meta: Dict[str, Any], fps: float) -> float:
67
+ """The one length a clip holds in a crossfaded join, for its picture and its sound alike: the
68
+ video stream's, or the audio stream's when the sound runs more than a frame past the picture
69
+ (narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
70
+ a longer track) is only the fallback when a stream length is unknown (a pending input)."""
71
+ v = (meta.get("video") or {}).get("duration")
72
+ a = (meta.get("audio") or {}).get("duration")
73
+ if not v:
74
+ return meta.get("duration") or 0.0
75
+ if a and fps and a > v + 1.0 / fps:
76
+ return a
77
+ return v
78
+
79
+
80
+ def expected_text(expected: Optional[float]) -> str:
81
+ """unpending_length() as the stderr summary line puts it."""
82
+ return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
40
83
 
41
84
 
42
85
  def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
@@ -47,8 +90,11 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
47
90
  for p, dur in zip(args.inputs, durs):
48
91
  if d and dur <= d * 2 and not STATE.dry_run:
49
92
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
50
- rates = [m["audio"].get("sample_rate") or 48000 for m in metas]
51
- chans = [m["audio"].get("channels") or 2 for m in metas]
93
+ # a pending input's probe is the dry-run stub (rate and layout unmeasured): plan from the
94
+ # clips that exist, and fall back to 48 kHz stereo only when none does
95
+ known = [m for m in metas if not m.get("dry_run")] or metas
96
+ rates = [m["audio"].get("sample_rate") or 48000 for m in known]
97
+ chans = [m["audio"].get("channels") or 2 for m in known]
52
98
  rate = args.sample_rate or rates[0]
53
99
  channels = args.channels or max(chans)
54
100
  layout = LAYOUTS.get(channels)
@@ -76,7 +122,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
76
122
  prev = out
77
123
  cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
78
124
  run(cmd)
79
- expected = sum(durs) - d * (n - 1)
125
+ expected = unpending_length(durs, d)
80
126
  r = probe(output, role="output")
81
127
  a = r.get("audio") or {}
82
128
  if not STATE.dry_run:
@@ -84,9 +130,9 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
84
130
  die(f"{output} unexpectedly contains a video stream")
85
131
  if a.get("sample_rate") != rate or a.get("channels") != channels:
86
132
  die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
87
- info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
133
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
88
134
  + ("crossfade" if d else "butt join") + ")")
89
- emit(output, mode="audio", skipped=list(STATE_SKIPPED), clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
135
+ emit(output, mode="audio", **reported(), clips=n, transition=args.transition if d else "none", expected_duration=expected,
90
136
  sample_rate=rate, channels=channels, video=False)
91
137
  return 0
92
138
 
@@ -118,17 +164,20 @@ def read_list(path: str) -> List[str]:
118
164
  return entries
119
165
 
120
166
 
121
- def preflight(paths: List[str]) -> List[Dict[str, Any]]:
167
+ def preflight(paths: List[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
122
168
  """Every problem with every input, found before anything runs: missing, empty (0 bytes,
123
169
  the usual trace of a TTS or download step that failed after creating its file), or not
124
170
  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."""
171
+ does not exist yet is not a problem but pending (the second list, with a note): in a
172
+ planned pipeline it is an earlier step's output, and the plan keeps it."""
126
173
  ffprobe = require_tool("ffprobe")
127
174
  problems: List[Dict[str, Any]] = []
175
+ pending: List[Dict[str, Any]] = []
128
176
  for i, p in enumerate(paths):
177
+ if dry_run_input_pending(p):
178
+ pending.append({"index": i, "path": p})
179
+ continue
129
180
  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
181
  problems.append({"index": i, "path": p, "reason": "missing"})
133
182
  continue
134
183
  if os.path.isdir(p):
@@ -143,7 +192,7 @@ def preflight(paths: List[str]) -> List[Dict[str, Any]]:
143
192
  if proc.returncode != 0 or not kinds & {"audio", "video"}:
144
193
  first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
145
194
  problems.append({"index": i, "path": p, "reason": f"unreadable: {first}"})
146
- return problems
195
+ return problems, pending
147
196
 
148
197
 
149
198
  def main() -> int:
@@ -180,7 +229,7 @@ def main() -> int:
180
229
  if not args.inputs:
181
230
  die("give at least two clips (or --list FILE)")
182
231
  skipped: List[Dict[str, Any]] = []
183
- problems = preflight(args.inputs)
232
+ problems, pending = preflight(args.inputs)
184
233
  if problems and args.on_missing == "fail":
185
234
  listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in problems)
186
235
  die(f"{len(problems)} of {len(args.inputs)} inputs cannot be joined: {listing}",
@@ -192,21 +241,53 @@ def main() -> int:
192
241
  for p in problems:
193
242
  info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
194
243
  STATE_SKIPPED[:] = skipped
244
+ STATE_PENDING[:] = pending
245
+ if pending:
246
+ # a dry run cannot know whether the file will be there: it plans on it, and says what a
247
+ # real run does if it still is not (the same --on-missing rule as any missing input --
248
+ # including the refusal when skipping would leave fewer than two inputs)
249
+ left = len(args.inputs) - len(pending)
250
+ if args.on_missing == "fail":
251
+ then = "refuses the join if one is still missing"
252
+ elif left >= 2:
253
+ then = "skips one that is still missing"
254
+ else:
255
+ then = (f"skips one that is still missing and refuses the join if that leaves fewer than two inputs "
256
+ f"(only {left} exist{'s' if left == 1 else ''} now)")
257
+ STATE_NOTES.append(f"{len(pending)} of {len(args.inputs) + len(skipped)} inputs do not exist yet and were planned on "
258
+ "as an earlier step's output: " + ", ".join(f"#{p['index'] + 1} {p['path']}" for p in pending)
259
+ + f"; a real run {then}")
195
260
  if len(args.inputs) < 2:
196
261
  die("give at least two clips" if not skipped else
197
262
  f"only {len(args.inputs)} usable input(s) left after skipping {len(skipped)}; nothing to join",
198
263
  skipped=skipped)
199
264
  validate_color(args.pad_color, "--pad-color")
200
265
  metas = [probe(p) for p in args.inputs]
201
- if all(not m.get("video") for m in metas):
266
+ # A pending input's probe is the dry-run stub, whose (0x0) video stream says nothing about the
267
+ # file an earlier step will write. Its extension stands in: a step names its output for what
268
+ # it holds, so an audio extension (.wav, .m4a, .aiff, ... -- every audio file this skill
269
+ # reads, not only the ones it writes) is taken for a file without a picture and any other for
270
+ # one with a picture. Measured and pending inputs then meet the same rules a real run
271
+ # applies: all without a picture is an audio join, a mix is refused.
272
+ pictured = [(os.path.splitext(p)[1].lower() not in AUDIO_MEDIA_EXT) if m.get("dry_run") else bool(m.get("video"))
273
+ for p, m in zip(args.inputs, metas)]
274
+ if all(m.get("dry_run") for m in metas):
275
+ STATE_NOTES.append(f"no input exists yet: {'a video' if any(pictured) else 'an audio'} join was planned from the file extensions")
276
+ if not any(pictured):
202
277
  for p, m in zip(args.inputs, metas):
203
278
  if not m.get("audio"):
204
279
  die(f"{p} has neither a video nor an audio stream")
205
280
  return join_audio(args, metas)
206
- for p, m in zip(args.inputs, metas):
207
- if not m.get("video"):
208
- others = [q for q, mm in zip(args.inputs, metas) if mm.get("video")]
209
- die(f"{p} has no video stream" + (f" while {others[0]} has one; join audio with audio or give every clip a picture" if others else ""))
281
+ for p, m, pic in zip(args.inputs, metas, pictured):
282
+ if not pic:
283
+ # name an input measured to have a picture first; a pending one only as expected to
284
+ measured = [q for q, mm in zip(args.inputs, metas) if mm.get("video") and not mm.get("dry_run")]
285
+ planned = [q for q, mm, pc in zip(args.inputs, metas, pictured) if pc and mm.get("dry_run")]
286
+ other = (f"{measured[0]} has one" if measured else
287
+ f"{planned[0]}, which does not exist yet, is expected to have one (its extension is not an audio one)" if planned else "")
288
+ die((f"{p} does not exist yet, and its audio extension says it will have no video stream" if m.get("dry_run")
289
+ else f"{p} has no video stream")
290
+ + (f" while {other}; join audio with audio or give every clip a picture" if other else ""))
210
291
  first = metas[0]["video"]
211
292
  fw, fh = first["width"], first["height"]
212
293
  if first.get("rotation") in (90, -90, 270, -270):
@@ -226,7 +307,10 @@ def main() -> int:
226
307
  w, h = w - (w % 2), h - (h % 2)
227
308
  durs = [m.get("duration") or 0.0 for m in metas]
228
309
  d = args.duration if args.transition != "none" else 0.0
229
- for p, dur in zip(args.inputs, durs):
310
+ # a crossfaded join gives each clip one length for both streams, so the offsets that place
311
+ # every picture also place its sound; the plain cut (concat) keeps the streams paired itself
312
+ lens = [clip_length(m, fps) for m in metas] if d else durs
313
+ for p, dur in zip(args.inputs, lens):
230
314
  if d and dur <= d * 2 and not STATE.dry_run:
231
315
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
232
316
 
@@ -263,8 +347,9 @@ def main() -> int:
263
347
  hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
264
348
  pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
265
349
  # the audio-only join keeps the widest layout; the video join used to force stereo and
266
- # silently dropped the centre/LFE of 5.1 material
267
- chans = [(m.get("audio") or {}).get("channels") or 2 for m in metas]
350
+ # silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
351
+ # layout: plan from the inputs that exist, as join_audio() does.
352
+ chans = [(m.get("audio") or {}).get("channels") or 2 for m in ([m for m in metas if not m.get("dry_run")] or metas)]
268
353
  channels = args.channels or max(chans)
269
354
  layout = LAYOUTS.get(channels)
270
355
  if layout is None:
@@ -279,24 +364,54 @@ def main() -> int:
279
364
  chain = "".join(f"[v{i}][a{i}]" for i in range(n))
280
365
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
281
366
  else:
282
- vprev, aprev = "v0", "a0"
367
+ # pad the shorter stream to the clip's length: the audio with silence, the picture by
368
+ # holding its last frame, then cut both there -- each xfade and acrossfade then starts
369
+ # at the same clip boundary instead of the audio chain drifting by every clip's gap
370
+ for i in range(n):
371
+ vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
372
+ hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
373
+ parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
374
+ parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
375
+ vprev, aprev = "vp0", "ap0"
283
376
  offset = 0.0
284
377
  for i in range(1, n):
285
- offset += durs[i - 1] - d
378
+ offset += lens[i - 1] - d
286
379
  vout = f"vx{i}" if i < n - 1 else "vout"
287
380
  aout = f"ax{i}" if i < n - 1 else "aout"
288
- parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
289
- parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
381
+ parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
382
+ parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
290
383
  vprev, aprev = vout, aout
291
384
 
385
+ # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
386
+ # plausible), and the planned command is built on it: say which numbers are placeholders.
387
+ if metas[0].get("dry_run") and not (args.width and args.height and args.fps):
388
+ STATE_NOTES.append(f"the first input is pending, so the planned frame and rate ({w}x{h} @ {fps:g}fps) are "
389
+ "placeholders: a real run takes them from that file")
390
+ if d and any(m.get("dry_run") for m in metas[:-1]):
391
+ STATE_NOTES.append("the planned xfade offsets count each pending input as 0 s long: a real run offsets "
392
+ "by its measured length")
292
393
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
293
394
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
395
+ if d:
396
+ # the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
397
+ # 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
398
+ # dissolve came out 52), which newer builds no longer do -- pass the frames through as built
399
+ cmd += ["-fps_mode", "passthrough"]
294
400
  cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
295
401
  run(cmd)
296
- expected = sum(durs) - d * (n - 1)
402
+ expected = unpending_length(lens, d)
297
403
  r = probe(output, role="output")
298
- info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
299
- emit(output, mode="video", skipped=list(STATE_SKIPPED), clips=n, transition=args.transition, expected_duration=round(expected, 3),
404
+ # the picture is what the offsets place: measure the video stream, not the container (whose
405
+ # length also counts the AAC tail), against the length the clips add up to
406
+ measured = (r.get("video") or {}).get("duration") or r.get("duration")
407
+ verification = []
408
+ if expected is not None and measured is not None and not STATE.dry_run:
409
+ tol = max(1.5 / fps, 0.05)
410
+ verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
411
+ "measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
412
+ info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
413
+ emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
414
+ verification=verification,
300
415
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
301
416
  return 0
302
417
 
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, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
67
+ from _common import STATE, add_common, aspect_ratio, 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
@@ -394,7 +394,8 @@ def render_pack(names: List[str], args) -> int:
394
394
 
395
395
 
396
396
  def check_keys(obj: Any, schema: str, label: str) -> None:
397
- """Refuse an unrecognised key, naming the object, the key and the nearest valid one."""
397
+ """Refuse an unrecognised key, naming the object, the key and the nearest valid one. A value
398
+ that is not an object is require_object()'s to refuse, and only where the run reads it."""
398
399
  if not isinstance(obj, dict):
399
400
  return
400
401
  valid = OBJECT_KEYS[schema]
@@ -406,25 +407,76 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
406
407
  else f" (valid keys: {', '.join(sorted(valid))})"))
407
408
 
408
409
 
409
- def validate_project(proj: Dict[str, Any]) -> None:
410
+ def require_object(obj: Any, label: str) -> None:
411
+ """Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
412
+ in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
413
+ and nothing on stdout under --json. null, false and {} still ask for nothing."""
414
+ if obj and not isinstance(obj, dict):
415
+ die(f"{label}: must be an object {{...}}, got {type(obj).__name__} {obj!r:.60}")
416
+
417
+
418
+ # the stage that first reads each section; --stop-after before it means the run never reads it
419
+ READ_AT = {"transition": "join", "silence": "silence", "fit": "fit", "captions": "captions",
420
+ "graphics": "graphics", "overlays": "overlays", "loudness": "loudness", "export": "export",
421
+ "check": "check"}
422
+ _READ_ORDER = ["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export", "check"]
423
+
424
+
425
+ def _reaches(stage: str, stop_after: Optional[str]) -> bool:
426
+ return not stop_after or _READ_ORDER.index(stage) <= _READ_ORDER.index(stop_after)
427
+
428
+
429
+ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: Optional[str] = None) -> None:
430
+ """Every project error the run would hit, before the first ffmpeg call. A value that is not
431
+ an object is refused only where this run reads it (require_object()): a full render reads
432
+ every stage's section, a transition only between two or more clips and a clip's snap only
433
+ on a clip it cuts; --export-timeline (`timeline`) reads the clips, frame, audio and a
434
+ transition between clips, and only names the other sections in not_exported. 2.2.1 rendered
435
+ a single clip with "transition": "none" and exported "captions": "subs.srt", reading neither,
436
+ and a patch release does not refuse them."""
410
437
  check_keys(proj, "project", "project")
438
+ clips = proj.get("clips")
439
+ # the join (or the timeline's dissolves) reads the transition; an audiogram render joins nothing
440
+ several = isinstance(clips, list) and len(clips) > 1 and (timeline or not proj.get("audiogram"))
441
+ read = {"frame", "audio"} | ({"transition"} if several else set())
442
+ if not timeline:
443
+ read |= {"silence", "audiogram", "captions", "loudness", "fit", "export", "check", "snap"}
444
+ # --stop-after ends the run before later stages read their sections: 2.2.1 completed a
445
+ # `--stop-after fit` preview of a project with "captions": "subs.srt", never reading it
446
+ read = {n for n in read if n not in READ_AT or _reaches(READ_AT[n], stop_after)}
411
447
  for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check", "snap"):
448
+ if name in read:
449
+ require_object(proj.get(name), name)
412
450
  check_keys(proj.get(name), name, name)
413
451
  check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
414
452
  if isinstance(proj.get("chapters"), list):
415
453
  # Every other project error is raised here, before the first ffmpeg call; a chapter typo
416
454
  # found inside the last stage costs a whole render and leaves an unchaptered file behind.
417
455
  for i, item in enumerate(proj["chapters"]):
418
- check_keys(item, "chapters[]", f"chapters[{i}]")
456
+ if isinstance(item, dict):
457
+ check_keys(item, "chapters[]", f"chapters[{i}]")
419
458
  if not isinstance(item, dict) or item.get("at") is None or not str(item.get("title") or "").strip():
420
459
  die(f'chapters[{i}]: needs {{"at": TIME, "title": STR}}')
421
460
  for name in ("clips", "graphics", "overlays"):
422
461
  items = proj.get(name)
423
- if isinstance(items, list):
424
- for i, item in enumerate(items):
425
- check_keys(item, f"{name}[]", f"{name}[{i}]")
426
- if name == "clips" and isinstance(item, dict) and item.get("snap") is not None:
427
- check_keys(item["snap"], "snap", f"clips[{i}].snap")
462
+ if name == "clips" or (not timeline and _reaches(READ_AT[name], stop_after)): # the export reads only the clips
463
+ if items and not isinstance(items, list):
464
+ die(f"{name}: must be a list of objects [{{...}}], got {type(items).__name__} {items!r:.60}")
465
+ for i, item in enumerate(items or []):
466
+ if not isinstance(item, dict):
467
+ die(f"{name}[{i}]: must be an object {{...}}, got {type(item).__name__} {item!r:.60}")
468
+ for i, item in enumerate(items if isinstance(items, list) else []):
469
+ check_keys(item, f"{name}[]", f"{name}[{i}]")
470
+ if name != "clips":
471
+ continue
472
+ # every clip stage starts from rel(c["src"]): without one it was a KeyError traceback
473
+ if not item.get("src"):
474
+ die(f"clips[{i}]: no src")
475
+ if item.get("snap") is not None:
476
+ # the render reads a clip's snap only on a clip it cuts (in/out), the export never
477
+ if not timeline and (item.get("in") is not None or item.get("out") is not None):
478
+ require_object(item["snap"], f"clips[{i}].snap")
479
+ check_keys(item["snap"], "snap", f"clips[{i}].snap")
428
480
 
429
481
 
430
482
  _LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
@@ -703,7 +755,11 @@ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
703
755
  """Fill frame.width/height from the export preset when the project gave only an aspect.
704
756
  Eval 7 (j08 twice, e01 by hand): "frame": {"aspect": "9:16"} with a reels export fitted a
705
757
  1280x720 source to 406x720, captions were burned at that size, and export.py upscaled them
706
- soft. A preset that names a delivery frame of the same aspect is that frame."""
758
+ soft. A preset that names a delivery frame of the same aspect is that frame.
759
+ The match is 2.2.1's, looser than aspect_ratio(): `16/9` and `1.78:1` still take a 16:9
760
+ preset's size. A render that hands such a frame.aspect to fit.py fails there, as in 2.2.1;
761
+ one whose own fit.aspect replaces it, or that stops before fit, is sized by this match, and
762
+ a patch release does not change a render 2.2.1 completed."""
707
763
  if not frame.get("aspect") or frame.get("width") or frame.get("height"):
708
764
  return
709
765
  preset = PRESETS.get(str(export.get("preset") or ""), {})
@@ -733,8 +789,20 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
733
789
  if not os.path.exists(path):
734
790
  die(f"timeline source not found: {path}")
735
791
  probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
792
+ # the sequence frame is the one the render would deliver: an aspect-only frame takes its size
793
+ # from the export preset exactly as the render's own frame does. An export that is not an
794
+ # object names no preset here: 2.2.1's export never read it, and validate_project() refuses
795
+ # it only in the render, which reads it.
796
+ frame = dict(proj.get("frame") or {})
797
+ export = proj.get("export")
798
+ frame_from_preset(frame, export if isinstance(export, dict) else {})
799
+ fit = proj.get("fit")
800
+ if isinstance(fit, dict) and fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
801
+ # the render hands fit.py the fit object's own aspect, never this one (only the preset
802
+ # match above reads it), and completes: the sequence is the frame's size without it
803
+ del frame["aspect"]
736
804
  try:
737
- tl = tlmod.build(proj, probes, rel)
805
+ tl = tlmod.build(dict(proj, frame=frame), probes, rel)
738
806
  except tlmod.TimelineError as exc:
739
807
  die(f"--export-timeline: {exc}", kind="input")
740
808
  text = tlmod.WRITERS[fmt](tl)
@@ -834,7 +902,7 @@ def main() -> int:
834
902
  if "plan_version" in proj:
835
903
  return execute_plan(proj, os.path.abspath(args.project))
836
904
  base = Path(args.project).resolve().parent
837
- validate_project(proj)
905
+ validate_project(proj, timeline=bool(args.export_timeline), stop_after=args.stop_after)
838
906
  if args.write_project:
839
907
  # Only the filled project: the point is to edit it before rendering, so nothing runs.
840
908
  try:
@@ -1059,9 +1127,21 @@ def main() -> int:
1059
1127
  fit.setdefault("aspect", frame["aspect"])
1060
1128
  if frame.get("fit"):
1061
1129
  fit.setdefault("fit", frame["fit"])
1062
- if frame.get("width") and len(parts) == 1:
1130
+ # Several clips were joined by join.py: into frame.width x frame.height when the frame gives
1131
+ # both, else at the FIRST clip's aspect. A frame of an aspect and ONE side then reached fit.py
1132
+ # as the aspect alone, which fit.py bounds by the joined picture: {aspect 9:16, width 1080}
1133
+ # over two 16:9 clips rendered 342x608, where one clip (and --export-timeline's sequence) is
1134
+ # 1080x1920. Only that case gets the frame's side. A frame with both sides is already the
1135
+ # join's size, and a project fit object with its own width, height or another aspect renders
1136
+ # as 2.2.1 rendered it: filling the frame's sides in there overrode the fit object's aspect
1137
+ # (fit {width 540} under a 1080x1920 frame came out 540x1920).
1138
+ own = proj.get("fit") or {}
1139
+ one_side = bool(frame.get("width")) != bool(frame.get("height"))
1140
+ sized = len(parts) == 1 or bool(frame.get("aspect") and one_side and not own.get("width") and not own.get("height")
1141
+ and own.get("aspect") in (None, frame.get("aspect")))
1142
+ if frame.get("width") and sized:
1063
1143
  fit.setdefault("width", frame["width"])
1064
- if frame.get("height") and len(parts) == 1:
1144
+ if frame.get("height") and sized:
1065
1145
  fit.setdefault("height", frame["height"])
1066
1146
  if frame.get("fps") and len(parts) == 1:
1067
1147
  fit.setdefault("fps", frame["fps"])