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.
@@ -9,6 +9,10 @@ Templates:
9
9
  progress thin progress bar along the bottom that fills over the clip (or --start/--end)
10
10
  countdown big numbers counting down from --from to 0 (--start/--end define the window)
11
11
  bug persistent text bug (--title) in a corner, e.g. "@handle" or "LIVE"
12
+ sticker rounded filled chip of --text that pops in at --position (the social sticker)
13
+ hook full-width opening title card (--title) for --duration seconds with a thin
14
+ progress bar along the top -- the TikTok/Shorts opener
15
+ meme white upper-case --top / --bottom lines with a heavy black outline
12
16
 
13
17
  Examples:
14
18
  python3 graphics.py talk.mp4 --template lower-third --name "Ada Lovelace" --title "Analyst" --start 2 --end 8
@@ -17,14 +21,27 @@ Examples:
17
21
  python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
18
22
  python3 graphics.py talk.mp4 --template lower-third --name "김민준" --title "감독" --lang ko
19
23
  python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
24
+ python3 graphics.py reel.mp4 --template sticker --text "NEW" --position top-right --platform tiktok
25
+ python3 graphics.py reel.mp4 --template hook --title "How I cut this in one command" --duration 3
26
+ python3 graphics.py clip.mp4 --template meme --top "when the render" --bottom "finally finishes"
20
27
  """
21
28
  import argparse
29
+ import os
30
+ import re
22
31
  import sys
23
32
  from typing import List, Optional
24
33
 
25
- from _common import aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
34
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
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,
37
+ ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args,
38
+ drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
39
+ LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
40
+ emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
41
+ EMOJI_ASSET_HINT, text_width_em, drawtext_shaping)
42
+ from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
26
43
 
27
- TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
44
+ TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
28
45
 
29
46
 
30
47
  def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
@@ -53,12 +70,20 @@ def main() -> int:
53
70
  ap.add_argument("--template", choices=TEMPLATES, required=True)
54
71
  ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
55
72
  ap.add_argument("--name", help="lower-third: name line")
56
- ap.add_argument("--title", help="title / chapter / bug text, or lower-third second line")
73
+ ap.add_argument("--title", help="title / chapter / bug / hook text, or lower-third second line")
74
+ ap.add_argument("--text", help="sticker: the chip's text")
75
+ ap.add_argument("--top", help="meme: upper line")
76
+ ap.add_argument("--bottom", help="meme: lower line")
77
+ ap.add_argument("--duration", type=float, default=3.0, help="hook: seconds the opening card stays up (default 3)")
57
78
  ap.add_argument("--subtitle", help="title: smaller second line")
58
79
  ap.add_argument("--from", dest="count_from", type=int, default=5, help="countdown start number (default 5)")
59
80
  ap.add_argument("--start", help="show from (default 0)")
60
81
  ap.add_argument("--end", help="hide after (default end of clip)")
61
- ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug (default bottom-left / top-right)")
82
+ ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug/sticker (default bottom-left / top-right / top-right)")
83
+ ap.add_argument("--margin", type=int, default=None, help="distance from the frame edge in px (default: brand safe_margin, or the --platform safe zone)")
84
+ ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
85
+ help="keep the graphic out of this destination's UI: margins become the platform's safe zone "
86
+ "(TikTok's description bar and like column, the Reels/Shorts chrome). An explicit --margin wins")
62
87
  ap.add_argument("--primary", help="override brand primary colour RRGGBB")
63
88
  ap.add_argument("--text-color", help="override text colour RRGGBB")
64
89
  ap.add_argument("--font")
@@ -66,12 +91,33 @@ def main() -> int:
66
91
  ap.add_argument("--lang", help="language code of the text (e.g. ja, zh, ko): the hint that says whether Han-only "
67
92
  "text is Chinese, Japanese or Korean when a font is picked by script")
68
93
  ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
94
+ ap.add_argument("--text-render", choices=["auto", "ass", "drawtext"], default="auto",
95
+ help="which renderer draws the template's text: 'auto' (default) uses libass for "
96
+ "scripts drawtext cannot shape (Devanagari, Bengali, Tamil, Thai, Lao ...) and for "
97
+ "emoji overlays, and drawtext for everything else -- Latin/CJK/Arabic frames are "
98
+ "pixel-identical to 1.14 (the drawtext command itself changed: the label is "
99
+ "passed as textfile=, not text=); 'ass' always uses libass; 'drawtext' forces the old renderer and is "
100
+ "refused for a script it cannot shape")
101
+ ap.add_argument("--write-ass", metavar="PATH",
102
+ help="where to save the generated ASS when the libass route is used (default: <output stem>_gfx.ass)")
103
+ emo = ap.add_argument_group("emoji (1.15)")
104
+ emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
105
+ help="how emoji in the template text are drawn (see caption.py --emoji)")
106
+ emo.add_argument("--emoji-assets", metavar="DIR",
107
+ help="directory of emoji PNGs named by code point (1f389.png); nothing is ever downloaded")
108
+ emo.add_argument("--emoji-scale", type=float, default=1.0,
109
+ help="emoji box as a multiple of the line's font size (default 1.0)")
110
+ emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build (default 60)")
69
111
  ap.add_argument("--crf", type=int, default=18)
70
112
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
71
113
  add_common(ap)
72
114
  args = ap.parse_args()
73
115
  apply_common(args)
74
116
 
117
+ args.platform = resolve_platform(args.platform)
118
+ if args.platform and not PLATFORMS[args.platform].get("frame"):
119
+ info(f"--platform {args.platform}: this destination has no frame and no app chrome; margins unchanged")
120
+ args.platform = None
75
121
  brand = load_brand(args.brand)
76
122
  primary = color_hex(args.primary or brand["colors"]["primary"])
77
123
  text_c = color_hex(args.text_color or brand["colors"]["text"])
@@ -85,7 +131,7 @@ def main() -> int:
85
131
  args.lang = args.lang or (brand.get("lang") if args.brand else None)
86
132
  # a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
87
133
  _script, script_file, _family = script_font_for_text(
88
- " ".join(t for t in (args.name, args.title, args.subtitle) if t),
134
+ " ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
89
135
  lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
90
136
  fo = font_opts(brand, args.font, args.font_file, script_file)
91
137
 
@@ -101,6 +147,19 @@ def main() -> int:
101
147
  if meta["video"].get("rotation") in (90, -90, 270, -270):
102
148
  W, H = H, W
103
149
  dur = meta.get("duration") or 0.0
150
+ # Per-edge margins. Without --margin/--platform every edge is the brand safe margin, exactly
151
+ # as before; --margin sets all four; --platform takes each edge from the destination's safe
152
+ # zone (scripts/_platforms.py), which is what keeps a sticker off TikTok's like column.
153
+ m_top = m_bottom = m_left = m_right = margin
154
+ if args.margin is not None:
155
+ if args.margin < 0:
156
+ die(f"--margin must be >= 0, got {args.margin}")
157
+ m_top = m_bottom = m_left = m_right = margin = args.margin
158
+ elif args.platform:
159
+ px = safe_margins_px(args.platform, W, H)
160
+ m_top, m_bottom, m_left, m_right = px["top"], px["bottom"], px["left"], px["right"]
161
+ margin = m_left
162
+ info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
104
163
  fps = meta["video"].get("fps")
105
164
  s = time_arg(args.start, "--start", fps) if args.start else 0.0
106
165
  e = time_arg(args.end, "--end", fps) if args.end else dur
@@ -111,6 +170,92 @@ def main() -> int:
111
170
  filters: List[str] = []
112
171
  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))"
113
172
 
173
+ # --- which renderer draws the text (1.15) --------------------------------------------------
174
+ # drawtext does bidi and Arabic joining on a fribidi build, but it never reorders or
175
+ # re-clusters (no harfbuzz), so Indic matras and Thai/Lao mark stacking come out wrong on
176
+ # every build. Those scripts -- and any run that has to reserve a gap for an emoji PNG -- go
177
+ # through libass instead; everything else keeps the exact drawtext graph 1.14 produced.
178
+ all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t)
179
+ shaping = needs_shaping(_script)
180
+ emoji_assets = resolve_emoji_assets(args.emoji_assets, None, brand if args.brand else None)
181
+ emoji_mode = None
182
+ emoji_overlays: List[dict] = []
183
+ if has_emoji(all_text):
184
+ support = emoji_support(emoji_assets, probe=True)
185
+ emoji_mode = support["mode"] if args.emoji == "auto" else args.emoji
186
+ if args.emoji == "color" and not support["libass_color"]:
187
+ die("--emoji color: this ffmpeg renders emoji monochrome through libass "
188
+ f"({support['detail']}) -- pass --emoji-assets DIR for colour, or --emoji mono", kind="input")
189
+ if args.emoji == "png" and not emoji_assets:
190
+ die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
191
+ if args.text_render == "drawtext" and shaping:
192
+ die(f"{LANGUAGE_NAMES.get(_script, _script)} text cannot be shaped by drawtext on any ffmpeg "
193
+ "build (the marks are reordered by harfbuzz, which drawtext does not use): drop "
194
+ "--text-render drawtext to render it through libass, or draw it with caption.py",
195
+ kind="input")
196
+ route = "ass" if (args.text_render == "ass" or
197
+ (args.text_render == "auto" and (shaping or emoji_mode == "png"))) else "drawtext"
198
+ # drawtext loads exactly ONE font file and has no fallback chain, so "whatever glyph the text
199
+ # font has" for an emoji is an empty box on DejaVu Sans and on every script font: reporting
200
+ # mode "mono" from the drawtext route is a claim the frame does not keep. libass DOES have a
201
+ # fallback chain, so an auto run routes there instead; a run that pinned --text-render
202
+ # drawtext degrades to "none" (strip) and says so rather than drawing tofu.
203
+ if emoji_mode == "mono" and route == "drawtext":
204
+ if args.text_render == "auto":
205
+ route = "ass"
206
+ else:
207
+ emoji_mode = "none"
208
+ info("emoji: --text-render drawtext has no font fallback chain, so the cluster would "
209
+ "be drawn as an empty box -- stripped from the text instead "
210
+ "(--text-render auto renders it monochrome through libass)")
211
+ if emoji_mode == "mono":
212
+ info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
213
+ "--emoji-assets DIR for colour). " + support["detail"])
214
+ if emoji_mode == "none":
215
+ if not "".join(ch for ch in all_text if char_script(ch) != "emoji").strip():
216
+ die("the template's text is nothing but emoji and this machine can draw none of them "
217
+ "(no glyph, no --emoji-assets DIR): that frame would be blank, which is not a "
218
+ "delivery -- " + EMOJI_ASSET_HINT, kind="input")
219
+
220
+ def _strip_emoji(text):
221
+ if not text:
222
+ return text
223
+ for _i, cl in emoji_clusters(text):
224
+ text = text.replace(cl, "")
225
+ return re.sub(r"[ \t]{2,}", " ", text).strip()
226
+
227
+ for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
228
+ setattr(args, _attr, _strip_emoji(getattr(args, _attr, None)))
229
+ all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text,
230
+ args.top, args.bottom) if t)
231
+ info("emoji: stripped from the drawn text (--emoji none)")
232
+ elements: List[dict] = []
233
+
234
+ def ass_font_family() -> "Optional[str]":
235
+ explicit = args.font_file or brand.get("font_file")
236
+ if explicit:
237
+ return font_family_of_file(explicit) or args.font or brand.get("font")
238
+ if script_file:
239
+ return font_family_of_file(script_file) or font_family_for_script(_script)
240
+ return args.font or brand.get("font", "DejaVu Sans")
241
+
242
+ def ass_fonts_dir() -> "Optional[str]":
243
+ explicit = args.font_file or brand.get("font_file")
244
+ if explicit:
245
+ return os.path.dirname(os.path.abspath(explicit))
246
+ if script_file:
247
+ return os.path.dirname(os.path.abspath(script_file))
248
+ return None
249
+
250
+ def add_text(text, drawtext, *, target=None, **el):
251
+ """One line of template text: a drawtext filter on the old route, an ASS element on the
252
+ new one. The geometry is computed identically either way."""
253
+ if route != "ass":
254
+ (filters if target is None else target).append(drawtext)
255
+ return
256
+ el["text"] = text
257
+ elements.append(el)
258
+
114
259
  extra_inputs: List[str] = []
115
260
  fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
116
261
  if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
@@ -123,18 +268,38 @@ def main() -> int:
123
268
  pad = int(base * 0.02)
124
269
  bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
125
270
  bar_w = int(base * 0.62)
126
- y0 = H - margin - bar_h
271
+ y0 = H - m_bottom - bar_h
127
272
  # slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
128
- x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{margin})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{margin}-({bar_w}+{margin})*(1-({e:.3f}-t)/0.3),{margin}))"
273
+ 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}))"
129
274
  fc.append(f"color=c=0x{bg}@0.85:s={bar_w}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[bar]")
130
275
  fc.append(f"color=c=0x{primary}:s={int(base * 0.012)}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[acc]")
131
276
  fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
132
277
  fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
133
- tx = f"({x_expr})+{int(base * 0.035)}"
134
- chain = f"drawtext=text='{escape_drawtext(args.name)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x='{tx}':y={y0 + pad}:{en}"
135
- if args.title:
136
- chain += f",drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x='{tx}':y={y0 + pad + h1 + pad // 2}:{en}"
137
- fc.append(f"[v2]{chain}[vout]")
278
+ tx_pad = int(base * 0.035)
279
+ tx = f"({x_expr})+{tx_pad}"
280
+ x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
281
+ draws = []
282
+ for text, fs, colour, ty in ((args.name, h1, text_c, y0 + pad),
283
+ (args.title, h2, primary, y0 + pad + h1 + pad // 2)):
284
+ if not text:
285
+ continue
286
+ draws.append(f"drawtext={drawtext_text_opts(text)}:{fo}:fontsize={fs}:"
287
+ f"fontcolor={ff_color(colour)}:x='{tx}':y={ty}:{en}")
288
+ if route == "ass":
289
+ # the same three phases the bar itself slides through, as \move Dialogues
290
+ elements.append(dict(text=text, size=fs, color=colour, font=ass_font_family(),
291
+ align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
292
+ outline_color="000000", start=s, end=min(e, s + 0.4),
293
+ move=(x_off, ty, x_rest, ty, 0, 400), x_expr=tx))
294
+ elements.append(dict(text=text, size=fs, color=colour, font=ass_font_family(),
295
+ align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
296
+ outline_color="000000", start=min(e, s + 0.4), end=max(s, e - 0.3),
297
+ x_expr=tx))
298
+ elements.append(dict(text=text, size=fs, color=colour, font=ass_font_family(),
299
+ align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
300
+ outline_color="000000", start=max(s, e - 0.3), end=e,
301
+ move=(x_rest, ty, x_off, ty, 0, 300), x_expr=tx))
302
+ fc.append(f"[v2]{','.join(draws)}[vout]" if route != "ass" else "[v2]null[vout]")
138
303
 
139
304
  elif args.template == "title":
140
305
  if not args.title:
@@ -142,10 +307,18 @@ def main() -> int:
142
307
  h1 = int(base * 0.11)
143
308
  h2 = int(base * 0.045)
144
309
  filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
145
- filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}")
310
+ add_text(args.title,
311
+ f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
312
+ size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
313
+ y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
314
+ outline_color="000000", start=s, end=e, fade=(300, 300))
146
315
  filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
147
316
  if args.subtitle:
148
- filters.append(f"drawtext=text='{escape_drawtext(args.subtitle)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}")
317
+ add_text(args.subtitle,
318
+ f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
319
+ size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
320
+ y=H / 2 + h1 // 2 + int(base * 0.03), outline=max(1.0, h2 / 20.0),
321
+ outline_color="000000", start=s, end=e, fade=(300, 300))
149
322
 
150
323
  elif args.template in ("chapter", "bug"):
151
324
  if not args.title:
@@ -153,11 +326,20 @@ def main() -> int:
153
326
  pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
154
327
  fs = int(base * (0.04 if args.template == "chapter" else 0.032))
155
328
  padx, pady = int(fs * 0.6), int(fs * 0.35)
156
- xe = f"{margin}" if "left" in pos else f"w-text_w-{margin}"
157
- ye = f"{margin}" if "top" in pos else f"h-text_h-{margin}"
329
+ xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
330
+ ye = f"{m_top}" if "top" in pos else f"h-text_h-{m_bottom}"
158
331
  box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
159
332
  txt_color = ff_color(bg if args.template == "chapter" else text_c)
160
- filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}")
333
+ box_hex = primary if args.template == "chapter" else bg
334
+ txt_hex = bg if args.template == "chapter" else text_c
335
+ align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
336
+ add_text(args.title,
337
+ f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}",
338
+ size=fs, color=txt_hex, font=ass_font_family(), align=align,
339
+ x=(m_left if "left" in pos else W - m_right),
340
+ y=(m_top if "top" in pos else H - m_bottom),
341
+ box=True, box_color=box_hex, box_alpha=(0x19 if args.template == "chapter" else 0x4C),
342
+ outline=float(pady), outline_color=box_hex, start=s, end=e, fade=(300, 300))
161
343
 
162
344
  elif args.template == "progress":
163
345
  h = max(3, int(base * 0.008))
@@ -166,6 +348,76 @@ def main() -> int:
166
348
  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]")
167
349
  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]")
168
350
 
351
+ elif args.template == "sticker":
352
+ # A social sticker: a filled chip of text that pops in. drawtext's box gives the chip
353
+ # (its corners are square -- drawtext has no rounded box), and the pop is the two things
354
+ # drawtext *can* animate per frame: alpha and position, so the chip fades up while
355
+ # rising the last few pixels into place over 0.25 s.
356
+ if not args.text:
357
+ die("sticker needs --text")
358
+ pos = args.position or "top-right"
359
+ fs = int(base * 0.05)
360
+ padx, pady = int(fs * 0.7), int(fs * 0.45)
361
+ rise = int(fs * 0.5)
362
+ pop = f"min(1,(t-{s:.3f})/0.25)"
363
+ xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
364
+ ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}-{rise}*(1-{pop})")
365
+ alpha = f"min({pop},{fade_a})"
366
+ align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
367
+ y_rest = m_top if "top" in pos else H - m_bottom
368
+ y_start = y_rest + rise if "top" in pos else y_rest + rise
369
+ add_text(args.text,
370
+ f"drawtext={drawtext_text_opts(args.text)}:{fo}:fontsize={fs}:fontcolor={ff_color(bg)}:"
371
+ f"x={xe}:y='{ye}':box=1:boxcolor={ff_color(primary, 0.95)}:boxborderw={drawtext_boxborderw(pady, padx)}:"
372
+ f"alpha='{alpha}':{en}",
373
+ size=fs, color=bg, font=ass_font_family(), align=align,
374
+ x=(m_left if "left" in pos else W - m_right), y=y_rest,
375
+ box=True, box_color=primary, box_alpha=0x0D, outline=float(pady),
376
+ outline_color=primary, start=s, end=e, fade=(250, 250),
377
+ move=(m_left if "left" in pos else W - m_right, y_start,
378
+ m_left if "left" in pos else W - m_right, y_rest, 0, 250))
379
+
380
+ elif args.template == "hook":
381
+ # The opener: a full-width card over the first --duration seconds with a thin bar along
382
+ # the top that empties as the card's time runs out, so the viewer sees how long it lasts.
383
+ if not args.title:
384
+ die("hook needs --title")
385
+ if args.duration <= 0:
386
+ die(f"--duration must be > 0, got {args.duration:g}")
387
+ he = min(e, s + args.duration)
388
+ hen = f"enable='between(t,{s:.3f},{he:.3f})'"
389
+ h1 = int(base * 0.085)
390
+ bar_h = max(3, int(base * 0.01))
391
+ band_h = int(base * 0.30)
392
+ y0 = (H - band_h) // 2
393
+ filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
394
+ add_text(args.title,
395
+ f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
396
+ f"x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
397
+ size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
398
+ outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
399
+ filters.append(f"drawbox=x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}:"
400
+ f"color={ff_color(primary)}:t=fill:{hen}")
401
+
402
+ elif args.template == "meme":
403
+ # The classic layout: heavy white upper-case lines with a black outline, top and bottom,
404
+ # sized so a short line fills the frame's width without wrapping (drawtext never wraps).
405
+ if not (args.top or args.bottom):
406
+ die("meme needs --top and/or --bottom")
407
+ fs = int(base * 0.09)
408
+ bw = max(2, int(fs / 12))
409
+ white, black = ff_color("FFFFFF"), ff_color("000000")
410
+ for text, y in ((args.top, f"{m_top}"), (args.bottom, f"h-text_h-{m_bottom}")):
411
+ if not text:
412
+ continue
413
+ add_text(text.upper(),
414
+ f"drawtext={drawtext_text_opts(text.upper())}:{fo}:fontsize={fs}:fontcolor={white}:"
415
+ f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}",
416
+ size=fs, color="FFFFFF", font=ass_font_family(), bold=True,
417
+ align=(8 if y == f"{m_top}" else 2), x=W / 2,
418
+ y=(m_top if y == f"{m_top}" else H - m_bottom),
419
+ outline=float(bw), outline_color="000000", start=s, end=e)
420
+
169
421
  elif args.template == "countdown":
170
422
  n = args.count_from
171
423
  seg = (e - s) / (n + 1)
@@ -174,11 +426,114 @@ def main() -> int:
174
426
  ks = s + (n - k) * seg
175
427
  ke = ks + seg
176
428
  pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
177
- filters.append(f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='between(t,{ks:.3f},{ke:.3f})'")
429
+ add_text(str(k),
430
+ f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='between(t,{ks:.3f},{ke:.3f})'",
431
+ size=fs, color=primary, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
432
+ outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
433
+ scale_t=(0, seg * 500, 115))
178
434
 
179
435
  output = args.output or default_output(args.input, "gfx")
436
+ ass_path = None
437
+ emoji_result = None
438
+ if route == "ass":
439
+ scale_em = float(args.emoji_scale or 1.0)
440
+ missing: List[str] = []
441
+ seen: List[str] = []
442
+ for el in elements:
443
+ el["box_px"] = int(round(el["size"] * scale_em))
444
+ line = el["text"]
445
+ clusters = emoji_clusters(line)
446
+ if not clusters or emoji_mode not in ("png",):
447
+ continue
448
+ line_w = text_width_em(line, scale_em) * el["size"]
449
+ align = int(el.get("align", 7))
450
+ left = el["x"] if align in (7, 4, 1) else (
451
+ el["x"] - line_w / 2.0 if align in (8, 5, 2) else el["x"] - line_w)
452
+ line_h = el["size"] * 1.2
453
+ y_top = el["y"] if align in (7, 8, 9) else (
454
+ el["y"] - line_h / 2.0 if align in (4, 5, 6) else el["y"] - line_h)
455
+ # An RTL line is rendered right-to-left: measure the suffix, not the logical prefix.
456
+ rtl = detect_script(line) in BIDI_SCRIPTS
457
+ rebuilt, cursor = "", 0
458
+ for idx, cluster in clusters:
459
+ name = emoji_codepoint_name(cluster)
460
+ if name not in seen:
461
+ seen.append(name)
462
+ asset = emoji_asset_for(cluster, emoji_assets)
463
+ if not asset:
464
+ if name not in missing:
465
+ missing.append(name)
466
+ rebuilt += line[cursor:idx + len(cluster)]
467
+ cursor = idx + len(cluster)
468
+ continue
469
+ if rtl:
470
+ prefix_px = line_w - text_width_em(line[:idx] + cluster, scale_em) * el["size"]
471
+ else:
472
+ prefix_px = text_width_em(line[:idx], scale_em) * el["size"]
473
+ if el.get("x_expr"):
474
+ # the lower-third slides: the emoji rides the same expression the bar does
475
+ x = f"({el['x_expr']})+{prefix_px:.0f}"
476
+ else:
477
+ x = int(round(max(0.0, min(left + prefix_px, W - el["box_px"]))))
478
+ emoji_overlays.append({"asset": asset, "cluster": name, "x": x,
479
+ "y": int(round(max(0.0, min(y_top + (line_h - el["box_px"]) / 2.0,
480
+ H - el["box_px"])))),
481
+ "start": round(el["start"], 3), "end": round(el["end"], 3),
482
+ "box": el["box_px"],
483
+ # every template fades its text in and out over 0.3 s
484
+ # (fade_a above); the PNG rides the same envelope.
485
+ "fade_in": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3),
486
+ "fade_out": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3)})
487
+ rebuilt += line[cursor:idx] + EMOJI_SENTINEL
488
+ cursor = idx + len(cluster)
489
+ el["text"] = rebuilt + line[cursor:]
490
+ # `or 60` would swallow --emoji-max 0, the one value meaning "none at all".
491
+ _max = 60 if args.emoji_max is None else int(args.emoji_max)
492
+ if len(emoji_overlays) > _max:
493
+ die(f"{len(emoji_overlays)} emoji overlays would be built for this job "
494
+ f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
495
+ "per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
496
+ if missing:
497
+ info("warning: no PNG in the assets directory for " + ", ".join(missing))
498
+ if emoji_mode:
499
+ emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
500
+ "clusters": sorted(seen) or sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
501
+ "assets": emoji_assets, "missing": missing,
502
+ "overlays": len(emoji_overlays)}
503
+ ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
504
+ if STATE.dry_run:
505
+ info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
506
+ else:
507
+ text_overlay_ass(elements, play_w=W, play_h=H, path=ass_path, fonts_dir=ass_fonts_dir())
508
+ info(f"wrote {ass_path} ({len(elements)} text elements, rendered through libass)")
509
+ elif emoji_mode:
510
+ emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
511
+ "clusters": sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
512
+ "assets": emoji_assets, "missing": [], "overlays": 0}
513
+
180
514
  cmd = ffmpeg_base() + ["-i", args.input]
181
- if fc:
515
+ if route == "ass" or emoji_overlays:
516
+ chains = list(fc) if fc else [f"[0:v]{','.join(filters) if filters else 'null'}[vout]"]
517
+ last = "vout"
518
+ if route == "ass":
519
+ vf = f"ass={escape_filter_path(ass_path)}"
520
+ fdir = ass_fonts_dir()
521
+ if fdir:
522
+ vf += f":fontsdir={escape_filter_path(fdir)}"
523
+ chains.append(f"[{last}]{vf}[vtxt]")
524
+ last = "vtxt"
525
+ eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1)
526
+ for spec in emoji_inputs:
527
+ cmd += spec
528
+ asset = spec[-1]
529
+ if asset not in STATE.plan_inputs:
530
+ STATE.plan_inputs.append(asset)
531
+ if eo:
532
+ chains += eo
533
+ last = "vfinal"
534
+ cmd += ["-filter_complex", ";".join(chains), "-map", f"[{last}]",
535
+ "-map", f"0:a:{args.audio_stream}?"]
536
+ elif fc:
182
537
  cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
183
538
  else:
184
539
  cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
@@ -187,7 +542,13 @@ def main() -> int:
187
542
  dropped_streams = run_keeping_subtitles(cmd, output)
188
543
  r = probe(output, role="output")
189
544
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
190
- emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
545
+ extra = {"template": args.template, "dropped_non_av_streams": dropped_streams,
546
+ "text_renderer": route, "script": _script}
547
+ if ass_path:
548
+ extra["ass"] = ass_path
549
+ if emoji_result:
550
+ extra["emoji"] = emoji_result
551
+ emit(output, **extra)
191
552
  return 0
192
553
 
193
554
 
package/scripts/look.py CHANGED
@@ -7,6 +7,7 @@ Examples:
7
7
  python3 look.py final.mp4 --tiles 4x5 --width 1600
8
8
  python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
9
9
  python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
10
+ python3 look.py reel.mp4 --safe tiktok --at 3 # shade what TikTok's own UI covers
10
11
  Then view the PNG (Read tool / image viewer) and verify before reporting.
11
12
  """
