ffmpeg-skill 3.1.5 → 3.1.6

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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.1.5`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.6`) | 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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.1.5", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.6", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -886,7 +886,8 @@ present on success and failure documents alike, only when one list is not empty,
886
886
  `render.py` stage reported carries `stage`. `contract --json` → `json_output.diagnostic_codes`
887
887
  lists every code with its bucket and meaning (a test reads every `warn()`/`record_fallback()`/
888
888
  `record_skip()` call against it). Among them: the runner's `odd_dimensions_scaled_even` (an encode
889
- re-run with an even scale; the frame loses a row or column), `non_av_streams_dropped` (with the
889
+ re-run with an even scale, or broll.py's odd-sized A-roll scaled even up front; the frame loses a
890
+ row or column), `non_av_streams_dropped` (with the
890
891
  first attempt's error beside `dropped_non_av_streams`), `measurement_input_pending` (skipped: a
891
892
  dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ffmpeg -encoders` or
892
893
  `-bsfs` could not be read; what needed it names the read failure, not a missing encoder) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.1.5",
3
+ "version": "3.1.6",
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",
@@ -178,8 +178,9 @@ these had ever shown up before.
178
178
  pads audio should do the same.
179
179
  - **FFmpeg 8.0 and 8.0.1's `aselect` passes every frame through** (its `activate` callback was lost
180
180
  in the filter conversion; fixed in 8.0.2/8.1). Nothing fails: the picture is cut and the sound is
181
- not. silence.py tags the kept frames with `ametadata=mode=add:...:enable='<expr>'` and keeps them with
182
- `ametadata=mode=select`; do not use `aselect` for removal.
181
+ not. Do not use `aselect` for removal: cut each range with `atrim` (silence.py re-cuts its ranges
182
+ through cut.py's `--segments --accurate` path; through 3.1.3 it tagged the kept frames with
183
+ `ametadata=mode=add` and kept them with `ametadata=mode=select`).
183
184
  - **FFmpeg 8.0, 8.0.1 and 8.1.0 draw the font's missing-glyph box at each line break of a multi-line
184
185
  `drawtext`** (each line is shaped with its newline; fixed in 8.0.2/8.1.1). `ffmpeg_version()` cannot
185
186
  tell 8.1.0 from 8.1.1; `drawtext_draws_newline_glyph()` reads the point release. graphics.py routes
@@ -319,7 +319,11 @@ the stored width and height plus that rotation. A tool that places or measures
319
319
  a rectangle (`fit.py`, `crop.py`, `cropdetect.py`, `broll.py`, `overlay.py
320
320
  --scale-percent` / `--platform`, `look.py --safe`) uses the displayed size,
321
321
  the two swapped for 90° and 270°, because ffmpeg applies the tag before the
322
- filter graph.
322
+ filter graph. A JPEG's EXIF Orientation counts as that rotation too. Footage that
323
+ stores non-square pixels (DV, HDV/XDCAM 1440x1080, DVD) is reported with its
324
+ `sample_aspect_ratio`; every tool that sizes, places or draws on the picture
325
+ first scales it to square pixels at its display aspect (its height kept, a
326
+ `notes` line says so), so rectangles and margins are in that displayed frame.
323
327
 
324
328
  ## Planning
325
329
 
@@ -513,7 +513,8 @@ freeze.py INPUT --hold T [--at T] [--mode insert|extend] [-o OUT]
513
513
  how long the freeze lasts. `--mode insert` (default) inserts the hold at
514
514
  `--at`, pushing everything after it later by `--hold` seconds. `--mode
515
515
  extend` only works with `--at` at (or past) the clip's end and just makes
516
- the last frame last `--hold` seconds longer, with nothing pushed. Audio is
516
+ the last frame last `--hold` seconds longer, with nothing pushed; `--mode insert` at
517
+ the clip's end (no `--at`) does the same. Audio is
517
518
  silent during the held frame in `--mode insert` (there is no source audio
518
519
  for a frozen moment that didn't exist before). `--mode insert` drops a
519
520
  subtitle/data track rather than copy it with timestamps that no longer match
@@ -650,9 +651,9 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|z
650
651
  [--model MODEL] [--filler-list] [--max-cuts 400]
651
652
  ```
652
653
  Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
653
- gaps shorter than `--min-silence`, and re-encodes once: `select` for the picture
654
- (frame accurate), the same ranges through `ametadata` for the sound (not `aselect`, which passes every frame
655
- through on FFmpeg 8.0 and 8.0.1). `--list` prints silences, kept ranges and seconds removed
654
+ gaps shorter than `--min-silence`, and re-encodes the kept ranges the way `cut.py --segments
655
+ --accurate` does: both streams cut on the same frame times, so the sound never drifts from the
656
+ picture at the cuts, and a sound that starts late keeps its offset. `--list` prints silences, kept ranges and seconds removed
656
657
  without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
657
658
  so the user can edit the list by hand. Quiet rooms need `--threshold -40`
658
659
  to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
@@ -70,7 +70,8 @@ from _common.emit import (
70
70
  from _common.probe import (
71
71
  analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
72
72
  _fraction, keyframes_near, classify_frame_timing, measure_frame_timing, SCORE_RE, SIL_RE, decode_gray_frames,
73
- measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
73
+ measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output,
74
+ display_size, pixel_aspect, square_pixels, square_pixels_note
74
75
  )
75
76
  from _common.decision import (
76
77
  aac_args, add_pad_fill_args, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
@@ -84,7 +85,7 @@ from _common.decision import (
84
85
  _onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
85
86
  brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
86
87
  fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
87
- signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, matrix_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
88
+ signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
88
89
  )
89
90
  from _common.asr import (
90
91
  ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
@@ -93,7 +94,7 @@ from _common.asr import (
93
94
  )
94
95
  from _common.color import (
95
96
  bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, color_hex, _COLOR_TOKEN_RE, drawn_stream_format, fold_bt709_matrix,
96
- is_neutral_color, is_rgb, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
97
+ as_limited, is_full_range, is_neutral_color, is_rgb, limited_range_filter, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
97
98
  )
98
99
  from _common.text import (
99
100
  ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align, drawtext_fontfile,
@@ -215,7 +216,7 @@ __all__ = [
215
216
  "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp", "still_input_args",
216
217
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
217
218
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
218
- "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
219
+ "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "layer_base_chain", "matrix_chain", "range_chain", "as_limited", "is_full_range", "limited_range_filter", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
219
220
  "_x264_raw", "_ZWJ",
220
221
  "absorb_stage", "audit", "note", "_plan_unbound_inputs", "record_fallback", "record_skip", "refuse", "warn",
221
222
  "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
@@ -229,5 +230,6 @@ __all__ = [
229
230
  "PENALTY_IDEOGRAPHS", "PENALTY_NEUTRAL", "PENALTY_OKURIGANA", "PENALTY_PARTICLE", "PENALTY_SENTENCE_END",
230
231
  "_rebalance", "_rebalance_phrase", "SAFE_WIDTH_FRACTION", "_split_hyphens", "_slice_atom", "_particle_ends",
231
232
  "_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES",
232
- "fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION", "ass_units_local"
233
+ "fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION", "ass_units_local",
234
+ "display_size", "pixel_aspect", "square_pixels", "square_pixels_note"
233
235
  ]
@@ -153,6 +153,45 @@ def is_neutral_color(value: Any) -> bool:
153
153
  return False
154
154
 
155
155
 
156
+ def is_full_range(meta: Optional[Dict[str, Any]]) -> bool:
157
+ """True when the probed YUV picture is full range: a `yuvj` format, or any other YUV format
158
+ tagged `pc` (VP9 and AV1 full range decode as plain `yuv420p` tagged pc, 10-bit full range as
159
+ `yuv420p10le` tagged pc). An RGB picture is not: bt709_matrix_filter() makes it limited YUV."""
160
+ v = (meta or {}).get("video") or {}
161
+ if is_rgb(meta) or not v.get("pix_fmt"):
162
+ return False
163
+ return str(v["pix_fmt"]).startswith("yuvj") or v.get("color_range") == "pc"
164
+
165
+
166
+ def limited_range_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
167
+ """The filter that takes a full-range SDR YUV picture (is_full_range) to limited range, or None
168
+ for any other picture (limited, RGB, HDR/BT.2020, no probe).
169
+
170
+ A tool that puts several pictures in one frame or one stream (concat, xfade, xstack, overlay)
171
+ passes their samples through as they are, so a full-range clip beside a limited one was shown
172
+ with the other's range: C03028 came out C9251B (#410). The input range is left to `scale`,
173
+ which reads it from each frame (FFmpeg 6.1 already converts a full-range non-`yuvj` frame to
174
+ limited at any resize, so a stated `in_range=pc` would convert some frames twice); only the
175
+ output range is stated. A `yuvj` format is full range by name, so it is also given its limited
176
+ name (`format=`), or swscale would write full range under the limited tag."""
177
+ v = (meta or {}).get("video") or {}
178
+ if v.get("bt2020_or_hdr") or not is_full_range(meta):
179
+ return None
180
+ pf = str(v["pix_fmt"])
181
+ return "scale=out_range=tv" + (f",format=yuv{pf[4:]}" if pf.startswith("yuvj") else "")
182
+
183
+
184
+ def as_limited(meta: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
185
+ """`meta` as it reads after limited_range_filter(): the same probe with a limited-range format and
186
+ tag, for the code that plans what is drawn on the converted picture (drawn_stream_format)."""
187
+ if not limited_range_filter(meta):
188
+ return meta
189
+ v = dict((meta or {}).get("video") or {})
190
+ pf = str(v["pix_fmt"])
191
+ v.update(pix_fmt="yuv" + pf[4:] if pf.startswith("yuvj") else pf, color_range="tv")
192
+ return {**(meta or {}), "video": v}
193
+
194
+
156
195
  def drawn_stream_format(meta: Optional[Dict[str, Any]]) -> "Tuple[str, bool]":
157
196
  """(pix_fmt, full range) of the picture a tool draws on, for rgb_island(): the probed source's own
158
197
  8-bit planar YUV format (an RGB source has become yuv444p, BT.709 limited, through
@@ -161,7 +200,7 @@ def drawn_stream_format(meta: Optional[Dict[str, Any]]) -> "Tuple[str, bool]":
161
200
  if is_rgb(meta):
162
201
  return "yuv444p", False
163
202
  pf = str(v.get("pix_fmt") or "yuv420p")
164
- full = pf.startswith("yuvj") or v.get("color_range") == "pc"
203
+ full = is_full_range(meta)
165
204
  if pf not in ("yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p"):
166
205
  pf = "yuv420p"
167
206
  return pf, full
@@ -14,8 +14,8 @@ import argparse
14
14
  from fractions import Fraction
15
15
  from pathlib import Path
16
16
  from typing import Any, Dict, List, Optional, Sequence, Tuple
17
- from _common.color import (bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
18
- is_neutral_color, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
17
+ from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
18
+ is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
19
19
  from _common.emit import die, record_fallback, refuse, warn
20
20
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
21
21
 
@@ -199,17 +199,60 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
199
199
  return total
200
200
 
201
201
 
202
- def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
202
+ def time_arg(value: str, flag: str, fps: Optional[float] = None,
203
+ source: Optional[Dict[str, Any]] = None) -> float:
203
204
  """parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
204
205
  the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
205
- `--json` callers get a failure document, never a traceback)."""
206
+ `--json` callers get a failure document, never a traceback). `source` is the probe of the
207
+ input the time is in when that is not the first one (a B-roll's --from, an overlay layer's
208
+ --place range): the SMPTE hint then names that input's start timecode and length."""
206
209
  try:
207
- return parse_time(value, fps)
210
+ seconds = parse_time(value, fps)
208
211
  except MissingFpsError as e:
209
212
  die(f"{flag} {value!r}: {e}")
210
213
  except ValueError as e:
211
214
  die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the source's fps or with an explicit @fps suffix)")
212
- return 0.0 # unreachable
215
+ _remember_smpte_hint(value, flag, fps, seconds, source)
216
+ return seconds
217
+
218
+
219
+ def _remember_smpte_hint(value: str, flag: str, fps: Optional[float], seconds: float,
220
+ source: Optional[Dict[str, Any]] = None) -> None:
221
+ """An hh:mm:ss:ff time counts from the file's first frame (00:00:00:00), not from a camera's
222
+ start timecode (docs/design-decisions.md, time grammar). When one reads past the end of a
223
+ source that carries a start timecode it was almost certainly typed off the source's own
224
+ burned-in or NLE timecode: keep a hint naming the frame it would be from the source's start,
225
+ which die() adds to the refusal a tool then raises for the out-of-range time (#411). The
226
+ grammar itself, and what a valid time means, are unchanged. A time in another input than the
227
+ first is judged against that input's own timecode and length (#416 review)."""
228
+ if source is not None:
229
+ own = (source.get("timecode") or {}).get("value")
230
+ tc = (own, source.get("duration")) if own else None
231
+ else:
232
+ tc = getattr(STATE, "source_timecode", None)
233
+ text = value.strip().rpartition("@")[0] if "@" in value else value.strip()
234
+ if not tc or len(text.split(":")) != 4:
235
+ return
236
+ start_tc, duration = tc
237
+ rate = fps
238
+ if "@" in value:
239
+ try:
240
+ rate = float(value.rpartition("@")[2])
241
+ except ValueError:
242
+ return
243
+ try:
244
+ start = parse_time(re.sub(r"[;.](\d+)$", r":\1", start_tc), rate)
245
+ except ValueError:
246
+ return
247
+ if not duration or seconds <= duration or seconds < start:
248
+ return
249
+ within = seconds - start
250
+ where = (f"{flag} {text} is {fmt_smpte_time(within, rate)} from the file's start"
251
+ if within <= duration else f"{flag} {text} is past the end of this {duration:.3f} s file even so")
252
+ prefix = (f"hh:mm:ss:ff times count from the file's first frame (00:00:00:00), not from the source's "
253
+ f"start timecode {start_tc}: ")
254
+ before = STATE.smpte_hint[len(prefix):] + "; " if STATE.smpte_hint and STATE.smpte_hint.startswith(prefix) else ""
255
+ STATE.smpte_hint = prefix + before + where
213
256
 
214
257
 
215
258
  def signed_time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
@@ -291,6 +334,97 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
291
334
  return p
292
335
 
293
336
 
337
+ # ------------------------------------------------------- both streams on one clock (#409)
338
+ #
339
+ # ffmpeg shifts every stream of an input by one constant (the file's start_time), so a filter graph
340
+ # sees a sound that starts 0.3 s after its picture at 0.3. A per-stream `PTS-STARTPTS` moved that
341
+ # sound 0.3 s early. cut.py's re-cut is the reference: both streams are cut on the same bounds,
342
+ # shifted by the same constant, and the audio is padded to the origin
343
+ # (docs/design-decisions.md, "Both streams of a segment shift by one constant").
344
+
345
+ def stream_starts(meta: Optional[Dict[str, Any]]) -> Tuple[float, float]:
346
+ """Where the video and the audio start on the clock a filter graph reads the input on (each
347
+ stream's start_time minus the file's); 0.0 for a stream whose start is unknown."""
348
+ from _common.emit import file_origin
349
+ meta = meta or {}
350
+ origin = file_origin(meta)
351
+ out = []
352
+ for kind in ("video", "audio"):
353
+ start = (meta.get(kind) or {}).get("start_time")
354
+ out.append(float(start) - origin if isinstance(start, (int, float)) else 0.0)
355
+ return out[0], out[1]
356
+
357
+
358
+ def frame_rate(meta: Optional[Dict[str, Any]]) -> float:
359
+ """The video's frame rate as cut.py's grid takes it: r_frame_rate, else fps; 0.0 when unknown."""
360
+ v = (meta or {}).get("video") or {}
361
+ try:
362
+ num, _, den = str(v.get("r_frame_rate") or "").partition("/")
363
+ rate = float(num) / float(den or 1)
364
+ except (ValueError, ZeroDivisionError):
365
+ rate = 0.0
366
+ if not 0 < rate <= 240: # a time base (90000/1), not a rate
367
+ rate = float(v.get("fps") or 0.0)
368
+ return rate
369
+
370
+
371
+ def frame_durations(meta: Optional[Dict[str, Any]], rate: float) -> str:
372
+ """An `fps` filter at the video's own rate (r_frame_rate as a fraction when it is one), the
373
+ last filter of a graph whose frames sit on that rate's grid: it moves none of them, and gives
374
+ each frame its duration back. From FFmpeg 7.0 setpts sets every frame's duration to 0 and
375
+ leaves the frame rate unknown, so the last frame of a graph that ends after a setpts (av_trim)
376
+ was written with no duration and the picture read a frame short (3.967 s for 120 frames)."""
377
+ v = (meta or {}).get("video") or {}
378
+ try:
379
+ q = Fraction(str(v.get("r_frame_rate") or ""))
380
+ except (ValueError, ZeroDivisionError):
381
+ q = None
382
+ if q is not None and abs(float(q) - rate) < 1e-6:
383
+ return f"fps={q.numerator}/{q.denominator}"
384
+ return f"fps={rate:.6f}"
385
+
386
+
387
+ def grid_at_or_after(t: float, v0: float, rate: float) -> float:
388
+ """The time of the first frame at or after `t` on a constant-rate grid starting at `v0`."""
389
+ if not rate:
390
+ return t
391
+ return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
392
+
393
+
394
+ def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bool = False) -> Tuple[str, str]:
395
+ """The video and the audio filters that cut one segment [start, end) of the graph clock out of
396
+ both streams and shift both by the same constant, so an offset between them survives; `end`
397
+ None runs to the stream's end. The picture's trim sits half a frame before each bound (the
398
+ bounds are frame times: grid_at_or_after), so no FFmpeg version rounds a boundary frame in or
399
+ out; the audio is padded to the segment's origin when it starts later. `fill_gaps` also fills
400
+ every later gap in the sound with silence, however short (aresample's default only fills one
401
+ longer than 0.1 s and stretches the sound by a sample a second for the rest)."""
402
+ half = 0.5 / rate if rate else 0.0
403
+ pad = "aresample=async=1:min_hard_comp=0.001:first_pts=0" if fill_gaps else "aresample=async=1:first_pts=0"
404
+ vend = f":end={end - half:.6f}" if end is not None else ""
405
+ aend = f":end={end:.6f}" if end is not None else ""
406
+ return (f"trim=start={start - half:.6f}{vend},setpts=PTS-{start:.6f}/TB",
407
+ f"atrim=start={start:.6f}{aend},asetpts=PTS-{start:.6f}/TB,{pad}")
408
+
409
+
410
+ def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
411
+ """The one length a clip holds once both streams start at its first frame (av_trim from the
412
+ video's start): the video's, or where the sound ends when it runs more than a frame past the
413
+ picture (narration is never cut; an AAC tail adds no frame). The container duration is only the
414
+ fallback when the video's length is unknown (a pending input)."""
415
+ meta = meta or {}
416
+ v = (meta.get("video") or {}).get("duration")
417
+ a = (meta.get("audio") or {}).get("duration")
418
+ if not v:
419
+ return meta.get("duration") or 0.0
420
+ if a:
421
+ v0, a0 = stream_starts(meta)
422
+ a = a + a0 - v0 # where the sound ends, counted from the first frame
423
+ if a and fps and a > v + 1.0 / fps:
424
+ return a
425
+ return v
426
+
427
+
294
428
  def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> List[str]:
295
429
  """Force a constant frame rate on output when the source looks VFR (or fps is given).
296
430
 
@@ -661,6 +795,28 @@ def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, An
661
795
  return f + "," if f else ""
662
796
 
663
797
 
798
+ def range_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
799
+ """For a picture that a mixing filter (concat, xfade, xstack, overlay) puts beside or under
800
+ another, encoded through video_args(line_meta): the conversion to limited range its chain
801
+ takes after its matrix_chain()/label_chain() ("<filter>," -- limited_range_filter), or "" (a
802
+ limited, RGB or HDR picture, or a line that does not tag BT.709). Every SDR picture such a
803
+ tool mixes is then limited range, whatever each one was: the filters mix samples, not colours."""
804
+ if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
805
+ return ""
806
+ f = limited_range_filter(meta)
807
+ return f + "," if f else ""
808
+
809
+
810
+ def layer_base_chain(meta: Optional[Dict[str, Any]]) -> str:
811
+ """The chain in front of the picture `meta` probes where converted RGB layers (rgb_chain_to_bt709:
812
+ a still, an emoji PNG, a box of chroma) are overlaid on it: an untagged picture's BT.709 label
813
+ (label_chain) and, for a full-range one, its conversion to the limited range the layers are
814
+ made in (range_chain). "" when the line converts no layer (HDR, prores)."""
815
+ if not rgb_chain_to_bt709(meta):
816
+ return ""
817
+ return label_chain(meta, meta) + range_chain(meta, meta)
818
+
819
+
664
820
  def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva420p") -> str:
665
821
  """For a picture the tool's own graph has made RGB(A) -- a still's `format=rgba` chain
666
822
  (overlay.py --image, an emoji PNG) or an RGBA composite (waveform.py's --image plate) -- that
@@ -695,14 +851,25 @@ def drawn_chain(colour_chain: str, meta: Optional[Dict[str, Any]], colours: Sequ
695
851
 
696
852
  Unchanged: a chain whose colours are all greys (the same YUV under every matrix: the default
697
853
  black pads and white text), a line that does not tag BT.709 (HDR, prores). `labelled`: the
698
- caller's graph already labels this picture (label_chain()) earlier in the same chain."""
854
+ caller's graph already labels this picture (label_chain()) earlier in the same chain.
855
+
856
+ Before 7.1 `ff_draw` also takes the range from the pixel format alone: full for `yuvj`, limited
857
+ for every other, so on a full-range picture in a plain format (VP9/AV1 full range, 10-bit full
858
+ range) even black and white were drawn limited (pad black read 101010, #410), and 6.1's scale
859
+ has often made such a picture limited before the drawing anyway. There the picture is first
860
+ taken to limited range (range_chain(), which reads the range from the frames), and the
861
+ drawing, the colours and rgb_island() all work in limited range. 7.1+ draws in the link's
862
+ range and is left as it is."""
699
863
  line = meta if line_meta == "same" else line_meta
864
+ pre = ""
865
+ if ffmpeg_version() < (7, 1) and not str(((meta or {}).get("video") or {}).get("pix_fmt") or "").startswith("yuvj"):
866
+ pre = range_chain(meta, line)
700
867
  if not draws_chroma(colours, line):
701
- return colour_chain
868
+ return pre + colour_chain
702
869
  if ffmpeg_version() >= (7, 1):
703
870
  return ("" if labelled else label_chain(meta, line)) + colour_chain
704
- pix_fmt, full = drawn_stream_format(meta)
705
- return rgb_island(colour_chain, neutral_chain, pix_fmt, full, tag)
871
+ pix_fmt, full = drawn_stream_format(as_limited(meta) if pre else meta)
872
+ return pre + rgb_island(colour_chain, neutral_chain, pix_fmt, full, tag)
706
873
 
707
874
 
708
875
  def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
@@ -79,6 +79,8 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
79
79
 
80
80
  `error_extra` goes inside `error` (refuse() passes `reason` and `details` there); the
81
81
  collector's records (warn() and friends) are merged into the document as emit() merges them."""
82
+ if extra.get("hint") is None and kind == "input" and getattr(ctx or STATE, "smpte_hint", None):
83
+ extra["hint"] = (ctx or STATE).smpte_hint # an hh:mm:ss:ff flag read past a timecoded source's end (#411)
82
84
  hint = neutralize(extra.pop("hint", None))
83
85
  msg = neutralize(msg)
84
86
  ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
@@ -38,7 +38,8 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
38
38
  *, check: bool = True) -> List[float]:
39
39
  """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
40
40
  single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
41
- sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
41
+ sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
42
+ The samples are on the file's clock: a sound that starts late is padded at its head."""
42
43
  ffmpeg = require_tool("ffmpeg")
43
44
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
44
45
  if start:
@@ -46,7 +47,10 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
46
47
  cmd += ["-i", path]
47
48
  if seconds is not None:
48
49
  cmd += ["-t", f"{seconds:.3f}"]
49
- cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
50
+ # first_pts=0 pads a sound that starts after the file's start (or after `start`) with silence,
51
+ # so sample i is at start + i / sample_rate on the file's clock: sync.py measured a track that
52
+ # starts 0.3 s late 0.279 s off, and --replace-audio placed it that far late (#409)
53
+ cmd += ["-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
50
54
  proc = run_analysis(cmd, check=False, text=False)
51
55
  if proc.returncode != 0 or not proc.stdout:
52
56
  if check:
@@ -284,6 +288,12 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
284
288
  rotation = int(video["tags"]["rotate"])
285
289
  except ValueError:
286
290
  pass
291
+ if not rotation and _is_still(fmt, video):
292
+ # an EXIF Orientation reaches ffmpeg as the decoded frame's display matrix, not as
293
+ # stream side data, so -show_streams has no rotation for a phone's portrait JPEG;
294
+ # ffmpeg still turns the picture upright on decode, and every tool that sizes the
295
+ # frame has to know it did (#411)
296
+ rotation = _still_rotation(path)
287
297
  pix = video.get("pix_fmt") or ""
288
298
  trc = video.get("color_transfer") or ""
289
299
  prim = video.get("color_primaries") or ""
@@ -301,6 +311,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
301
311
  "width": w,
302
312
  "height": h,
303
313
  "display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
314
+ # the stored pixel's shape (DV, HDV 1440x1080, DVD and XDCAM store non-square pixels);
315
+ # every tool that sizes or draws on the picture works on square pixels at the display
316
+ # aspect (square_pixels() / display_size() below)
317
+ "sample_aspect_ratio": _sar_string(video.get("sample_aspect_ratio")),
304
318
  "fps": round(fps, 3) if fps else None,
305
319
  "r_frame_rate": video.get("r_frame_rate"),
306
320
  "avg_frame_rate": video.get("avg_frame_rate"),
@@ -369,6 +383,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
369
383
  if role == "input" and getattr(STATE, "first_input", None) is None:
370
384
  STATE.first_input = (path, len(out.get("audio_streams") or []),
371
385
  (out.get("video") or {}).get("hdr10_static"))
386
+ if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
387
+ STATE.source_timecode = (out["timecode"]["value"], duration)
372
388
  return out
373
389
 
374
390
 
@@ -658,6 +674,83 @@ def _fraction(v: Optional[str]) -> Optional[Fraction]:
658
674
  return None
659
675
 
660
676
 
677
+ def _sar_string(v: Optional[str]) -> str:
678
+ """ffprobe's sample_aspect_ratio as "N:D", "1:1" when unset (0:1, N/A, missing)."""
679
+ f = _fraction((v or "").replace(":", "/"))
680
+ return f"{f.numerator}:{f.denominator}" if f else "1:1"
681
+
682
+
683
+ def pixel_aspect(video: Optional[Dict[str, Any]]) -> Fraction:
684
+ """The stored pixel's width over its height (the SAR); 1 when the stream does not say."""
685
+ return _fraction(str((video or {}).get("sample_aspect_ratio") or "").replace(":", "/")) or Fraction(1)
686
+
687
+
688
+ def _square_size(video: Optional[Dict[str, Any]]) -> "Tuple[int, int, Fraction]":
689
+ """(width, height, SAR) of the frame the filter graph receives: rotation applied (ffmpeg
690
+ autorotates on decode, and its transpose turns the SAR over too), pixels not yet squared."""
691
+ video = video or {}
692
+ w, h = int(video.get("width") or 0), int(video.get("height") or 0)
693
+ sar = pixel_aspect(video)
694
+ if video.get("rotation") in (90, -90, 270, -270):
695
+ w, h, sar = h, w, 1 / sar
696
+ return w, h, sar
697
+
698
+
699
+ def display_size(video: Optional[Dict[str, Any]]) -> "Tuple[int, int]":
700
+ """The displayed frame in square pixels: rotation applied, and a non-square-pixel picture
701
+ widened or narrowed to its display aspect at its own height (1440x1080 at 4:3 is 1920x1080,
702
+ 720x480 at 8:9 is 640x480). It is the frame square_pixels() hands the rest of a graph, so a
703
+ tool sizes, places and draws in it. 0x0 when unknown (a dry run's pending input)."""
704
+ w, h, sar = _square_size(video)
705
+ if w and h and sar != 1:
706
+ w = max(2, int(round(w * sar / 2)) * 2)
707
+ return w, h
708
+
709
+
710
+ def square_pixels(video: Optional[Dict[str, Any]]) -> str:
711
+ """The filter that makes a non-square-pixel picture square at its display aspect
712
+ ("scale=640:360,setsar=1"), or "" when the pixels are square already. A tool that scales,
713
+ crops, places or draws starts its video chain with it, so every such tool agrees on one
714
+ frame (display_size()) and nothing is squashed or drawn stretched (#411). Tools that only
715
+ re-encode keep the stored size and its SAR, which the encoder writes through."""
716
+ w, h, sar = _square_size(video)
717
+ if not (w and h) or sar == 1:
718
+ return ""
719
+ dw, dh = display_size(video)
720
+ return f"scale={dw}:{dh},setsar=1"
721
+
722
+
723
+ def square_pixels_note(video: Optional[Dict[str, Any]], what: str = "the input") -> Optional[str]:
724
+ """The notes line for a square_pixels() scale, or None when there is none."""
725
+ if not square_pixels(video):
726
+ return None
727
+ w, h, _ = _square_size(video)
728
+ dw, dh = display_size(video)
729
+ return (f"{what} stores non-square pixels (SAR {(video or {}).get('sample_aspect_ratio')}): "
730
+ f"its {w}x{h} picture was scaled to {dw}x{dh} square pixels, its display aspect")
731
+
732
+
733
+ def _is_still(fmt: Dict[str, Any], video: Dict[str, Any]) -> bool:
734
+ """A single picture read by an image demuxer (image2, jpeg_pipe, png_pipe, ...)."""
735
+ name = fmt.get("format_name") or ""
736
+ return name == "image2" or name.endswith("_pipe")
737
+
738
+
739
+ def _still_rotation(path: str) -> int:
740
+ """The display rotation of a still's first decoded frame (an EXIF Orientation), 0 if none."""
741
+ proc = run([require_tool("ffprobe"), "-v", "error", *still_input_args(path), "-select_streams", "v:0",
742
+ "-read_intervals", "%+#1", "-show_entries", "frame_side_data=rotation", "-of", "json", path],
743
+ quiet=True, check=False)
744
+ try:
745
+ frames = json.loads(proc.stdout or "{}").get("frames") or []
746
+ for sd in (frames[0].get("side_data_list") or []) if frames else []:
747
+ if "rotation" in sd:
748
+ return int(round(float(sd["rotation"])))
749
+ except (ValueError, TypeError, AttributeError):
750
+ pass
751
+ return 0
752
+
753
+
661
754
  def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
662
755
  if not w or not h:
663
756
  return None
@@ -324,7 +324,8 @@ class Context:
324
324
 
325
325
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
326
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
- "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
327
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
328
+ "source_timecode", "smpte_hint")
328
329
 
329
330
  def __init__(self) -> None:
330
331
  self.reset()
@@ -334,6 +335,8 @@ class Context:
334
335
  self.json = False # emit() prints a JSON document instead of the output path
335
336
  self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
336
337
  self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
338
+ self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
339
+ self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
337
340
  self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
338
341
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
339
342
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
@@ -30,6 +30,7 @@ from xml.sax.saxutils import escape as _xml_escape
30
30
 
31
31
  from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
32
32
  from _common.emit import warn
33
+ from _common.probe import display_size
33
34
 
34
35
  FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
35
36
  # Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
@@ -101,12 +102,12 @@ def _seconds(value: Any, what: str, fps: Optional[float]) -> float:
101
102
 
102
103
 
103
104
  def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
104
- """A source's displayed width x height (rotation metadata swaps them, as fit.py reads it)."""
105
- video = meta.get("video") or {}
106
- w, h = int(video.get("width") or 0), int(video.get("height") or 0)
105
+ """A source's displayed width x height, as fit.py reads it: rotation metadata swaps them, and
106
+ non-square pixels are squared at the display aspect (probe.display_size(), #411)."""
107
+ w, h = display_size(meta.get("video") or {})
107
108
  if not (w and h):
108
109
  return None
109
- return (h, w) if video.get("rotation") in (90, -90, 270, -270) else (w, h)
110
+ return (w, h)
110
111
 
111
112
 
112
113
  def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]: