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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a set of positioned text elements as an ASS file, so libass draws them instead of
|
|
3
|
+
drawtext.
|
|
4
|
+
|
|
5
|
+
Private helper (leading underscore): not a tool, no TOOL_META entry, no contract or MCP surface.
|
|
6
|
+
|
|
7
|
+
Why it exists (1.15): drawtext cannot SHAPE. On a build with fribidi it gets bidi and Arabic
|
|
8
|
+
joining right, but it never reorders or re-clusters -- Devanagari matras come out in logical
|
|
9
|
+
order and Thai/Lao marks stack wrongly -- because drawtext does not use harfbuzz even in an
|
|
10
|
+
--enable-libharfbuzz build. libass does. `graphics.py` keeps computing exactly the geometry it
|
|
11
|
+
computed before; only the renderer changes, and only for text that needs it.
|
|
12
|
+
|
|
13
|
+
Each element is a dict:
|
|
14
|
+
|
|
15
|
+
{"text", "x", "y", "size", "color", "font", "bold", "outline", "outline_color",
|
|
16
|
+
"shadow", "align", "start", "end", "fade", "move", "scale_t", "box", "box_color"}
|
|
17
|
+
|
|
18
|
+
`align` is the ASS numpad alignment of (x, y) -- 7 is top-left, 5 centre, 2 bottom-centre -- so
|
|
19
|
+
a centred title needs no text-width measurement at all. `move` is (x1, y1, x2, y2, t1, t2) in
|
|
20
|
+
milliseconds relative to the line's own start; `fade` is (in_ms, out_ms); `scale_t` is
|
|
21
|
+
(t1_ms, t2_ms, percent) for a pop.
|
|
22
|
+
"""
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# One sentinel character stands in drawn text where an emoji was, so the placeholder is inserted
|
|
27
|
+
# AFTER the brace-stripping that keeps caller-supplied text from injecting override commands.
|
|
28
|
+
EMOJI_SENTINEL = "\ue000"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def emoji_placeholder(box_px: float) -> str:
|
|
32
|
+
"""The ASS override that reserves exactly `box_px` of advance and draws nothing.
|
|
33
|
+
|
|
34
|
+
Measured, not assumed (1.15 spec, open question 2). U+2588 FULL BLOCK is NOT 1.0 em: its
|
|
35
|
+
advance measured 0.83 em in FreeSans, 0.79 in WenQuanYi Zen Hei and 0.66 in DejaVu Sans,
|
|
36
|
+
IPAPGothic and Loma, so a block reserves the wrong gap in every face this repo resolves.
|
|
37
|
+
The spec's fallback, alpha-hidden figure spaces (U+2007), measured 0.46-0.55 em and only
|
|
38
|
+
quantises the gap to half an em. What IS exact in all five faces is that same alpha-hidden
|
|
39
|
+
whitespace carried by `\fsp` (letter spacing, in script pixels) on a zero-width space: the
|
|
40
|
+
next glyph starts exactly `box_px` later, with no font dependence at all (verified by render,
|
|
41
|
+
including inside a karaoke run, where the placeholder is its own zero-duration \kf segment).
|
|
42
|
+
`\r` restores the style for the rest of the line.
|
|
43
|
+
"""
|
|
44
|
+
return "{\\alpha&HFF&\\fsp%.1f}\u200b{\\r}" % box_px
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ass_time(sec: float) -> str:
|
|
48
|
+
cs = int(round(max(0.0, sec) * 100))
|
|
49
|
+
h, rem = divmod(cs, 360000)
|
|
50
|
+
m, rem = divmod(rem, 6000)
|
|
51
|
+
s, cs = divmod(rem, 100)
|
|
52
|
+
return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
56
|
+
h = str(hex_rgb).lstrip("#")
|
|
57
|
+
if len(h) != 6:
|
|
58
|
+
raise ValueError(f"colour must be RRGGBB hex, got '{hex_rgb}'")
|
|
59
|
+
return f"&H{alpha:02X}{h[4:6]}{h[2:4]}{h[0:2]}".upper()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ass_field(name: str) -> str:
|
|
63
|
+
"""A font name or style name for a comma-delimited ASS field: no escape mechanism exists, so
|
|
64
|
+
the delimiters are dropped (the same call caption.py's ass_font_name() makes)."""
|
|
65
|
+
out = "".join(ch for ch in str(name or "") if ord(ch) >= 0x20 and ch not in ",:\\'")
|
|
66
|
+
return out or "Sans"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# After a literal backslash, these characters would start an ASS sequence libass acts on
|
|
70
|
+
# (\N, \n, \h) or an override block (\{ is an escape, so \\{ is ambiguous). A zero-width space
|
|
71
|
+
# between the two breaks the sequence without changing what the reader sees.
|
|
72
|
+
_ASS_AFTER_BACKSLASH = frozenset("Nnh{}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ass_escape(text: str) -> str:
|
|
76
|
+
"""Element text for a Dialogue, with every character the user typed still in it.
|
|
77
|
+
|
|
78
|
+
`{` and `}` would open and close an override block -- real style and animation commands
|
|
79
|
+
(\\pos, \\t, \\fscx), so caller-supplied text containing them could reposition, rescale or
|
|
80
|
+
recolour itself and everything after it. libass has escapes for exactly this (`\\{`, `\\}`),
|
|
81
|
+
so 1.15.0 escapes them instead of deleting them: `A {b} c \\ d` now reaches the picture
|
|
82
|
+
verbatim through the ASS route, the way it already did through drawtext. A literal backslash
|
|
83
|
+
needs no escape of its own in libass (`\\\\` renders as TWO backslashes, it is not an escape);
|
|
84
|
+
only a backslash immediately before one of _ASS_AFTER_BACKSLASH is ambiguous, and a
|
|
85
|
+
zero-width space parts them.
|
|
86
|
+
"""
|
|
87
|
+
s = str(text or "")
|
|
88
|
+
out = []
|
|
89
|
+
for i, ch in enumerate(s):
|
|
90
|
+
if ch == "{":
|
|
91
|
+
out.append("\\{")
|
|
92
|
+
elif ch == "}":
|
|
93
|
+
out.append("\\}")
|
|
94
|
+
elif ch == "\n":
|
|
95
|
+
out.append("\\N")
|
|
96
|
+
elif ch == "\\":
|
|
97
|
+
out.append("\\\u200b" if (i + 1 < len(s) and s[i + 1] in _ASS_AFTER_BACKSLASH) else "\\")
|
|
98
|
+
else:
|
|
99
|
+
out.append(ch)
|
|
100
|
+
return "".join(out)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ass_text(text: str) -> str:
|
|
104
|
+
"""Back-compatible name for ass_escape()."""
|
|
105
|
+
return ass_escape(text)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def text_overlay_ass(elements: Sequence[Dict[str, Any]], *, play_w: int, play_h: int,
|
|
109
|
+
path: str, fonts_dir: Optional[str] = None) -> str:
|
|
110
|
+
"""Write `elements` as an ASS file at `path` and return the path."""
|
|
111
|
+
header = [
|
|
112
|
+
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}",
|
|
113
|
+
"WrapStyle: 2", "ScaledBorderAndShadow: yes", "",
|
|
114
|
+
"[V4+ Styles]",
|
|
115
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
|
|
116
|
+
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
|
|
117
|
+
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
118
|
+
]
|
|
119
|
+
events: List[str] = []
|
|
120
|
+
for i, el in enumerate(elements):
|
|
121
|
+
name = f"E{i}"
|
|
122
|
+
colour = ass_color(el.get("color", "FFFFFF"))
|
|
123
|
+
outline_colour = ass_color(el.get("outline_color", "000000"))
|
|
124
|
+
box = bool(el.get("box"))
|
|
125
|
+
back = ass_color(el.get("box_color", el.get("outline_color", "000000")),
|
|
126
|
+
int(el.get("box_alpha", 0)))
|
|
127
|
+
header.append(
|
|
128
|
+
f"Style: {name},{ass_field(el.get('font') or 'Sans')},{int(round(el['size']))},"
|
|
129
|
+
f"{colour},{colour},{outline_colour},{back},{-1 if el.get('bold') else 0},0,0,0,"
|
|
130
|
+
f"100,100,0,0,{3 if box else 1},{float(el.get('outline', 0)):.1f},"
|
|
131
|
+
f"{float(el.get('shadow', 0)):.1f},{int(el.get('align', 7))},0,0,0,1")
|
|
132
|
+
tags = [f"\\an{int(el.get('align', 7))}"]
|
|
133
|
+
move = el.get("move")
|
|
134
|
+
if move:
|
|
135
|
+
x1, y1, x2, y2, t1, t2 = move
|
|
136
|
+
tags.append(f"\\move({x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f},{t1:.0f},{t2:.0f})")
|
|
137
|
+
else:
|
|
138
|
+
tags.append(f"\\pos({el['x']:.0f},{el['y']:.0f})")
|
|
139
|
+
fade = el.get("fade")
|
|
140
|
+
if fade:
|
|
141
|
+
tags.append(f"\\fad({fade[0]:.0f},{fade[1]:.0f})")
|
|
142
|
+
scale_t = el.get("scale_t")
|
|
143
|
+
if scale_t:
|
|
144
|
+
t1, t2, pct = scale_t
|
|
145
|
+
tags.append(f"\\fscx{pct:.0f}\\fscy{pct:.0f}\\t({t1:.0f},{t2:.0f},\\fscx100\\fscy100)")
|
|
146
|
+
events.append(
|
|
147
|
+
f"Dialogue: 0,{ass_time(el['start'])},{ass_time(el['end'])},{name},,0,0,0,,"
|
|
148
|
+
"{" + "".join(tags) + "}" + ass_text(el["text"]).replace(
|
|
149
|
+
EMOJI_SENTINEL, emoji_placeholder(el.get("box_px") or el["size"])))
|
|
150
|
+
body = "\n".join(header + ["", "[Events]",
|
|
151
|
+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, "
|
|
152
|
+
"MarginV, Effect, Text"] + events) + "\n"
|
|
153
|
+
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
154
|
+
fh.write(body)
|
|
155
|
+
return path
|