ffmpeg-skill 3.8.0 → 3.8.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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.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`
@@ -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.8.0", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.2", "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"},
@@ -835,7 +835,7 @@ Keys added for the alpha path beyond `export.py` (additive; the `alpha` step on
835
835
 
836
836
  | key | tool | what it holds |
837
837
  |---|---|---|
838
- | `alpha_codec`, `alpha_mode`; step `alpha` | `color.py --correct/--lut --keep-alpha`, `graphics.py --keep-alpha`, `overlay.py --keep-alpha`, `background.py --transparent` | `alpha_codec`: the alpha line written (`--alpha-codec prores4444` (default), `qtrle`, `png`, `vp9`; the output must be a container that holds it, `.mov`/`.mkv`, `.mov` for qtrle, `.webm`/`.mkv` for vp9). `alpha_mode`: `straight` or `premultiplied` (`--alpha-mode`). The line keeps the source's colour tags and converts no matrix, as `--codec prores` does; `--codec` and `--keep-alpha` together are refused, and `--hw` leaves the line on the CPU. `alpha` step: `written {codec, pix_fmt, alpha_plane}` read back, `frames {expected, measured}`. `color.py`: the alpha is split off before the grade and merged back, so the written alpha must equal the source's frame by frame within 2 of 255 (`comparison {ok, paired, worst_delta, worst_frame}`). `graphics.py`/`overlay.py` (`property: alpha_at_least_source`): drawn layers add coverage, so every written frame's alpha must be at or above the source's within 2 of 255, the stream must store an alpha plane, and the output must not have turned opaque in every frame the source was transparent in (`reason`: `alpha_below_source`, `no_alpha_plane`, `opaque_everywhere`). `background.py --transparent`: every frame's alpha read back as 0 (`provenance: specification`). `--keep-alpha` on a source with no alpha plane is refused (`kind: input`) |
838
+ | `alpha_codec`, `alpha_mode`; step `alpha` | `color.py --correct/--lut --keep-alpha`, `graphics.py --keep-alpha`, `overlay.py --keep-alpha`, `background.py --transparent` | `alpha_codec`: the alpha line written (`--alpha-codec prores4444` (default), `qtrle`, `png`, `vp9`; the output must be a container that holds it, `.mov`/`.mkv`, `.mov` for qtrle, `.webm`/`.mkv` for vp9). `alpha_mode`: `straight` or `premultiplied` (`--alpha-mode`). The line keeps the source's colour tags and converts no matrix, as `--codec prores` does (an RGB source that is neither HDR nor BT.2020, on a `prores4444`/`vp9` line has no matrix to keep: it is converted to BT.709 and tagged so; an HDR or BT.2020 one is not: FFmpeg's own conversion makes its YUV, as before); `--codec` and `--keep-alpha` together are refused, and `--hw` leaves the line on the CPU. `alpha` step: `written {codec, pix_fmt, alpha_plane}` read back, `frames {expected, measured}`. `color.py`: the alpha is split off before the grade and merged back, so the written alpha must equal the source's frame by frame within 2 of 255 (`comparison {ok, paired, worst_delta, worst_frame}`). `graphics.py`/`overlay.py` (`property: alpha_at_least_source`): drawn layers add coverage, so every written frame's alpha must be at or above the source's within 2 of 255, the stream must store an alpha plane, and the output must not have turned opaque in every frame the source was transparent in (`reason`: `alpha_below_source`, `no_alpha_plane`, `opaque_everywhere`). `background.py --transparent`: every frame's alpha read back as 0 (`provenance: specification`). `--keep-alpha` on a source with no alpha plane is refused (`kind: input`) |
839
839
  | `alpha_dropped`; step `alpha` with `reason: alpha_dropped` | `color.py --correct/--lut/--to-sdr` (and `--retag` when its copy falls back to a re-encode), `graphics.py`, `overlay.py` without `--keep-alpha` | on a source whose alpha is used (a frame below opaque; measured, streamed frame by frame, only when the source stores an alpha plane or a palette, so an opaque source runs and reports exactly as before; a stored plane whose alpha cannot be read counts as used and a kept step on it fails with `reason: source_alpha_unmeasured`; a VP9 source or layer with alpha needs the libvpx-vp9 decoder, `error.reason: decoder_missing` without it): the output's opaque line drops it, so the `alpha` step fails (`attribution: artifact`, a `hint`, an `alpha_dropped` warning) and `verified` is false. `--drop-alpha` says an opaque output is wanted: no step, `alpha_dropped: true` and a note. An `overlay.py --video` layer with alpha keeps it: a VP9 one is decoded with libvpx-vp9, an RGB one (qtrle, PNG) is converted to BT.709 with its alpha plane |
840
840
 
841
841
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.0",
3
+ "version": "3.8.2",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -76,7 +76,8 @@ opaque and the result says `verified: false` with a failing `alpha` step (`alpha
76
76
  transparency, start from `background.py --transparent --duration 5 --width 1920 --height 1080 -o canvas.mov`
77
77
  and draw one element on it
78
78
  with `graphics.py`/`overlay.py --keep-alpha`. An alpha line keeps the source's colour tags (as
79
- `--codec prores` does). `--alpha-mode premultiplied` multiplies the written colour by the alpha;
79
+ `--codec prores` does); an RGB source that is neither HDR nor BT.2020 (qtrle, PNG) written to ProRes 4444 or VP9 becomes BT.709
80
+ YUV, tagged BT.709. `--alpha-mode premultiplied` multiplies the written colour by the alpha;
80
81
  sources are taken as straight. A VP9 layer with alpha is decoded with libvpx-vp9; FFmpeg's own VP9
81
82
  decoder drops the alpha.
82
83
 
@@ -1457,7 +1457,7 @@ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug|st
1457
1457
  ```
1458
1458
  `--keep-alpha` draws the template on the input's alpha (a canvas from `background.py --transparent`,
1459
1459
  or any alpha clip) and writes it in an alpha line (see `background.py`; ProRes 4444 by default) with
1460
- the source's colour tags kept: boxes and bars are composited as layers with their alpha, drawtext and
1460
+ the source's colour tags kept (an RGB input that is neither HDR nor BT.2020, on a ProRes 4444/VP9 line: BT.709 YUV, tagged BT.709): boxes and bars are composited as layers with their alpha, drawtext and
1461
1461
  libass (`alpha=1`) draw into the alpha, all at 4:4:4. The `alpha` step checks that the written
1462
1462
  stream stores an alpha plane, that no frame's alpha fell below the source's, and that the output
1463
1463
  did not go opaque in every frame the source was transparent in. Without `--keep-alpha` on a source
@@ -1870,7 +1870,8 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
1870
1870
  [--keep-alpha [--alpha-codec prores4444|qtrle|png|vp9] [--alpha-mode straight|premultiplied] | --drop-alpha]
1871
1871
  ```
1872
1872
  `--keep-alpha` composites onto the input's alpha (a `background.py --transparent` canvas or an alpha