12
13
  import argparse
@@ -15,6 +16,7 @@ import sys
15
16
  from pathlib import Path
16
17
  from typing import List
17
18
 
19
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
18
20
  from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
19
21
 
20
22
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
@@ -39,6 +41,8 @@ def main() -> int:
39
41
  ap.add_argument("--width", type=int, default=1280, help="total width of the sheet / compare image (default 1280)")
40
42
  ap.add_argument("--compare", help="second video: place its frame next to the first (needs --at)")
41
43
  ap.add_argument("--no-timecode", action="store_true")
44
+ ap.add_argument("--safe", choices=PLATFORM_CHOICES, help="shade the zones this platform's UI covers (its description bar, "
45
+ "like column, status bar) so you can see whether anything readable is under them")
42
46
  add_common(ap)
43
47
  args = ap.parse_args()
44
48
  apply_common(args)
@@ -55,6 +59,37 @@ def main() -> int:
55
59
  default_font = default_font_file("DejaVu Sans")
56
60
  font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
57
61
  tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
62
+ # --safe: the platform's occluded zones, drawn as shaded boxes in fractions of the frame so
63
+ # the same filter is right at any scale (a tile of a contact sheet as much as a full frame).
64
+ safe_filter = ""
65
+ args.safe = resolve_platform(args.safe)
66
+ if args.safe and not PLATFORMS[args.safe].get("frame"):
67
+ info(f"--safe {args.safe}: this destination has no frame and no app chrome; nothing to shade")
68
+ args.safe = None
69
+ if args.safe:
70
+ z = PLATFORMS[args.safe]["safe"]
71
+ frame = PLATFORMS[args.safe]["frame"]
72
+ src = meta["video"]
73
+ if src.get("width") and src.get("height") and abs(src["width"] / src["height"] - frame["w"] / frame["h"]) > 0.02:
74
+ info(f"--safe {args.safe}: this source is {src['width']}x{src['height']}, not {args.safe}'s "
75
+ f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
76
+ f"so reframe first (fit.py --aspect) to see what the app really covers")
77
+ boxes = []
78
+ for edge, frac in (("top", z["top"]), ("bottom", z["bottom"]), ("left", z["left"]), ("right", z["right"])):
79
+ if frac <= 0:
80
+ continue
81
+ if edge == "top":
82
+ boxes.append(f"drawbox=x=0:y=0:w=iw:h=ih*{frac:g}:color=red@0.35:t=fill")
83
+ elif edge == "bottom":
84
+ boxes.append(f"drawbox=x=0:y=ih*(1-{frac:g}):w=iw:h=ih*{frac:g}:color=red@0.35:t=fill")
85
+ elif edge == "left":
86
+ boxes.append(f"drawbox=x=0:y=0:w=iw*{frac:g}:h=ih:color=red@0.20:t=fill")
87
+ else:
88
+ boxes.append(f"drawbox=x=iw*(1-{frac:g}):y=0:w=iw*{frac:g}:h=ih:color=red@0.20:t=fill")
89
+ safe_filter = "," + ",".join(boxes) if boxes else ""
90
+ info(f"--safe {args.safe}: shaded top {z['top'] * 100:.0f}% / bottom {z['bottom'] * 100:.0f}% / "
91
+ f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
92
+ tc = safe_filter + tc
58
93
  # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
59
94
  if meta["video"].get("hdr"):
60
95
  v = meta["video"]