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 +3 -3
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +6 -3
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/alpha.py +77 -7
- package/scripts/_common/decision.py +31 -4
- package/scripts/_common/emit.py +23 -0
- package/scripts/_contract.py +1 -0
- package/scripts/audio.py +4 -14
- package/scripts/broll.py +13 -5
- package/scripts/color.py +58 -14
- package/scripts/export.py +2 -2
- package/scripts/fit.py +5 -0
- package/scripts/freeze.py +2 -1
- package/scripts/graphics.py +5 -4
- package/scripts/grid.py +19 -5
- package/scripts/join.py +3 -0
- package/scripts/look.py +2 -0
- package/scripts/loop.py +2 -1
- package/scripts/metadata.py +3 -1
- package/scripts/multicam.py +12 -6
- package/scripts/overlay.py +23 -4
- package/scripts/scenes.py +2 -1
- package/scripts/silence.py +2 -1
- package/scripts/speedramp.py +2 -1
- package/scripts/sync.py +3 -2
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.
|
|
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.
|
|
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.
|
|
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": [
|
package/references/gotchas.md
CHANGED
|
@@ -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)
|
|
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
|
|
package/references/scripts.md
CHANGED
|
@@ -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,
|
|
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`).
|
|
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,
|
package/scripts/_common/alpha.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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.
|
|
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
|
|
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"
|
|
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 ""
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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)
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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"
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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"))
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
|
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]
|
|
753
|
-
square =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
package/scripts/metadata.py
CHANGED
|
@@ -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
|
-
|
|
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] = []
|
package/scripts/multicam.py
CHANGED
|
@@ -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]
|
|
191
|
-
sec_dur = probe(p)
|
|
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]
|
|
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
|
-
|
|
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
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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"
|
|
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
|
|
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]
|
package/scripts/silence.py
CHANGED
|
@@ -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
|
|
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)
|
package/scripts/speedramp.py
CHANGED
|
@@ -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
|
|
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)
|
|
231
|
-
sec_dur = probe(path)
|
|
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
|