← Files SSOT CheckARCHIVED FILE
scripts/render_demo.py
8.65 KB · Oct 2, 2026 · 00:33 UTC
#!/usr/bin/env python3
"""Deterministic terminal-style demo GIF renderer (Pillow, no external tools).
This is the fallback renderer used when `vhs`/`ttyd`/`ffmpeg` are unavailable.
The CANONICAL regeneration path is `demo.tape` (vhs) — see this repo's demo.tape.
This script exists so the GIF can be rebuilt reproducibly with only Pillow +
the DejaVu fonts, and so the frame content stays committed and reviewable.
Usage:
python3 scripts/render_demo.py # -> docs/demo.gif
python3 scripts/render_demo.py out.gif
Frames are defined in scripts/demo_frames.py (FRAMES, TITLE). See that file for
the step format. Colors, sizing, and pacing are configured below.
"""
from __future__ import annotations
import os
import sys
from PIL import Image, ImageDraw, ImageFont
# --- Terminal look -----------------------------------------------------------
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"
FONT_SIZE = 18
COLS = 100
MAX_ROWS = 26
PAD_X = 22
TITLE_H = 36
PAD_TOP = 12
PAD_BOTTOM = 14
LINE_H = 26
COLORS = {
"bg": (26, 26, 46), # #1a1a2e
"titlebar": (18, 18, 38),
"fg": (214, 214, 224),
"dim": (122, 122, 144),
"prompt": (110, 231, 160), # green
"cmd": (240, 240, 245),
"green": (110, 231, 160),
"red": (255, 107, 107),
"yellow": (255, 203, 107),
"cyan": (87, 199, 255),
"magenta": (199, 146, 234),
"blue": (130, 170, 255),
"white": (255, 255, 255),
"cursor": (110, 231, 160),
}
DOTS = [(255, 95, 86), (255, 189, 46), (39, 201, 63)]
PROMPT = "❯" # ❯
# --- Pacing (ms) -------------------------------------------------------------
TYPE_MS = 45 # per typing frame
TYPE_CHUNK = 2 # chars typed per frame
CMD_HOLD_MS = 450 # pause after a command finishes typing, before output
DEFAULT_HOLD_MS = 1500
FONT = ImageFont.truetype(FONT_PATH, FONT_SIZE)
FONTB = ImageFont.truetype(FONT_BOLD, FONT_SIZE)
CHAR_W = FONT.getlength("M")
def _seg(line):
"""Normalize a line into a list of (text, colorkey) segments."""
if isinstance(line, str):
return [(line, "fg")]
if isinstance(line, tuple) and len(line) == 2 and isinstance(line[0], str):
return [line]
# already a list of segments
return list(line)
def _wrap(segs, width=COLS, indent=4):
"""Soft-wrap a segment list to `width` cols (terminal-style), preserving
per-char color. Continuation lines get a hanging indent."""
chars = [(ch, ck) for text, ck in segs for ch in text]
if len(chars) <= width:
return [segs]
out, cur = [], []
limit = width
while chars:
if len(chars) <= limit:
cur.extend(chars)
chars = []
break
# find break point: last space within limit
brk = None
for i in range(min(limit, len(chars)) - 1, max(0, limit - 24), -1):
if chars[i][0] == " ":
brk = i
break
if brk is None:
brk = limit
cur.extend(chars[:brk])
out.append(cur)
# skip the break space
rest = chars[brk:]
while rest and rest[0][0] == " ":
rest = rest[1:]
chars = [(" ", "fg")] * indent + rest
cur = []
limit = width
if cur:
out.append(cur)
# recombine each row's chars into segments
rows = []
for row in out:
segs2, buf, cck = [], "", None
for ch, ck in row:
if ck != cck and buf:
segs2.append((buf, cck))
buf = ""
buf, cck = buf + ch, ck
if buf:
segs2.append((buf, cck))
rows.append(segs2)
return rows
class Screen:
"""A mutable scrollback the steps mutate; emits (lines, cursor, dur) frames."""
def __init__(self):
self.lines = [] # committed lines (list of segment-lists)
self.frames = [] # (lines_copy, cursor_pos_or_None, dur_ms)
def _emit(self, cursor=None, dur=DEFAULT_HOLD_MS):
self.frames.append(([list(l) for l in self.lines], cursor, dur))
def type_cmd(self, text):
# append a new prompt line and type into it
base = [(PROMPT + " ", "prompt")]
self.lines.append(list(base))
idx = len(self.lines) - 1
n = len(text)
i = 0
while i < n:
i = min(n, i + TYPE_CHUNK)
self.lines[idx] = base + [(text[:i], "cmd")]
col = 2 + i
self._emit(cursor=(idx, col), dur=TYPE_MS)
# settle: solid line + short hold with cursor
self.lines[idx] = base + [(text, "cmd")]
self._emit(cursor=(idx, 2 + n), dur=CMD_HOLD_MS)
def out(self, lines, hold=DEFAULT_HOLD_MS):
for l in lines:
for row in _wrap(_seg(l)):
self.lines.append(row)
self._emit(cursor=None, dur=hold)
def hold(self, dur=DEFAULT_HOLD_MS):
self._emit(cursor=None, dur=dur)
def clear(self):
self.lines = []
def build_frames(steps):
s = Screen()
for st in steps:
kind = st[0]
if kind == "cmd":
s.type_cmd(st[1])
elif kind == "out":
hold = st[2] if len(st) > 2 else DEFAULT_HOLD_MS
s.out(st[1], hold)
elif kind == "hold":
s.hold(st[1])
elif kind == "clear":
s.clear()
else:
raise ValueError(f"unknown step: {kind}")
return s.frames
def _term_rows(frames):
m = 1
for lines, _, _ in frames:
m = max(m, len(lines))
return min(max(m, 3), MAX_ROWS)
def render_rgb(lines, cursor, rows, title):
width = int(PAD_X * 2 + CHAR_W * COLS)
height = int(TITLE_H + PAD_TOP + rows * LINE_H + PAD_BOTTOM)
img = Image.new("RGB", (width, height), COLORS["bg"])
d = ImageDraw.Draw(img)
# title bar
d.rectangle([0, 0, width, TITLE_H], fill=COLORS["titlebar"])
for i, c in enumerate(DOTS):
cx = PAD_X + i * 22
d.ellipse([cx, TITLE_H // 2 - 6, cx + 12, TITLE_H // 2 + 6], fill=c)
tw = FONT.getlength(title)
d.text(((width - tw) / 2, (TITLE_H - FONT_SIZE) / 2 - 2), title,
font=FONT, fill=COLORS["dim"])
# content: show last `rows` lines
visible = lines[-rows:]
y = TITLE_H + PAD_TOP
base_index = len(lines) - len(visible)
for ri, segs in enumerate(visible):
x = PAD_X
for text, ckey in segs:
font = FONTB if ckey in ("prompt",) else FONT
d.text((x, y), text, font=font, fill=COLORS.get(ckey, COLORS["fg"]))
x += FONT.getlength(text)
# cursor block
if cursor is not None and cursor[0] == base_index + ri:
cx = PAD_X + cursor[1] * CHAR_W
d.rectangle([cx, y + 2, cx + CHAR_W - 1, y + FONT_SIZE + 4],
fill=COLORS["cursor"])
y += LINE_H
return img
def _calibration(rows, title):
"""A frame containing every color so the global palette captures all ramps."""
lines = []
for key in COLORS:
if key in ("bg", "titlebar", "cursor"):
continue
lines.append([(f"{key}: PASS FAIL DRIFTED file.py:42 {PROMPT} 0123", key)])
return render_rgb(lines, (0, 40), rows, title)
def main():
here = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, here)
import demo_frames
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
os.path.dirname(here), "docs", "demo.gif")
if os.path.dirname(out):
os.makedirs(os.path.dirname(out), exist_ok=True)
frames = build_frames(demo_frames.FRAMES)
rows = _term_rows(frames)
title = getattr(demo_frames, "TITLE", "demo")
rgb = [render_rgb(lines, cur, rows, title) for lines, cur, _ in frames]
durs = [dur for _, _, dur in frames]
# global palette from a calibration swatch (captures every color's AA ramp)
master = _calibration(rows, title).quantize(
colors=64, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
pal = [f.quantize(palette=master, dither=Image.Dither.NONE) for f in rgb]
pal[0].save(out, save_all=True, append_images=pal[1:], loop=0,
duration=durs, disposal=2, optimize=True)
size = os.path.getsize(out)
print(f"wrote {out}")
print(f"frames={len(pal)} size={size} bytes ({size/1024/1024:.2f} MB) "
f"dims={rgb[0].size[0]}x{rgb[0].size[1]}")
if size > 8 * 1024 * 1024:
print("WARNING: over 8 MB budget", file=sys.stderr)
if __name__ == "__main__":
main()
SHA-256: 57c6294ba439e856c42db1a13074cb2ba1971158283cffd7e986b15700ad7f65