ffmpeg-skill 3.8.1 → 3.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.3`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -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.1", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.3", "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.1",
3
+ "version": "3.8.3",
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
@@ -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, conform_filter, conform_to_length, conform_output_t, 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, length_output_t, silence_filter, 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,6 +15,7 @@ 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.alpha import rgb_to_bt709_line
18
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
@@ -455,6 +456,62 @@ def conform_output_t(length: float, rate: int) -> str:
455
456
  return f"{length + 0.5 / rate:.6f}"
456
457
 
457
458
 
459
+ def length_output_t(length: float, rate: Optional[int], runs_on: bool = False) -> str:
460
+ """The output `-t` of a tool that leaves the sound's end to it (overlay.py, waveform.py,
461
+ loop.py, sync.py --replace-audio): the millisecond text they always wrote unless it cuts
462
+ samples off the track at `rate` (`-t 1.234` wrote 59244 samples at 48 kHz as 59232), and then
463
+ the length to the microsecond, which FFmpeg rounds to the nearest sample. `runs_on` is for
464
+ sound that goes on past the length (an endless apad, a loop's next repeat): there the
465
+ millisecond text is also left when it rounds up (`-t 1.235` wrote 59261 as 59280). No half
466
+ sample of slack as in conform_output_t(): this `-t` ends the picture too, and with the slack a
467
+ layer's frame that starts exactly at the end came through on a 1/60000 or 1/90000 time base
468
+ (FFmpeg 6.1.1). A rate of 0 or None (no sound, a dry run's pending input) keeps the
469
+ millisecond text."""
470
+ text = f"{length:.3f}"
471
+ if rate:
472
+ def names(micro: int) -> int:
473
+ # the samples FFmpeg makes of a -t of `micro` microseconds: a rescale that rounds a
474
+ # half up, where Python's round() takes it to the even count (0.005 s at 44.1 kHz is
475
+ # 220.5 samples: 221 written, round() says 220)
476
+ return (micro * rate + 500_000) // 1_000_000
477
+ written, asked = names(round(float(text) * 1_000_000)), round(length * rate)
478
+ if written < asked or (runs_on and written != asked):
479
+ # the microsecond text is a rounding too (2.00001042 s is 96001 samples at 48 kHz and
480
+ # "2.000010" names 96000): the microsecond nearest the length, moved by whole
481
+ # microseconds until it names the count asked. A microsecond is a twentieth of a
482
+ # sample at 48 kHz, so this is a step or two, not conform_output_t()'s half sample
483
+ micro = round(length * 1_000_000)
484
+ while names(micro) < asked:
485
+ micro += 1
486
+ while names(micro) > asked:
487
+ micro -= 1
488
+ return f"{micro // 1_000_000}.{micro % 1_000_000:06d}"
489
+ return text
490
+
491
+
492
+ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Optional[float] = None) -> str:
493
+ """Silence of `lead` seconds ahead of a track and/or `tail` seconds after it (pad.py,
494
+ freeze.py). An amount in whole milliseconds is written as it always was (`adelay=MS:all=1`,
495
+ `apad=pad_dur=S.mmm`), the millisecond rounded, not cut: int(1.001 * 1000) is 1000. One that
496
+ is not is counted in samples at `rate`, pinned by an aformat as in conform_filter():
497
+ `adelay=234` and `apad=pad_dur=0.234` for 0.23425 s left the sound 12 samples short at 48 kHz.
498
+ A rate of 0 or None (a dry run's pending input) keeps the millisecond form."""
499
+ amounts = [v for v in (lead, tail) if v is not None]
500
+ if rate and any(round(v * rate) != round(round(v, 3) * rate) for v in amounts):
501
+ parts = [f"aformat=sample_rates={rate}"]
502
+ if lead:
503
+ parts.append(f"adelay={round(lead * rate)}S:all=1")
504
+ if tail:
505
+ parts.append(f"apad=pad_len={round(tail * rate)}")
506
+ return ",".join(parts)
507
+ parts = []
508
+ if lead is not None:
509
+ parts.append(f"adelay={round(lead * 1000)}:all=1")
510
+ if tail is not None:
511
+ parts.append(f"apad=pad_dur={tail:.3f}")
512
+ return ",".join(parts)
513
+
514
+
458
515
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
459
516
  """The one length a clip holds once both streams start at its first frame (av_trim from the
460
517
  video's start): the video's, or where the sound ends when it runs more than a frame past the
@@ -893,9 +950,11 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
893
950
  with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
894
951
  leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
895
952
  v = (line_meta or {}).get("video") or {}
896
- if STATE.alpha_line is not None and v.get("color_space") == "bt709" and not v.get("bt2020_or_hdr"):
953
+ if (STATE.alpha_line is not None and (v.get("color_space") == "bt709" or rgb_to_bt709_line(line_meta))
954
+ and not v.get("bt2020_or_hdr")):
897
955
  # an alpha line keeps the source's tags: on a BT.709-tagged picture an RGB layer is made
898
- # BT.709 too (FFmpeg 6.1's own conversion is BT.601), in 4:4:4 with its alpha plane
956
+ # BT.709 too (FFmpeg 6.1's own conversion is BT.601), in 4:4:4 with its alpha plane. An
957
+ # RGB source on a YUV line is a BT.709 picture as well (alpha.rgb_to_bt709_line, #496)
899
958
  return "," + rgb_to_bt709_filter("yuva444p")
900
959
  if keeps_source_tags() or v.get("bt2020_or_hdr"):
901
960
  return ""
@@ -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/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/freeze.py CHANGED
@@ -19,7 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
22
+ from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, 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
24
  from _common.emit import input_length
25
25
 
@@ -73,7 +73,7 @@ def main() -> int:
73
73
  vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
74
74
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
75
75
  if has_audio:
76
- cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
76
+ cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), tail=args.hold)]
77
77
  elif at == 0 or cut_at <= v0 + 1e-6:
78
78
  # A freeze at the very start has no preceding "head" segment to hold on to (the
79
79
  # split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
@@ -82,7 +82,7 @@ def main() -> int:
82
82
  vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
83
83
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
84
84
  if has_audio:
85
- cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
85
+ cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=args.hold)]
86
86
  else:
87
87
  # freeze N frames at `at` by holding on that one source frame for --hold seconds, then
88
88
  # resuming the rest of the clip: split the timeline at `at` and hold the remainder's
@@ -44,7 +44,7 @@ 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
@@ -750,8 +750,8 @@ def main(force_route: Optional[str] = None) -> int:
750
750
  # 4:4:4 (overlay's default is 4:2:0); drawtext and libass (alpha=1) draw into the alpha too
751
751
  fc = [c.replace(":eof_action=pass", ":eof_action=pass:format=yuv444") for c in fc]
752
752
  if fc:
753
- fc = ["[0:v]format=yuva444p[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
754
- 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 "")
755
755
  elif overlay_format(meta):
756
756
  # a 10-bit picture on a 10-bit line is composited at 10 bits (overlay's default is 8-bit)
757
757
  fc = [c.replace(":eof_action=pass", ":eof_action=pass" + overlay_format(meta)) for c in fc]
package/scripts/loop.py CHANGED
@@ -34,7 +34,7 @@ import tempfile
34
34
 
35
35
  from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
36
36
  from _common.emit import warn
37
- from _common.decision import av_trim
37
+ from _common.decision import av_trim, length_output_t
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
39
39
  from _common.emit import input_length
40
40
 
@@ -320,7 +320,8 @@ def main() -> int:
320
320
  else:
321
321
  cmd += ["-map", "0:v:0", "-vf", vtrim]
322
322
  if target is not None:
323
- cmd += ["-t", f"{target:.3f}"]
323
+ # the sound ends at --duration to the sample, not to the millisecond (length_output_t)
324
+ cmd += ["-t", length_output_t(target, (meta.get("audio") or {}).get("sample_rate") if has_audio else None, runs_on=True)]
324
325
  cmd += video_args(meta, args.crf, args.preset)
325
326
  # a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
326
327
  # not a variable-rate source's average, which re-timed every repeat by a frame or two
@@ -37,8 +37,10 @@ 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
41
+ from _common.decision import length_output_t
40
42
  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)
43
+ has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
42
44
  from _common.probe import display_size, square_pixels, square_pixels_note
43
45
  from _common.wrap import wrap_text
44
46
  from _common import text_width_em
@@ -361,6 +363,10 @@ def main() -> int:
361
363
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
362
364
  if args.audio_stream and not audio_streams:
363
365
  die("--audio-stream needs an input with audio streams")
366
+ # the -t that ends a layer at the main input's length: to the sample of the kept track when the
367
+ # millisecond would cut it (length_output_t); no sound, or a pending input, keeps the millisecond
368
+ kept_track = audio_streams[args.audio_stream] if audio_streams else (meta.get("audio") or {})
369
+ main_t = length_output_t(meta["duration"], kept_track.get("sample_rate")) if meta.get("duration") else None
364
370
  # ffmpeg applies display rotation before the filter graph, so the frame this overlay is
365
371
  # composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
366
372
  # A non-square-pixel source is made square at its display aspect first (#411), so a logo or
@@ -453,7 +459,7 @@ def main() -> int:
453
459
  # streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
454
460
  # must not be allowed to cut the whole output short via -shortest's "stop at whichever
455
461
  # mapped stream finishes first" semantics.
456
- cmd += ["-t", f"{meta['duration']:.3f}"]
462
+ cmd += ["-t", main_t]
457
463
  else:
458
464
  # No known duration to bound by -t (e.g. probe found no video duration): -shortest is
459
465
  # the only thing stopping the looped still from running forever. FFmpeg 7+'s
@@ -490,9 +496,27 @@ def main() -> int:
490
496
  # an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
491
497
  # a 10-bit layer over a main overlay_format() composites at 10 bits stays 10-bit
492
498
  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")
499
+ keyed_601 = False
500
+ if alpha_codec and is_rgb(pip_meta) and rgb_chain_to_bt709(meta):
501
+ # an RGB layer (qtrle, PNG in .mov) on an alpha line that is BT.709 (a BT.709-tagged
502
+ # main, or an RGB main on a ProRes 4444/VP9 line): matrix_chain() converts nothing on
503
+ # an alpha line, so the layer became YUV with FFmpeg's BT.601 default and pure red
504
+ # read FF1800 under the line's tag (#496's sweep)
505
+ pal = "format=rgba," if pip_meta["video"].get("pix_fmt") == "pal8" else ""
506
+ if args.chromakey:
507
+ # chromakey turns its RGB key into YUV with BT.601 whatever the frames say, so the
508
+ # layer is keyed as BT.601 YUV (what it was before #496, and what the key is
509
+ # computed in) and taken to the line's BT.709 after the key
510
+ chain.append(pal + "scale=out_color_matrix=bt601:out_range=tv,format=yuva444p")
511
+ keyed_601 = True
512
+ else:
513
+ chain.append(pal + rgb_chain_to_bt709(meta).lstrip(","))
514
+ else:
515
+ chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
494
516
  if args.chromakey:
495
517
  chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
518
+ if keyed_601:
519
+ chain.append("scale=in_color_matrix=bt601:out_color_matrix=bt709:in_range=tv:out_range=tv,format=yuva444p")
496
520
  if args.opacity < 1:
497
521
  # the alpha is scaled in YUV: colorchannelmixer takes RGB only, so FFmpeg converted the
498
522
  # layer to RGB and overlay=format=auto then composited the whole main picture in RGB,
@@ -548,7 +572,7 @@ def main() -> int:
548
572
  if meta.get("duration"):
549
573
  # See the --image branch above: -t (exact, bounds only the main input) instead of
550
574
  # -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
551
- cmd += ["-t", f"{meta['duration']:.3f}"]
575
+ cmd += ["-t", main_t]
552
576
  else:
553
577
  cmd += ["-shortest"]
554
578
  else:
@@ -588,12 +612,12 @@ def main() -> int:
588
612
  cmd[first:first] = decoder_args_for(meta, args.input)
589
613
  if "-filter_complex" in cmd:
590
614
  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")
615
+ cmd[i] = (f"[0:v]{source_format(meta)}[ka0];" + cmd[i].replace("[0:v]", "[ka0]").replace(":format=auto", ":format=yuv444")
592
616
  + f";[out]{finish}[ka_v]")
593
617
  cmd[cmd.index("[out]")] = "[ka_v]"
594
618
  else:
595
619
  i = cmd.index("-vf") + 1
596
- cmd[i] = f"format=yuva444p,{cmd[i]},{finish}"
620
+ cmd[i] = f"{source_format(meta)},{cmd[i]},{finish}"
597
621
  cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta)
598
622
  cmd += audio_line(alpha_codec) if meta.get("audio") else ["-an"]
599
623
  if STATE.hw:
package/scripts/pad.py CHANGED
@@ -17,6 +17,7 @@ import argparse
17
17
  import sys
18
18
 
19
19
  from _common.emit import record_fallback
20
+ from _common.decision import silence_filter
20
21
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
21
22
 
22
23
 
@@ -64,7 +65,8 @@ def main() -> int:
64
65
  # front (one -filter_complex), so the record is made here, as broll and stabilize do
65
66
  record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
66
67
  "odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
67
- af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
68
+ # the silence is counted in samples when --start/--end is not a whole millisecond
69
+ af = silence_filter(meta["audio"].get("sample_rate"), args.start, args.end)
68
70
  cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
69
71
  else:
70
72
  cmd += ["-vf", vf, "-map", "0:v:0"]
package/scripts/sync.py CHANGED
@@ -35,7 +35,7 @@ from typing import Dict, List
35
35
  from _common.runner import fail_if_classified
36
36
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
- from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
38
+ from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, stream_starts
39
39
  from _common.timeline import output_timecode_args
40
40
  from _common.runner import default_selection_maps
41
41
  from _common.emit import input_length
@@ -379,7 +379,10 @@ def main() -> int:
379
379
  ref_dur = probe(args.reference).get("duration")
380
380
  af_parts = on_clock + drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else []) + ["apad"]
381
381
  cmd += ["-af", ",".join(af_parts)]
382
- cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
382
+ # to the sample of the rate the second's sound is written at, not to the millisecond:
383
+ # the endless apad is ended by this -t alone (length_output_t)
384
+ ref_t = length_output_t(ref_dur, (second_meta.get("audio") or {}).get("sample_rate"), runs_on=True) if ref_dur else None
385
+ cmd += aac_args() + (["-t", ref_t] if ref_dur else ["-shortest"]) + [output]
383
386
  proc = run(cmd, check=False)
384
387
  fail_if_classified(proc) # a cause the re-encode would hit too is reported, not retried (#415)
385
388
  if proc.returncode != 0:
@@ -41,6 +41,7 @@ from _common import (add_common, apply_common, aac_args, default_output, die, dr
41
41
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
42
42
  fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
43
43
  from _common.emit import absorb_stage_hw, note, warn
44
+ from _common.decision import length_output_t
44
45
 
45
46
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
46
47
 
@@ -232,7 +233,9 @@ def main() -> int:
232
233
  # audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
233
234
  # at the source's own duration when probe knows it.
234
235
  if meta.get("duration"):
235
- cmd += ["-t", f"{float(meta['duration']):.3f}"]
236
+ # to the sample of the drawn track when the millisecond would cut it (length_output_t)
237
+ drawn_track = audio_streams[args.audio_stream] if audio_streams else meta["audio"]
238
+ cmd += ["-t", length_output_t(float(meta["duration"]), drawn_track.get("sample_rate"))]
236
239
  cmd += ["-shortest", render]
237
240
  titled_tmp = sibling_temp(output, "titled")
238
241
  intermediates = [p for p in (render, titled_tmp) if p != output]