scriptcast 0.1.0__py3-none-any.whl
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.
- scriptcast/__init__.py +0 -0
- scriptcast/__main__.py +371 -0
- scriptcast/assets/__init__.py +0 -0
- scriptcast/assets/fonts/DMSans-Regular.ttf +0 -0
- scriptcast/assets/fonts/Pacifico.ttf +0 -0
- scriptcast/assets/themes/aurora.sh +20 -0
- scriptcast/assets/themes/dark.sh +19 -0
- scriptcast/assets/themes/light.sh +19 -0
- scriptcast/config.py +199 -0
- scriptcast/directives.py +444 -0
- scriptcast/export.py +595 -0
- scriptcast/generator.py +265 -0
- scriptcast/recorder.py +212 -0
- scriptcast/shell/__init__.py +20 -0
- scriptcast/shell/adapter.py +13 -0
- scriptcast/shell/bash.py +11 -0
- scriptcast/shell/zsh.py +11 -0
- scriptcast-0.1.0.dist-info/METADATA +21 -0
- scriptcast-0.1.0.dist-info/RECORD +33 -0
- scriptcast-0.1.0.dist-info/WHEEL +5 -0
- scriptcast-0.1.0.dist-info/entry_points.txt +2 -0
- scriptcast-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cli.py +304 -0
- tests/test_config.py +400 -0
- tests/test_directives.py +606 -0
- tests/test_export.py +986 -0
- tests/test_generator.py +434 -0
- tests/test_integration.py +97 -0
- tests/test_recorder.py +462 -0
- tests/test_registry.py +57 -0
- tests/test_shell.py +34 -0
- tests/test_theme.py +204 -0
scriptcast/export.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# scriptcast/export.py
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
12
|
+
from PIL.Image import Dither
|
|
13
|
+
from PIL.ImageFont import FreeTypeFont
|
|
14
|
+
from PIL.ImageFont import ImageFont as _PILFont
|
|
15
|
+
|
|
16
|
+
from .config import ThemeConfig
|
|
17
|
+
|
|
18
|
+
TITLE_BAR_HEIGHT = 28
|
|
19
|
+
_PACIFICO = Path(__file__).parent / "assets" / "fonts" / "Pacifico.ttf"
|
|
20
|
+
_DM_SANS = Path(__file__).parent / "assets" / "fonts" / "DMSans-Regular.ttf"
|
|
21
|
+
_WATERMARK_TEXT = "ScriptCast"
|
|
22
|
+
_TRAFFIC_LIGHTS = [
|
|
23
|
+
(12, "#FF5F57", "#FF8C80"), # red
|
|
24
|
+
(32, "#FEBC2E", "#FFD466"), # yellow
|
|
25
|
+
(52, "#28C840", "#5DE87F"), # green
|
|
26
|
+
]
|
|
27
|
+
_LIGHT_RADIUS = 6
|
|
28
|
+
_TITLE_COLOR = "#8A8A8A"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AggNotFoundError(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _hex_rgba(hex_color: str) -> tuple[int, int, int, int]:
|
|
36
|
+
"""Parse a 6- or 8-character hex color string into an RGBA tuple."""
|
|
37
|
+
h = hex_color.lstrip("#")
|
|
38
|
+
if len(h) not in (6, 8):
|
|
39
|
+
raise ValueError(f"_hex_rgba expects 6 or 8 hex digits, got {len(h)} in {hex_color!r}")
|
|
40
|
+
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
41
|
+
a = int(h[6:8], 16) if len(h) == 8 else 255
|
|
42
|
+
return (r, g, b, a)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Layout:
|
|
48
|
+
content_w: int
|
|
49
|
+
content_h: int
|
|
50
|
+
half_bw: float
|
|
51
|
+
window_x: float
|
|
52
|
+
window_y: float
|
|
53
|
+
window_w: int
|
|
54
|
+
window_h: int
|
|
55
|
+
content_x: int
|
|
56
|
+
content_y: int
|
|
57
|
+
canvas_w: int
|
|
58
|
+
canvas_h: int
|
|
59
|
+
title_bar_h: int
|
|
60
|
+
title_cy: float
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_margin_sides(config: ThemeConfig) -> tuple[int, int, int, int]:
|
|
64
|
+
auto = 82 if config.background is not None else 0
|
|
65
|
+
sides = (config.margin_top, config.margin_right, config.margin_bottom, config.margin_left)
|
|
66
|
+
return tuple(s if s is not None else auto for s in sides) # type: ignore[return-value]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_layout(content_w: int, content_h: int, config: ThemeConfig) -> Layout:
|
|
70
|
+
mt, mr, mb, ml = _resolve_margin_sides(config)
|
|
71
|
+
half_bw = config.border_width / 2
|
|
72
|
+
title_bar_h = TITLE_BAR_HEIGHT if config.frame_bar else 0
|
|
73
|
+
|
|
74
|
+
window_w = content_w + config.border_width * 2
|
|
75
|
+
window_h = title_bar_h + content_h + config.border_width
|
|
76
|
+
|
|
77
|
+
window_x = ml + half_bw
|
|
78
|
+
window_y = mt + half_bw
|
|
79
|
+
|
|
80
|
+
content_x = int(window_x) + config.border_width
|
|
81
|
+
content_y = int(window_y + title_bar_h)
|
|
82
|
+
|
|
83
|
+
canvas_w = int(ml + half_bw + window_w + half_bw + mr)
|
|
84
|
+
canvas_h = int(mt + half_bw + window_h + half_bw + mb)
|
|
85
|
+
|
|
86
|
+
title_cy = (window_y + half_bw + (title_bar_h - half_bw) / 2) if title_bar_h > 0 else 0.0
|
|
87
|
+
|
|
88
|
+
return Layout(
|
|
89
|
+
content_w=content_w,
|
|
90
|
+
content_h=content_h,
|
|
91
|
+
half_bw=half_bw,
|
|
92
|
+
window_x=window_x,
|
|
93
|
+
window_y=window_y,
|
|
94
|
+
window_w=window_w,
|
|
95
|
+
window_h=window_h,
|
|
96
|
+
content_x=content_x,
|
|
97
|
+
content_y=content_y,
|
|
98
|
+
canvas_w=canvas_w,
|
|
99
|
+
canvas_h=canvas_h,
|
|
100
|
+
title_bar_h=title_bar_h,
|
|
101
|
+
title_cy=title_cy,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_bg_shadow(layout: Layout, config: ThemeConfig) -> Image.Image:
|
|
106
|
+
# Background
|
|
107
|
+
if config.background is None:
|
|
108
|
+
base = Image.new("RGBA", (layout.canvas_w, layout.canvas_h), (0, 0, 0, 0))
|
|
109
|
+
else:
|
|
110
|
+
stops = [p.strip() for p in config.background.split(",")]
|
|
111
|
+
if len(stops) == 1:
|
|
112
|
+
base = Image.new("RGBA", (layout.canvas_w, layout.canvas_h), _hex_rgba(stops[0]))
|
|
113
|
+
elif len(stops) == 2:
|
|
114
|
+
color1 = _hex_rgba(stops[0])
|
|
115
|
+
color2 = _hex_rgba(stops[1])
|
|
116
|
+
base = Image.new("RGBA", (layout.canvas_w, layout.canvas_h))
|
|
117
|
+
draw = ImageDraw.Draw(base)
|
|
118
|
+
for x in range(layout.canvas_w):
|
|
119
|
+
t = x / (layout.canvas_w - 1) if layout.canvas_w > 1 else 0.0
|
|
120
|
+
col = tuple(int(color1[i] + t * (color2[i] - color1[i])) for i in range(4))
|
|
121
|
+
draw.line([(x, 0), (x, layout.canvas_h - 1)], fill=col)
|
|
122
|
+
else:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"background gradient supports exactly 2 color stops, got {len(stops)}: "
|
|
125
|
+
f"{config.background!r}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if not config.shadow:
|
|
129
|
+
return base
|
|
130
|
+
|
|
131
|
+
# Drop shadow (PIL only — cairosvg blur is unreliable)
|
|
132
|
+
r = config.shadow_radius
|
|
133
|
+
offset_x = config.shadow_offset_x
|
|
134
|
+
offset_y = config.shadow_offset_y
|
|
135
|
+
shadow_color = _hex_rgba(config.shadow_color)
|
|
136
|
+
|
|
137
|
+
wx = int(layout.window_x)
|
|
138
|
+
wy = int(layout.window_y)
|
|
139
|
+
ww = layout.window_w
|
|
140
|
+
wh = layout.window_h
|
|
141
|
+
|
|
142
|
+
pad = r * 2
|
|
143
|
+
shadow_img = Image.new("RGBA", (ww + pad * 2, wh + pad * 2), (0, 0, 0, 0))
|
|
144
|
+
draw = ImageDraw.Draw(shadow_img)
|
|
145
|
+
draw.rounded_rectangle(
|
|
146
|
+
[pad, pad, pad + ww, pad + wh],
|
|
147
|
+
radius=config.radius,
|
|
148
|
+
fill=shadow_color,
|
|
149
|
+
)
|
|
150
|
+
shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(r))
|
|
151
|
+
|
|
152
|
+
dest_x = wx - pad + offset_x
|
|
153
|
+
dest_y = wy - pad + offset_y
|
|
154
|
+
shadow_canvas = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
|
155
|
+
shadow_canvas.paste(shadow_img, (dest_x, dest_y))
|
|
156
|
+
return Image.alpha_composite(base, shadow_canvas)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _preprocess_frames(
|
|
160
|
+
frames: list,
|
|
161
|
+
config: ThemeConfig,
|
|
162
|
+
) -> tuple:
|
|
163
|
+
"""Detect terminal bg colour and bake padding into every content frame.
|
|
164
|
+
|
|
165
|
+
Returns (padded_frames, terminal_bg) where terminal_bg is an RGB triple.
|
|
166
|
+
Alpha-composites frame onto a terminal_bg canvas so transparent corner pixels
|
|
167
|
+
show bg, not garbage palette RGB values.
|
|
168
|
+
"""
|
|
169
|
+
first = frames[0].convert("RGBA")
|
|
170
|
+
w, h = first.size
|
|
171
|
+
terminal_bg: tuple[int, int, int] = first.getpixel((w // 2, 1))[:3]
|
|
172
|
+
|
|
173
|
+
pl = config.padding_left
|
|
174
|
+
pr = config.padding_right
|
|
175
|
+
pt = config.padding_top
|
|
176
|
+
pb = config.padding_bottom
|
|
177
|
+
new_w = pl + w + pr
|
|
178
|
+
new_h = pt + h + pb
|
|
179
|
+
|
|
180
|
+
padded: list = []
|
|
181
|
+
bg_solid = (*terminal_bg, 255)
|
|
182
|
+
for frame in frames:
|
|
183
|
+
fw, fh = frame.size
|
|
184
|
+
px = frame.load()
|
|
185
|
+
# Patch 4x4 corners: transparent/mixed pixels carry black backing from the
|
|
186
|
+
# GIF palette; replace them with solid terminal_bg before compositing.
|
|
187
|
+
c = 4
|
|
188
|
+
for x0, y0 in [(0, 0), (fw - c, 0), (0, fh - c), (fw - c, fh - c)]:
|
|
189
|
+
for y in range(y0, y0 + c):
|
|
190
|
+
for x in range(x0, x0 + c):
|
|
191
|
+
if px[x, y] != bg_solid:
|
|
192
|
+
px[x, y] = bg_solid
|
|
193
|
+
|
|
194
|
+
canvas = Image.new("RGBA", (new_w, new_h), bg_solid)
|
|
195
|
+
canvas.paste(frame, (pl, pt))
|
|
196
|
+
padded.append(canvas)
|
|
197
|
+
|
|
198
|
+
return padded, terminal_bg
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _draw_gradient_circle(img, cx, cy, radius, base_color, highlight_color): # type: ignore[no-untyped-def]
|
|
202
|
+
base = _hex_rgba(base_color)
|
|
203
|
+
highlight = _hex_rgba(highlight_color)
|
|
204
|
+
|
|
205
|
+
# supersampling factor (anti-aliasing)
|
|
206
|
+
scale = 4
|
|
207
|
+
size = radius * 2 * scale
|
|
208
|
+
|
|
209
|
+
highres = Image.new("RGBA", (size, size))
|
|
210
|
+
px = highres.load()
|
|
211
|
+
|
|
212
|
+
for y in range(size):
|
|
213
|
+
for x in range(size):
|
|
214
|
+
dx = (x + 0.5) / scale - radius
|
|
215
|
+
dy = (y + 0.5) / scale - radius
|
|
216
|
+
dist = math.sqrt(dx * dx + dy * dy)
|
|
217
|
+
|
|
218
|
+
if dist <= radius:
|
|
219
|
+
t = dist / radius
|
|
220
|
+
r = int(highlight[0] * (1 - t) + base[0] * t)
|
|
221
|
+
g = int(highlight[1] * (1 - t) + base[1] * t)
|
|
222
|
+
b = int(highlight[2] * (1 - t) + base[2] * t)
|
|
223
|
+
a = int(highlight[3] * (1 - t) + base[3] * t)
|
|
224
|
+
px[x, y] = (r, g, b, a)
|
|
225
|
+
else:
|
|
226
|
+
px[x, y] = (0, 0, 0, 0)
|
|
227
|
+
|
|
228
|
+
# downscale with high-quality filter
|
|
229
|
+
circle = highres.resize((radius * 2, radius * 2), Image.LANCZOS)
|
|
230
|
+
|
|
231
|
+
img.paste(circle, (cx - radius, cy - radius), circle)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _build_chrome(
|
|
235
|
+
layout: Layout,
|
|
236
|
+
config: ThemeConfig,
|
|
237
|
+
window_bg: tuple[int, int, int] = (30, 30, 30),
|
|
238
|
+
) -> tuple[Image.Image, Image.Image]:
|
|
239
|
+
"""Build the window chrome and a content-area mask.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
chrome: RGBA image — window bg, title bar, traffic lights, border. No transparent hole.
|
|
243
|
+
mask: L-mode image — 255 where content is visible, 0 elsewhere.
|
|
244
|
+
"""
|
|
245
|
+
chrome = Image.new("RGBA", (layout.canvas_w, layout.canvas_h), (0, 0, 0, 0))
|
|
246
|
+
draw = ImageDraw.Draw(chrome)
|
|
247
|
+
|
|
248
|
+
wx = int(layout.window_x)
|
|
249
|
+
wy = int(layout.window_y)
|
|
250
|
+
ww = layout.window_w
|
|
251
|
+
wh = layout.window_h
|
|
252
|
+
r = config.radius
|
|
253
|
+
|
|
254
|
+
# Window background (rounded rect, no hole)
|
|
255
|
+
draw.rounded_rectangle([wx, wy, wx + ww, wy + wh], radius=r, fill=(*window_bg, 255))
|
|
256
|
+
|
|
257
|
+
# Title bar
|
|
258
|
+
if config.frame_bar and layout.title_bar_h > 0:
|
|
259
|
+
clip = Image.new("L", chrome.size, 0)
|
|
260
|
+
clip_draw = ImageDraw.Draw(clip)
|
|
261
|
+
clip_draw.rounded_rectangle([wx, wy, wx + ww, wy + wh], radius=r, fill=255)
|
|
262
|
+
clip_draw.rectangle([wx, wy + layout.title_bar_h, wx + ww, wy + wh], fill=0)
|
|
263
|
+
titlebar_fill = Image.new("RGBA", chrome.size, _hex_rgba(config.frame_bar_color))
|
|
264
|
+
chrome = Image.composite(titlebar_fill, chrome, clip)
|
|
265
|
+
|
|
266
|
+
title_cy = int(layout.title_cy)
|
|
267
|
+
|
|
268
|
+
if config.frame_bar_buttons:
|
|
269
|
+
for x_off, base_color, highlight_color in _TRAFFIC_LIGHTS:
|
|
270
|
+
_draw_gradient_circle(chrome, wx + x_off, title_cy,
|
|
271
|
+
_LIGHT_RADIUS, base_color, highlight_color)
|
|
272
|
+
|
|
273
|
+
if config.frame_bar_title:
|
|
274
|
+
title_font: FreeTypeFont | _PILFont
|
|
275
|
+
try:
|
|
276
|
+
title_font = ImageFont.truetype(str(_DM_SANS), size=12)
|
|
277
|
+
except Exception:
|
|
278
|
+
title_font = ImageFont.load_default()
|
|
279
|
+
draw = ImageDraw.Draw(chrome)
|
|
280
|
+
draw.text(
|
|
281
|
+
(wx + ww // 2, title_cy),
|
|
282
|
+
config.frame_bar_title,
|
|
283
|
+
fill=_hex_rgba(_TITLE_COLOR),
|
|
284
|
+
font=title_font,
|
|
285
|
+
anchor="mm",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Border
|
|
289
|
+
if config.border_width > 0:
|
|
290
|
+
draw = ImageDraw.Draw(chrome)
|
|
291
|
+
draw.rounded_rectangle(
|
|
292
|
+
[wx, wy, wx + ww, wy + wh],
|
|
293
|
+
radius=r,
|
|
294
|
+
outline=_hex_rgba(config.border_color),
|
|
295
|
+
width=config.border_width,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Content-area mask
|
|
299
|
+
cx, cy = layout.content_x, layout.content_y
|
|
300
|
+
cw, ch = layout.content_w, layout.content_h
|
|
301
|
+
r_punch = min(r, cw // 2, ch // 2)
|
|
302
|
+
|
|
303
|
+
mask = Image.new("L", chrome.size, 0)
|
|
304
|
+
mask_draw = ImageDraw.Draw(mask)
|
|
305
|
+
# Round all 4 corners first
|
|
306
|
+
mask_draw.rounded_rectangle([cx, cy, cx + cw - 1, cy + ch - 1], radius=r_punch, fill=255)
|
|
307
|
+
# Square off the top corners when content is not flush with the window top
|
|
308
|
+
if r_punch > 0 and cy > wy:
|
|
309
|
+
mask_draw.rectangle([cx, cy, cx + cw - 1, cy + r_punch], fill=255)
|
|
310
|
+
|
|
311
|
+
return chrome, mask
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _apply_watermark(base: Image.Image, config: ThemeConfig, margin_bottom: int = 0) -> Image.Image:
|
|
315
|
+
if config.watermark is None:
|
|
316
|
+
return base
|
|
317
|
+
|
|
318
|
+
result = base.copy()
|
|
319
|
+
draw = ImageDraw.Draw(result)
|
|
320
|
+
|
|
321
|
+
font_size = (
|
|
322
|
+
config.watermark_size
|
|
323
|
+
if config.watermark_size is not None
|
|
324
|
+
else int(max(20, min(30, base.width * 0.11)))
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
font: FreeTypeFont | _PILFont
|
|
328
|
+
try:
|
|
329
|
+
font = ImageFont.truetype(str(_DM_SANS), size=font_size)
|
|
330
|
+
except Exception:
|
|
331
|
+
try:
|
|
332
|
+
font = ImageFont.load_default(size=font_size)
|
|
333
|
+
except TypeError:
|
|
334
|
+
font = ImageFont.load_default()
|
|
335
|
+
|
|
336
|
+
x = base.width // 2
|
|
337
|
+
y = (
|
|
338
|
+
base.height - margin_bottom // 2
|
|
339
|
+
if margin_bottom > 0
|
|
340
|
+
else base.height - 22 - font_size // 2
|
|
341
|
+
)
|
|
342
|
+
draw.text(
|
|
343
|
+
(x, y),
|
|
344
|
+
config.watermark,
|
|
345
|
+
fill=_hex_rgba(config.watermark_color),
|
|
346
|
+
font=font,
|
|
347
|
+
anchor="mm",
|
|
348
|
+
)
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _apply_scriptcast_watermark(base: Image.Image, config: ThemeConfig) -> Image.Image:
|
|
353
|
+
if not config.scriptcast_watermark:
|
|
354
|
+
return base
|
|
355
|
+
|
|
356
|
+
result = base.copy()
|
|
357
|
+
draw = ImageDraw.Draw(result)
|
|
358
|
+
|
|
359
|
+
font_size = 14
|
|
360
|
+
wm_font: FreeTypeFont | _PILFont
|
|
361
|
+
try:
|
|
362
|
+
wm_font = ImageFont.truetype(str(_PACIFICO), size=font_size)
|
|
363
|
+
except (OSError, AttributeError):
|
|
364
|
+
try:
|
|
365
|
+
wm_font = ImageFont.load_default(size=font_size)
|
|
366
|
+
except TypeError:
|
|
367
|
+
wm_font = ImageFont.load_default()
|
|
368
|
+
|
|
369
|
+
x = base.width - 8
|
|
370
|
+
y = base.height - 8
|
|
371
|
+
draw.text((x + 1, y + 1), _WATERMARK_TEXT, fill=(0, 0, 0, 160), font=wm_font, anchor="rb")
|
|
372
|
+
draw.text((x, y), _WATERMARK_TEXT, fill=(255, 255, 255, 220), font=wm_font, anchor="rb")
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def apply_scriptcast_watermark(gif_path: Path, config: ThemeConfig) -> None:
|
|
377
|
+
"""Overlay the ScriptCast brand watermark on an existing GIF in-place (no frame path).
|
|
378
|
+
|
|
379
|
+
Used when --frame is not set but scriptcast_watermark is True.
|
|
380
|
+
"""
|
|
381
|
+
if not config.scriptcast_watermark:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
raw_frames: list[Image.Image] = []
|
|
385
|
+
durations: list[int] = []
|
|
386
|
+
with Image.open(gif_path) as img:
|
|
387
|
+
try:
|
|
388
|
+
while True:
|
|
389
|
+
raw_frames.append(img.copy().convert("RGBA"))
|
|
390
|
+
durations.append(img.info.get("duration", 100))
|
|
391
|
+
img.seek(img.tell() + 1)
|
|
392
|
+
except EOFError:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
out_frames = []
|
|
396
|
+
for raw in raw_frames:
|
|
397
|
+
with_wm = _apply_scriptcast_watermark(raw, config)
|
|
398
|
+
out_frames.append(with_wm.convert("RGB").quantize(colors=256, dither=Dither.NONE))
|
|
399
|
+
|
|
400
|
+
if not out_frames:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
out_frames[0].save(
|
|
404
|
+
gif_path,
|
|
405
|
+
save_all=True,
|
|
406
|
+
append_images=out_frames[1:],
|
|
407
|
+
loop=0,
|
|
408
|
+
duration=durations,
|
|
409
|
+
optimize=False,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _chrome_colors(
|
|
414
|
+
config: ThemeConfig, window_bg: tuple[int, int, int] = (30, 30, 30),
|
|
415
|
+
) -> list[tuple[int, int, int]]:
|
|
416
|
+
"""RGB colors that must be reserved in the GIF palette."""
|
|
417
|
+
colors = [_hex_rgba(base)[:3] for _, base, _ in _TRAFFIC_LIGHTS]
|
|
418
|
+
colors.append(window_bg)
|
|
419
|
+
colors.append(_hex_rgba(config.frame_bar_color)[:3])
|
|
420
|
+
# Deduplicate while preserving order
|
|
421
|
+
seen: set[tuple[int, int, int]] = set()
|
|
422
|
+
result = []
|
|
423
|
+
for c in colors:
|
|
424
|
+
if c not in seen:
|
|
425
|
+
seen.add(c)
|
|
426
|
+
result.append(c)
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _build_global_palette(
|
|
431
|
+
template_rgb: Image.Image,
|
|
432
|
+
rgb_canvases: list[Image.Image],
|
|
433
|
+
config: ThemeConfig,
|
|
434
|
+
window_bg: tuple[int, int, int] = (30, 30, 30),
|
|
435
|
+
max_samples: int = 20,
|
|
436
|
+
) -> Image.Image:
|
|
437
|
+
chrome_colors = _chrome_colors(config, window_bg)
|
|
438
|
+
n_chrome = len(chrome_colors)
|
|
439
|
+
|
|
440
|
+
n = len(rgb_canvases)
|
|
441
|
+
if max_samples <= 1 or n <= max_samples:
|
|
442
|
+
indices = list(range(n))
|
|
443
|
+
else:
|
|
444
|
+
indices = [int(round(i * (n - 1) / (max_samples - 1))) for i in range(max_samples)]
|
|
445
|
+
|
|
446
|
+
sampled = [rgb_canvases[i] for i in indices]
|
|
447
|
+
w, h = template_rgb.size
|
|
448
|
+
composite = Image.new("RGB", (w, h * (1 + len(sampled))))
|
|
449
|
+
composite.paste(template_rgb, (0, 0))
|
|
450
|
+
for idx, frame in enumerate(sampled):
|
|
451
|
+
composite.paste(frame, (0, h * (idx + 1)))
|
|
452
|
+
|
|
453
|
+
content_ref = composite.quantize(colors=256 - n_chrome)
|
|
454
|
+
chrome_bytes = b"".join(bytes(c) for c in chrome_colors)
|
|
455
|
+
raw_palette = content_ref.getpalette() or []
|
|
456
|
+
content_bytes = bytes(raw_palette[: (256 - n_chrome) * 3])
|
|
457
|
+
palette_img = Image.new("P", (1, 1))
|
|
458
|
+
palette_img.putpalette(chrome_bytes + content_bytes)
|
|
459
|
+
return palette_img
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def apply_export(
|
|
463
|
+
gif_path: Path,
|
|
464
|
+
config: ThemeConfig,
|
|
465
|
+
format: str = "gif",
|
|
466
|
+
on_frame: Callable[[int, int], None] | None = None,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Post-process a GIF in-place: apply background, shadow, chrome, and watermarks.
|
|
469
|
+
|
|
470
|
+
format: "gif" writes .gif (quantized 256 colours); "png" writes .png (full RGBA).
|
|
471
|
+
on_frame: optional callback called after each frame is processed with (current, total) where
|
|
472
|
+
current is 1-based frame number and total is the total number of frames.
|
|
473
|
+
"""
|
|
474
|
+
raw_frames: list[Image.Image] = []
|
|
475
|
+
durations: list[int] = []
|
|
476
|
+
with Image.open(gif_path) as img:
|
|
477
|
+
try:
|
|
478
|
+
while True:
|
|
479
|
+
raw_frames.append(img.copy().convert("RGBA"))
|
|
480
|
+
durations.append(img.info.get("duration", 100))
|
|
481
|
+
img.seek(img.tell() + 1)
|
|
482
|
+
except EOFError:
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
if not raw_frames:
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
# Pre-process: detect terminal bg, bake padding into frames, fix transparent corners
|
|
489
|
+
frames, terminal_bg = _preprocess_frames(raw_frames, config)
|
|
490
|
+
|
|
491
|
+
content_w, content_h = frames[0].size
|
|
492
|
+
layout = build_layout(content_w, content_h, config)
|
|
493
|
+
_, _, resolved_mb, _ = _resolve_margin_sides(config)
|
|
494
|
+
bg_shadow = _build_bg_shadow(layout, config)
|
|
495
|
+
chrome, content_mask = _build_chrome(layout, config, window_bg=terminal_bg)
|
|
496
|
+
|
|
497
|
+
output_path = gif_path if format == "gif" else gif_path.with_suffix(".png")
|
|
498
|
+
|
|
499
|
+
total_frames = len(frames)
|
|
500
|
+
rgba_frames = []
|
|
501
|
+
for i, frame in enumerate(frames):
|
|
502
|
+
canvas = bg_shadow.copy()
|
|
503
|
+
canvas = Image.alpha_composite(canvas, chrome)
|
|
504
|
+
content_canvas = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
505
|
+
content_canvas.paste(frame, (layout.content_x, layout.content_y))
|
|
506
|
+
canvas = Image.composite(content_canvas, canvas, content_mask)
|
|
507
|
+
canvas = _apply_watermark(canvas, config, margin_bottom=resolved_mb)
|
|
508
|
+
canvas = _apply_scriptcast_watermark(canvas, config)
|
|
509
|
+
rgba_frames.append(canvas)
|
|
510
|
+
if on_frame is not None:
|
|
511
|
+
on_frame(i + 1, total_frames)
|
|
512
|
+
|
|
513
|
+
if format == "png":
|
|
514
|
+
rgba_frames[0].save(
|
|
515
|
+
output_path,
|
|
516
|
+
format="PNG",
|
|
517
|
+
save_all=True,
|
|
518
|
+
append_images=rgba_frames[1:],
|
|
519
|
+
loop=0,
|
|
520
|
+
duration=durations,
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
template_rgb = Image.alpha_composite(bg_shadow, chrome).convert("RGB")
|
|
524
|
+
rgb_canvases = [c.convert("RGB") for c in rgba_frames]
|
|
525
|
+
palette_ref = _build_global_palette(
|
|
526
|
+
template_rgb, rgb_canvases, config, window_bg=terminal_bg,
|
|
527
|
+
)
|
|
528
|
+
out_frames = [
|
|
529
|
+
f.quantize(palette=palette_ref, dither=Dither.NONE)
|
|
530
|
+
for f in rgb_canvases
|
|
531
|
+
]
|
|
532
|
+
out_frames[0].save(
|
|
533
|
+
output_path,
|
|
534
|
+
save_all=True,
|
|
535
|
+
append_images=out_frames[1:],
|
|
536
|
+
loop=0,
|
|
537
|
+
duration=durations,
|
|
538
|
+
optimize=False,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def generate_export(
|
|
543
|
+
cast_path: str | Path,
|
|
544
|
+
frame_config: ThemeConfig | None = None,
|
|
545
|
+
format: str = "gif",
|
|
546
|
+
on_frame: Callable[[int, int], None] | None = None,
|
|
547
|
+
) -> Path:
|
|
548
|
+
"""Convert a .cast file to GIF or PNG using agg, then apply frame if configured.
|
|
549
|
+
|
|
550
|
+
Raises AggNotFoundError if agg is not installed.
|
|
551
|
+
Install: https://github.com/asciinema/agg
|
|
552
|
+
|
|
553
|
+
format: "gif" writes .gif; "png" writes .png (full RGBA). Default here is "gif"
|
|
554
|
+
for API backward compatibility — the CLI defaults to "png".
|
|
555
|
+
on_frame: optional callback called after each frame is processed with (current, total) where
|
|
556
|
+
current is 1-based frame number and total is the total number of frames.
|
|
557
|
+
"""
|
|
558
|
+
agg = shutil.which("agg")
|
|
559
|
+
if agg is None:
|
|
560
|
+
raise AggNotFoundError(
|
|
561
|
+
"agg not found. Install from: https://github.com/asciinema/agg"
|
|
562
|
+
)
|
|
563
|
+
cast_path = Path(cast_path)
|
|
564
|
+
|
|
565
|
+
tmp_fd, tmp_gif_str = tempfile.mkstemp(suffix=".gif")
|
|
566
|
+
os.close(tmp_fd)
|
|
567
|
+
tmp_gif_path = Path(tmp_gif_str)
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
subprocess.run([agg, str(cast_path), str(tmp_gif_path)], check=True)
|
|
571
|
+
|
|
572
|
+
if frame_config is not None:
|
|
573
|
+
apply_export(tmp_gif_path, frame_config, format=format, on_frame=on_frame)
|
|
574
|
+
if format == "gif":
|
|
575
|
+
final_path = cast_path.with_suffix(".gif")
|
|
576
|
+
shutil.move(str(tmp_gif_path), str(final_path))
|
|
577
|
+
output_path = final_path
|
|
578
|
+
else: # png
|
|
579
|
+
tmp_png_path = tmp_gif_path.with_suffix(".png")
|
|
580
|
+
final_path = cast_path.with_suffix(".png")
|
|
581
|
+
shutil.move(str(tmp_png_path), str(final_path))
|
|
582
|
+
tmp_gif_path.unlink() # consumed by apply_export
|
|
583
|
+
output_path = final_path
|
|
584
|
+
else:
|
|
585
|
+
final_path = cast_path.with_suffix(".gif")
|
|
586
|
+
shutil.move(str(tmp_gif_path), str(final_path))
|
|
587
|
+
output_path = final_path
|
|
588
|
+
finally:
|
|
589
|
+
if tmp_gif_path.exists():
|
|
590
|
+
tmp_gif_path.unlink()
|
|
591
|
+
tmp_png = tmp_gif_path.with_suffix(".png")
|
|
592
|
+
if tmp_png.exists():
|
|
593
|
+
tmp_png.unlink()
|
|
594
|
+
|
|
595
|
+
return output_path
|