ffmpeg-skill 1.13.0 → 1.15.0
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 +72 -29
- package/SKILL.md +46 -41
- package/bin/install.js +1 -1
- package/docs/contract.md +72 -10
- package/package.json +4 -2
- package/references/gotchas.md +85 -1
- package/references/scripts.md +146 -15
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common.py +596 -37
- package/scripts/_contract.py +27 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +343 -93
- package/scripts/check.py +13 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +381 -20
- package/scripts/look.py +35 -0
- package/scripts/overlay.py +72 -15
- package/scripts/render.py +313 -29
- package/scripts/report.py +73 -1
- package/templates/facebook.json +47 -0
- package/templates/linkedin.json +47 -0
- package/templates/podcast.json +22 -0
- package/templates/reels.json +47 -0
- package/templates/shorts.json +47 -0
- package/templates/tiktok.json +47 -0
- package/templates/x.json +47 -0
- package/templates/youtube-shorts.json +47 -0
- package/templates/youtube.json +47 -0
package/scripts/overlay.py
CHANGED
|
@@ -24,24 +24,30 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from
|
|
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
29
|
|
|
30
|
+
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
31
|
+
# like column is 14 % of the width, its description block 22 % of the height), so a position
|
|
32
|
+
# names the edges it is measured from rather than one --margin for all four.
|
|
29
33
|
POS = {
|
|
30
|
-
"top-left": ("{
|
|
31
|
-
"top": ("(W-w)/2", "{
|
|
32
|
-
"top-right": ("W-w-{
|
|
33
|
-
"left": ("{
|
|
34
|
+
"top-left": ("{left}", "{top}"),
|
|
35
|
+
"top": ("(W-w)/2", "{top}"),
|
|
36
|
+
"top-right": ("W-w-{right}", "{top}"),
|
|
37
|
+
"left": ("{left}", "(H-h)/2"),
|
|
34
38
|
"center": ("(W-w)/2", "(H-h)/2"),
|
|
35
|
-
"right": ("W-w-{
|
|
36
|
-
"bottom-left": ("{
|
|
37
|
-
"bottom": ("(W-w)/2", "H-h-{
|
|
38
|
-
"bottom-right": ("W-w-{
|
|
39
|
+
"right": ("W-w-{right}", "(H-h)/2"),
|
|
40
|
+
"bottom-left": ("{left}", "H-h-{bottom}"),
|
|
41
|
+
"bottom": ("(W-w)/2", "H-h-{bottom}"),
|
|
42
|
+
"bottom-right": ("W-w-{right}", "H-h-{bottom}"),
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
|
|
42
|
-
def position_exprs(pos: str, margin: int, text_mode: bool):
|
|
46
|
+
def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dict] = None):
|
|
47
|
+
edges = margins or {}
|
|
48
|
+
m = {edge: int(edges.get(edge, margin)) for edge in ("top", "bottom", "left", "right")}
|
|
43
49
|
if pos in POS:
|
|
44
|
-
x, y = (e.format(m
|
|
50
|
+
x, y = (e.format(**m) for e in POS[pos])
|
|
45
51
|
else:
|
|
46
52
|
try:
|
|
47
53
|
xs, ys = pos.split(",")
|
|
@@ -101,6 +107,10 @@ def main() -> int:
|
|
|
101
107
|
ap.add_argument("--brand", help="brand.json (logo, font, colours, safe margin)")
|
|
102
108
|
ap.add_argument("--position", default="top-right", help="named position or X,Y (default top-right)")
|
|
103
109
|
ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
|
|
110
|
+
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
|
|
111
|
+
help="keep the overlay out of this destination's UI: each edge's margin becomes that "
|
|
112
|
+
"platform's safe zone (scripts/_platforms.py), so a top-left logo clears TikTok's "
|
|
113
|
+
"status bar and a right-hand one clears the like column. An explicit --margin wins")
|
|
104
114
|
ap.add_argument("--start", help="show from this time (default: whole video)")
|
|
105
115
|
ap.add_argument("--end", help="hide after this time")
|
|
106
116
|
ap.add_argument("--fade", type=float, default=0.0, help="fade-in duration in seconds (at --start or 0); the fade-out happens only at --end")
|
|
@@ -116,6 +126,15 @@ def main() -> int:
|
|
|
116
126
|
txt.add_argument("--border", type=int, default=2, help="text outline width (default 2)")
|
|
117
127
|
txt.add_argument("--border-color", default="black")
|
|
118
128
|
txt.add_argument("--box", action="store_true", help="draw a translucent box behind the text")
|
|
129
|
+
emo = ap.add_argument_group("emoji (1.15)")
|
|
130
|
+
emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
|
|
131
|
+
help="how emoji in --text are drawn. overlay.py draws through drawtext, which cannot load "
|
|
132
|
+
"a colour emoji font at all, so only 'mono'/'none' render here -- 'png'/'color' name "
|
|
133
|
+
"caption.py/graphics.py instead")
|
|
134
|
+
emo.add_argument("--emoji-assets", metavar="DIR", help="directory of emoji PNGs named by code point "
|
|
135
|
+
"(used by caption.py/graphics.py; overlay.py has no PNG route)")
|
|
136
|
+
emo.add_argument("--emoji-scale", type=float, default=1.0, help="emoji box as a multiple of the font size")
|
|
137
|
+
emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build")
|
|
119
138
|
txt.add_argument("--box-color", default="black@0.5")
|
|
120
139
|
enc = ap.add_argument_group("encoding")
|
|
121
140
|
enc.add_argument("--crf", type=int, default=18)
|
|
@@ -144,6 +163,28 @@ def main() -> int:
|
|
|
144
163
|
args.font = brand.get("font", args.font)
|
|
145
164
|
if not args.font_file and brand.get("font_file"):
|
|
146
165
|
args.font_file = brand["font_file"]
|
|
166
|
+
if args.text:
|
|
167
|
+
# 1.15: drawtext never reorders or re-clusters (no harfbuzz), so Devanagari matras and
|
|
168
|
+
# Thai/Lao mark stacking come out wrong on EVERY build. A wrong frame is not a delivery:
|
|
169
|
+
# refuse and name the two tools that render the script correctly through libass.
|
|
170
|
+
_sc = detect_script(args.text, getattr(args, "lang", None))
|
|
171
|
+
if needs_shaping(_sc):
|
|
172
|
+
die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text cannot be shaped by drawtext on any ffmpeg build "
|
|
173
|
+
"(the marks are reordered by harfbuzz, which drawtext does not use): draw it with "
|
|
174
|
+
"caption.py (--text cues, burned through libass) or graphics.py (--template with "
|
|
175
|
+
"--text-render ass) instead", kind="input")
|
|
176
|
+
if has_emoji(args.text):
|
|
177
|
+
if args.emoji in ("png", "color"):
|
|
178
|
+
die(f"--emoji {args.emoji}: overlay.py draws text with drawtext, which cannot load a colour "
|
|
179
|
+
"emoji font and cannot place a PNG inside a line -- use caption.py (--emoji-assets) for "
|
|
180
|
+
"cues or graphics.py (--template) for a title card", kind="input")
|
|
181
|
+
if args.emoji == "none":
|
|
182
|
+
for _i, _cl in list(reversed(emoji_clusters(args.text))):
|
|
183
|
+
args.text = args.text[:_i] + args.text[_i + len(_cl):]
|
|
184
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
185
|
+
else:
|
|
186
|
+
info("warning: emoji are drawn by the text font here (monochrome at best); "
|
|
187
|
+
"caption.py/graphics.py composite colour PNGs with --emoji-assets")
|
|
147
188
|
if args.text and not args.font_file:
|
|
148
189
|
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
149
190
|
# Arabic ... draws glyphs instead of boxes. drawtext does not shape or reorder RTL text --
|
|
@@ -163,6 +204,20 @@ def main() -> int:
|
|
|
163
204
|
if args.audio_stream and not audio_streams:
|
|
164
205
|
die("--audio-stream needs an input with audio streams")
|
|
165
206
|
vw = meta["video"]["width"]
|
|
207
|
+
# --platform: the edges this destination's own UI covers, in pixels of this frame. An
|
|
208
|
+
# explicit --margin (or a brand safe_margin, applied above) is the more specific statement
|
|
209
|
+
# and wins; without either, the historical 24 px default is unchanged.
|
|
210
|
+
safe_margins = None
|
|
211
|
+
args.platform = resolve_platform(args.platform)
|
|
212
|
+
if args.platform and args.margin == ap.get_default("margin") and PLATFORMS[args.platform].get("frame"):
|
|
213
|
+
# a dry run has no real frame to measure (the probe is stubbed rather than guessed), so
|
|
214
|
+
# fall back to the destination's own delivery frame -- which is what the fitted
|
|
215
|
+
# intermediate this stage runs on will be anyway
|
|
216
|
+
frame = PLATFORMS[args.platform]["frame"]
|
|
217
|
+
pw, ph = vw or frame["w"], meta["video"].get("height") or frame["h"]
|
|
218
|
+
safe_margins = safe_margins_px(args.platform, pw, ph)
|
|
219
|
+
info(f"--platform {args.platform}: safe margins top {safe_margins['top']} / bottom {safe_margins['bottom']} / "
|
|
220
|
+
f"left {safe_margins['left']} / right {safe_margins['right']} px (clear of the app's own UI)")
|
|
166
221
|
fps = meta["video"].get("fps")
|
|
167
222
|
start = time_arg(args.start, "--start", fps) if args.start else None
|
|
168
223
|
end = time_arg(args.end, "--end", fps) if args.end else None
|
|
@@ -203,7 +258,7 @@ def main() -> int:
|
|
|
203
258
|
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
204
259
|
if end is not None and end > args.fade:
|
|
205
260
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
206
|
-
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
261
|
+
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
207
262
|
ov = f"overlay={x}:{y}:format=auto"
|
|
208
263
|
if enable:
|
|
209
264
|
ov += f":enable='{enable}'"
|
|
@@ -237,7 +292,7 @@ def main() -> int:
|
|
|
237
292
|
chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
|
|
238
293
|
if args.opacity < 1:
|
|
239
294
|
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
240
|
-
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
295
|
+
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
241
296
|
ov = f"overlay={x}:{y}:format=auto"
|
|
242
297
|
if enable:
|
|
243
298
|
ov += f":enable='{enable}'"
|
|
@@ -251,8 +306,10 @@ def main() -> int:
|
|
|
251
306
|
else:
|
|
252
307
|
cmd += ["-shortest"]
|
|
253
308
|
else:
|
|
254
|
-
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
255
|
-
|
|
309
|
+
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
310
|
+
# 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
|
|
311
|
+
# (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
|
|
312
|
+
opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
256
313
|
f"borderw={args.border}", f"bordercolor={args.border_color}"]
|
|
257
314
|
if args.font_file:
|
|
258
315
|
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|