ffmpeg-skill 2.10.2 → 2.10.4
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 +18 -11
- package/docs/contract.md +8 -3
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +5 -0
- package/references/gotchas.md +3 -1
- package/scripts/_ass_overlay.py +3 -1
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +44 -4
- package/scripts/background.py +8 -1
- package/scripts/broll.py +14 -6
- package/scripts/caption.py +10 -2
- package/scripts/export.py +4 -1
- package/scripts/fit.py +1 -1
- package/scripts/graphics.py +81 -13
- package/scripts/grid.py +24 -13
- package/scripts/join.py +18 -7
- package/scripts/multicam.py +8 -3
- package/scripts/overlay.py +11 -5
- package/scripts/pad.py +5 -2
- package/scripts/straighten.py +5 -3
- package/scripts/waveform.py +9 -2
package/README.md
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="brand/app-icon-dark.png">
|
|
4
|
+
<img src="brand/app-icon.png" width="128" alt="ffmpeg-skill app icon">
|
|
5
|
+
</picture>
|
|
3
6
|
</p>
|
|
4
7
|
|
|
5
|
-
<
|
|
8
|
+
<p align="center">
|
|
9
|
+
<picture>
|
|
10
|
+
<source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-wordmark-white.svg">
|
|
11
|
+
<img src="brand/logo/ffmpeg-skill-wordmark-black.svg" height="48" alt="ffmpeg-skill">
|
|
12
|
+
</picture>
|
|
13
|
+
</p>
|
|
6
14
|
|
|
7
|
-
<p align="center"><
|
|
15
|
+
<p align="center"><b>Give your coding agent a video editor.</b><br>
|
|
16
|
+
Claude Code, Cursor, Codex or any MCP client edits your footage with local FFmpeg, then checks its own output.<br>
|
|
17
|
+
No cloud · No API keys · Python standard library</p>
|
|
8
18
|
|
|
9
19
|
<p align="center">
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
<a href="#quick-start"><b>Quick start</b></a> ·
|
|
21
|
+
<a href="docs/demos.md">Demos</a> ·
|
|
22
|
+
<a href="#tools">Tools</a> ·
|
|
23
|
+
<a href="#mcp">MCP</a> ·
|
|
24
|
+
<a href="https://www.npmjs.com/package/ffmpeg-skill">npm</a>
|
|
12
25
|
</p>
|
|
13
26
|
|
|
14
27
|
<p align="center">
|
|
15
28
|
<a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
|
|
16
|
-
<a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/codeql.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
|
|
17
29
|
<a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
|
|
18
|
-
<a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/dm/ffmpeg-skill" alt="npm downloads"></a>
|
|
19
|
-
<a href="https://github.com/kajisho5/ffmpeg-skill/stargazers"><img src="https://img.shields.io/github/stars/kajisho5/ffmpeg-skill" alt="GitHub stars"></a>
|
|
20
|
-
<a href="https://github.com/kajisho5/ffmpeg-skill/commits/main"><img src="https://img.shields.io/github/last-commit/kajisho5/ffmpeg-skill" alt="last commit"></a>
|
|
21
|
-
<img src="https://img.shields.io/badge/python-3.9%20%7C%203.13-blue" alt="Python 3.9 and 3.13 tested">
|
|
22
30
|
<a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-5.1%20%7C%206.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 5.1, 6.1, 7.1, 8 and 9 tested in CI"></a>
|
|
23
31
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
|
|
24
|
-
<a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
|
|
25
32
|
</p>
|
|
26
33
|
|
|
27
34
|
```bash
|
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.4`) | 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.4", "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"},
|
|
@@ -225,7 +225,12 @@ source adds no filter, and the tags themselves never convert (`bt709_tag_args()`
|
|
|
225
225
|
`waveform --image`) is converted to BT.709 YUV explicitly (`scale=out_color_matrix=bt709`; a JPEG is
|
|
226
226
|
a `bt470bg` source); `grid.py` converts each cell by its own tags. Where a converted picture meets an
|
|
227
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
|
|
228
|
+
FFmpeg 7.1+'s colourspace negotiation converts neither (`join`, `multicam`, `broll` and `overlay --video`
|
|
229
|
+
too, beside a converted clip). A colour a tool draws from a hex or named value (a pad, `tpad` or
|
|
230
|
+
`rotate` fill, `drawtext` and libass text and boxes, a `graphics.py` box or bar, `background.py`'s
|
|
231
|
+
`color`/`gradients`, `waveform.py`'s plate and waves) lands in the same BT.709: from FFmpeg 7.1
|
|
232
|
+
an untagged picture is labelled for the drawing filters, before 7.1 the drawing runs on an RGB copy
|
|
233
|
+
and only the pixels it touched are taken from it (`_common.drawn_chain()`); a grey changes nothing.
|
|
229
234
|
`cut.py --keep-hevc` (opt-in) re-encodes an SDR HEVC source as x265 8-bit BT.709 instead of x264; an HDR or
|
|
230
235
|
BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
|
|
231
236
|
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.4",
|
|
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",
|
|
@@ -162,6 +162,11 @@ these had ever shown up before.
|
|
|
162
162
|
before measuring (40 dB for re-matrixed pixels), and it then flagged the fixed, byte-clean
|
|
163
163
|
output as 26 dB instead. The tests' `_psnr` helper now pins identical colour tags on both
|
|
164
164
|
inputs so every build reports the same number for the same two files.
|
|
165
|
+
- **A test fixture drawn with `scale=out_color_matrix=bt709` is not untagged on 7.1+.** The scale
|
|
166
|
+
labels its output and x264 writes that label, so an "untagged" clip made that way probes as
|
|
167
|
+
`color_space=bt709` on the trixie/macOS/Windows jobs and the untagged case silently went untested
|
|
168
|
+
there (#333's colour fixtures until 2.10.3). Relabel the frames `setparams=colorspace=unknown`
|
|
169
|
+
before the encode, and assert the probe says unknown.
|
|
165
170
|
- **The conda-forge 7.1.1 build deadlocks on `tpad` + `adelay`/`apad` (pad.py) and ignores
|
|
166
171
|
SIGTERM.** Debian's 7.1.5 in CI does not. Run pad tests against CI, not that build, and note
|
|
167
172
|
that the tools have no subprocess timeout to get an agent out of such a hang.
|
package/references/gotchas.md
CHANGED
|
@@ -14,7 +14,9 @@ by every re-encode (through 2.10.0 only the label changed); `color.py --retag bt
|
|
|
14
14
|
stream copy and still only relabels. Untagged sources are taken as BT.709 and left alone. An RGB
|
|
15
15
|
picture (PNG/JPEG still, logo, emoji PNG, PNG sequence, qtrle clip) is converted to BT.709 YUV
|
|
16
16
|
explicitly (through 2.10.1 FFmpeg's BT.601 default did it), and `grid.py` converts each cell by its
|
|
17
|
-
own tags.
|
|
17
|
+
own tags. A colour the tools draw (pad and background colours, text, boxes, captions, waveform
|
|
18
|
+
colours) is drawn in BT.709 too; through 2.10.2 it was BT.601 under the BT.709 tag (pure red
|
|
19
|
+
showed as (255,24,0)). When `probe.py` reports
|
|
18
20
|
`bt2020_or_hdr: true` (HDR10/PQ, HLG, Dolby Vision, or BT.2020 SDR), every editing script keeps
|
|
19
21
|
the output on the 10-bit path (HEVC Main10, source colour tags) so nothing is silently
|
|
20
22
|
flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
|
package/scripts/_ass_overlay.py
CHANGED
|
@@ -110,7 +110,9 @@ def text_overlay_ass(elements: Sequence[Dict[str, Any]], *, play_w: int, play_h:
|
|
|
110
110
|
"""Write `elements` as an ASS file at `path` and return the path."""
|
|
111
111
|
header = [
|
|
112
112
|
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}",
|
|
113
|
-
|
|
113
|
+
# YCbCr Matrix: None -- the colours are RGB, to be put in the video's own matrix: FFmpeg 8.x's
|
|
114
|
+
# ass filter reads an absent header as BT.601 whatever the picture is tagged (drawn_chain)
|
|
115
|
+
"WrapStyle: 2", "ScaledBorderAndShadow: yes", "YCbCr Matrix: None", "",
|
|
114
116
|
"[V4+ Styles]",
|
|
115
117
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
|
|
116
118
|
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
|
|
@@ -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, label_chain, matrix_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
86
|
+
signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, matrix_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
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,8 +91,8 @@ from _common.asr import (
|
|
|
91
91
|
whisper_word_timings, write_srt
|
|
92
92
|
)
|
|
93
93
|
from _common.color import (
|
|
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
|
|
94
|
+
bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, color_hex, _COLOR_TOKEN_RE, drawn_stream_format, fold_bt709_matrix,
|
|
95
|
+
is_neutral_color, is_rgb, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
|
|
96
96
|
)
|
|
97
97
|
from _common.text import (
|
|
98
98
|
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
|
|
@@ -213,7 +213,7 @@ __all__ = [
|
|
|
213
213
|
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
|
|
214
214
|
"_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
|
|
215
215
|
"time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
|
|
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
|
+
"source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
217
217
|
"_x264_raw", "_ZWJ",
|
|
218
218
|
"ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
|
|
219
219
|
"_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
|
package/scripts/_common/color.py
CHANGED
|
@@ -123,6 +123,75 @@ def bt709_label_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
|
123
123
|
return "setparams=colorspace=bt709" + ("" if v.get("color_range") == "pc" else ":range=tv")
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
# FFmpeg's named colours that are greys (libavutil/parseutils.c's table): R = G = B.
|
|
127
|
+
_GREY_NAMES = frozenset(("black", "white", "gray", "grey", "silver", "darkgray", "darkgrey", "dimgray", "dimgrey",
|
|
128
|
+
"gainsboro", "lightgray", "lightgrey", "whitesmoke"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _yuv8(rgb: Sequence[int], kr: float, kb: float) -> "Tuple[int, int, int]":
|
|
132
|
+
"""8-bit limited-range YUV of an RGB colour under the matrix (kr, kb), rounded as FFmpeg's
|
|
133
|
+
drawing code rounds it (ff_draw_color)."""
|
|
134
|
+
r, g, b = (c / 255 for c in rgb)
|
|
135
|
+
y = kr * r + (1 - kr - kb) * g + kb * b
|
|
136
|
+
return (int(16 + 219 * y + 0.5), int(128 + 224 * (b - y) / (2 * (1 - kb)) + 0.5),
|
|
137
|
+
int(128 + 224 * (r - y) / (2 * (1 - kr)) + 0.5))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_neutral_color(value: Any) -> bool:
|
|
141
|
+
"""True for a colour whose 8-bit YUV is the same under BT.601 and BT.709, however translucent: a
|
|
142
|
+
grey (R = G = B; black and white included), or one so near grey that both round to the same
|
|
143
|
+
bytes (graphics.py's default background 101418). Only another colour lands differently, so only
|
|
144
|
+
it needs drawn_chain(). A name counts only if it is one of FFmpeg's greys; anything else
|
|
145
|
+
(`red`, `random`) is not neutral."""
|
|
146
|
+
v = str(value or "").strip().split("@")[0].lower()
|
|
147
|
+
if v in _GREY_NAMES:
|
|
148
|
+
return True
|
|
149
|
+
v = v[1:] if v.startswith("#") else v[2:] if v.startswith("0x") else v
|
|
150
|
+
if re.fullmatch(r"[0-9a-f]{6}(?:[0-9a-f]{2})?", v):
|
|
151
|
+
rgb = (int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16))
|
|
152
|
+
return _yuv8(rgb, 0.299, 0.114) == _yuv8(rgb, 0.2126, 0.0722)
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def drawn_stream_format(meta: Optional[Dict[str, Any]]) -> "Tuple[str, bool]":
|
|
157
|
+
"""(pix_fmt, full range) of the picture a tool draws on, for rgb_island(): the probed source's own
|
|
158
|
+
8-bit planar YUV format (an RGB source has become yuv444p, BT.709 limited, through
|
|
159
|
+
bt709_matrix_filter), or yuv420p for anything else (the format the encoder line takes it to)."""
|
|
160
|
+
v = (meta or {}).get("video") or {}
|
|
161
|
+
if is_rgb(meta):
|
|
162
|
+
return "yuv444p", False
|
|
163
|
+
pf = str(v.get("pix_fmt") or "yuv420p")
|
|
164
|
+
full = pf.startswith("yuvj") or v.get("color_range") == "pc"
|
|
165
|
+
if pf not in ("yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p"):
|
|
166
|
+
pf = "yuv420p"
|
|
167
|
+
return pf, full
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def rgb_island(colour_chain: str, neutral_chain: str = "null", pix_fmt: str = "yuv420p", full: bool = False,
|
|
171
|
+
tag: str = "d") -> str:
|
|
172
|
+
"""`colour_chain` (filters that draw a colour: drawtext, pad, tpad, rotate's fill, libass) run on
|
|
173
|
+
an RGB copy of the picture, as one simple filtergraph (one input, one output; `tag` keeps its
|
|
174
|
+
labels unique in a bigger graph). For FFmpeg < 7.1, whose drawing filters turn every colour into
|
|
175
|
+
YUV with BT.601 whatever the picture is tagged (`ff_draw_init`), and drawbox on every version:
|
|
176
|
+
in RGB a colour is written as it is, and the copy goes back to YUV with BT.709.
|
|
177
|
+
|
|
178
|
+
Only the pixels the drawing touched come from that copy. The same round trip without the colour
|
|
179
|
+
(`neutral_chain`: `null`, or the same pad/tpad/rotate with a black fill, which is the same under
|
|
180
|
+
every matrix) is compared with it plane by plane, and maskedmerge takes the drawn copy where
|
|
181
|
+
the two differ and the untouched picture (`neutral_chain` on the YUV itself) everywhere else:
|
|
182
|
+
the RGB round trip is not lossless, so a picture that only passes through keeps its pixels."""
|
|
183
|
+
rng = "pc" if full else "tv"
|
|
184
|
+
to_rgb = f"scale=in_color_matrix=bt709:in_range={rng},format=rgb24"
|
|
185
|
+
to_yuv = f"scale=out_color_matrix=bt709:out_range={rng},format={pix_fmt}"
|
|
186
|
+
keep = "'255*gt(val,0)'"
|
|
187
|
+
t = f"_{tag}"
|
|
188
|
+
return (f"split=3[{t}p][{t}c][{t}n];[{t}p]format={pix_fmt},{neutral_chain}[{t}p1];"
|
|
189
|
+
f"[{t}c]{to_rgb},{colour_chain},{to_yuv},split[{t}c1][{t}c2];"
|
|
190
|
+
f"[{t}n]{to_rgb},{neutral_chain},{to_yuv}[{t}n1];"
|
|
191
|
+
f"[{t}c1][{t}n1]blend=all_mode=difference,lut=c0={keep}:c1={keep}:c2={keep}[{t}m];"
|
|
192
|
+
f"[{t}p1][{t}c2][{t}m]maskedmerge")
|
|
193
|
+
|
|
194
|
+
|
|
126
195
|
def bt709_matrix_args(meta: Optional[Dict[str, Any]]) -> List[str]:
|
|
127
196
|
"""["-vf", bt709_matrix_filter(meta)] for an encoder line that tags its output BT.709, or [].
|
|
128
197
|
|
|
@@ -14,12 +14,14 @@ import argparse
|
|
|
14
14
|
from fractions import Fraction
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
-
from _common.color import bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args,
|
|
17
|
+
from _common.color import (bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
18
|
+
is_neutral_color, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
18
19
|
from _common.emit import die
|
|
19
20
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_version, hw_platform_reason
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0
|
|
23
|
+
def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0,
|
|
24
|
+
meta: Optional[Dict[str, Any]] = None) -> str:
|
|
23
25
|
"""The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
|
|
24
26
|
|
|
25
27
|
fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
|
|
@@ -30,7 +32,10 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken
|
|
|
30
32
|
needed beyond the usual scale/pad set, and that is already required by redact.py.
|
|
31
33
|
`darken` > 0 also dims that background copy by that much brightness (eq), so the picture in
|
|
32
34
|
front reads as the subject instead of competing with a bright blurred copy of itself --
|
|
33
|
-
what `fit.py --fit blur` uses (1.14).
|
|
35
|
+
what `fit.py --fit blur` uses (1.14).
|
|
36
|
+
`meta`: the probe of the picture being padded, when the output is that picture's re-encode: a
|
|
37
|
+
--pad-color with chroma is then drawn in the BT.709 the line tags (drawn_chain()). Without it
|
|
38
|
+
(a still made RGB, an export to GIF) the pad is what it always was."""
|
|
34
39
|
if fill == "blur":
|
|
35
40
|
# boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
|
|
36
41
|
# <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
|
|
@@ -40,7 +45,9 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken
|
|
|
40
45
|
f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
|
|
41
46
|
f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
|
|
42
47
|
f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
|
|
43
|
-
|
|
48
|
+
pad = f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
49
|
+
return (f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,"
|
|
50
|
+
+ (pad + color if meta is None else drawn_chain(pad + color, meta, [color], pad + "black", tag="pad")))
|
|
44
51
|
|
|
45
52
|
|
|
46
53
|
def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
|
|
@@ -574,6 +581,39 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
|
|
|
574
581
|
return "," + rgb_to_bt709_filter(pix_fmt)
|
|
575
582
|
|
|
576
583
|
|
|
584
|
+
def draws_chroma(colours: Sequence[Any], line_meta: Optional[Dict[str, Any]]) -> bool:
|
|
585
|
+
"""True when one of `colours` has chroma (is_neutral_color) and the line video_args(line_meta)
|
|
586
|
+
tags the output BT.709 -- when a drawn colour needs drawn_chain() at all."""
|
|
587
|
+
return (not all(is_neutral_color(c) for c in colours if c is not None) and STATE.codec != "prores"
|
|
588
|
+
and not ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"))
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def drawn_chain(colour_chain: str, meta: Optional[Dict[str, Any]], colours: Sequence[Any], neutral_chain: str = "null",
|
|
592
|
+
line_meta: Any = "same", tag: str = "d", labelled: bool = False) -> str:
|
|
593
|
+
"""`colour_chain` -- filters that draw a colour into the picture `meta` probes (drawtext's
|
|
594
|
+
font/box/border colours, pad/tpad's fill, rotate's fillcolor, libass) -- so that a colour of
|
|
595
|
+
`colours` lands in the BT.709 the line video_args(line_meta) tags the output with (line_meta
|
|
596
|
+
defaults to meta).
|
|
597
|
+
|
|
598
|
+
FFmpeg's drawing filters turn an RGB colour into YUV with a matrix of their own (ff_draw):
|
|
599
|
+
BT.601 always up to 7.0, the link's colourspace from 7.1 on, "unknown" read as BT.601. Through
|
|
600
|
+
2.10.2 a drawn colour was BT.601 under a BT.709 tag on every build (pure red decoded as
|
|
601
|
+
(255,24,0)), and on 5.1/6.1 for a BT.709-tagged source too. From 7.1 an untagged picture gets
|
|
602
|
+
its BT.709 label (label_chain(): tags only) and the filters follow it; before 7.1 the drawing
|
|
603
|
+
runs in RGB (rgb_island(), `neutral_chain` being the same filters with a black fill, or null).
|
|
604
|
+
|
|
605
|
+
Unchanged: a chain whose colours are all greys (the same YUV under every matrix: the default
|
|
606
|
+
black pads and white text), a line that does not tag BT.709 (HDR, prores). `labelled`: the
|
|
607
|
+
caller's graph already labels this picture (label_chain()) earlier in the same chain."""
|
|
608
|
+
line = meta if line_meta == "same" else line_meta
|
|
609
|
+
if not draws_chroma(colours, line):
|
|
610
|
+
return colour_chain
|
|
611
|
+
if ffmpeg_version() >= (7, 1):
|
|
612
|
+
return ("" if labelled else label_chain(meta, line)) + colour_chain
|
|
613
|
+
pix_fmt, full = drawn_stream_format(meta)
|
|
614
|
+
return rgb_island(colour_chain, neutral_chain, pix_fmt, full, tag)
|
|
615
|
+
|
|
616
|
+
|
|
577
617
|
def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
|
|
578
618
|
keep_hevc: bool = False) -> List[str]:
|
|
579
619
|
"""video_args(), except that with `keep_hevc` (cut.py --keep-hevc) an SDR HEVC source re-encodes
|
package/scripts/background.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import math
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
17
|
+
from _common import add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -61,9 +61,16 @@ def main() -> int:
|
|
|
61
61
|
# speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
|
|
62
62
|
# motionless the filter allows.
|
|
63
63
|
src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
|
|
64
|
+
colours = [c0, c1]
|
|
64
65
|
else:
|
|
65
66
|
validate_color(args.color, "--color")
|
|
66
67
|
src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
|
|
68
|
+
colours = [args.color]
|
|
69
|
+
if draws_chroma(colours, None):
|
|
70
|
+
# the colours are made in RGB and converted to the BT.709 the output is tagged with: left to
|
|
71
|
+
# the encoder line's own conversion (or `color`'s YUV), they were BT.601 under that tag
|
|
72
|
+
# (pure red decoded as (255,24,0)); a grey is the same under every matrix
|
|
73
|
+
src_filter += (",format=rgb24" if not args.gradient else "") + rgb_chain_to_bt709(None, "yuv420p")
|
|
67
74
|
|
|
68
75
|
output = args.output
|
|
69
76
|
cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
|
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, matrix_chain, 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, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -101,14 +101,22 @@ 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
|
-
|
|
106
|
-
|
|
104
|
+
# every clip is converted by its own colour tags to the BT.709 the output is tagged with; beside
|
|
105
|
+
# a converted clip (or under a --pad-color with chroma) an untagged one gets its BT.709 label,
|
|
106
|
+
# tags only, so 7.1+'s overlay and pad have nothing to convert or misread
|
|
107
|
+
convert = [matrix_chain(m, meta_a) for m in [meta_a] + [c["meta"] for c in cutaways]]
|
|
108
|
+
if any(convert) or draws_chroma([args.pad_color], meta_a):
|
|
109
|
+
convert = [cv or label_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
|
|
110
|
+
if convert[0]:
|
|
111
|
+
parts.append(f"[0:v]{convert[0]}null[a709]")
|
|
107
112
|
cur = "[a709]"
|
|
113
|
+
pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
108
114
|
for i, c in enumerate(cutaways):
|
|
109
|
-
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,
|
|
115
|
+
geo = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
116
|
+
+ drawn_chain(pad + args.pad_color, c["meta"], [args.pad_color], pad + "black", line_meta=meta_a,
|
|
117
|
+
tag=f"pad{i}", labelled=True))
|
|
110
118
|
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
111
|
-
f"{
|
|
119
|
+
f"{convert[i + 1]}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
112
120
|
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}]")
|
|
113
121
|
cur = f"[v{i}]"
|
|
114
122
|
vout = cur
|
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, 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
|
|
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, drawn_chain, 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
|
|
@@ -633,7 +633,9 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
633
633
|
# jump every time the highlight advances (the trap the issue this implements is filed against).
|
|
634
634
|
header = [
|
|
635
635
|
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}",
|
|
636
|
-
|
|
636
|
+
# YCbCr Matrix: None -- the colours are RGB, to be put in the video's own matrix: FFmpeg 8.x's
|
|
637
|
+
# ass filter reads an absent header as BT.601 whatever the picture is tagged (drawn_chain)
|
|
638
|
+
f"WrapStyle: {2 if word_style else 0}", "ScaledBorderAndShadow: yes", "YCbCr Matrix: None", "",
|
|
637
639
|
"[V4+ Styles]",
|
|
638
640
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
639
641
|
f"Style: Default,{ass_font_name(args.font)},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin_l},{margin_r},{margin},1",
|
|
@@ -1560,6 +1562,12 @@ def main() -> int:
|
|
|
1560
1562
|
vf = f"subtitles={escape_filter_path(srt_path)}:force_style='{force}'"
|
|
1561
1563
|
if args.fonts_dir:
|
|
1562
1564
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
1565
|
+
if generated_ass or not args.ass:
|
|
1566
|
+
# a text/outline/karaoke colour with chroma lands in the BT.709 the output is tagged with
|
|
1567
|
+
# (drawn_chain). A caller's own --ass is burned as libass and FFmpeg read it: its colours,
|
|
1568
|
+
# and its YCbCr Matrix header, are the file's.
|
|
1569
|
+
vf = drawn_chain(vf, meta, [args.color, args.outline_color]
|
|
1570
|
+
+ ([args.highlight_color, args.upcoming_color] if args.karaoke else []))
|
|
1563
1571
|
|
|
1564
1572
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1565
1573
|
chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
|
package/scripts/export.py
CHANGED
|
@@ -165,7 +165,10 @@ def main() -> int:
|
|
|
165
165
|
if args.fit == "crop":
|
|
166
166
|
vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=increase", f"crop={p['w']}:{p['h']}"]
|
|
167
167
|
else:
|
|
168
|
-
|
|
168
|
+
# a --pad-color with chroma is drawn in the BT.709 the line is tagged with; a GIF is
|
|
169
|
+
# not tagged, and palettegen reads the pad back with the same BT.601 it was drawn in
|
|
170
|
+
vf.append(pad_filters(p["w"], p["h"], args.pad_fill, args.pad_color, args.pad_blur,
|
|
171
|
+
meta=None if args.preset == "gif" else meta))
|
|
169
172
|
vf.append("setsar=1")
|
|
170
173
|
else:
|
|
171
174
|
vf.append(f"scale={p['w']}:-2")
|
package/scripts/fit.py
CHANGED
|
@@ -202,7 +202,7 @@ def main() -> int:
|
|
|
202
202
|
"run color.py --to-sdr first for the SDR look")
|
|
203
203
|
vf.append(pad_filters(out_w, out_h, "blur", args.pad_color, args.pad_blur, darken))
|
|
204
204
|
else:
|
|
205
|
-
vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur))
|
|
205
|
+
vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur, meta=meta))
|
|
206
206
|
vf.append("setsar=1")
|
|
207
207
|
|
|
208
208
|
if args.fps:
|
package/scripts/graphics.py
CHANGED
|
@@ -33,7 +33,7 @@ from typing import List, Optional
|
|
|
33
33
|
|
|
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
|
-
color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path,
|
|
36
|
+
color_hex, default_font_file, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
37
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,
|
|
@@ -42,6 +42,9 @@ from _common import (aac_args, add_common, brand_caption_style, script_font_for_
|
|
|
42
42
|
SAFE_WIDTH_FRACTION)
|
|
43
43
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
44
44
|
|
|
45
|
+
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
46
|
+
TEXT = "@TEXT@"
|
|
47
|
+
BOX = "@BOX@" # a box's place in the filter chain, followed by its index (graphics.main fill_box)
|
|
45
48
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
|
|
46
49
|
|
|
47
50
|
|
|
@@ -185,6 +188,35 @@ def main() -> int:
|
|
|
185
188
|
en = f"enable='between(t,{s:.3f},{e:.3f})'"
|
|
186
189
|
base = min(W, H) * args.scale # scale everything from the short side
|
|
187
190
|
filters: List[str] = []
|
|
191
|
+
rate = f"{meta['video'].get('fps') or 30:g}"
|
|
192
|
+
# A drawn colour lands in the BT.709 the output is tagged with. When a template's box has
|
|
193
|
+
# chroma, its boxes are colour sources made in RGB and converted (rgb_chain_to_bt709), overlaid
|
|
194
|
+
# under the text, in order: drawbox turns its colour into YUV with BT.601 on every FFmpeg (and
|
|
195
|
+
# blends 4:2:0 chroma four times over, so a translucent box was nearly opaque in colour). A
|
|
196
|
+
# template whose boxes are all grey keeps its drawboxes, which are the same under every matrix.
|
|
197
|
+
# The text (drawtext, libass) goes through drawn_chain() at the end.
|
|
198
|
+
boxes: List[tuple] = [] # (colour, alpha, drawbox geometry, x, y, w, h, enable, x_expr), in order
|
|
199
|
+
layers: List[tuple] = [] # (colour source chain, overlay position and enable), under the text
|
|
200
|
+
converted: List[str] = [] # the colour sources layer_src() converted
|
|
201
|
+
|
|
202
|
+
def fill_box(colour: str, alpha: float, drawbox_geo: str, x: int, y: int, w: int, h: int, enable: str,
|
|
203
|
+
x_expr: Optional[str] = None) -> None:
|
|
204
|
+
"""A filled box: a drawbox (`drawbox_geo`) at its place in the filter chain, or a colour layer
|
|
205
|
+
of w x h at x, y (or at the per-frame `x_expr`) -- decided for all of a template's boxes."""
|
|
206
|
+
filters.append(f"{BOX}{len(boxes)}")
|
|
207
|
+
boxes.append((colour, alpha, drawbox_geo, x, y, w, h, enable, x_expr))
|
|
208
|
+
|
|
209
|
+
def layer_src(colour: str, alpha: float, w: int, h: int, left: int = 0, top: int = 0) -> str:
|
|
210
|
+
"""A colour source to overlay (a box, the lower-third bar, the progress fill), converted when it
|
|
211
|
+
has chroma. overlay places a 4:2:0 picture on even rows and columns only: a box that starts on
|
|
212
|
+
an odd one is placed one earlier with a transparent first column/row (`left`, `top`), so it
|
|
213
|
+
covers the rows and columns the drawbox did."""
|
|
214
|
+
conv = rgb_chain_to_bt709(meta) if draws_chroma([colour], meta) else ""
|
|
215
|
+
if conv:
|
|
216
|
+
converted.append(colour)
|
|
217
|
+
edge = f",pad={w + left}:{h + top}:{left}:{top}:color=black@0" if left or top else ""
|
|
218
|
+
return (f"color=c=0x{colour}" + (f"@{alpha:g}" if alpha < 1 else "") + f":s={w}x{h}:r={rate},format=rgba"
|
|
219
|
+
+ edge + conv)
|
|
188
220
|
fade_a = f"if(lt(t,{s:.3f}+0.3),(t-{s:.3f})/0.3,if(gt(t,{e:.3f}-0.3),({e:.3f}-t)/0.3,1))"
|
|
189
221
|
|
|
190
222
|
# --- which renderer draws the text (1.15) --------------------------------------------------
|
|
@@ -333,8 +365,8 @@ def main() -> int:
|
|
|
333
365
|
y0 = H - m_bottom - bar_h
|
|
334
366
|
# slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
|
|
335
367
|
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
336
|
-
fc.append(f"
|
|
337
|
-
fc.append(f"
|
|
368
|
+
fc.append(f"{layer_src(bg, 0.85, bar_w, bar_h)}[bar]")
|
|
369
|
+
fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
|
|
338
370
|
fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
|
|
339
371
|
fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
|
|
340
372
|
tx_pad = int(base * 0.035)
|
|
@@ -361,14 +393,16 @@ def main() -> int:
|
|
|
361
393
|
align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
|
|
362
394
|
outline_color="000000", start=max(s, e - 0.3), end=e,
|
|
363
395
|
move=(x_rest, ty, x_off, ty, 0, 300), x_expr=tx))
|
|
364
|
-
fc.append(f"[v2]{
|
|
396
|
+
fc.append(f"[v2]{TEXT}[vout]" if route != "ass" else "[v2]null[vout]")
|
|
397
|
+
if route != "ass":
|
|
398
|
+
filters.extend(draws) # the text chain, which drawn_chain() places at TEXT
|
|
365
399
|
|
|
366
400
|
elif args.template == "title":
|
|
367
401
|
if not args.title:
|
|
368
402
|
die("title needs --title")
|
|
369
403
|
h1 = int(base * 0.11)
|
|
370
404
|
h2 = int(base * 0.045)
|
|
371
|
-
|
|
405
|
+
fill_box(bg, 0.55, "x=0:y=0:w=iw:h=ih", 0, 0, W, H, en)
|
|
372
406
|
# the title is centred as a block, so the bar and the subtitle sit below its last line:
|
|
373
407
|
# `title_h` is the block's height, as the one-line layout has always taken h1; a further
|
|
374
408
|
# subtitle line hangs below the first, whose position the one-line layout fixes
|
|
@@ -380,7 +414,9 @@ def main() -> int:
|
|
|
380
414
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
|
|
381
415
|
y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
|
|
382
416
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
383
|
-
|
|
417
|
+
acc_w, acc_h = int(base * 0.12), max(2, int(base * 0.006))
|
|
418
|
+
fill_box(primary, 1.0, f"x=(iw-{acc_w})/2:y=(ih)/2+{title_h // 2}:w={acc_w}:h={acc_h}",
|
|
419
|
+
(W - acc_w) // 2, H // 2 + title_h // 2, acc_w, acc_h, en)
|
|
384
420
|
if args.subtitle:
|
|
385
421
|
add_text(args.subtitle,
|
|
386
422
|
f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{sub_y + sub_extra // 2}:alpha='{fade_a}':{en}",
|
|
@@ -412,8 +448,12 @@ def main() -> int:
|
|
|
412
448
|
elif args.template == "progress":
|
|
413
449
|
h = max(3, int(base * 0.008))
|
|
414
450
|
fps = meta['video'].get('fps') or 30
|
|
415
|
-
fc.append(f"
|
|
416
|
-
|
|
451
|
+
fc.append(f"{layer_src(primary, 1.0, W, h)}[pb]")
|
|
452
|
+
if draws_chroma([bg], meta):
|
|
453
|
+
fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
|
|
454
|
+
fc.append(f"[0:v][pbg]overlay=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
|
|
455
|
+
else:
|
|
456
|
+
fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
|
|
417
457
|
fc.append(f"[v1][pb]overlay=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
|
|
418
458
|
|
|
419
459
|
elif args.template == "sticker":
|
|
@@ -465,14 +505,15 @@ def main() -> int:
|
|
|
465
505
|
# drawing white text on the bare video. The band is clipped at the frame edges.
|
|
466
506
|
band_h = min(H, max(int(base * 0.30), block_h(hook_title, h1) + int(h1 * 0.7)))
|
|
467
507
|
y0 = (H - band_h) // 2
|
|
468
|
-
|
|
508
|
+
fill_box(bg, 0.78, f"x=0:y={y0}:w=iw:h={band_h}", 0, y0, W, band_h, hen)
|
|
469
509
|
add_text(hook_title,
|
|
470
510
|
f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}"
|
|
471
511
|
f"{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
|
|
472
512
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
473
513
|
outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
|
|
474
|
-
|
|
475
|
-
|
|
514
|
+
# the bar empties from the right: the overlay slides a full-width bar out to the left
|
|
515
|
+
fill_box(primary, 1.0, f"x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}", 0, 0, W, bar_h,
|
|
516
|
+
hen, x_expr=f"'-W*min(1,max(0,(t-{s:.3f})/{max(0.001, he - s):.3f}))'")
|
|
476
517
|
|
|
477
518
|
elif args.template == "meme":
|
|
478
519
|
# The classic layout: heavy white upper-case lines with a black outline, top and bottom,
|
|
@@ -588,15 +629,42 @@ def main() -> int:
|
|
|
588
629
|
"clusters": sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
|
|
589
630
|
"assets": emoji_assets, "missing": [], "overlays": 0}
|
|
590
631
|
|
|
632
|
+
# The boxes with chroma (layers) go under the text, and the picture they and a converted colour
|
|
633
|
+
# source are overlaid on gets its BT.709 label (an untagged one, tags only), so 7.1+'s overlay
|
|
634
|
+
# converts neither picture to the other's matrix. The text is drawn in BT.709 (drawn_chain).
|
|
635
|
+
as_layers = any(draws_chroma([b[0]], meta) for b in boxes)
|
|
636
|
+
drawboxes = []
|
|
637
|
+
for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
|
|
638
|
+
if as_layers:
|
|
639
|
+
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2), f"x={x_expr or x - x % 2}:y={y - y % 2}:{enable}"))
|
|
640
|
+
drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
|
|
641
|
+
filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
|
|
642
|
+
filters = [f for f in filters if f]
|
|
643
|
+
base_label = label_chain(meta, meta) if (layers or converted) else ""
|
|
644
|
+
text = (drawn_chain(",".join(filters), meta, re.findall(r"color=([0-9A-Za-z#@.]+)", ",".join(filters)),
|
|
645
|
+
labelled=bool(base_label)) if filters else "")
|
|
646
|
+
if layers:
|
|
647
|
+
cur = "0:v"
|
|
648
|
+
for k, (src, pos) in enumerate(layers):
|
|
649
|
+
fc.append(f"{src}[gl{k}]")
|
|
650
|
+
fc.append(f"[{cur}][gl{k}]overlay={pos}:eof_action=pass[gv{k}]")
|
|
651
|
+
cur = f"gv{k}"
|
|
652
|
+
fc.append(f"[{cur}]{TEXT}[vout]")
|
|
653
|
+
fc = [c.replace(TEXT, text or "null") for c in fc]
|
|
654
|
+
if fc and base_label:
|
|
655
|
+
fc = [f"[0:v]{base_label}null[gbase]"] + [c.replace("[0:v]", "[gbase]") for c in fc]
|
|
656
|
+
|
|
591
657
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
592
658
|
if route == "ass" or emoji_overlays:
|
|
593
|
-
chains = list(fc) if fc else [f"[0:v]{
|
|
659
|
+
chains = list(fc) if fc else [f"[0:v]{text or 'null'}[vout]"]
|
|
594
660
|
last = "vout"
|
|
595
661
|
if route == "ass":
|
|
596
662
|
vf = f"ass={escape_filter_path(ass_path)}"
|
|
597
663
|
fdir = ass_fonts_dir()
|
|
598
664
|
if fdir:
|
|
599
665
|
vf += f":fontsdir={escape_filter_path(fdir)}"
|
|
666
|
+
vf = drawn_chain(vf, meta, [el.get(k) for el in elements for k in ("color", "outline_color", "box_color")],
|
|
667
|
+
labelled=bool(base_label))
|
|
600
668
|
chains.append(f"[{last}]{vf}[vtxt]")
|
|
601
669
|
last = "vtxt"
|
|
602
670
|
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
|
|
@@ -615,7 +683,7 @@ def main() -> int:
|
|
|
615
683
|
elif fc:
|
|
616
684
|
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
617
685
|
else:
|
|
618
|
-
cmd += ["-vf",
|
|
686
|
+
cmd += ["-vf", text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
619
687
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
620
688
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
621
689
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
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, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
29
|
+
draws_chroma, drawn_chain, 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
|
|
|
@@ -95,28 +95,39 @@ def main() -> int:
|
|
|
95
95
|
# input): without it 5.1/6.1 relabelled them, and 7.1+ converted cells to one another's matrix.
|
|
96
96
|
# Beside a converted cell an untagged one gets a BT.709 label, so 7.1+'s xstack has nothing to
|
|
97
97
|
# convert; a grid of untagged/BT.709 clips is what it was.
|
|
98
|
+
# A --background or --font-color with chroma is drawn in the BT.709 the grid is tagged with
|
|
99
|
+
# (drawn_chain): every untagged cell is labelled for it too, and the gaps are each cell's own
|
|
100
|
+
# pad instead of xstack's fill, which 5.1/6.1 draw in BT.601 (a grey needs neither).
|
|
101
|
+
colours = [args.background] + ([args.font_color] if args.label == "auto" else [])
|
|
102
|
+
chroma = draws_chroma(colours, None)
|
|
98
103
|
convert = [matrix_chain(m, None) for m in metas]
|
|
99
|
-
if any(convert):
|
|
104
|
+
if any(convert) or chroma:
|
|
100
105
|
convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
|
|
101
106
|
parts = []
|
|
102
107
|
for i, p in enumerate(args.inputs):
|
|
103
|
-
|
|
104
|
-
f"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"setsar=1",
|
|
108
|
+
drawn = [ # (with this colour, with black)
|
|
109
|
+
(f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color={args.background}",
|
|
110
|
+
f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color=black"),
|
|
111
|
+
("setsar=1",) * 2,
|
|
108
112
|
]
|
|
109
113
|
if args.pad and durations[i] < target_duration:
|
|
110
|
-
|
|
114
|
+
drawn.append((f"tpad=stop_mode=clone:stop_duration={target_duration - durations[i]:.3f}",) * 2)
|
|
111
115
|
elif not args.pad:
|
|
112
|
-
|
|
116
|
+
drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
|
|
113
117
|
if args.label == "auto":
|
|
114
118
|
stem = os.path.splitext(os.path.basename(p))[0]
|
|
115
119
|
font_opt = f"fontfile={escape_filter_path(font_file)}" if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
drawn.append((f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
|
|
121
|
+
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
122
|
+
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
123
|
+
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
|
124
|
+
gap_b = args.gap if i // args.cols < args.rows - 1 else 0
|
|
125
|
+
if chroma and (gap_r or gap_b):
|
|
126
|
+
drawn.append((f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color={args.background}", f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color=black"))
|
|
127
|
+
chain = f"fps={args.fps:g},scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
|
|
128
|
+
chain += drawn_chain(",".join(c for c, _n in drawn), metas[i], colours, ",".join(n for _c, n in drawn),
|
|
129
|
+
line_meta=None, tag=f"c{i}", labelled=True)
|
|
130
|
+
parts.append(f"[{i}:v]{convert[i]}{chain}[v{i}]")
|
|
120
131
|
|
|
121
132
|
cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
|
|
122
133
|
layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
|
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, 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
|
|
42
|
+
from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, 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"]
|
|
@@ -478,14 +478,26 @@ def main() -> int:
|
|
|
478
478
|
added += 1
|
|
479
479
|
cmd += extra_inputs
|
|
480
480
|
|
|
481
|
-
if args.fit == "crop":
|
|
482
|
-
geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
483
|
-
else:
|
|
484
|
-
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
485
481
|
# Any HDR input makes the join HDR (10-bit, HEVC via video_args on that clip's tags): an SDR
|
|
486
482
|
# first clip used to drag an HDR second clip down to 8-bit without a tone map.
|
|
487
483
|
hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
|
|
488
484
|
pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
|
|
485
|
+
line = hdr_meta or metas[0]
|
|
486
|
+
# a BT.601 or RGB clip is converted to the BT.709 the join is tagged with, each clip by its own
|
|
487
|
+
# tags; beside a converted clip (or under a --pad-color with chroma) an untagged one gets its
|
|
488
|
+
# BT.709 label, tags only, so 7.1+'s concat/xfade and pad have nothing to convert or misread
|
|
489
|
+
convert = [matrix_chain(m, line) for m in metas]
|
|
490
|
+
chroma_pad = args.fit != "crop" and draws_chroma([args.pad_color], line)
|
|
491
|
+
if any(convert) or chroma_pad:
|
|
492
|
+
convert = [c or label_chain(m, line) for c, m in zip(convert, metas)]
|
|
493
|
+
|
|
494
|
+
def geo(i: int) -> str:
|
|
495
|
+
if args.fit == "crop":
|
|
496
|
+
return f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
497
|
+
pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
498
|
+
return (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
499
|
+
+ drawn_chain(pad + args.pad_color, metas[i], [args.pad_color], pad + "black", line_meta=line,
|
|
500
|
+
tag=f"pad{i}", labelled=True))
|
|
489
501
|
# the audio-only join keeps the widest layout; the video join used to force stereo and
|
|
490
502
|
# silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
|
|
491
503
|
# layout: plan from the inputs that exist, as join_audio() does.
|
|
@@ -497,8 +509,7 @@ def main() -> int:
|
|
|
497
509
|
if len(set(chans)) > 1:
|
|
498
510
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
499
511
|
for i in range(n):
|
|
500
|
-
|
|
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}]")
|
|
512
|
+
parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
502
513
|
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
|
|
503
514
|
|
|
504
515
|
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
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 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
|
|
34
|
+
from _common import label_chain, 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
|
|
@@ -269,6 +269,12 @@ def main() -> int:
|
|
|
269
269
|
parts: List[str] = []
|
|
270
270
|
labels: List[str] = []
|
|
271
271
|
project_clips: List[Dict] = []
|
|
272
|
+
# each camera converted by its own colour tags to the BT.709 the cut is tagged with; beside a
|
|
273
|
+
# converted camera an untagged one gets its BT.709 label, tags only, so 7.1+'s concat does not
|
|
274
|
+
# convert one to the other's "unknown" (BT.601) matrix
|
|
275
|
+
convert = [matrix_chain(m, metas[0]) for m in metas]
|
|
276
|
+
if any(convert):
|
|
277
|
+
convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
|
|
272
278
|
for i, (s, e, c) in enumerate(filled):
|
|
273
279
|
# reference time t maps to source time (t - offset_c) * ratio_c
|
|
274
280
|
src_s = (s - offsets[c]) * ratios[c]
|
|
@@ -276,8 +282,7 @@ def main() -> int:
|
|
|
276
282
|
if src_s < 0:
|
|
277
283
|
info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
|
|
278
284
|
c, src_s, src_e = 0, s, e
|
|
279
|
-
|
|
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}]")
|
|
285
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
|
|
281
286
|
labels.append(f"[v{i}]")
|
|
282
287
|
project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
|
|
283
288
|
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
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, 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
|
|
28
|
+
from _common import STATE, script_font_for_text, drawtext_text_opts, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, 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
|
|
@@ -304,9 +304,13 @@ def main() -> int:
|
|
|
304
304
|
if enable:
|
|
305
305
|
ov += f":enable='{enable}'"
|
|
306
306
|
cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
|
|
307
|
-
# both pictures converted by their own colour tags to the BT.709 the output is tagged with
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
# both pictures converted by their own colour tags to the BT.709 the output is tagged with;
|
|
308
|
+
# beside a converted one an untagged one gets its BT.709 label, tags only, so 7.1+'s
|
|
309
|
+
# overlay does not convert one to the other's "unknown" (BT.601) matrix
|
|
310
|
+
base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
|
|
311
|
+
if base or pip:
|
|
312
|
+
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
313
|
+
fc = (f"[1:v]{pip}{','.join(chain)}[ov];"
|
|
310
314
|
+ (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
|
|
311
315
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
312
316
|
if meta.get("duration"):
|
|
@@ -333,7 +337,9 @@ def main() -> int:
|
|
|
333
337
|
opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
|
|
334
338
|
if enable:
|
|
335
339
|
opts.append(f"enable='{enable}'")
|
|
336
|
-
|
|
340
|
+
# a font/border/box colour with chroma lands in the BT.709 the output is tagged with
|
|
341
|
+
colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
|
|
342
|
+
cmd += ["-vf", drawn_chain("drawtext=" + ":".join(opts), meta, colours), "-map", "0:v:0"]
|
|
337
343
|
if meta.get("audio"):
|
|
338
344
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
339
345
|
|
package/scripts/pad.py
CHANGED
|
@@ -16,7 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main() -> int:
|
|
@@ -46,7 +46,10 @@ def main() -> int:
|
|
|
46
46
|
has_audio = bool(meta.get("audio"))
|
|
47
47
|
output = args.output or default_output(args.input, "pad")
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
tpad = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color="
|
|
50
|
+
# a --color with chroma lands in the BT.709 the output is tagged with (tpad's own YUV is BT.601
|
|
51
|
+
# before FFmpeg 7.1, and for an untagged picture after it); black is the same under every matrix
|
|
52
|
+
vf = drawn_chain(tpad + args.color, meta, [args.color], tpad + "black")
|
|
50
53
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
51
54
|
if has_audio:
|
|
52
55
|
af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
|
package/scripts/straighten.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import math
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def main() -> int:
|
|
@@ -63,8 +63,10 @@ def main() -> int:
|
|
|
63
63
|
|
|
64
64
|
radians = math.radians(args.degrees)
|
|
65
65
|
if args.fit == "pad":
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
geometry = f"ow=trunc(rotw({radians:.8f})/2)*2:oh=trunc(roth({radians:.8f})/2)*2"
|
|
67
|
+
# a --fill-color with chroma lands in the BT.709 the output is tagged with (drawn_chain)
|
|
68
|
+
rotate = drawn_chain(f"rotate={radians:.8f}:fillcolor={args.fill_color}:{geometry}", meta, [args.fill_color],
|
|
69
|
+
f"rotate={radians:.8f}:fillcolor=black:{geometry}")
|
|
68
70
|
else:
|
|
69
71
|
# Pre-scale the frame up by a safe, conservative factor (|cos|+|sin|, the exact growth
|
|
70
72
|
# factor for a square, over-generous for a rectangle) so the rotated content fully
|
package/scripts/waveform.py
CHANGED
|
@@ -35,7 +35,7 @@ import os
|
|
|
35
35
|
import sys
|
|
36
36
|
|
|
37
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
38
|
-
from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
|
|
38
|
+
from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
|
|
39
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
|
|
@@ -193,7 +193,14 @@ def main() -> int:
|
|
|
193
193
|
# showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
|
|
194
194
|
# it over an explicit solid background instead of assuming that canvas already matches
|
|
195
195
|
# --background.
|
|
196
|
-
|
|
196
|
+
bg, vis = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}", vf
|
|
197
|
+
if draws_chroma([args.background] + (args.color.split("|") if args.style == "waveform" else []), None):
|
|
198
|
+
# the background and the RGBA waves are converted to the BT.709 the output is tagged
|
|
199
|
+
# with (the encoder line's own RGB-to-YUV step used BT.601); the spectrum is YUV of its
|
|
200
|
+
# own and only gets the label the background carries, so 7.1+'s overlay converts neither
|
|
201
|
+
bg += ",format=rgb24" + rgb_chain_to_bt709(None, "yuv420p")
|
|
202
|
+
vis += rgb_chain_to_bt709(None, "yuva420p") if args.style == "waveform" else ",setparams=colorspace=bt709"
|
|
203
|
+
vf = f"{bg}[bg];[0:a:{args.audio_stream}]{vis}[vis];[bg][vis]overlay=format=auto"
|
|
197
204
|
|
|
198
205
|
render = output
|
|
199
206
|
if args.title or args.srt or args.text:
|