1873
- clip) and writes the result in an alpha line with the source's tags kept, checked as
1873
+ clip) and writes the result in an alpha line with the source's tags kept (an RGB input that is neither HDR nor BT.2020, on a
1874
+ ProRes 4444/VP9 line: BT.709 YUV, tagged BT.709), checked as
1874
1875
  `graphics.py --keep-alpha` is. A `--video` layer with alpha keeps it: VP9 with alpha (`export.py
1875
1876
  --preset vp9-alpha`) is decoded with libvpx-vp9 (FFmpeg's own VP9 decoder drops the alpha), and an
1876
1877
  RGB layer (qtrle, PNG in .mov, e.g. a `graphics.py --keep-alpha` element) is converted to BT.709 with
@@ -1981,7 +1982,9 @@ color.py INPUT --correct ... | --lut grade.cube ... -o OUT.mov [--keep-alpha [--
1981
1982
  `--keep-alpha` (with `--correct` or `--lut`) grades an alpha clip and keeps its alpha: the alpha is
1982
1983
  split off before the grade, never touched, and merged back in front of the alpha line (ProRes 4444
1983
1984
  by default, `--alpha-codec qtrle|png|vp9`); the grade runs at 4:4:4 and the source's colour tags are
1984
- kept (no BT.709 conversion, as `--codec prores`). The `alpha` step compares the written alpha with
1985
+ kept (no BT.709 conversion, as `--codec prores`). An RGB source that is neither HDR nor BT.2020 (qtrle, PNG in `.mov`) has no matrix
1986
+ to keep: a ProRes 4444 or VP9 line of it is BT.709 YUV and tagged BT.709 (an HDR or BT.2020 one is not: FFmpeg's own conversion makes its YUV, as before), a qtrle or PNG line keeps
1987
+ its RGB. The `alpha` step compares the written alpha with
1985
1988
  the source's frame by frame on 64x36 copies, each within 2 of 255. A source without an alpha plane is
1986
1989
  refused. Without `--keep-alpha`, a grade of a source whose alpha is used writes opaque H.264 and the
1987
1990
  `alpha` step fails (`reason: alpha_dropped`, `verified: false`); `--drop-alpha` makes it a note
@@ -63,7 +63,7 @@ from _common.runner import (
63
63
  STATE, _timed_out, _unwatch, _watch, X264_PRESETS
64
64
  )
65
65
  from _common.emit import (
66
- absorb_stage, audit, _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
66
+ absorb_stage, audit, _brief, input_length, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
67
67
  _plan_unbound_inputs, _PLAN_STRIP, PLAN_VERSION, print_json, record_fallback, record_skip, refuse, _set_current_ctx, warn,
68
68
  write_plan
69
69
  )
@@ -74,7 +74,7 @@ from _common.probe import (
74
74
  display_size, pixel_aspect, square_pixels, square_pixels_note
75
75
  )
76
76
  from _common.decision import (
77
- aac_args, add_pad_fill_args, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
77
+ aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
78
78
  description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
79
79
  frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
80
80
  filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
@@ -9,7 +9,9 @@ background.py's transparent canvas can do the same:
9
9
  writes a fully transparent canvas in the same lines. `--alpha-mode straight|premultiplied` says
10
10
  how the colour is stored, as export.py's does.
11
11
  - An alpha line keeps the source's colour tags and converts no matrix, as `--codec prores` does
12
- (`keeps_source_tags()` in decision.py): an alpha master is an editing master.
12
+ (`keeps_source_tags()` in decision.py): an alpha master is an editing master. An RGB source has
13
+ no matrix to keep: on a line that stores YUV (ProRes 4444, VP9) it is converted to BT.709 and
14
+ tagged so (`rgb_to_bt709_line()`, #496); on an RGB line (qtrle, PNG) it stays the RGB it was.
13
15
  - Every completed run on a source whose alpha is really used (a frame below OPAQUE) gets an
14
16
  `alpha` verification step. Without `--keep-alpha` the output's picture has no alpha and that step
15
17
  fails (the output reported `verified: true` with the alpha gone before this module existed);
@@ -25,7 +27,7 @@ import threading
25
27
  from typing import Any, Dict, List, Optional
26
28
 
27
29
  from _common import runner as _runner
28
- from _common.color import carried_tag
30
+ from _common.color import carried_tag, is_rgb, rgb_to_bt709_filter
29
31
  from _common.runner import STATE, ffmpeg_encoders, require_tool, run
30
32
 
31
33
  # A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
@@ -283,13 +285,72 @@ def audio_line(codec: str) -> List[str]:
283
285
 
284
286
  _TAG_KEYS = (("colorspace", "color_space", "-colorspace"), ("color_primaries", "color_primaries", "-color_primaries"),
285
287
  ("color_trc", "color_transfer", "-color_trc"))
288
+ # swscale's name for a tagged SDR matrix (scale=in_color_matrix / out_color_matrix)
289
+ _SWS_MATRIX = {"bt709": "bt709", "smpte170m": "bt601", "bt470bg": "bt601", "smpte240m": "smpte240m", "fcc": "fcc"}
290
+
291
+
292
+ def rgb_to_bt709_line(meta: Optional[Dict[str, Any]], codec: Optional[str] = None) -> bool:
293
+ """True when the alpha line being written (`codec`, default STATE.alpha_line) stores YUV
294
+ (ProRes 4444, VP9) and the source `meta` probes is RGB (qtrle, PNG in .mov/.mkv, an image).
295
+
296
+ An RGB source has no matrix for the line to keep, and the one FFmpeg picks where the graph
297
+ first needs YUV is BT.601, written with no tag on 6.1: a 1280x720 ProRes 4444 master of a
298
+ qtrle clip was read as BT.709 by every player and showed 30C050 as 26AB4D (#496). Such a
299
+ source is converted to BT.709 (source_format(); color.py's grade legs), RGB layers are
300
+ converted to match (decision.rgb_chain_to_bt709) and the line is tagged BT.709 (_source_tags),
301
+ as the H.264 line does for an RGB source. An RGB line (qtrle, PNG) is left as it was; an
302
+ HDR/BT.2020 source is not converted to BT.709 here (FFmpeg's own conversion makes its YUV, as
303
+ before)."""
304
+ codec = codec or STATE.alpha_line
305
+ v = (meta or {}).get("video") or {}
306
+ return bool(codec) and ALPHA_CODECS[codec]["pix_fmt"].startswith("yuv") and is_rgb(meta) and not v.get("bt2020_or_hdr")
307
+
308
+
309
+ def source_format(meta: Optional[Dict[str, Any]]) -> str:
310
+ """The filter that starts a compositing graph on an alpha line: the source at 4:4:4 with its
311
+ alpha plane. An RGB source on a YUV line gets there by an explicit BT.709 conversion
312
+ (rgb_to_bt709_line); a palette is expanded first, because swscale turns one into YUV with
313
+ BT.601 whatever matrix is asked for. Every other source: `format=yuva444p`, as before."""
314
+ if rgb_to_bt709_line(meta):
315
+ pal = ((meta or {}).get("video") or {}).get("pix_fmt") == "pal8"
316
+ return ("format=rgba," if pal else "") + rgb_to_bt709_filter("yuva444p")
317
+ return "format=yuva444p"
318
+
319
+
320
+ def rgb_line(codec: Optional[str] = None) -> bool:
321
+ """True when the alpha line being written stores RGB (qtrle, PNG)."""
322
+ codec = codec or STATE.alpha_line
323
+ return bool(codec) and not ALPHA_CODECS[codec]["pix_fmt"].startswith("yuv")
324
+
325
+
326
+ def matrix_label(meta: Optional[Dict[str, Any]]) -> str:
327
+ """",setparams=colorspace=<the source's matrix>" to end a YUV chain with that an RGB line
328
+ (qtrle, PNG) converts, or "" (a YUV line, an untagged or RGB source).
329
+
330
+ finish_chain() labels the frames after the last filter of the graph, but FFmpeg places the
331
+ conversion to an RGB line where the formats first differ, which in color.py is in front of
332
+ `alphamerge` and so in front of that label: the graded YUV of a BT.709-tagged source reached
333
+ it untagged (6.1) or carrying the grade legs' working matrix (7.1+) and was read as BT.601
334
+ (30C050 written as 3CD753). Labelled here, the conversion reads the matrix the samples are in."""
335
+ if not rgb_line() or is_rgb(meta):
336
+ return ""
337
+ space = [val for p, val, _ in _source_tags(meta, "setparams") if p == "colorspace"]
338
+ return f",setparams=colorspace={space[0]}" if space else ""
286
339
 
287
340
 
288
341
  def _source_tags(meta: Optional[Dict[str, Any]], dest: str = "option") -> List[tuple]:
289
342
  """(setparams name, value, output option) per source tag that is passed on, the value spelled
290
- for `dest` (carried_tag: an RGB source's `gbr` is left out, #454)."""
343
+ for `dest` (carried_tag: an RGB source's `gbr` is left out, #454). An RGB source on a YUV line
344
+ (rgb_to_bt709_line) is BT.709 YUV by then and says so: matrix bt709, and bt709 for a primaries
345
+ or transfer tag the source does not carry (the H.264 line's reading of an untagged picture)."""
291
346
  v = (meta or {}).get("video") or {}
292
- tags = [(param, carried_tag(param, v.get(key), dest), flag) for param, key, flag in _TAG_KEYS]
347
+ to_709 = rgb_to_bt709_line(meta)
348
+ # the matrix is the one the conversion used, whatever the RGB source's own tag says (a qtrle
349
+ # clip retagged smpte170m would label its BT.709 samples BT.601); primaries and transfer are
350
+ # not converted, so the source's own are kept when it has them
351
+ tags = [(param, "bt709" if to_709 and param == "colorspace"
352
+ else carried_tag(param, v.get(key), dest) or ("bt709" if to_709 else None), flag)
353
+ for param, key, flag in _TAG_KEYS]
293
354
  return [t for t in tags if t[1]]
294
355
 
295
356
 
@@ -300,15 +361,24 @@ def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]]
300
361
  format (so no later conversion can drop the plane)."""
301
362
  tags = _source_tags(meta, "setparams")
302
363
  label = ("setparams=" + ":".join(f"{p}={val}" for p, val, _ in tags) + ",") if tags else ""
364
+ pix = ALPHA_CODECS[codec]["pix_fmt"]
303
365
  # premultiplied colour is colour x alpha in RGB: multiplied there (a YUV premultiply scales the
304
366
  # limited-range offsets too), after the tags are back so both conversions use the same matrix
305
- return (label + ("format=gbrap,premultiply=inplace=1," if mode == "premultiplied" else "")
306
- + f"format={ALPHA_CODECS[codec]['pix_fmt']}")
367
+ pre = "format=gbrap,premultiply=inplace=1," if mode == "premultiplied" else ""
368
+ matrix = _SWS_MATRIX.get(next((val for p, val, _ in tags if p == "colorspace"), None))
369
+ if pre and matrix and pix.startswith("yuv"):
370
+ # ... which 6.1 does not do by itself on a tagged YUV line: the way to RGB is placed in
371
+ # front of color.py's alphamerge (so in front of the label, and reads BT.601) and the way
372
+ # back takes swscale's BT.601 default whatever the frames say, so an opaque 30C050 of a
373
+ # BT.709 line came back 3CD753 (color.py) or 25AC4E (overlay.py). Both legs name the matrix.
374
+ pre = f"scale=in_color_matrix={matrix},format=gbrap,premultiply=inplace=1,scale=out_color_matrix={matrix},"
375
+ return label + pre + f"format={pix}"
307
376
 
308
377
 
309
378
  def tag_args(meta: Optional[Dict[str, Any]]) -> List[str]:
310
379
  """The source's colour tags as output options, so the written stream says what the frames do
311
- (an alpha line keeps the source's tags, as --codec prores does)."""
380
+ (an alpha line keeps the source's tags, as --codec prores does; an RGB source on a YUV line is
381
+ tagged the BT.709 it was converted to)."""
312
382
  out: List[str] = []
313
383
  for _p, val, flag in _source_tags(meta):
314
384
  out += [flag, val]
@@ -15,7 +15,8 @@ from pathlib import Path
15
15
  from typing import Any, Dict, List, Optional, Sequence, Tuple
16
16
  from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
17
17
  is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
18
- from _common.emit import die, record_fallback, refuse, warn
18
+ from _common.alpha import rgb_to_bt709_line
19
+ from _common.emit import die, input_length, record_fallback, refuse, warn
19
20
  from _common.runner import read_json
20
21
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
21
22
 
@@ -431,6 +432,30 @@ def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bo
431
432
  f"atrim=start={start:.6f}{aend},asetpts=PTS-{start:.6f}/TB,{pad}")
432
433
 
433
434
 
435
+ def conform_filter(samples: int, rate: int) -> str:
436
+ """Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
437
+ seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
438
+ aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
439
+ head = f"aformat=sample_rates={rate}," if rate else ""
440
+ return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
441
+
442
+
443
+ def conform_to_length(length: float, rate: int) -> str:
444
+ """Pad with silence or trim a track to `length` seconds of a timeline that starts at 0, to the
445
+ sample (#478, #493). The count is of samples, and sound that starts after 0 keeps its start
446
+ (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s past
447
+ `length`. The trim on the timestamp ends it there; half a sample of slack keeps the Nth sample
448
+ of a track that starts at 0. audio.py, grid.py, broll.py and multicam.py end their sound with
449
+ this; `atrim=duration={length:.3f}` cut 59244 samples (48 kHz) to 59232."""
450
+ return conform_filter(round(length * rate), rate) + f",atrim=end={length + 0.5 / rate:.6f}"
451
+
452
+
453
+ def conform_output_t(length: float, rate: int) -> str:
454
+ """The output `-t` beside conform_to_length(): `-t {length:.3f}` would cut the conformed sound
455
+ back to the millisecond, so the limit sits half a sample past the last one."""
456
+ return f"{length + 0.5 / rate:.6f}"
457
+
458
+
434
459
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
435
460
  """The one length a clip holds once both streams start at its first frame (av_trim from the
436
461
  video's start): the video's, or where the sound ends when it runs more than a frame past the
@@ -440,7 +465,7 @@ def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
440
465
  v = (meta.get("video") or {}).get("duration")
441
466
  a = (meta.get("audio") or {}).get("duration")
442
467
  if not v:
443
- return meta.get("duration") or 0.0
468
+ return input_length(meta) # a file that states no length at all is refused, not 0 s long (#491)
444
469
  if a:
445
470
  v0, a0 = stream_starts(meta)
446
471
  a = a + a0 - v0 # where the sound ends, counted from the first frame
@@ -869,9 +894,11 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
869
894
  with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
870
895
  leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
871
896
  v = (line_meta or {}).get("video") or {}
872
- if STATE.alpha_line is not None and v.get("color_space") == "bt709" and not v.get("bt2020_or_hdr"):
897
+ if (STATE.alpha_line is not None and (v.get("color_space") == "bt709" or rgb_to_bt709_line(line_meta))
898
+ and not v.get("bt2020_or_hdr")):
873
899
  # an alpha line keeps the source's tags: on a BT.709-tagged picture an RGB layer is made
874
- # BT.709 too (FFmpeg 6.1's own conversion is BT.601), in 4:4:4 with its alpha plane
900
+ # BT.709 too (FFmpeg 6.1's own conversion is BT.601), in 4:4:4 with its alpha plane. An
901
+ # RGB source on a YUV line is a BT.709 picture as well (alpha.rgb_to_bt709_line, #496)
875
902
  return "," + rgb_to_bt709_filter("yuva444p")
876
903
  if keeps_source_tags() or v.get("bt2020_or_hdr"):
877
904
  return ""
@@ -647,6 +647,29 @@ def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
647
647
  return None
648
648
 
649
649
 
650
+ LENGTH_UNREAD_HINT = ("remux the file so its container states a length (export.py INPUT --preset copy -o remuxed.mkv), "
651
+ "then run this on the remux")
652
+
653
+
654
+ def input_length(meta: Optional[Dict[str, Any]], path: Optional[str] = None) -> float:
655
+ """An input's length in seconds, for a tool that computes with it (#491). A file that states
656
+ no length (Matroska written to a pipe: ffprobe prints N/A for the container and every stream)
657
+ probes as duration None, and `meta.get("duration") or 0.0` read that as a clip 0 s long: a
658
+ speed factor of 0.00x, "--at 1 is outside the clip (0..0.000)", a reverse of no frames. Unread
659
+ is not zero, so this refuses, as check.py's duration row says "could not measure". A dry run's
660
+ pending input (probe()'s stub, duration 0.0), a stated length of 0 and a still picture (one
661
+ image read by an image demuxer has no length to state) pass through as before."""
662
+ meta = meta or {}
663
+ length = meta.get("duration")
664
+ fmt = meta.get("format") or ""
665
+ still = fmt == "image2" or fmt.endswith("_pipe") # probe._is_still()'s rule, on the probe document
666
+ if length is None and not meta.get("dry_run") and not still:
667
+ die(f"the length of {path or meta.get('file') or 'the input'} could not be read: neither its container nor "
668
+ "its streams state one, and this operation is computed from the input's length",
669
+ kind="input", hint=LENGTH_UNREAD_HINT)
670
+ return float(length or 0.0)
671
+
672
+
650
673
  def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
651
674
  """Where one stream ends on the file's timeline: its start plus its own length."""
652
675
  length = stream_length(meta, kind)
@@ -578,6 +578,7 @@ def alpha_result_schema(name: str) -> Dict[str, Any]:
578
578
  if name == "background":
579
579
  out["alpha_codec"]["description"] += ". The `alpha` verification step reads every written frame back and expects alpha 0 (provenance specification)"
580
580
  return out
581
+ out["alpha_codec"]["description"] += " (an RGB source that is neither HDR nor BT.2020, on a prores4444/vp9 line has no matrix to keep: it is converted to BT.709 and tagged so; an HDR or BT.2020 one is not: FFmpeg's own conversion makes its YUV, as before)"
581
582
  out["alpha_dropped"] = {"type": "boolean", "description": "true when the source's alpha is used (a frame below opaque, or a stored alpha plane that could not be read) and the output does not keep it (color.py --retag too, when its copy fell back to an H.264 re-encode): without --keep-alpha the `alpha` verification step fails (reason alpha_dropped, attribution artifact, with an alpha_dropped warning); with --drop-alpha there is no such step and a note says the alpha was dropped on purpose. Absent on a source without alpha"}
582
583
  if name == "color":
583
584
  out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back unchanged; the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
package/scripts/audio.py CHANGED
@@ -30,6 +30,7 @@ from typing import Any, Dict, List
30
30
  from _common.probe import SILENT_PEAK_DBFS
31
31
  from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
32
32
  from _common.emit import warn
33
+ from _common.decision import conform_filter, conform_to_length
33
34
 
34
35
  # --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
35
36
  DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
@@ -254,14 +255,6 @@ def property_step(name: str, expected: int, measured: Any) -> Dict[str, Any]:
254
255
  return {"step": name, "expected": expected, "measured": value, "ok": value == expected}
255
256
 
256
257
 
257
- def conform_filter(samples: int, rate: int) -> str:
258
- """Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
259
- seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
260
- aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
261
- head = f"aformat=sample_rates={rate}," if rate else ""
262
- return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
263
-
264
-
265
258
  def main() -> int:
266
259
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
267
260
  ap.add_argument("input")
@@ -564,12 +557,9 @@ def main() -> int:
564
557
  elif main_dur and (args.replace or args.music or keep_video):
565
558
  if rate:
566
559
  conform_samples = round(main_dur * rate)
567
- # The count is of samples, and sound that starts after the picture keeps its start
568
- # (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s
569
- # past the picture. The trim on the timestamp, which is what this was before #478,
570
- # ends it with the picture; half a sample of slack keeps the Nth sample of a track
571
- # that starts at 0.
572
- post.append(conform_filter(conform_samples, rate) + f",atrim=end={main_dur + 0.5 / rate:.6f}")
560
+ # counted in samples and ended on the timestamp, so sound that starts after the picture
561
+ # still ends with it (conform_to_length, shared with grid, broll and multicam since #493)
562
+ post.append(conform_to_length(main_dur, rate))
573
563
  else:
574
564
  # a dry run's pending input: no rate to count in yet; the real run counts samples
575
565
  post.append(f"apad,atrim=0:{main_dur:.6f}")
package/scripts/broll.py CHANGED
@@ -21,10 +21,14 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
24
+ from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, stream_starts
25
25
  from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
  from _common.emit import note, record_fallback
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note
28
+ from _common.emit import input_length
29
+
30
+
31
+ MIX_RATE = 48000 # every layer of a b/mix sound is resampled to it before the mix
28
32
 
29
33
 
30
34
  def main() -> int:
@@ -57,7 +61,7 @@ def main() -> int:
57
61
  meta_a = probe(args.input)
58
62
  if not meta_a.get("video"):
59
63
  die("A-roll has no video stream")
60
- dur_a = meta_a.get("duration") or 0.0
64
+ dur_a = input_length(meta_a)
61
65
  # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one, and
62
66
  # onto a non-square-pixel A-roll squared at its display aspect (#411)
63
67
  w, h = display_size(meta_a["video"])
@@ -98,7 +102,7 @@ def main() -> int:
98
102
  die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
99
103
  if dur_a and at + length > dur_a + 0.01:
100
104
  die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
101
- dur_b = meta_b.get("duration") or 0.0
105
+ dur_b = input_length(meta_b)
102
106
  if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
103
107
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
104
108
  if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
@@ -161,7 +165,11 @@ def main() -> int:
161
165
  parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
162
166
  f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
163
167
  layers.append(f"[ab{i}]")
164
- parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,atrim=duration={dur_a:.3f}[aout]")
168
+ # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
169
+ # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
170
+ # dry run's pending A-roll has no length to count, and keeps the old filter.
171
+ tail = conform_to_length(dur_a, MIX_RATE) if dur_a > 0 else f"atrim=duration={dur_a:.3f}"
172
+ parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,{tail}[aout]")
165
173
  aout = "[aout]"
166
174
 
167
175
  cmd += ["-filter_complex", ";".join(parts), "-map", vout]
@@ -174,7 +182,7 @@ def main() -> int:
174
182
  cmd += aac_args()
175
183
  else:
176
184
  cmd += ["-an"]
177
- cmd += ["-t", f"{dur_a:.3f}", output]
185
+ cmd += ["-t", conform_output_t(dur_a, MIX_RATE) if aout == "[aout]" and dur_a > 0 else f"{dur_a:.3f}", output]
178
186
  run(cmd)
179
187
 
180
188
  result = probe(output, role="output")
package/scripts/color.py CHANGED
@@ -35,8 +35,10 @@ from typing import List, Optional
35
35
  from _common.runner import fail_if_classified
36
36
  from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
37
37
  from _common.emit import note, warn
38
+ from _common.color import is_rgb, rgb_to_bt709_filter
38
39
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
39
- report as alpha_report, source_alpha, tag_args, video_line)
40
+ matrix_label, report as alpha_report, rgb_line, rgb_to_bt709_line, source_alpha, tag_args,
41
+ video_line)
40
42
  from _common.decision import keeps_source_tags
41
43
  from _common.color import bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402,F401
42
44
 
@@ -97,7 +99,8 @@ def _checked_levels(args: argparse.Namespace, flag: str) -> int:
97
99
  return value
98
100
 
99
101
 
100
- def correction_chain(args: argparse.Namespace, out_fmt: str = "yuv420p") -> str:
102
+ def correction_chain(args: argparse.Namespace, out_fmt: str = "yuv420p", matrix: str = "bt601",
103
+ rgb_in: bool = False) -> str:
101
104
  """Always-present filter stages, in a fixed order chosen so each stage sees a picture already
102
105
  corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
103
106
  lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
@@ -137,7 +140,7 @@ def correction_chain(args: argparse.Namespace, out_fmt: str = "yuv420p") -> str:
137
140
  # "unchanged" adds no term at all
138
141
  rgb.append(f"colortemperature=temperature={temperature:g}")
139
142
  rgb.append(f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}")
140
- terms = [_rgb_stage(rgb, out_fmt), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
143
+ terms = [_rgb_stage(rgb, out_fmt, matrix, rgb_in), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
141
144
  rgb2 = []
142
145
  if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
143
146
  rimin, rimax = in_black / 255.0, in_white / 255.0
@@ -151,11 +154,11 @@ def correction_chain(args: argparse.Namespace, out_fmt: str = "yuv420p") -> str:
151
154
  if args.curves:
152
155
  rgb2.append(f"curves=preset={args.curves}")
153
156
  if rgb2:
154
- terms.append(_rgb_stage(rgb2, out_fmt))
157
+ terms.append(_rgb_stage(rgb2, out_fmt, matrix))
155
158
  return ",".join(terms)
156
159
 
157
160
 
158
- def _rgb_stage(filters: list, out_fmt: str = "yuv420p") -> str:
161
+ def _rgb_stage(filters: list, out_fmt: str = "yuv420p", matrix: str = "bt601", rgb_in: bool = False) -> str:
159
162
  """Wrap a run of RGB-only filters in explicit, matching YUV<->RGB conversions.
160
163
 
161
164
  exposure/colortemperature/colorbalance/colorlevels/curves take RGB, so libavfilter inserts a
@@ -168,9 +171,41 @@ def _rgb_stage(filters: list, out_fmt: str = "yuv420p") -> str:
168
171
  sides measured better than bt709 on both (34 dB) because swscale's 601 path round-trips
169
172
  8-bit 4:2:0 more exactly. The matrix here is only the working space of the conversion pair,
170
173
  never a tag: the output carries the encoder's BT.709 tags as before. `out_fmt` is yuv444p for
171
- an alpha line (--keep-alpha), whose 4:4:4 master would otherwise get 4:2:0 chroma."""
172
- return ("scale=in_color_matrix=bt601,format=gbrpf32le," + ",".join(filters)
173
- + f",scale=out_color_matrix=bt601,format={out_fmt}")
174
+ an alpha line (--keep-alpha), whose 4:4:4 master would otherwise get 4:2:0 chroma.
175
+
176
+ The pair cancels only when the picture comes in as YUV. An RGB source (qtrle, PNG in .mov)
177
+ meets no matrix on the way in, so the way out is the one real RGB-to-YUV conversion of the
178
+ graph and `matrix` is the matrix of the YUV that leaves it. The H.264 line converts such a
179
+ source to BT.709 YUV first (x264_args(source=...)), so the pair cancels there as well. An
180
+ alpha line converts nothing in front, and with bt601 here its ProRes 4444 or VP9 master of an
181
+ RGB source held BT.601 YUV under no tag, which a player reads as BT.709 at HD (30C050 shown as
182
+ 26AB4D, #496): for an RGB source on a YUV alpha line `matrix` is bt709 (working_matrix()),
183
+ and the line is tagged so. A later stage of the same chain takes the same matrix on both legs.
184
+
185
+ `rgb_in`: the picture reaches this stage as RGB (the first stage of an RGB source on an alpha
186
+ line), so the way in names no matrix. It has none to read, and with
187
+ `scale=in_color_matrix=bt709` in front of RGB frames FFmpeg 7.1 wrote BT.601 samples whatever
188
+ the way out asked for (30C050 as 26AB4D again; 6.1 was right either way). Seen in CI, not
189
+ traced inside FFmpeg: --lut-strength 0, whose chain has the way out alone, was right there."""
190
+ way_in = "format=gbrpf32le," if rgb_in else f"scale=in_color_matrix={matrix},format=gbrpf32le,"
191
+ return (way_in + ",".join(filters)
192
+ + f",scale=out_color_matrix={matrix},format={out_fmt}")
193
+
194
+
195
+ def working_matrix(meta=None) -> "str | None":
196
+ """The matrix of the YUV an RGB source's grade writes on an alpha line, or None for every other
197
+ case (a YUV source, the H.264 line), whose legs are what they were.
198
+
199
+ bt709 on a line that stores YUV (ProRes 4444, VP9): that YUV is the master, tagged BT.709
200
+ (alpha.rgb_to_bt709_line). bt601 on a line that stores RGB (qtrle, PNG): the YUV is only the
201
+ grade's working format and FFmpeg converts it back for the line with its own default, BT.601
202
+ (unlabelled frames on 6.1, the tag the leg wrote on 7.1+), so the legs use that and the written
203
+ RGB is the graded RGB. --lut's legs said bt709 there and an identity LUT wrote 30C050 as 3CD753."""
204
+ if rgb_to_bt709_line(meta):
205
+ return "bt709"
206
+ if rgb_line() and is_rgb(meta):
207
+ return "bt601"
208
+ return None
174
209
 
175
210
 
176
211
  # swscale's names for the non-BT.709 matrices a --codec prores re-encode keeps (it does not convert)
@@ -195,8 +230,12 @@ def lut_legs(meta=None, out_fmt: str = "yuv420p") -> "tuple":
195
230
  matrix = "bt709"
196
231
  if keeps_source_tags():
197
232
  matrix = _SWS_MATRIX.get(((meta or {}).get("video") or {}).get("color_space"), "bt709")
198
- return (f"scale=in_color_matrix={matrix},format=gbrpf32le",
199
- f"scale=out_color_matrix={matrix}:out_range=tv,format={out_fmt}")
233
+ # an RGB source on an alpha line: the matrix of the YUV its line is written from (#496)
234
+ matrix = working_matrix(meta) or matrix
235
+ # an RGB source on an alpha line reaches the way in as RGB: no matrix is named there (see
236
+ # _rgb_stage's rgb_in; with one, FFmpeg 7.1 wrote BT.601 samples)
237
+ way_in = "format=gbrpf32le" if keeps_source_tags() and working_matrix(meta) else f"scale=in_color_matrix={matrix},format=gbrpf32le"
238
+ return (way_in, f"scale=out_color_matrix={matrix}:out_range=tv,format={out_fmt}")
200
239
 
201
240
 
202
241
  # --retag targets: ffprobe's names (colorspace, primaries, transfer) and the ITU-T H.273 code points
@@ -507,7 +546,8 @@ def main() -> int:
507
546
  if v.get("bt2020_or_hdr") and not args.force:
508
547
  die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
509
548
  f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
510
- vf = "" if args.legalize and correction_is_identity(args) else correction_chain(args, out_fmt)
549
+ vf = ("" if args.legalize and correction_is_identity(args)
550
+ else correction_chain(args, out_fmt, working_matrix(meta) or "bt601", working_matrix(meta) is not None))
511
551
  output = args.output or default_output(args.input, "correct", ext)
512
552
  tag = "correct"
513
553
  # OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
@@ -528,7 +568,9 @@ def main() -> int:
528
568
  # below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
529
569
  # value did before the guard above existed: the one strength value documented to mean
530
570
  # "don't grade it" instead silently graded it at 100%, the opposite of what was asked.
531
- vf = f"format={out_fmt}"
571
+ # (an RGB source on a YUV alpha line is still converted to the BT.709 that line is, #496)
572
+ vf = (("format=rgb24," if v.get("pix_fmt") == "pal8" else "") + rgb_to_bt709_filter(out_fmt)
573
+ if rgb_to_bt709_line(meta) else f"format={out_fmt}")
532
574
  elif args.lut_strength < 1.0:
533
575
  # blend graded and original
534
576
  # blended in RGB, both pictures through the same two legs
@@ -550,8 +592,10 @@ def main() -> int:
550
592
  if alpha_codec:
551
593
  # --keep-alpha: the grade works on the colour and never touches the alpha, which is split off
552
594
  # the source, kept as it is and merged back (alphamerge) in front of the alpha line; the
553
- # source's colour tags are kept, as --codec prores keeps them (no BT.709 conversion)
554
- fc = (f"[0:v]split[ka_c][ka_a];[ka_c]{vf}[ka_g];[ka_a]alphaextract[ka_m];"
595
+ # source's colour tags are kept, as --codec prores keeps them (no BT.709 conversion). An
596
+ # RGB source has none: its YUV line is BT.709 and tagged so (working_matrix). An RGB line
597
+ # converts the graded YUV in front of alphamerge, so the matrix is labelled there too
598
+ fc = (f"[0:v]split[ka_c][ka_a];[ka_c]{vf}{matrix_label(meta)}[ka_g];[ka_a]alphaextract[ka_m];"
555
599
  f"[ka_g][ka_m]alphamerge,{finish_chain(alpha_codec, args.alpha_mode, meta)}[ka_v]")
556
600
  cmd = ffmpeg_base() + decoder_args_for(meta, args.input) + ["-i", args.input, "-filter_complex", fc,
557
601
  "-map", "[ka_v]", "-map", f"0:a:{args.audio_stream}?"]
package/scripts/export.py CHANGED
@@ -52,7 +52,7 @@ from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
53
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
54
54
  from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
- from _common.emit import file_identity
55
+ from _common.emit import file_identity, input_length
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
57
  from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
58
58
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
@@ -443,7 +443,7 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
443
443
  + (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
444
444
  if not math.isfinite(args.max_mb) or args.max_mb <= 0:
445
445
  die(f"--max-mb must be a positive number of MiB, got {args.max_mb:g}")
446
- dur = float(meta.get("duration") or 0.0)
446
+ dur = input_length(meta) # the bitrate is the size over the length: no stated length is refused (#491)
447
447
  if p["max"] and not args.allow_long:
448
448
  dur = min(dur, float(p["max"]))
449
449
  if dur <= 0:
package/scripts/fit.py CHANGED
@@ -42,6 +42,7 @@ from typing import List
42
42
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
43
43
  from _common.timeline import exact_rate, output_timecode_args
44
44
  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, display_size, square_pixels, square_pixels_note, note
45
+ from _common.emit import input_length
45
46
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
46
47
 
47
48
 
@@ -157,6 +158,10 @@ def main() -> int:
157
158
  target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
158
159
  if target <= 0:
159
160
  die("target duration must be > 0")
161
+ # speed and trim are both computed from the source's length: one that states none is refused
162
+ # (#491). Not under --dry-run, where render.py plans a clip's speed stage on the source in
163
+ # place of a cut that does not exist yet and records the stand-in itself (A97)
164
+ src_dur = (meta.get("duration") or 0.0) if STATE.dry_run else input_length(meta)
160
165
  if args.method == "speed":
161
166
  if src_dur <= 0 and STATE.dry_run:
162
167
  src_dur = target # planning against an intermediate that does not exist yet
package/scripts/freeze.py CHANGED
@@ -21,6 +21,7 @@ import sys
21
21
 
22
22
  from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
23
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
24
+ from _common.emit import input_length
24
25
 
25
26
 
26
27
  def main() -> int:
@@ -43,7 +44,7 @@ def main() -> int:
43
44
  meta = probe(args.input)
44
45
  if not meta.get("video"):
45
46
  die("input has no video stream")
46
- dur = meta.get("duration") or 0.0
47
+ dur = input_length(meta)
47
48
  fps = meta["video"].get("fps") or 30.0
48
49
  if args.at is not None:
49
50
  at = time_arg(args.at, "--at", (meta.get("video") or {}).get("fps"))
@@ -44,11 +44,12 @@ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_
44
44
  SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
45
45
  from _common.emit import note, warn
46
46
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
47
- report as alpha_report, source_alpha, tag_args, video_line)
47
+ report as alpha_report, source_alpha, source_format, tag_args, video_line)
48
48
  from _common.fonts import font_choice, record_font_choice
49
49
  from _common.probe import display_size, square_pixels, square_pixels_note
50
50
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
51
51
  from _common import drawtext_fontfile # noqa: E402
52
+ from _common.emit import input_length # noqa: E402
52
53
 
53
54
  # where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
54
55
  TEXT = "@TEXT@"
@@ -201,7 +202,7 @@ def main(force_route: Optional[str] = None) -> int:
201
202
  info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
202
203
  fps = meta["video"].get("fps")
203
204
  s = time_arg(args.start, "--start", fps) if args.start else 0.0
204
- e = time_arg(args.end, "--end", fps) if args.end else dur
205
+ e = time_arg(args.end, "--end", fps) if args.end else input_length(meta) # no --end is the clip's end (#491)
205
206
  # the picture's own length first: sound that runs on keeps the container open past the last frame
206
207
  picture_len = meta["video"].get("duration") or dur
207
208
  if picture_len and s >= picture_len:
@@ -749,8 +750,8 @@ def main(force_route: Optional[str] = None) -> int:
749
750
  # 4:4:4 (overlay's default is 4:2:0); drawtext and libass (alpha=1) draw into the alpha too
750
751
  fc = [c.replace(":eof_action=pass", ":eof_action=pass:format=yuv444") for c in fc]
751
752
  if fc:
752
- fc = ["[0:v]format=yuva444p[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
753
- square = "format=yuva444p" + ("," + square if square else "")
753
+ fc = [f"[0:v]{source_format(meta)}[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
754
+ square = source_format(meta) + ("," + square if square else "")
754
755
  elif overlay_format(meta):
755
756
  # a 10-bit picture on a 10-bit line is composited at 10 bits (overlay's default is 8-bit)
756
757
  fc = [c.replace(":eof_action=pass", ":eof_action=pass" + overlay_format(meta)) for c in fc]
package/scripts/grid.py CHANGED
@@ -34,6 +34,8 @@ from _common.emit import note, record_fallback # noqa: E402
34
34
  from _common.fonts import script_font_for_text # noqa: E402
35
35
  from _common.probe import square_pixels # noqa: E402
36
36
  from _common.wrap import text_width_em # noqa: E402
37
+ from _common.emit import input_length # noqa: E402
38
+ from _common.decision import conform_output_t, conform_to_length # noqa: E402
37
39
 
38
40
  LABEL_MARGIN = 10
39
41
 
@@ -114,7 +116,7 @@ def main() -> int:
114
116
  if args.audio_from is not None and not metas[args.audio_from].get("audio"):
115
117
  die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
116
118
 
117
- durations = [m.get("duration") or 0.0 for m in metas]
119
+ durations = [input_length(m) for m in metas]
118
120
  target_duration = max(durations) if args.pad else min(durations)
119
121
 
120
122
  # each label is drawn with a font that covers its own script, as every other text-drawing tool
@@ -188,12 +190,24 @@ def main() -> int:
188
190
  cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
189
191
  layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
190
192
  parts.append("".join(f"[v{i}]" for i in range(n)) + f"xstack=inputs={n}:layout={layout}:fill={args.background}[out]")
191
- if args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
193
+ # The sound ends with the grid, to the sample (#493): padded when its clip is shorter than a
194
+ # --pad grid, trimmed when it is longer. `apad,atrim=duration={:.3f}` and the output's
195
+ # `-t {:.3f}` both cut it to the millisecond (59244 samples at 48 kHz came out as 59232). A dry
196
+ # run's pending input has no rate to count in yet, and keeps the old filter, as audio.py does.
197
+ audio_rate = (metas[args.audio_from]["audio"].get("sample_rate") or 0) if args.audio_from is not None else 0
198
+ conformed = bool(audio_rate and target_duration > 0)
199
+ audio_source = None
200
+ if conformed:
201
+ parts.append(f"[{args.audio_from}:a:0]{conform_to_length(target_duration, audio_rate)}[aout]")
202
+ audio_source = "[aout]"
203
+ elif args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
192
204
  parts.append(f"[{args.audio_from}:a:0]apad,atrim=duration={target_duration:.3f}[aout]")
205
+ audio_source = "[aout]"
206
+ elif args.audio_from is not None:
207
+ audio_source = f"{args.audio_from}:a:0"
193
208
  cmd += ["-filter_complex", ";".join(parts), "-map", "[out]"]
194
209
 
195
- if args.audio_from is not None:
196
- audio_source = "[aout]" if (args.pad and durations[args.audio_from] < target_duration) else f"{args.audio_from}:a:0"
210
+ if audio_source:
197
211
  cmd += ["-map", audio_source]
198
212
  # A grid is an 8-bit SDR composite by design (a comparison/contact artefact, not a
199
213
  # deliverable); an HDR input is flattened like look.py's contact sheet flattens it. Say so
@@ -206,7 +220,7 @@ def main() -> int:
206
220
  cmd += aac_args()
207
221
  else:
208
222
  cmd += ["-an"]
209
- cmd += ["-t", f"{target_duration:.3f}", output]
223
+ cmd += ["-t", conform_output_t(target_duration, audio_rate) if conformed else f"{target_duration:.3f}", output]
210
224
  run(cmd)
211
225
 
212
226
  result = probe(output, role="output")
package/scripts/join.py CHANGED
@@ -50,6 +50,7 @@ from _common.emit import note, warn
50
50
  from _common.decision import av_clip_length, av_trim, stream_starts
51
51
  from _common.probe import display_size, square_pixels, square_pixels_note
52
52
  from _common.timeline import exact_rate
53
+ from _common.emit import input_length
53
54
 
54
55
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
55
56
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -704,6 +705,8 @@ def main() -> int:
704
705
  skipped=skipped)
705
706
  validate_color(args.pad_color, "--pad-color")
706
707
  metas = [probe(p) for p in args.inputs]
708
+ for p, m in zip(args.inputs, metas):
709
+ input_length(m, p) # every clip is placed by its length: one that states none is refused, not 0 s (#491)
707
710
  # A pending input's probe is the dry-run stub, whose (0x0) video stream says nothing about the
708
711
  # file an earlier step will write. Its extension stands in: a step names its output for what
709
712
  # it holds, so an audio extension (.wav, .m4a, .aiff, ... -- every audio file this skill
package/scripts/look.py CHANGED
@@ -25,6 +25,7 @@ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
25
25
  from _common.emit import note, record_fallback # noqa: E402
26
26
  from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
27
27
  from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note # noqa: E402
28
+ from _common.emit import input_length # noqa: E402
28
29
 
29
30
  # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
30
31
  # Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
@@ -363,6 +364,7 @@ def main() -> int:
363
364
  except ValueError:
364
365
  die("--tiles must look like 4x3")
365
366
  n = cols * rows
367
+ dur = input_length(meta) # the sheet is spread over the length: none stated is refused with the way out (#491)
366
368
  if not dur:
367
369
  die("cannot build a contact sheet without a known duration")
368
370
  frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
package/scripts/loop.py CHANGED
@@ -36,6 +36,7 @@ from _common import STATE, concat_list_line, add_common, aac_args, apply_common,
36
36
  from _common.emit import warn
37
37
  from _common.decision import av_trim
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
39
+ from _common.emit import input_length
39
40
 
40
41
 
41
42
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -248,7 +249,7 @@ def main() -> int:
248
249
  meta = probe(args.input)
249
250
  if not meta.get("video"):
250
251
  die("input has no video stream")
251
- src_dur = meta.get("duration") or 0.0
252
+ src_dur = input_length(meta) # no stated length: refused with the way out (#491)
252
253
  if src_dur <= 0:
253
254
  die("input has no measurable duration to loop")
254
255
  has_audio = bool(meta.get("audio")) and not args.boomerang
@@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional
33
33
  from _common import (guard_sidecar, add_common, apply_common, default_output, description_block, detect_scenes, detect_silences,
34
34
  die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
35
35
  STATE, read_text_or_die)
36
+ from _common.emit import input_length
36
37
 
37
38
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
38
39
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -230,7 +231,8 @@ def main() -> int:
230
231
  die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
231
232
  f"{', '.join(sorted(CHAPTER_CONTAINERS))} (the streams are copied, so choose the matching family: .mp4/.mov/.m4a for MPEG-4, .mkv/.mka/.webm for Matroska)")
232
233
 
233
- duration = meta.get("duration") or 0.0
234
+ # the last chapter ends at the file's end and --auto-chapters spaces them over its length (#491)
235
+ duration = input_length(meta) if (args.chapters or args.auto_chapters) else (meta.get("duration") or 0.0)
234
236
  chapters: Optional[List[Dict[str, Any]]] = parse_chapters(args.chapters, duration) if args.chapters else None
235
237
  auto: Optional[Dict[str, Any]] = None
236
238
  notes: List[str] = []
@@ -34,8 +34,9 @@ from typing import Dict, List, Tuple
34
34
 
35
35
  from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
36
36
  from _common.emit import note, record_fallback, record_skip, warn
37
- from _common.decision import av_trim, rebase_project_paths
37
+ from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
38
38
  from _common.probe import display_size, square_pixels, square_pixels_note
39
+ from _common.emit import input_length
39
40
  from sync import measure_offset
40
41
 
41
42
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -187,8 +188,8 @@ def main() -> int:
187
188
  ratio = 1.0
188
189
  skipped_reason = None
189
190
  if args.fix_drift:
190
- ref_dur = metas[0]["duration"] or 0.0
191
- sec_dur = probe(p)["duration"] or 0.0
191
+ ref_dur = input_length(metas[0])
192
+ sec_dur = input_length(probe(p))
192
193
  overlap_end = min(ref_dur, sec_dur + off)
193
194
  window = 60.0
194
195
  head_len = min(args.analyze_seconds, overlap_end)
@@ -242,7 +243,7 @@ def main() -> int:
242
243
 
243
244
  if args.min_shot <= 0:
244
245
  die(f"--min-shot must be positive, got {args.min_shot:g}")
245
- ref_dur = metas[0]["duration"] or 0.0
246
+ ref_dur = input_length(metas[0])
246
247
  energy_mode = args.switch == "energy"
247
248
  if energy_mode:
248
249
  cuts = energy_switch(args.inputs, metas, offsets, ratios, ref_dur, args.min_shot)
@@ -339,12 +340,17 @@ def main() -> int:
339
340
  afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
340
341
  if offsets[a] > 0:
341
342
  afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
342
- afx += [f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
343
+ # the sound ends with the reference, to the sample (#493), counted at the 48 kHz it is written
344
+ # in; `atrim=0:{:.3f}` and `-t {:.3f}` cut it to the millisecond. A dry run's pending reference
345
+ # has no length to count, and keeps the old filter.
346
+ conformed = ref_dur > 0
347
+ afx += [conform_to_length(ref_dur, 48000) if conformed else f"atrim=0:{ref_dur:.3f}",
348
+ "aformat=sample_rates=48000:channel_layouts=stereo"]
343
349
  parts.append(f"[{a}:a]{','.join(afx)}[aout]")
344
350
 
345
351
  output = args.output or default_output(args.inputs[0], "multicam", "mp4")
346
352
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
347
- cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
353
+ cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", conform_output_t(ref_dur, 48000) if conformed else f"{ref_dur:.3f}", output]
348
354
 
349
355
  if args.write_project:
350
356
  # Same cut decision as the combined render above (project_clips was built alongside the
@@ -37,8 +37,9 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as
37
37
  from _common import still_input_args # noqa: E402
38
38
  from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
39
39
  from _common.emit import note, warn
40
+ from _common.color import is_rgb
40
41
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
41
- has_alpha_plane, report as alpha_report, source_alpha, tag_args, video_line)
42
+ has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
42
43
  from _common.probe import display_size, square_pixels, square_pixels_note
43
44
  from _common.wrap import wrap_text
44
45
  from _common import text_width_em
@@ -490,9 +491,27 @@ def main() -> int:
490
491
  # an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
491
492
  # a 10-bit layer over a main overlay_format() composites at 10 bits stays 10-bit
492
493
  deep = bool(overlay_format(meta)) and (pip_meta["video"].get("bit_depth") or 8) > 8
493
- chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
494
+ keyed_601 = False
495
+ if alpha_codec and is_rgb(pip_meta) and rgb_chain_to_bt709(meta):
496
+ # an RGB layer (qtrle, PNG in .mov) on an alpha line that is BT.709 (a BT.709-tagged
497
+ # main, or an RGB main on a ProRes 4444/VP9 line): matrix_chain() converts nothing on
498
+ # an alpha line, so the layer became YUV with FFmpeg's BT.601 default and pure red
499
+ # read FF1800 under the line's tag (#496's sweep)
500
+ pal = "format=rgba," if pip_meta["video"].get("pix_fmt") == "pal8" else ""
501
+ if args.chromakey:
502
+ # chromakey turns its RGB key into YUV with BT.601 whatever the frames say, so the
503
+ # layer is keyed as BT.601 YUV (what it was before #496, and what the key is
504
+ # computed in) and taken to the line's BT.709 after the key
505
+ chain.append(pal + "scale=out_color_matrix=bt601:out_range=tv,format=yuva444p")
506
+ keyed_601 = True
507
+ else:
508
+ chain.append(pal + rgb_chain_to_bt709(meta).lstrip(","))
509
+ else:
510
+ chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
494
511
  if args.chromakey:
495
512
  chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
513
+ if keyed_601:
514
+ chain.append("scale=in_color_matrix=bt601:out_color_matrix=bt709:in_range=tv:out_range=tv,format=yuva444p")
496
515
  if args.opacity < 1:
497
516
  # the alpha is scaled in YUV: colorchannelmixer takes RGB only, so FFmpeg converted the
498
517
  # layer to RGB and overlay=format=auto then composited the whole main picture in RGB,
@@ -588,12 +607,12 @@ def main() -> int:
588
607
  cmd[first:first] = decoder_args_for(meta, args.input)
589
608
  if "-filter_complex" in cmd:
590
609
  i = cmd.index("-filter_complex") + 1
591
- cmd[i] = ("[0:v]format=yuva444p[ka0];" + cmd[i].replace("[0:v]", "[ka0]").replace(":format=auto", ":format=yuv444")
610
+ cmd[i] = (f"[0:v]{source_format(meta)}[ka0];" + cmd[i].replace("[0:v]", "[ka0]").replace(":format=auto", ":format=yuv444")
592
611
  + f";[out]{finish}[ka_v]")
593
612
  cmd[cmd.index("[out]")] = "[ka_v]"
594
613
  else:
595
614
  i = cmd.index("-vf") + 1
596
- cmd[i] = f"format=yuva444p,{cmd[i]},{finish}"
615
+ cmd[i] = f"{source_format(meta)},{cmd[i]},{finish}"
597
616
  cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta)
598
617
  cmd += audio_line(alpha_codec) if meta.get("audio") else ["-an"]
599
618
  if STATE.hw:
package/scripts/scenes.py CHANGED
@@ -32,6 +32,7 @@ from _common.emit import note, record_fallback # noqa: E402
32
32
  from _common.color import bt709_label_filter # noqa: E402
33
33
  from _common.probe import PROBED_VIDEO, square_pixels # noqa: E402
34
34
  from _common import dry_run_input_pending # noqa: E402
35
+ from _common.emit import input_length # noqa: E402
35
36
 
36
37
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
37
38
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -176,7 +177,7 @@ def main() -> int:
176
177
  die("--beats needs an audio stream; this file has none", kind="input")
177
178
  if args.beat_step <= 0:
178
179
  die("--beat-step must be greater than 0", kind="input")
179
- dur = meta.get("duration") or 0.0
180
+ dur = input_length(meta)
180
181
  cuts = [0.0] if (audio_only or pending) else detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
181
182
  has_audio = bool(meta.get("audio")) and not pending
182
183
  bounds = cuts + [dur]
@@ -35,6 +35,7 @@ from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
36
36
  from _common.decision import frame_rate, stream_starts
37
37
  from _common.probe import PROBED_AUDIO
38
+ from _common.emit import input_length
38
39
 
39
40
 
40
41
 
@@ -657,7 +658,7 @@ def main() -> int:
657
658
  meta = probe(args.input)
658
659
  if not meta.get("audio"):
659
660
  die("input has no audio stream to analyse")
660
- duration = meta.get("duration") or 0.0
661
+ duration = input_length(meta)
661
662
  breaths: List[Tuple[float, float]] = []
662
663
  if args.speech_aware:
663
664
  silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
@@ -21,6 +21,7 @@ from typing import List, Tuple
21
21
 
22
22
  from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
23
  from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
24
+ from _common.emit import input_length
24
25
 
25
26
  MAX_SPEED = 20.0
26
27
  MIN_SPEED = 0.05
@@ -72,7 +73,7 @@ def main() -> int:
72
73
  meta = probe(args.input)
73
74
  if not meta.get("video"):
74
75
  die("input has no video stream")
75
- dur = meta.get("duration") or 0.0
76
+ dur = input_length(meta)
76
77
  if abs(segments[0][0] - 0.0) > 0.01:
77
78
  die(f"segments must start at 0, first segment starts at {segments[0][0]:g}")
78
79
  if abs(segments[-1][1] - dur) > 0.5:
package/scripts/sync.py CHANGED
@@ -38,6 +38,7 @@ from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
39
39
  from _common.timeline import output_timecode_args
40
40
  from _common.runner import default_selection_maps
41
+ from _common.emit import input_length
41
42
 
42
43
  SR = 8000 # decode sample rate
43
44
 
@@ -227,8 +228,8 @@ def measure_one(reference: str, path: str, args) -> Dict:
227
228
  # the head to measure over) or low_confidence (the tail did not correlate); None when applied
228
229
  skipped_reason = None
229
230
  if args.fix_drift:
230
- ref_dur = probe(reference)["duration"] or 0.0
231
- sec_dur = probe(path)["duration"] or 0.0
231
+ ref_dur = input_length(probe(reference))
232
+ sec_dur = input_length(probe(path))
232
233
  overlap_end = min(ref_dur, sec_dur + offset)
233
234
  head_len = min(args.analyze_seconds, overlap_end)
234
235
  tail_start = overlap_end - args.drift_window