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.
@@ -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