ffmpeg-skill 2.10.0 → 2.10.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/README.md +1 -1
- package/docs/contract.md +11 -2
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +1 -1
- package/scripts/_common/__init__.py +7 -6
- package/scripts/_common/color.py +151 -4
- package/scripts/_common/decision.py +63 -10
- package/scripts/_common/emoji.py +9 -1
- package/scripts/_common/runner.py +5 -0
- package/scripts/broll.py +8 -4
- package/scripts/caption.py +4 -2
- package/scripts/check.py +3 -3
- package/scripts/color.py +3 -1
- package/scripts/export.py +6 -1
- package/scripts/graphics.py +4 -2
- package/scripts/grid.py +9 -2
- package/scripts/insert.py +3 -1
- package/scripts/join.py +4 -3
- package/scripts/multicam.py +4 -3
- package/scripts/overlay.py +12 -4
- package/scripts/sequence.py +2 -1
- package/scripts/waveform.py +4 -2
package/README.md
CHANGED
|
@@ -248,7 +248,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
248
248
|
|
|
249
249
|
| Tool | What it does |
|
|
250
250
|
|---|---|
|
|
251
|
-
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR; `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
|
|
251
|
+
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
|
|
252
252
|
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
|
|
253
253
|
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row |
|
|
254
254
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.10.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.10.2`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.10.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.10.2", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -217,6 +217,15 @@ BT.709 for SDR; av1 is SVT-AV1 with libaom as the fallback; prores is 422 HQ and
|
|
|
217
217
|
its default is the tool's own (18, `proxy.py` 30). 1.x also accepted `--crf` as an alias; 2.0
|
|
218
218
|
removed it (`export.py` keeps its own `--crf`). Without `--codec` the encoder is what it always was (x264 for SDR, x265
|
|
219
219
|
Main10 for HDR), so the flags add no behaviour to a caller that does not pass them.
|
|
220
|
+
Every SDR line except prores is tagged BT.709; a source whose matrix is BT.601 (`smpte170m`,
|
|
221
|
+
`bt470bg`, or `fcc`/`smpte240m`) is converted to it in the filter graph (`colorspace`, matrix
|
|
222
|
+
only; `_common.bt709_matrix_filter()`), so the label and the pixels agree. An untagged or BT.709
|
|
223
|
+
source adds no filter, and the tags themselves never convert (`bt709_tag_args()`). An RGB picture
|
|
224
|
+
(a PNG or JPEG still, a PNG sequence, a qtrle clip, an `overlay --image` logo, an emoji PNG,
|
|
225
|
+
`waveform --image`) is converted to BT.709 YUV explicitly (`scale=out_color_matrix=bt709`; a JPEG is
|
|
226
|
+
a `bt470bg` source); `grid.py` converts each cell by its own tags. Where a converted picture meets an
|
|
227
|
+
untagged one in a mixing filter, the untagged one is labelled BT.709 (`setparams`, tags only), so
|
|
228
|
+
FFmpeg 7.1+'s colourspace negotiation converts neither.
|
|
220
229
|
`cut.py --keep-hevc` (opt-in) re-encodes an SDR HEVC source as x265 8-bit BT.709 instead of x264; an HDR or
|
|
221
230
|
BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
|
|
222
231
|
encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.2",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/gotchas.md
CHANGED
|
@@ -8,7 +8,13 @@ detail, not an aside.
|
|
|
8
8
|
## Colour, HDR and Log
|
|
9
9
|
|
|
10
10
|
### HDR and colour
|
|
11
|
-
SDR outputs are H.264 tagged BT.709 `yuv420p`.
|
|
11
|
+
SDR outputs are H.264 tagged BT.709 `yuv420p`. A source tagged with a BT.601 matrix
|
|
12
|
+
(`smpte170m`, `bt470bg`; also `fcc`, `smpte240m`) has its pixels converted to that BT.709 matrix
|
|
13
|
+
by every re-encode (through 2.10.0 only the label changed); `color.py --retag bt709` is a
|
|
14
|
+
stream copy and still only relabels. Untagged sources are taken as BT.709 and left alone. An RGB
|
|
15
|
+
picture (PNG/JPEG still, logo, emoji PNG, PNG sequence, qtrle clip) is converted to BT.709 YUV
|
|
16
|
+
explicitly (through 2.10.1 FFmpeg's BT.601 default did it), and `grid.py` converts each cell by its
|
|
17
|
+
own tags. When `probe.py` reports
|
|
12
18
|
`bt2020_or_hdr: true` (HDR10/PQ, HLG, Dolby Vision, or BT.2020 SDR), every editing script keeps
|
|
13
19
|
the output on the 10-bit path (HEVC Main10, source colour tags) so nothing is silently
|
|
14
20
|
flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
|
package/references/scripts.md
CHANGED
|
@@ -1615,7 +1615,7 @@ export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook
|
|
|
1615
1615
|
[--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
|
|
1616
1616
|
export.py --list
|
|
1617
1617
|
```
|
|
1618
|
-
Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
|
|
1618
|
+
Scales into the preset frame (pad by default), tags BT.709 (a BT.601-matrix or RGB source is converted to it, as by every re-encode), sets `+faststart`,
|
|
1619
1619
|
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
|
|
1620
1620
|
does not touch levels: for youtube / youtube4k / reels / x the written file is
|
|
1621
1621
|
measured and the result's `loudness` (and a `notes` line) says when it is
|
|
@@ -83,7 +83,7 @@ from _common.decision import (
|
|
|
83
83
|
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
84
84
|
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
85
85
|
fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
86
|
-
signed_time_arg, source_codec_video_args, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
86
|
+
signed_time_arg, source_codec_video_args, label_chain, matrix_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
87
87
|
)
|
|
88
88
|
from _common.asr import (
|
|
89
89
|
ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
|
|
@@ -91,7 +91,8 @@ from _common.asr import (
|
|
|
91
91
|
whisper_word_timings, write_srt
|
|
92
92
|
)
|
|
93
93
|
from _common.color import (
|
|
94
|
-
bt709_tag_args, color_hex, _COLOR_TOKEN_RE,
|
|
94
|
+
bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, color_hex, _COLOR_TOKEN_RE, fold_bt709_matrix,
|
|
95
|
+
is_rgb, NON_BT709_MATRICES, rgb_to_bt709_filter, _sdr_bt709, validate_color
|
|
95
96
|
)
|
|
96
97
|
from _common.text import (
|
|
97
98
|
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
|
|
@@ -178,7 +179,7 @@ __all__ = [
|
|
|
178
179
|
"platform", "re", "shutil", "subprocess", "sys", "unicodedata",
|
|
179
180
|
"aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
|
|
180
181
|
"audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
|
|
181
|
-
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
|
|
182
|
+
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "_char_em",
|
|
182
183
|
"char_script", "_check_existing_output", "refuse_existing_outputs", "_check_no_overwrite_input", "_check_output_path", "child_args",
|
|
183
184
|
"child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
|
|
184
185
|
"concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
|
|
@@ -196,12 +197,12 @@ __all__ = [
|
|
|
196
197
|
"_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
|
|
197
198
|
"escape_filter_path", "EVEN_SCALE", "_execute", "_fail", "_family_rank", "FC_LANG", "_fc_list_fonts",
|
|
198
199
|
"FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_VERSION", "ffmpeg_version", "fingerprint",
|
|
199
|
-
"flush_drawtext_textfiles", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
|
|
200
|
+
"flush_drawtext_textfiles", "fold_bt709_matrix", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
|
|
200
201
|
"font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
|
|
201
202
|
"fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
|
|
202
203
|
"is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
|
|
203
204
|
"keyframes_near", "classify_frame_timing", "measure_frame_timing", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
|
|
204
|
-
"load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
|
|
205
|
+
"load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS", "NON_BT709_MATRICES",
|
|
205
206
|
"_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
|
|
206
207
|
"place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
|
|
207
208
|
"print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
|
|
@@ -212,7 +213,7 @@ __all__ = [
|
|
|
212
213
|
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
|
|
213
214
|
"_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
|
|
214
215
|
"time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
|
|
215
|
-
"source_codec_video_args", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
216
|
+
"source_codec_video_args", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
216
217
|
"_x264_raw", "_ZWJ",
|
|
217
218
|
"ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
|
|
218
219
|
"_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
|
package/scripts/_common/color.py
CHANGED
|
@@ -4,7 +4,7 @@ validation for the tools that take a `--color`.
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import re
|
|
7
|
-
from typing import List, Tuple
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
8
8
|
from _common.emit import die
|
|
9
9
|
from _common.runner import ffmpeg_version
|
|
10
10
|
|
|
@@ -18,9 +18,13 @@ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
|
|
|
18
18
|
screen recordings, many cameras) the CLI now auto-inserts a *real* matrix conversion (its
|
|
19
19
|
guess for "unknown" is bt601) into every SDR re-encode: a --lut-strength 0 no-op grade
|
|
20
20
|
came back ~24 dB PSNR from its source on 7.1. From 7.1 on, the tags therefore go through
|
|
21
|
-
the encoder's own VUI parameters instead, which libavfilter never sees
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
the encoder's own VUI parameters instead, which libavfilter never sees.
|
|
22
|
+
|
|
23
|
+
These tags are a label on every FFmpeg version: they never convert the pixels. A source
|
|
24
|
+
whose matrix is not BT.709 (a BT.601 `smpte170m`/`bt470bg` file) gets its pixels converted
|
|
25
|
+
separately, by bt709_matrix_filter() in the filter graph; through 2.10.0 nothing did, and
|
|
26
|
+
such a source came out labelled BT.709 over BT.601 pixels (measured on 6.1: 48.5 dB YUV
|
|
27
|
+
PSNR against the source, i.e. untouched, tagged bt709/bt709/bt709).
|
|
24
28
|
"""
|
|
25
29
|
if ffmpeg_version() < (7, 1):
|
|
26
30
|
return ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
@@ -42,6 +46,149 @@ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
|
|
|
42
46
|
return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
|
|
43
47
|
|
|
44
48
|
|
|
49
|
+
# The SDR matrices (ffprobe's names) whose pixels an output tagged BT.709 has to convert:
|
|
50
|
+
# BT.601 (`smpte170m` 525-line, `bt470bg` 625-line: the same coefficients) and the two rare
|
|
51
|
+
# pre-BT.709 ones the colorspace filter also reads. bt2020nc/c sources take the HDR/BT.2020 path.
|
|
52
|
+
NON_BT709_MATRICES = ("smpte170m", "bt470bg", "fcc", "smpte240m")
|
|
53
|
+
|
|
54
|
+
# RGB pictures (ffprobe's pix_fmt names: PNG rgb24/rgba/pal8/rgb48be, qtrle, gbrp...). Their
|
|
55
|
+
# conversion to YUV is wherever the graph first needs YUV, and FFmpeg's default for it is BT.601
|
|
56
|
+
# (5.1, 6.1, 7.0; 7.1+ follows the link's tag, unspecified = BT.601). Gray has no matrix.
|
|
57
|
+
_RGB_PIX_FMT_RE = re.compile(r"^(?:rgb|bgr|argb|abgr|0rgb|0bgr|gbr|x2rgb|x2bgr|pal8)")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def rgb_to_bt709_filter(pix_fmt: str = "yuv444p") -> str:
|
|
61
|
+
"""An RGB picture converted to BT.709 limited-range YUV right here, in `pix_fmt`, so no later
|
|
62
|
+
conversion (an auto-inserted one, with FFmpeg's BT.601 default) decides the matrix. 4:4:4 by
|
|
63
|
+
default: the tool's own scaling and the encoder's 4:2:0 come after, as they did on the RGB."""
|
|
64
|
+
return f"scale=out_color_matrix=bt709:out_range=tv,format={pix_fmt}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
|
|
68
|
+
"""True when the probed picture is RGB (a PNG/still, a qtrle/png-in-mov clip, an image sequence)."""
|
|
69
|
+
return bool(_RGB_PIX_FMT_RE.match(str(((meta or {}).get("video") or {}).get("pix_fmt") or "")))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_MATRIX_FILTER_RE = re.compile(r"^(?:colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709|(?:format=rgb24,)?%s)$"
|
|
73
|
+
% ("|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
|
|
74
|
+
_INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
78
|
+
"""The filter that turns the probed source's pixels into BT.709 ones before an output is
|
|
79
|
+
tagged BT.709, or None when nothing needs converting: an SDR source tagged with a non-BT.709
|
|
80
|
+
matrix (NON_BT709_MATRICES) only. An untagged or BT.709 source, an HDR/BT.2020 one (it keeps
|
|
81
|
+
its own tags, or color.py --to-sdr converts it) and a missing probe get None, so their
|
|
82
|
+
commands are what they were.
|
|
83
|
+
|
|
84
|
+
The matrix is converted and nothing else: primaries and transfer are passed through
|
|
85
|
+
(iprimaries/itrc = the output's), so the picture a player that decodes with the source's
|
|
86
|
+
matrix showed is the picture the BT.709 output shows. The `colorspace` filter is in every
|
|
87
|
+
FFmpeg build (no zscale needed) and takes explicit input properties, so the conversion does
|
|
88
|
+
not depend on how a build reads or negotiates the frames' own tags. The range is left as it is.
|
|
89
|
+
|
|
90
|
+
An RGB source (is_rgb: a PNG/still, an image sequence, a qtrle clip) has no matrix of its own
|
|
91
|
+
but gets one where the graph first needs YUV, and FFmpeg's default there is BT.601: through
|
|
92
|
+
2.10.1 such a source came out labelled BT.709 over BT.601-encoded pixels (pure red decoded as
|
|
93
|
+
(255,24,0), pure green as (0,216,0), on 5.1.1 to 8.1). It is converted to BT.709 YUV here
|
|
94
|
+
(rgb_to_bt709_filter). A JPEG (yuvj, tagged bt470bg) is a BT.601 source like any other."""
|
|
95
|
+
v = (meta or {}).get("video") or {}
|
|
96
|
+
matrix = v.get("color_space")
|
|
97
|
+
if v.get("bt2020_or_hdr"):
|
|
98
|
+
return None
|
|
99
|
+
if is_rgb(meta):
|
|
100
|
+
# a palette is turned into YUV with fixed BT.601 coefficients whatever out_color_matrix
|
|
101
|
+
# says (5.1.1, 6.1): expand it to RGB first
|
|
102
|
+
return ("format=rgb24," if v.get("pix_fmt") == "pal8" else "") + rgb_to_bt709_filter()
|
|
103
|
+
if matrix not in NON_BT709_MATRICES:
|
|
104
|
+
return None
|
|
105
|
+
return f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def bt709_label_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
109
|
+
"""For an UNTAGGED SDR YUV picture that meets a converted (BT.709-tagged) one in a filter that
|
|
110
|
+
mixes pictures (overlay, xstack): its BT.709 label, tags only (setparams), or None for any
|
|
111
|
+
other picture (tagged, RGB, HDR, no probe).
|
|
112
|
+
|
|
113
|
+
FFmpeg 7.1+ negotiates one colourspace for all of a mixing filter's inputs and converts the
|
|
114
|
+
others to it, taking "unknown" as BT.601: the untagged main under a converted logo, or an
|
|
115
|
+
untagged grid cell beside a converted one, was shifted (7.08 mean / 25 max RGB on 7.1.1 and
|
|
116
|
+
8.1.3), or the converted picture converted back. Labelled as what the output says it is, the
|
|
117
|
+
inputs agree and nothing is converted; on 5.1-7.0 the label changes nothing. The pixels are not
|
|
118
|
+
touched, as for the untagged source of a one-input re-encode (bt709_tag_args)."""
|
|
119
|
+
v = (meta or {}).get("video") or {}
|
|
120
|
+
if (not v.get("pix_fmt") or v.get("bt2020_or_hdr") or is_rgb(meta)
|
|
121
|
+
or (v.get("color_space") or "unknown") not in ("unknown", "unspecified")):
|
|
122
|
+
return None
|
|
123
|
+
return "setparams=colorspace=bt709" + ("" if v.get("color_range") == "pc" else ":range=tv")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def bt709_matrix_args(meta: Optional[Dict[str, Any]]) -> List[str]:
|
|
127
|
+
"""["-vf", bt709_matrix_filter(meta)] for an encoder line that tags its output BT.709, or [].
|
|
128
|
+
|
|
129
|
+
It rides on the encoder line (decision.video_args() and friends) so every tool that
|
|
130
|
+
re-encodes through that line converts without building it into its own graph. That -vf is
|
|
131
|
+
valid as it stands for a command with no other filter; run() folds it into the tool's own
|
|
132
|
+
-vf or -filter_complex (fold_bt709_matrix()) before the command is recorded or run."""
|
|
133
|
+
f = bt709_matrix_filter(meta)
|
|
134
|
+
return ["-vf", f] if f else []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
|
|
138
|
+
"""`cmd` with the conversion bt709_matrix_args() appended to its encoder line moved into the
|
|
139
|
+
tool's own filter graph, where it runs first, on the decoded source:
|
|
140
|
+
|
|
141
|
+
- no other filter: unchanged (the -vf is the graph);
|
|
142
|
+
- a -vf: the conversion is prepended to that chain;
|
|
143
|
+
- a -filter_complex: every `[N:v]` / `[N:v:0]` reference to an input that reads the same file
|
|
144
|
+
as input 0 (the file the encoder line was built for; cut.py's segment join opens it once
|
|
145
|
+
per segment) reads that input through the conversion instead. Other inputs are untouched.
|
|
146
|
+
|
|
147
|
+
A -filter_complex with no such reference is refused rather than run with two graphs (ffmpeg
|
|
148
|
+
would refuse it anyway, less clearly). Every other command is returned as it is."""
|
|
149
|
+
out: List[str] = []
|
|
150
|
+
conv, at = None, -1
|
|
151
|
+
i = 0
|
|
152
|
+
while i < len(cmd):
|
|
153
|
+
if cmd[i] == "-vf" and i + 1 < len(cmd) and _MATRIX_FILTER_RE.match(str(cmd[i + 1])):
|
|
154
|
+
conv, at = conv or str(cmd[i + 1]), (at if at >= 0 else len(out))
|
|
155
|
+
i += 2
|
|
156
|
+
continue
|
|
157
|
+
out.append(cmd[i])
|
|
158
|
+
i += 1
|
|
159
|
+
if conv is None:
|
|
160
|
+
return list(cmd)
|
|
161
|
+
graph_at = next((k for k, a in enumerate(out[:-1]) if a in ("-filter_complex", "-lavfi")), None)
|
|
162
|
+
if graph_at is not None:
|
|
163
|
+
inputs = [out[k + 1] for k in range(len(out) - 1) if out[k] == "-i"]
|
|
164
|
+
source = {n for n, p in enumerate(inputs) if p == inputs[0]} if inputs else set()
|
|
165
|
+
refs: Dict[int, List[str]] = {} # input index -> [its first reference, the labels standing in for it]
|
|
166
|
+
|
|
167
|
+
def through(m: "re.Match[str]") -> str:
|
|
168
|
+
n = int(m.group(1))
|
|
169
|
+
if n not in source:
|
|
170
|
+
return m.group(0)
|
|
171
|
+
label = f"[bt709src{sum(len(v) - 1 for v in refs.values())}]"
|
|
172
|
+
refs.setdefault(n, [m.group(0)]).append(label)
|
|
173
|
+
return label
|
|
174
|
+
|
|
175
|
+
graph = _INPUT_VIDEO_REF.sub(through, str(out[graph_at + 1]))
|
|
176
|
+
if not refs:
|
|
177
|
+
die("internal: a BT.709 matrix conversion was requested for a -filter_complex that never reads "
|
|
178
|
+
"input 0's video as [0:v]", kind="ffmpeg")
|
|
179
|
+
# one conversion per input, split when the graph reads that input more than once
|
|
180
|
+
# (speedramp.py trims [0:v] once per segment)
|
|
181
|
+
chains = [f"{ref}{conv}" + (f",split={len(labels)}" if len(labels) > 1 else "") + "".join(labels)
|
|
182
|
+
for ref, *labels in refs.values()]
|
|
183
|
+
out[graph_at + 1] = ";".join(chains + [graph])
|
|
184
|
+
return out
|
|
185
|
+
vf_at = next((k for k, a in enumerate(out[:-1]) if a == "-vf"), None)
|
|
186
|
+
if vf_at is not None:
|
|
187
|
+
out[vf_at + 1] = f"{conv},{out[vf_at + 1]}"
|
|
188
|
+
return out
|
|
189
|
+
return out[:at] + ["-vf", conv] + out[at:]
|
|
190
|
+
|
|
191
|
+
|
|
45
192
|
def color_hex(value: str) -> str:
|
|
46
193
|
"""Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
|
|
47
194
|
v = str(value).strip().lstrip("#")
|
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
from fractions import Fraction
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
-
from _common.color import bt709_tag_args, _sdr_bt709
|
|
17
|
+
from _common.color import bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, rgb_to_bt709_filter, _sdr_bt709
|
|
18
18
|
from _common.emit import die
|
|
19
19
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_version, hw_platform_reason
|
|
20
20
|
|
|
@@ -497,30 +497,83 @@ def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
|
|
|
497
497
|
return args
|
|
498
498
|
|
|
499
499
|
|
|
500
|
-
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True
|
|
500
|
+
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
|
|
501
|
+
source: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
501
502
|
"""SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
|
|
502
|
-
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it).
|
|
503
|
+
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it).
|
|
504
|
+
|
|
505
|
+
`source`: the probe of the file being re-encoded, when the pixels reaching the encoder are
|
|
506
|
+
still that file's. A BT.709-tagged line then carries the conversion of a non-BT.709 matrix
|
|
507
|
+
(bt709_matrix_args); a caller whose graph already outputs BT.709 (--to-sdr) leaves it out."""
|
|
503
508
|
if STATE.codec and STATE.codec != "h264":
|
|
504
|
-
|
|
505
|
-
|
|
509
|
+
args = encoder_args(STATE.codec, crf, preset, None, keep_bt709)
|
|
510
|
+
else:
|
|
511
|
+
args = _maybe_hw("h264", crf, None, keep_bt709, _x264_raw(crf, preset, keep_bt709))
|
|
512
|
+
return args + (bt709_matrix_args(source) if keep_bt709 and STATE.codec != "prores" else [])
|
|
506
513
|
|
|
507
514
|
|
|
508
|
-
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium"
|
|
515
|
+
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True,
|
|
516
|
+
source: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
509
517
|
"""Encoder args that preserve what the source is.
|
|
510
518
|
|
|
511
519
|
SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
|
|
512
520
|
Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
|
|
513
521
|
so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
|
|
514
522
|
washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
|
|
523
|
+
|
|
524
|
+
An SDR source tagged with a BT.601 (or another non-BT.709) matrix is converted to BT.709
|
|
525
|
+
before it is tagged so: the line ends with the `-vf` of bt709_matrix_args(), which run()
|
|
526
|
+
folds into the tool's own graph (color.fold_bt709_matrix). `convert_matrix=False` is for a
|
|
527
|
+
tool that mixes several sources and converts each one in its own graph (join, multicam,
|
|
528
|
+
broll, overlay --video). --codec prores keeps the source's tags, so it converts nothing.
|
|
529
|
+
An RGB source (a PNG, an image sequence, a qtrle clip) is converted the same way.
|
|
530
|
+
|
|
531
|
+
`source`: the probe of the picture the encoder gets when the line is chosen without it
|
|
532
|
+
(insert.py and sequence.py make a still or a PNG sequence into video on the SDR line,
|
|
533
|
+
video_args(None)); its RGB or BT.601 pixels are converted to the BT.709 that line is tagged with.
|
|
515
534
|
"""
|
|
516
|
-
if STATE.codec:
|
|
517
|
-
return encoder_args(STATE.codec, crf, preset, meta)
|
|
518
535
|
v = (meta or {}).get("video") or {}
|
|
536
|
+
fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and STATE.codec != "prores" else []
|
|
537
|
+
if STATE.codec:
|
|
538
|
+
return encoder_args(STATE.codec, crf, preset, meta) + fix
|
|
519
539
|
if not v.get("bt2020_or_hdr"):
|
|
520
|
-
return x264_args(crf, preset)
|
|
540
|
+
return x264_args(crf, preset) + fix
|
|
521
541
|
return _maybe_hw("hevc", crf, meta, True, _x265_hdr_default(v, crf, preset))
|
|
522
542
|
|
|
523
543
|
|
|
544
|
+
def matrix_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
|
|
545
|
+
"""For a tool that mixes several sources in one graph and encodes it through
|
|
546
|
+
video_args(line_meta, convert_matrix=False): the conversion one source's own chain starts
|
|
547
|
+
with ("<filter>," -- bt709_matrix_filter), or "" when that source needs none or the line does
|
|
548
|
+
not tag BT.709 (an HDR/BT.2020 line keeps its tags, --codec prores keeps the source's)."""
|
|
549
|
+
if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
550
|
+
return ""
|
|
551
|
+
f = bt709_matrix_filter(meta)
|
|
552
|
+
return f + "," if f else ""
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
|
|
556
|
+
"""For a picture that meets a converted one in a mixing filter (a still over the video, a grid
|
|
557
|
+
cell beside a converted cell), encoded through video_args(line_meta): the BT.709 label an
|
|
558
|
+
untagged one's chain starts with ("<setparams>," -- bt709_label_filter), or "" (a tagged, RGB or
|
|
559
|
+
HDR picture, or a line that does not tag BT.709)."""
|
|
560
|
+
if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
561
|
+
return ""
|
|
562
|
+
f = bt709_label_filter(meta)
|
|
563
|
+
return f + "," if f else ""
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva420p") -> str:
|
|
567
|
+
"""For a picture the tool's own graph has made RGB(A) -- a still's `format=rgba` chain
|
|
568
|
+
(overlay.py --image, an emoji PNG) or an RGBA composite (waveform.py's --image plate) -- that
|
|
569
|
+
is encoded through video_args(line_meta): "," + the conversion to BT.709 YUV to end that chain
|
|
570
|
+
with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
|
|
571
|
+
leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
|
|
572
|
+
if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
573
|
+
return ""
|
|
574
|
+
return "," + rgb_to_bt709_filter(pix_fmt)
|
|
575
|
+
|
|
576
|
+
|
|
524
577
|
def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
|
|
525
578
|
keep_hevc: bool = False) -> List[str]:
|
|
526
579
|
"""video_args(), except that with `keep_hevc` (cut.py --keep-hevc) an SDR HEVC source re-encodes
|
|
@@ -530,7 +583,7 @@ def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, prese
|
|
|
530
583
|
source always takes video_args()'s Main10 line, and --codec always wins."""
|
|
531
584
|
v = (meta or {}).get("video") or {}
|
|
532
585
|
if keep_hevc and not STATE.codec and v.get("codec") == "hevc" and not v.get("bt2020_or_hdr"):
|
|
533
|
-
return encoder_args("hevc", crf, preset, meta)
|
|
586
|
+
return encoder_args("hevc", crf, preset, meta) + bt709_matrix_args(meta)
|
|
534
587
|
return video_args(meta, crf, preset)
|
|
535
588
|
|
|
536
589
|
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -286,12 +286,16 @@ def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]"
|
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
|
|
289
|
-
def emoji_filter_chain(plan, base_label, out_label, first_input=1):
|
|
289
|
+
def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", base_chain=""):
|
|
290
290
|
"""(chains, inputs) that composite the planned PNGs on top of `base_label`.
|
|
291
291
|
|
|
292
292
|
`inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
|
|
293
293
|
ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
|
|
294
294
|
a timeline the fade filter can move along; one that does not is a plain `-i`).
|
|
295
|
+
`to_yuv` ends each PNG's chain: the caller's rgb_chain_to_bt709(meta), so the RGBA asset is
|
|
296
|
+
converted to the BT.709 the output is tagged with instead of by overlay's BT.601 default;
|
|
297
|
+
`base_chain` (label_chain(meta, meta): an untagged source's BT.709 label) goes in front of
|
|
298
|
+
the first overlay, so 7.1+ does not convert the two pictures to one another.
|
|
295
299
|
"""
|
|
296
300
|
overlays = plan.get("overlays") or []
|
|
297
301
|
if not overlays:
|
|
@@ -329,11 +333,15 @@ def emoji_filter_chain(plan, base_label, out_label, first_input=1):
|
|
|
329
333
|
chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
|
|
330
334
|
else:
|
|
331
335
|
inputs.append(["-i", asset])
|
|
336
|
+
chain += to_yuv
|
|
332
337
|
if len(labels) > 1:
|
|
333
338
|
chain += f",split={len(labels)}"
|
|
334
339
|
chains.append(chain + "".join(f"[{l}]" for l in labels))
|
|
335
340
|
pads[key] = labels
|
|
336
341
|
cur = base_label
|
|
342
|
+
if base_chain:
|
|
343
|
+
chains.append(f"[{cur}]{base_chain}null[ebase]")
|
|
344
|
+
cur = "ebase"
|
|
337
345
|
remaining = {key: list(v) for key, v in pads.items()}
|
|
338
346
|
for j, o in enumerate(overlays):
|
|
339
347
|
label = remaining[_key(o)].pop(0)
|
|
@@ -878,6 +878,11 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
878
878
|
ctx = ctx or STATE
|
|
879
879
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
880
880
|
if is_ffmpeg:
|
|
881
|
+
# an encoder line that converts a BT.601 source to the BT.709 it is tagged with carries
|
|
882
|
+
# that conversion as a -vf; it joins the tool's own graph here, before anything records
|
|
883
|
+
# or runs the command (color.py imports this module, hence the late import)
|
|
884
|
+
from _common.color import fold_bt709_matrix
|
|
885
|
+
cmd = fold_bt709_matrix(cmd)
|
|
881
886
|
_check_no_overwrite_input(cmd)
|
|
882
887
|
_check_output_path(cmd)
|
|
883
888
|
_check_existing_output(cmd)
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
24
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, time_arg, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -87,7 +87,7 @@ def main() -> int:
|
|
|
87
87
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
88
88
|
if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
|
|
89
89
|
die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
|
|
90
|
-
cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio"))})
|
|
90
|
+
cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b})
|
|
91
91
|
if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
|
|
92
92
|
die("--audio b/mix needs audio on every B-roll clip")
|
|
93
93
|
|
|
@@ -101,10 +101,14 @@ def main() -> int:
|
|
|
101
101
|
# A shows through unchanged, so A's timeline is never touched.
|
|
102
102
|
parts: List[str] = []
|
|
103
103
|
cur = "[0:v]"
|
|
104
|
+
# every clip is converted by its own colour tags to the BT.709 the output is tagged with
|
|
105
|
+
if matrix_chain(meta_a, meta_a):
|
|
106
|
+
parts.append(f"[0:v]{matrix_chain(meta_a, meta_a)}null[a709]")
|
|
107
|
+
cur = "[a709]"
|
|
104
108
|
for i, c in enumerate(cutaways):
|
|
105
109
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
106
110
|
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
107
|
-
f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
111
|
+
f"{matrix_chain(c['meta'], meta_a)}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
108
112
|
parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
|
|
109
113
|
cur = f"[v{i}]"
|
|
110
114
|
vout = cur
|
|
@@ -131,7 +135,7 @@ def main() -> int:
|
|
|
131
135
|
cmd += ["-filter_complex", ";".join(parts), "-map", vout]
|
|
132
136
|
if aout:
|
|
133
137
|
cmd += ["-map", aout]
|
|
134
|
-
cmd += video_args(meta_a, args.crf, args.preset) + cfr_args(meta_a, None)
|
|
138
|
+
cmd += video_args(meta_a, args.crf, args.preset, convert_matrix=False) + cfr_args(meta_a, None)
|
|
135
139
|
if aout == "0:a:0":
|
|
136
140
|
cmd += ["-c:a", "copy"]
|
|
137
141
|
elif aout:
|
package/scripts/caption.py
CHANGED
|
@@ -44,7 +44,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
44
44
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
45
|
resolve as resolve_platform)
|
|
46
46
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
47
|
-
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
|
|
47
|
+
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
|
|
48
48
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
49
49
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
50
50
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -1562,7 +1562,9 @@ def main() -> int:
|
|
|
1562
1562
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
1563
1563
|
|
|
1564
1564
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1565
|
-
chains, emoji_inputs = emoji_filter_chain(emoji_plan or {}, "vsub", "vout"
|
|
1565
|
+
chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
|
|
1566
|
+
base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
|
|
1567
|
+
if emoji_plan else ([], []))
|
|
1566
1568
|
if chains:
|
|
1567
1569
|
for spec in emoji_inputs:
|
|
1568
1570
|
cmd += spec
|
package/scripts/check.py
CHANGED
|
@@ -175,10 +175,10 @@ def main() -> int:
|
|
|
175
175
|
# untagged 8-bit video is treated as BT.709 by every player and platform; nothing to fix
|
|
176
176
|
row("colour", "PASS", shown + " (players assume bt709)" if matrix else "untagged (players assume bt709)", "bt709/bt709 tagged (or HDR)")
|
|
177
177
|
elif matrix in ("smpte170m", "bt470bg"):
|
|
178
|
-
# The pixels are BT.601-encoded.
|
|
179
|
-
#
|
|
178
|
+
# The pixels are BT.601-encoded. A re-encode here converts them to the BT.709 it tags
|
|
179
|
+
# (bt709_matrix_filter); --retag bt709 is a stream copy and only relabels the matrix.
|
|
180
180
|
row("colour", "WARN", shown, "bt709/bt709 tagged (or HDR)",
|
|
181
|
-
"re-
|
|
181
|
+
"export.py (any re-encode here converts the BT.601 matrix to BT.709); color.py --retag bt709 only relabels it: right only if the pixels really are BT.709",
|
|
182
182
|
reason="a player that honours the BT.601 tag and one that ignores it on HD video (decoding it as BT.709) show different colours")
|
|
183
183
|
else:
|
|
184
184
|
row("colour", "WARN", shown, "bt709/bt709 tagged (or HDR)", "color.py --retag bt709 when the picture really is 709; color.py --to-sdr when it is HDR")
|
package/scripts/color.py
CHANGED
|
@@ -353,7 +353,9 @@ def main() -> int:
|
|
|
353
353
|
tag = "lut"
|
|
354
354
|
|
|
355
355
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
356
|
-
|
|
356
|
+
# --lut/--correct grade the source's own pixels, so a BT.601 source is converted to the BT.709
|
|
357
|
+
# the line is tagged with; --to-sdr's zscale chain already writes BT.709 and converts nothing more
|
|
358
|
+
cmd += x264_args(args.crf, args.preset, source=None if args.to_sdr else meta) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
357
359
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
358
360
|
r = probe(output, role="output")
|
|
359
361
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
|
package/scripts/export.py
CHANGED
|
@@ -36,7 +36,7 @@ import sys
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from typing import Dict, List
|
|
38
38
|
|
|
39
|
-
from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
39
|
+
from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
40
40
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
41
41
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
42
42
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
|
|
@@ -216,6 +216,11 @@ def main() -> int:
|
|
|
216
216
|
cmd += cfr_args(meta)
|
|
217
217
|
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
218
218
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
219
|
+
if args.preset != "prores":
|
|
220
|
+
# every line but the prores master's says BT.709 (the fixed presets' tags above, an
|
|
221
|
+
# SDR hevc/av1 line's own): a BT.601 source's pixels are converted to match, in front
|
|
222
|
+
# of the -vf above (run() folds the two); an untagged, BT.709 or HDR source adds nothing
|
|
223
|
+
cmd += bt709_matrix_args(meta)
|
|
219
224
|
if out_ext == "mp4":
|
|
220
225
|
cmd += ["-movflags", "+faststart"]
|
|
221
226
|
cmd += (p["audio"] if has_audio else ["-an"])
|
package/scripts/graphics.py
CHANGED
|
@@ -34,7 +34,7 @@ from typing import List, Optional
|
|
|
34
34
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
35
35
|
from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
36
|
color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path,
|
|
37
|
-
ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args,
|
|
37
|
+
ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
38
38
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
39
39
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
40
40
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
@@ -599,7 +599,9 @@ def main() -> int:
|
|
|
599
599
|
vf += f":fontsdir={escape_filter_path(fdir)}"
|
|
600
600
|
chains.append(f"[{last}]{vf}[vtxt]")
|
|
601
601
|
last = "vtxt"
|
|
602
|
-
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1
|
|
602
|
+
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
|
|
603
|
+
to_yuv=rgb_chain_to_bt709(meta),
|
|
604
|
+
base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
|
|
603
605
|
for spec in emoji_inputs:
|
|
604
606
|
cmd += spec
|
|
605
607
|
asset = spec[-1]
|
package/scripts/grid.py
CHANGED
|
@@ -26,7 +26,7 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
|
|
29
|
+
escape_drawtext, escape_filter_path, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
30
30
|
|
|
31
31
|
LABEL_MARGIN = 10
|
|
32
32
|
|
|
@@ -91,6 +91,13 @@ def main() -> int:
|
|
|
91
91
|
for p in args.inputs:
|
|
92
92
|
cmd += ["-i", p]
|
|
93
93
|
|
|
94
|
+
# each cell converted by its own tags to the BT.709 the grid is tagged with (a BT.601 or an RGB
|
|
95
|
+
# input): without it 5.1/6.1 relabelled them, and 7.1+ converted cells to one another's matrix.
|
|
96
|
+
# Beside a converted cell an untagged one gets a BT.709 label, so 7.1+'s xstack has nothing to
|
|
97
|
+
# convert; a grid of untagged/BT.709 clips is what it was.
|
|
98
|
+
convert = [matrix_chain(m, None) for m in metas]
|
|
99
|
+
if any(convert):
|
|
100
|
+
convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
|
|
94
101
|
parts = []
|
|
95
102
|
for i, p in enumerate(args.inputs):
|
|
96
103
|
chain = [
|
|
@@ -109,7 +116,7 @@ def main() -> int:
|
|
|
109
116
|
chain.append(f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
|
|
110
117
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
111
118
|
f"box=1:boxcolor=black@0.5:boxborderw=4")
|
|
112
|
-
parts.append(f"[{i}:v]{','.join(chain)}[v{i}]")
|
|
119
|
+
parts.append(f"[{i}:v]{convert[i]}{','.join(chain)}[v{i}]")
|
|
113
120
|
|
|
114
121
|
cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
|
|
115
122
|
layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
|
package/scripts/insert.py
CHANGED
|
@@ -116,7 +116,9 @@ def main() -> int:
|
|
|
116
116
|
|
|
117
117
|
output = args.output or default_output(args.input, "insert", "mp4")
|
|
118
118
|
cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
119
|
-
|
|
119
|
+
# the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
|
|
120
|
+
# the BT.709 that line is tagged with
|
|
121
|
+
cmd += video_args(None, args.crf, args.preset, source=meta)
|
|
120
122
|
cmd += ["-an", output]
|
|
121
123
|
run(cmd)
|
|
122
124
|
|
package/scripts/join.py
CHANGED
|
@@ -39,7 +39,7 @@ import os
|
|
|
39
39
|
import sys
|
|
40
40
|
from typing import Any, Dict, List, Optional, Tuple
|
|
41
41
|
|
|
42
|
-
from _common import STATE, AUDIO_MEDIA_EXT, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
42
|
+
from _common import STATE, AUDIO_MEDIA_EXT, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
43
43
|
|
|
44
44
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
45
45
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -497,7 +497,8 @@ def main() -> int:
|
|
|
497
497
|
if len(set(chans)) > 1:
|
|
498
498
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
499
499
|
for i in range(n):
|
|
500
|
-
|
|
500
|
+
# a BT.601 clip is converted to the BT.709 the join is tagged with, each clip by its own tags
|
|
501
|
+
parts.append(f"[{i}:v]{matrix_chain(metas[i], hdr_meta or metas[0])}{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
501
502
|
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
|
|
502
503
|
|
|
503
504
|
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
|
@@ -541,7 +542,7 @@ def main() -> int:
|
|
|
541
542
|
# to meet the AAC tail (a 51-frame dissolve came out 52), which newer builds no longer do -- pass
|
|
542
543
|
# the frames through as built, for a plain cut as much as for a transition
|
|
543
544
|
cmd += ["-fps_mode", "passthrough"]
|
|
544
|
-
cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
545
|
+
cmd += video_args(hdr_meta or metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + [output]
|
|
545
546
|
run(cmd)
|
|
546
547
|
expected = unpending_length(lens, d)
|
|
547
548
|
r = probe(output, role="output")
|
package/scripts/multicam.py
CHANGED
|
@@ -31,7 +31,7 @@ import math
|
|
|
31
31
|
import sys
|
|
32
32
|
from typing import Dict, List, Tuple
|
|
33
33
|
|
|
34
|
-
from _common import 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
|
|
34
|
+
from _common import matrix_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
|
|
35
35
|
from sync import measure_offset
|
|
36
36
|
|
|
37
37
|
ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
|
|
@@ -276,7 +276,8 @@ def main() -> int:
|
|
|
276
276
|
if src_s < 0:
|
|
277
277
|
info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
|
|
278
278
|
c, src_s, src_e = 0, s, e
|
|
279
|
-
|
|
279
|
+
# each camera converted by its own colour tags to the BT.709 the cut is tagged with
|
|
280
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{matrix_chain(metas[c], metas[0])}{geo}[v{i}]")
|
|
280
281
|
labels.append(f"[v{i}]")
|
|
281
282
|
project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
|
|
282
283
|
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
@@ -297,7 +298,7 @@ def main() -> int:
|
|
|
297
298
|
|
|
298
299
|
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
299
300
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
300
|
-
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
|
|
301
|
+
cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
|
|
301
302
|
|
|
302
303
|
if args.write_project:
|
|
303
304
|
# Same cut decision as the combined render above (project_clips was built alongside the
|
package/scripts/overlay.py
CHANGED
|
@@ -25,7 +25,7 @@ import sys
|
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
27
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
28
|
-
from _common import STATE, script_font_for_text, drawtext_text_opts, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
|
+
from _common import STATE, script_font_for_text, drawtext_text_opts, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
29
29
|
|
|
30
30
|
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
31
31
|
# like column is 14 % of the width, its description block 22 % of the height), so a position
|
|
@@ -266,7 +266,12 @@ def main() -> int:
|
|
|
266
266
|
ov += f":enable='{enable}'"
|
|
267
267
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
268
268
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
269
|
-
|
|
269
|
+
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
|
270
|
+
# RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
|
|
271
|
+
# 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
|
|
272
|
+
base = label_chain(meta, meta) if rgb_chain_to_bt709(meta) else ""
|
|
273
|
+
fc = (f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS{rgb_chain_to_bt709(meta)}[ov];"
|
|
274
|
+
+ (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
|
|
270
275
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
271
276
|
if meta.get("duration"):
|
|
272
277
|
# An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
|
|
@@ -299,7 +304,10 @@ def main() -> int:
|
|
|
299
304
|
if enable:
|
|
300
305
|
ov += f":enable='{enable}'"
|
|
301
306
|
cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
|
|
302
|
-
|
|
307
|
+
# both pictures converted by their own colour tags to the BT.709 the output is tagged with
|
|
308
|
+
base = matrix_chain(meta, meta)
|
|
309
|
+
fc = (f"[1:v]{matrix_chain(pip_meta, meta)}{','.join(chain)}[ov];"
|
|
310
|
+
+ (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
|
|
303
311
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
304
312
|
if meta.get("duration"):
|
|
305
313
|
# See the --image branch above: -t (exact, bounds only the main input) instead of
|
|
@@ -329,7 +337,7 @@ def main() -> int:
|
|
|
329
337
|
if meta.get("audio"):
|
|
330
338
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
331
339
|
|
|
332
|
-
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
340
|
+
cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
|
|
333
341
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
334
342
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
335
343
|
if not STATE.dry_run:
|
package/scripts/sequence.py
CHANGED
|
@@ -131,7 +131,8 @@ def main() -> int:
|
|
|
131
131
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", str(list_path)]
|
|
132
132
|
vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={args.fps:g}"]
|
|
133
133
|
cmd += ["-vf", ",".join(vf)]
|
|
134
|
-
|
|
134
|
+
# RGB (PNG) frames are converted to the BT.709 the line is tagged with (the first frame's probe)
|
|
135
|
+
cmd += video_args(None, args.crf, args.preset, source=frame_meta)
|
|
135
136
|
# The concat demuxer's trailing repeated-last-file trick (needed so the last real file's
|
|
136
137
|
# duration line takes effect) has been observed to produce an extra frame's worth of
|
|
137
138
|
# duration on some ffmpeg builds -- force the exact intended length rather than trust it.
|
package/scripts/waveform.py
CHANGED
|
@@ -36,7 +36,7 @@ import sys
|
|
|
36
36
|
|
|
37
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
38
38
|
from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
|
|
39
|
-
info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
39
|
+
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
40
40
|
fmt_secs, child_args, run_tool, measured_level_dbfs)
|
|
41
41
|
from _common.emit import absorb_stage_hw
|
|
42
42
|
|
|
@@ -185,7 +185,9 @@ def main() -> int:
|
|
|
185
185
|
graph = f"[1:v]{plate},format=rgba[plate];[0:a:{args.audio_stream}]{vf},format=rgba"
|
|
186
186
|
if args.opacity < 1.0:
|
|
187
187
|
graph += f",colorchannelmixer=aa={args.opacity:g}"
|
|
188
|
-
|
|
188
|
+
# the composite is RGBA (the plate is): converted to the BT.709 the output is tagged with,
|
|
189
|
+
# not by the encoder's BT.601 default
|
|
190
|
+
graph += f"[vis];[plate][vis]overlay=x=(W-w)/2:y={vis_y}:format=auto{rgb_chain_to_bt709(None, 'yuv420p')}[v]"
|
|
189
191
|
vf = graph
|
|
190
192
|
else:
|
|
191
193
|
# showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
|