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/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