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.
@@ -24,24 +24,30 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, script_font_for_text, 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
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": ("{m}", "{m}"),
31
- "top": ("(W-w)/2", "{m}"),
32
- "top-right": ("W-w-{m}", "{m}"),
33
- "left": ("{m}", "(H-h)/2"),
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-{m}", "(H-h)/2"),
36
- "bottom-left": ("{m}", "H-h-{m}"),
37
- "bottom": ("(W-w)/2", "H-h-{m}"),
38
- "bottom-right": ("W-w-{m}", "H-h-{m}"),
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=margin) for e in POS[pos])
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
- opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
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)}")