ffmpeg-skill 2.2.1 → 2.2.2

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.2`) | 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.2", "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"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
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,7 +563,13 @@ 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` +
@@ -586,13 +592,20 @@ Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, othe
586
592
  OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
587
593
  `LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
588
594
  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.
595
+ track; inline `chapters` are markers. Times take the render's own grammar (`"0:05"`,
596
+ `hh:mm:ss:ff` at the source's fps), and the sequence is the project's `frame` sized by
597
+ fit.py's rule (`{aspect 16:9, width 1920}` is 1920x1080; an aspect alone takes the export
598
+ preset's size when the preset has that aspect; an aspect fit.py refuses, such as `16/9`, is
599
+ refused unless the project's own `fit.aspect` replaces it in the render). A clip of another
600
+ aspect gets its spatial conform in FCPXML (`adjust-conform`: `fill` for `frame.fit: crop`,
601
+ `fit` for pad); EDL and OTIO cannot state one. Everything else in the project -- captions,
602
+ graphics, overlays, the silence cut, fit, a reframe the format cannot state (and `blur`'s
603
+ background), audio processing, loudness, export -- is listed in the result's
604
+ `timeline.not_exported` and on stderr, never dropped silently. Media paths are absolute file URLs to the sources, nothing is
605
+ copied or encoded, and the existing-output rule applies (`--overwrite` to replace). The
606
+ timeline follows the project's numbers exactly; a render of the same project can come out a
607
+ few frames longer, because `join.py` offsets its crossfades by each part's container duration,
608
+ audio included.
596
609
  A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
597
610
  --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
598
611
  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,
@@ -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)}
@@ -451,10 +451,13 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
451
451
  "reencoded": {"type": "boolean"},
452
452
  "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
453
  elif name == "join":
454
- extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
454
+ extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
455
+ "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
456
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
456
457
  "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)"}}
458
+ "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
459
+ "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)"},
460
+ "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
461
  elif name == "audio":
459
462
  extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
460
463
  "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
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,48 @@ 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 expected_text(expected: Optional[float]) -> str:
67
+ """unpending_length() as the stderr summary line puts it."""
68
+ return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
40
69
 
41
70
 
42
71
  def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
@@ -47,8 +76,11 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
47
76
  for p, dur in zip(args.inputs, durs):
48
77
  if d and dur <= d * 2 and not STATE.dry_run:
49
78
  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]
79
+ # a pending input's probe is the dry-run stub (rate and layout unmeasured): plan from the
80
+ # clips that exist, and fall back to 48 kHz stereo only when none does
81
+ known = [m for m in metas if not m.get("dry_run")] or metas
82
+ rates = [m["audio"].get("sample_rate") or 48000 for m in known]
83
+ chans = [m["audio"].get("channels") or 2 for m in known]
52
84
  rate = args.sample_rate or rates[0]
53
85
  channels = args.channels or max(chans)
54
86
  layout = LAYOUTS.get(channels)
@@ -76,7 +108,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
76
108
  prev = out
77
109
  cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
78
110
  run(cmd)
79
- expected = sum(durs) - d * (n - 1)
111
+ expected = unpending_length(durs, d)
80
112
  r = probe(output, role="output")
81
113
  a = r.get("audio") or {}
82
114
  if not STATE.dry_run:
@@ -84,9 +116,9 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
84
116
  die(f"{output} unexpectedly contains a video stream")
85
117
  if a.get("sample_rate") != rate or a.get("channels") != channels:
86
118
  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, "
119
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
88
120
  + ("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),
121
+ emit(output, mode="audio", **reported(), clips=n, transition=args.transition if d else "none", expected_duration=expected,
90
122
  sample_rate=rate, channels=channels, video=False)
91
123
  return 0
92
124
 
@@ -118,17 +150,20 @@ def read_list(path: str) -> List[str]:
118
150
  return entries
119
151
 
120
152
 
121
- def preflight(paths: List[str]) -> List[Dict[str, Any]]:
153
+ def preflight(paths: List[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
122
154
  """Every problem with every input, found before anything runs: missing, empty (0 bytes,
123
155
  the usual trace of a TTS or download step that failed after creating its file), or not
124
156
  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."""
157
+ does not exist yet is not a problem but pending (the second list, with a note): in a
158
+ planned pipeline it is an earlier step's output, and the plan keeps it."""
126
159
  ffprobe = require_tool("ffprobe")
127
160
  problems: List[Dict[str, Any]] = []
161
+ pending: List[Dict[str, Any]] = []
128
162
  for i, p in enumerate(paths):
163
+ if dry_run_input_pending(p):
164
+ pending.append({"index": i, "path": p})
165
+ continue
129
166
  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
167
  problems.append({"index": i, "path": p, "reason": "missing"})
133
168
  continue
134
169
  if os.path.isdir(p):
@@ -143,7 +178,7 @@ def preflight(paths: List[str]) -> List[Dict[str, Any]]:
143
178
  if proc.returncode != 0 or not kinds & {"audio", "video"}:
144
179
  first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
145
180
  problems.append({"index": i, "path": p, "reason": f"unreadable: {first}"})
146
- return problems
181
+ return problems, pending
147
182
 
148
183
 
149
184
  def main() -> int:
@@ -180,7 +215,7 @@ def main() -> int:
180
215
  if not args.inputs:
181
216
  die("give at least two clips (or --list FILE)")
182
217
  skipped: List[Dict[str, Any]] = []
183
- problems = preflight(args.inputs)
218
+ problems, pending = preflight(args.inputs)
184
219
  if problems and args.on_missing == "fail":
185
220
  listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in problems)
186
221
  die(f"{len(problems)} of {len(args.inputs)} inputs cannot be joined: {listing}",
@@ -192,21 +227,53 @@ def main() -> int:
192
227
  for p in problems:
193
228
  info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
194
229
  STATE_SKIPPED[:] = skipped
230
+ STATE_PENDING[:] = pending
231
+ if pending:
232
+ # a dry run cannot know whether the file will be there: it plans on it, and says what a
233
+ # real run does if it still is not (the same --on-missing rule as any missing input --
234
+ # including the refusal when skipping would leave fewer than two inputs)
235
+ left = len(args.inputs) - len(pending)
236
+ if args.on_missing == "fail":
237
+ then = "refuses the join if one is still missing"
238
+ elif left >= 2:
239
+ then = "skips one that is still missing"
240
+ else:
241
+ then = (f"skips one that is still missing and refuses the join if that leaves fewer than two inputs "
242
+ f"(only {left} exist{'s' if left == 1 else ''} now)")
243
+ STATE_NOTES.append(f"{len(pending)} of {len(args.inputs) + len(skipped)} inputs do not exist yet and were planned on "
244
+ "as an earlier step's output: " + ", ".join(f"#{p['index'] + 1} {p['path']}" for p in pending)
245
+ + f"; a real run {then}")
195
246
  if len(args.inputs) < 2:
196
247
  die("give at least two clips" if not skipped else
197
248
  f"only {len(args.inputs)} usable input(s) left after skipping {len(skipped)}; nothing to join",
198
249
  skipped=skipped)
199
250
  validate_color(args.pad_color, "--pad-color")
200
251
  metas = [probe(p) for p in args.inputs]
201
- if all(not m.get("video") for m in metas):
252
+ # A pending input's probe is the dry-run stub, whose (0x0) video stream says nothing about the
253
+ # file an earlier step will write. Its extension stands in: a step names its output for what
254
+ # it holds, so an audio extension (.wav, .m4a, .aiff, ... -- every audio file this skill
255
+ # reads, not only the ones it writes) is taken for a file without a picture and any other for
256
+ # one with a picture. Measured and pending inputs then meet the same rules a real run
257
+ # applies: all without a picture is an audio join, a mix is refused.
258
+ pictured = [(os.path.splitext(p)[1].lower() not in AUDIO_MEDIA_EXT) if m.get("dry_run") else bool(m.get("video"))
259
+ for p, m in zip(args.inputs, metas)]
260
+ if all(m.get("dry_run") for m in metas):
261
+ STATE_NOTES.append(f"no input exists yet: {'a video' if any(pictured) else 'an audio'} join was planned from the file extensions")
262
+ if not any(pictured):
202
263
  for p, m in zip(args.inputs, metas):
203
264
  if not m.get("audio"):
204
265
  die(f"{p} has neither a video nor an audio stream")
205
266
  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 ""))
267
+ for p, m, pic in zip(args.inputs, metas, pictured):
268
+ if not pic:
269
+ # name an input measured to have a picture first; a pending one only as expected to
270
+ measured = [q for q, mm in zip(args.inputs, metas) if mm.get("video") and not mm.get("dry_run")]
271
+ planned = [q for q, mm, pc in zip(args.inputs, metas, pictured) if pc and mm.get("dry_run")]
272
+ other = (f"{measured[0]} has one" if measured else
273
+ f"{planned[0]}, which does not exist yet, is expected to have one (its extension is not an audio one)" if planned else "")
274
+ die((f"{p} does not exist yet, and its audio extension says it will have no video stream" if m.get("dry_run")
275
+ else f"{p} has no video stream")
276
+ + (f" while {other}; join audio with audio or give every clip a picture" if other else ""))
210
277
  first = metas[0]["video"]
211
278
  fw, fh = first["width"], first["height"]
212
279
  if first.get("rotation") in (90, -90, 270, -270):
@@ -263,8 +330,9 @@ def main() -> int:
263
330
  hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
264
331
  pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
265
332
  # 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]
333
+ # silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
334
+ # layout: plan from the inputs that exist, as join_audio() does.
335
+ 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
336
  channels = args.channels or max(chans)
269
337
  layout = LAYOUTS.get(channels)
270
338
  if layout is None:
@@ -289,14 +357,22 @@ def main() -> int:
289
357
  parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
290
358
  vprev, aprev = vout, aout
291
359
 
360
+ # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
361
+ # plausible), and the planned command is built on it: say which numbers are placeholders.
362
+ if metas[0].get("dry_run") and not (args.width and args.height and args.fps):
363
+ STATE_NOTES.append(f"the first input is pending, so the planned frame and rate ({w}x{h} @ {fps:g}fps) are "
364
+ "placeholders: a real run takes them from that file")
365
+ if d and any(m.get("dry_run") for m in metas[:-1]):
366
+ STATE_NOTES.append("the planned xfade offsets count each pending input as 0 s long: a real run offsets "
367
+ "by its measured length")
292
368
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
293
369
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
294
370
  cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
295
371
  run(cmd)
296
- expected = sum(durs) - d * (n - 1)
372
+ expected = unpending_length(durs, d)
297
373
  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),
374
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
375
+ emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
300
376
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
301
377
  return 0
302
378
 
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"])