|
""" |
|
video-explainer engine.py |
|
Reusable rendering primitives + scene renderers for 7 scene types. |
|
All renderers share the signature: render_*(ax, i, n, scene_dict) |
|
""" |
|
|
|
import math |
|
import numpy as np |
|
import matplotlib |
|
matplotlib.use("Agg") |
|
import matplotlib.pyplot as plt |
|
from matplotlib.patches import FancyBboxPatch |
|
|
|
# ── Palette ─────────────────────────────────────────────────────── |
|
BG = "#0d1117"; CARD = "#161b22"; BRD = "#30363d" |
|
P = "#7c3aed"; PL = "#a78bfa" |
|
B = "#1d4ed8"; BL = "#60a5fa" |
|
GL = "#34d399"; YL = "#fbbf24" |
|
OL = "#fb923c"; RL = "#f87171" |
|
TX = "#e6edf3"; DM = "#8b949e" |
|
|
|
# Color key → (background, foreground) |
|
CMAP = { |
|
"purple": (P, PL), |
|
"blue": (B, BL), |
|
"green": ("#065f46", GL), |
|
"yellow": ("#92400e", YL), |
|
"orange": ("#7c2d12", OL), |
|
"red": ("#7f1d1d", RL), |
|
"deep_purple": ("#6b21a8", "#d8b4fe"), |
|
"teal": ("#0f4c5c", "#67e8f9"), |
|
"pink": ("#831843", "#f9a8d4"), |
|
"gray": (CARD, DM), |
|
} |
|
_CYCLE = ["purple","blue","green","yellow","orange","red","deep_purple","teal"] |
|
|
|
def color(key, idx=None): |
|
"""Return (bg, fg) for a named key or auto-cycle index.""" |
|
if key and key in CMAP: |
|
return CMAP[key] |
|
if idx is not None: |
|
return CMAP[_CYCLE[idx % len(_CYCLE)]] |
|
return CMAP["gray"] |
|
|
|
FPS = 24 |
|
DPI = 96 |
|
FW, FH = 1280, 720 |
|
HOLD_FRM = 60 |
|
ANIM_FRAC = 0.60 |
|
|
|
# ── Primitives ──────────────────────────────────────────────────── |
|
def make_fig(): |
|
fig = plt.figure(figsize=(FW/DPI, FH/DPI), dpi=DPI) |
|
fig.patch.set_facecolor(BG) |
|
ax = fig.add_axes([0,0,1,1]) |
|
ax.set_facecolor(BG); ax.set_xlim(0,16); ax.set_ylim(0,9); ax.axis("off") |
|
return fig, ax |
|
|
|
def box(ax, x, y, w, h, fc="none", ec="none", alpha=1.0, r=0.22, lw=2.0, z=2): |
|
ax.add_patch(FancyBboxPatch((x,y), w, h, |
|
boxstyle=f"round,pad=0,rounding_size={r}", |
|
facecolor=fc, edgecolor=ec, alpha=alpha, linewidth=lw, zorder=z)) |
|
|
|
def txt(ax, x, y, s, sz=11, c=None, ha="center", va="center", |
|
w="normal", z=5, a=1.0, mono=True): |
|
return ax.text(x, y, s, fontsize=sz, color=c or TX, |
|
ha=ha, va=va, fontweight=w, zorder=z, alpha=a, |
|
fontfamily="monospace" if mono else "sans-serif", |
|
wrap=False) |
|
|
|
def arr(ax, x1, y1, x2, y2, c=None, lw=2.0, z=4): |
|
ax.annotate("", xy=(x2,y2), xytext=(x1,y1), |
|
arrowprops=dict(arrowstyle="->,head_width=0.18,head_length=0.25", |
|
color=c or DM, lw=lw, connectionstyle="arc3,rad=0", |
|
shrinkA=4, shrinkB=4), zorder=z) |
|
|
|
def hline(ax, x1, x2, y, c=BRD, lw=1.0): |
|
ax.plot([x1,x2],[y,y], color=c, lw=lw, zorder=1) |
|
|
|
def circ(ax, x, y, r=0.12, c=YL, z=10, a=1.0): |
|
ax.add_patch(plt.Circle((x,y), r, color=c, zorder=z, alpha=a)) |
|
|
|
def ease_out(t): return 1-(1-max(0,min(1,t)))**3 |
|
def ease_io(t): t=max(0,min(1,t)); return t*t*(3-2*t) |
|
def lerp(a,b,t): return a+(b-a)*t |
|
|
|
def av(i, n, start, dur=0.16): |
|
"""Alpha for an element. start/dur are fractions of the animation phase.""" |
|
tv = min(1.0, (i/n) / ANIM_FRAC) |
|
return ease_out(min(1.0, max(0.0, (tv-start)/dur))) |
|
|
|
def scene_header(ax, title, i, n): |
|
a = av(i, n, 0.01, 0.14) |
|
txt(ax, 8, 8.56, title, sz=15, c=TX, w="bold", mono=False, a=a) |
|
hline(ax, 0.5, 15.5, 8.2, c=BRD if a > 0 else BG) |
|
|
|
def save_frame(fig, path): |
|
fig.savefig(path, dpi=DPI, facecolor=BG, edgecolor="none") |
|
plt.close(fig) |
|
|
|
# ── Scene Renderers ─────────────────────────────────────────────── |
|
|
|
def render_title(ax, i, n, s): |
|
"""Scene type: title""" |
|
theme_bg, theme_fg = color(s.get("theme_color", "purple")) |
|
for row in range(9): |
|
box(ax, 0, row, 16, 1, fc=theme_bg, alpha=0.02+row*0.004, r=0, lw=0, z=0) |
|
box(ax, 0, 8.55, 16, 0.45, fc=theme_bg, alpha=0.18, r=0, lw=0, z=1) |
|
|
|
a = av(i,n, 0.05, 0.20) |
|
subtitle = s.get("subtitle","") |
|
if subtitle and a>0: |
|
bw = max(4.0, len(subtitle)*0.13 + 1.0) |
|
box(ax, 8-bw/2, 6.4, bw, 0.58, fc=theme_bg, alpha=0.22*a, r=0.25, lw=0) |
|
box(ax, 8-bw/2, 6.4, bw, 0.58, fc="none", ec=theme_fg, alpha=a, r=0.25, lw=1.5) |
|
txt(ax, 8, 6.69, subtitle, sz=9.5, c=theme_fg, a=a) |
|
|
|
a = av(i,n, 0.20, 0.38) |
|
if a>0: |
|
t = s.get("title","") |
|
sz = max(18, 42-max(0,len(t)-20)*0.9) |
|
txt(ax, 8, 5.35, t, sz=sz, c=TX, w="bold", a=a) |
|
|
|
a = av(i,n, 0.54, 0.28) |
|
tagline = s.get("tagline","") |
|
if tagline and a>0: |
|
txt(ax, 8, 4.52, tagline, sz=13, c=DM, a=a, mono=False) |
|
|
|
tags = s.get("tags", []) |
|
tag_colors = s.get("tag_colors", []) |
|
a = av(i,n, 0.70, 0.26) |
|
if tags and a>0: |
|
pw = 3.0; sx = 8-(len(tags)*pw+(len(tags)-1)*0.25)/2 |
|
for j, tag in enumerate(tags): |
|
c_key = tag_colors[j] if j < len(tag_colors) else _CYCLE[j % len(_CYCLE)] |
|
bg_, fg_ = color(c_key) |
|
px = sx + j*(pw+0.25) |
|
box(ax, px, 3.08, pw, 0.52, fc=bg_, alpha=0.3*a, r=0.2, lw=0) |
|
box(ax, px, 3.08, pw, 0.52, fc="none", ec=fg_, alpha=a, r=0.2, lw=1.5) |
|
txt(ax, px+pw/2, 3.34, tag, sz=9, c=fg_, a=a) |
|
|
|
note = s.get("source_note","") |
|
if note: |
|
txt(ax, 8, 0.28, note, sz=7.5, c=DM, a=0.45) |
|
|
|
|
|
def render_stack(ax, i, n, s): |
|
"""Scene type: stack — vertical list of labeled layers.""" |
|
scene_header(ax, s.get("heading",""), i, n) |
|
layers = s.get("layers", []) |
|
ch = min(1.28, 6.8 / max(len(layers),1)) |
|
gap = 0.14 |
|
total_h = len(layers)*ch + (len(layers)-1)*gap |
|
start_y = (8.1 - total_h) / 2 + total_h - ch + 0.1 |
|
|
|
for j, layer in enumerate(layers): |
|
bg_, fg_ = color(layer.get("color"), idx=j) |
|
a = av(i, n, j*0.16, 0.20) |
|
if a <= 0: continue |
|
ly = start_y - j*(ch+gap) |
|
box(ax, 0.55, ly, 14.9, ch, fc=bg_, alpha=0.15*a, r=0.28, lw=0) |
|
box(ax, 0.55, ly, 14.9, ch, fc="none", ec=fg_, alpha=a, r=0.28, lw=2) |
|
box(ax, 0.55, ly, 0.74, ch, fc=bg_, alpha=0.55*a, r=0.2, lw=0) |
|
num = layer.get("num", str(j+1)) |
|
txt(ax, 0.92, ly+ch/2, num, sz=14, c=TX, w="bold", a=a) |
|
title_ = layer.get("title","") |
|
body_ = layer.get("body","") |
|
meta_ = layer.get("meta","") |
|
if ch > 1.0: |
|
txt(ax, 8.3, ly+ch*0.73, title_, sz=11, c=fg_, w="bold", a=a) |
|
txt(ax, 8.3, ly+ch*0.45, body_, sz=8.5, c=DM, a=a) |
|
else: |
|
txt(ax, 8.3, ly+ch*0.6, title_, sz=10, c=fg_, w="bold", a=a) |
|
txt(ax, 8.3, ly+ch*0.28, body_, sz=8, c=DM, a=a) |
|
if meta_: |
|
txt(ax, 15.0, ly+ch/2, meta_, sz=8.5, c=fg_, a=a*0.85, ha="right", mono=False) |
|
if j < len(layers)-1: |
|
a2 = av(i, n, j*0.16+0.13, 0.10) |
|
if a2>0: arr(ax, 8, ly, 8, ly-gap-0.05, c=BRD, lw=1.5) |
|
|
|
|
|
def render_flow_table(ax, i, n, s): |
|
"""Scene type: flow_table — horizontal flow nodes + rules table below.""" |
|
scene_header(ax, s.get("heading",""), i, n) |
|
nodes = s.get("flow", []) |
|
rows = s.get("rows", []) |
|
cols_h = s.get("table_header", ["Pattern","Target"]) |
|
|
|
# Flow diagram |
|
n_nodes = len(nodes) |
|
xs = [1.2 + k*(13.6/(max(n_nodes-1,1))) for k in range(n_nodes)] |
|
node_y = 7.3 - (0.25 if rows else 0) |
|
|
|
for k, node in enumerate(nodes): |
|
bg_, fg_ = color(node.get("color"), idx=k) |
|
a = av(i, n, 0.04+k*0.12, 0.16) |
|
if a>0: |
|
box(ax, xs[k]-1.15, node_y-0.44, 2.3, 0.88, fc=CARD, ec=fg_, alpha=a, r=0.2, lw=1.5) |
|
txt(ax, xs[k], node_y+0.17, node.get("label",""), sz=9, c=fg_, w="bold", a=a, mono=False) |
|
txt(ax, xs[k], node_y-0.16, node.get("sub",""), sz=7, c=DM, a=a*0.85) |
|
if k>0: |
|
a2 = av(i, n, 0.04+k*0.12, 0.10) |
|
if a2>0: arr(ax, xs[k-1]+1.15, node_y, xs[k]-1.15, node_y, c=fg_, lw=2) |
|
|
|
metaphor = s.get("metaphor","") |
|
if metaphor: |
|
a = av(i, n, 0.38, 0.18) |
|
if a>0: txt(ax, 8, node_y-0.8, metaphor, sz=8.5, c=DM, a=a, mono=False) |
|
|
|
# Table |
|
if rows: |
|
tbl_start_y = node_y - (1.1 if metaphor else 0.7) |
|
a_h = av(i, n, 0.44, 0.16) |
|
if a_h>0: |
|
hline(ax, 0.4, 15.6, tbl_start_y, c=BRD) |
|
ncols = len(cols_h) |
|
col_xs = [0.4 + (15.2/(ncols+0.5))*(k+0.75) for k in range(ncols)] |
|
for k, ch in enumerate(cols_h): |
|
txt(ax, col_xs[k], tbl_start_y-0.18, ch, sz=8, c=DM, w="bold", a=a_h) |
|
|
|
row_h = min(0.70, (tbl_start_y-0.3) / max(len(rows),1)) |
|
for j, row in enumerate(rows): |
|
a = av(i, n, 0.50+j*0.08, 0.12) |
|
if a<=0: continue |
|
ry = tbl_start_y - 0.45 - j*row_h |
|
highlight = row.get("highlight", False) |
|
bg_, fg_ = color(row.get("color"), idx=j) |
|
box(ax, 0.4, ry-row_h*0.42, 15.2, row_h*0.84, |
|
fc=bg_, alpha=(0.28 if highlight else 0.09)*a, r=0.13, lw=0) |
|
vals = [row.get(f"col{k+1}","") for k in range(len(cols_h))] |
|
for k, val in enumerate(vals): |
|
col_x = 0.4 + (15.2/(len(cols_h)+0.5))*(k+0.75) |
|
txt(ax, col_x, ry, val, sz=8 if not highlight else 8.5, |
|
c=fg_ if highlight else (DM if k>0 else TX), a=a, |
|
ha="center", w="bold" if highlight else "normal") |
|
if j < len(rows)-1: |
|
ax.plot([9.0],[ry],"->",color=fg_,alpha=a,markersize=7,zorder=5) |
|
|
|
|
|
def render_cards(ax, i, n, s): |
|
"""Scene type: cards — one featured card + side cards, or grid.""" |
|
scene_header(ax, s.get("heading",""), i, n) |
|
code_note = s.get("code_note","") |
|
if code_note: |
|
a = av(i,n,0.02,0.14) |
|
txt(ax, 8, 7.88, code_note, sz=7.8, c=DM, a=a) |
|
|
|
cards = s.get("cards", []) |
|
featured = [c for c in cards if c.get("featured")] |
|
others = [c for c in cards if not c.get("featured")] |
|
|
|
if featured: |
|
# Layout: big card left, stacked cards right |
|
fc_ = featured[0] |
|
bg_, fg_ = color(fc_.get("color"), idx=0) |
|
a = av(i,n, 0.0, 0.20) |
|
if a>0: |
|
box(ax, 0.30, 0.40, 4.75, 6.55, fc=bg_, alpha=0.17*a, r=0.28, lw=0) |
|
box(ax, 0.30, 0.40, 4.75, 6.55, fc="none", ec=fg_, alpha=a, r=0.28, lw=2) |
|
box(ax, 0.30, 6.47, 4.75, 0.48, fc=bg_, alpha=0.55*a, r=0.2, lw=0) |
|
txt(ax, 2.68, 6.71, fc_.get("title",""), sz=9.5, c=fg_, w="bold", a=a) |
|
mid=3.50 |
|
txt(ax, 2.68, mid+0.72, fc_.get("file",""), sz=8, c=DM, a=a) |
|
txt(ax, 2.68, mid+0.18, fc_.get("desc",""), sz=9, c=TX, a=a, mono=False) |
|
txt(ax, 2.68, mid-0.38, fc_.get("specs",""), sz=7.8, c=DM, a=a) |
|
|
|
row_h = 6.55 / max(len(others),1) |
|
for j, card in enumerate(others): |
|
bg_, fg_ = color(card.get("color"), idx=j+1) |
|
a = av(i, n, j*0.12, 0.20) |
|
if a<=0: continue |
|
cy = 0.40 + 6.55 - (j+1)*row_h |
|
box(ax, 5.35, cy, 10.25, row_h-0.12, fc=bg_, alpha=0.17*a, r=0.28, lw=0) |
|
box(ax, 5.35, cy, 10.25, row_h-0.12, fc="none", ec=fg_, alpha=a, r=0.28, lw=2) |
|
box(ax, 5.35, cy, 10.25, 0.45, fc=bg_, alpha=0.55*a, r=0.2, lw=0) |
|
txt(ax, 10.5, cy+row_h-0.38, card.get("title",""), sz=9, c=fg_, w="bold", a=a) |
|
m=cy+row_h*0.45 |
|
txt(ax, 10.5, m+0.22, card.get("file",""), sz=7.5, c=DM, a=a) |
|
txt(ax, 10.5, m-0.08, card.get("desc",""), sz=8.5, c=TX, a=a, mono=False) |
|
txt(ax, 10.5, m-0.38, card.get("specs",""), sz=7.5, c=DM, a=a) |
|
else: |
|
# Grid layout |
|
nc = min(3, len(cards)) |
|
nr = math.ceil(len(cards)/nc) |
|
cw = 14.8/nc - 0.15 |
|
ch_ = 6.8/nr - 0.15 |
|
for j, card in enumerate(cards): |
|
r_,c_ = divmod(j,nc) |
|
bg_, fg_ = color(card.get("color"), idx=j) |
|
a = av(i, n, j*0.12, 0.20) |
|
if a<=0: continue |
|
cx = 0.55 + c_*(cw+0.15) |
|
cy = 0.55 + (nr-1-r_)*(ch_+0.15) |
|
box(ax, cx, cy, cw, ch_, fc=bg_, alpha=0.17*a, r=0.25, lw=0) |
|
box(ax, cx, cy, cw, ch_, fc="none", ec=fg_, alpha=a, r=0.25, lw=2) |
|
box(ax, cx, cy+ch_-0.44, cw, 0.44, fc=bg_, alpha=0.55*a, r=0.18, lw=0) |
|
txt(ax, cx+cw/2, cy+ch_-0.22, card.get("title",""), sz=9, c=fg_, w="bold", a=a) |
|
m=cy+ch_/2 |
|
txt(ax, cx+cw/2, m+0.22, card.get("file",""), sz=7.5, c=DM, a=a) |
|
txt(ax, cx+cw/2, m-0.08, card.get("desc",""), sz=8.5, c=TX, a=a, mono=False) |
|
txt(ax, cx+cw/2, m-0.38, card.get("specs",""), sz=7.5, c=DM, a=a) |
|
|
|
|
|
def render_route_table(ax, i, n, s): |
|
"""Scene type: route_table — sidebar + method/path/handler table.""" |
|
scene_header(ax, s.get("heading",""), i, n) |
|
sidebar = s.get("sidebar", {}) |
|
routes = s.get("routes", []) |
|
cols = s.get("columns", ["METHOD","PATH","HANDLER"]) |
|
footer = s.get("footer","") |
|
sb_label = sidebar.get("label","") |
|
sb_subs = sidebar.get("sub",[]) |
|
|
|
a_side = av(i,n, 0.04, 0.18) |
|
if a_side>0 and sb_label: |
|
box(ax, 0.22, 0.72, 2.12, 7.14, fc=P, alpha=0.13*a_side, r=0.28, lw=0) |
|
box(ax, 0.22, 0.72, 2.12, 7.14, fc="none", ec=PL, alpha=a_side, r=0.28, lw=2) |
|
txt(ax, 1.28, 7.50, sb_label, sz=11, c=PL, w="bold", a=a_side) |
|
for k, sub in enumerate(sb_subs): |
|
txt(ax, 1.28, 7.10-k*0.36, sub, sz=8.5, c=DM, a=a_side) |
|
|
|
a_hdr = av(i,n, 0.15, 0.16) |
|
if a_hdr>0: |
|
box(ax, 2.55, 7.62, 13.1, 0.46, fc=CARD, ec=BRD, alpha=a_hdr, r=0.15, lw=1) |
|
col_xs = [2.55 + 13.1*(k+0.5)/len(cols) for k in range(len(cols))] |
|
for k, c_lbl in enumerate(cols): |
|
txt(ax, col_xs[k], 7.85, c_lbl, sz=8, c=DM, w="bold", a=a_hdr) |
|
|
|
row_h = min(0.70, 6.8/max(len(routes),1)) |
|
for j, route in enumerate(routes): |
|
bg_, fg_ = color(route.get("color"), idx=j) |
|
a = av(i, n, 0.22+j*0.07, 0.10) |
|
if a<=0: continue |
|
ry = 7.05 - j*row_h |
|
highlight = route.get("highlight", False) |
|
if sb_label: |
|
ax.plot([2.32, 2.57],[ry+0.06,ry+0.06], color=fg_, lw=1.5, alpha=a*0.5, zorder=3) |
|
box(ax, 2.55, ry-0.20, 13.1, row_h*0.84, |
|
fc=fg_, alpha=(0.24 if highlight else 0.08)*a, r=0.12, lw=0) |
|
vals = [route.get(f"col{k+1}","") for k in range(len(cols))] |
|
# First column gets a badge treatment |
|
box(ax, 2.60, ry-0.16, min(1.6,len(vals[0])*0.09+0.4), 0.35, |
|
fc=fg_, alpha=0.32*a, r=0.09, lw=0) |
|
col_xs = [2.55 + 13.1*(k+0.5)/len(cols) for k in range(len(cols))] |
|
for k, val in enumerate(vals): |
|
txt(ax, col_xs[k], ry+0.02, val, sz=8, |
|
c=fg_ if highlight or k==0 else (TX if k==1 else DM), |
|
a=a, ha="center", |
|
w="bold" if highlight or k==0 else "normal") |
|
|
|
if footer: |
|
a_ft = av(i,n, 0.90, 0.10) |
|
txt(ax, 8, 0.36, footer, sz=8.5, c=DM, a=a_ft, mono=False) |
|
|
|
|
|
def render_trace(ax, i, n, s): |
|
"""Scene type: trace — animated request/data flow through waypoints.""" |
|
scene_header(ax, s.get("heading",""), i, n) |
|
wps = s.get("waypoints", []) |
|
bands = s.get("bands", []) |
|
success_label = s.get("success_label","") |
|
chunked = s.get("_wp_chunked", False) |
|
|
|
# Compute grid layout |
|
rows_set = sorted(set(w.get("row",0) for w in wps)) |
|
cols_set = sorted(set(w.get("col",0) for w in wps)) |
|
n_rows = max(rows_set)+1 if rows_set else 1 |
|
n_cols = max(cols_set)+1 if cols_set else 1 |
|
|
|
def wp_xy(w): |
|
r = w.get("row",0); c = w.get("col",0) |
|
y = 7.85 - r*(6.5/max(n_rows-1,1)) if n_rows > 1 else 5.0 |
|
# Keep nodes within [1.4, 14.6] so ±1.2 boxes never clip the frame edge |
|
x = 1.4 + c*(13.2/max(n_cols-1,1)) if n_cols > 1 else 8.0 |
|
return x, y |
|
|
|
# Band stripes |
|
if bands: |
|
band_h = 7.4 / max(len(bands),1) |
|
for k, band in enumerate(bands): |
|
bg_, fg_ = color(band.get("color"), idx=k) |
|
by = 0.55 + (len(bands)-1-k)*band_h |
|
box(ax, 0.2, by, 15.6, band_h, fc=bg_, alpha=0.07, r=0.1, lw=0, z=1) |
|
a_bl = av(i,n, 0.02, 0.10) if chunked else av(i,n, 0.04, 0.18) |
|
if a_bl>0: |
|
for k, band in enumerate(bands): |
|
bg_, fg_ = color(band.get("color"), idx=k) |
|
by = 0.55 + (len(bands)-1-k)*band_h |
|
txt(ax, 15.55, by+band_h/2, band.get("label",""), |
|
sz=7.5, c=fg_, a=a_bl*0.8, ha="right") |
|
|
|
frac = i / n |
|
|
|
# Static nodes — chunked mode: all visible within first 1.5s (before pre-roll ends) |
|
positions = [] |
|
for k, wp in enumerate(wps): |
|
x, y = wp_xy(wp) |
|
positions.append((x,y)) |
|
bg_, fg_ = color(wp.get("color"), idx=k) |
|
if chunked: |
|
node_start = 0.005 + k * 0.002 |
|
a = ease_out(min(1.0, max(0.0, (frac - node_start) / 0.015))) |
|
else: |
|
node_start = 0.01 + k * 0.006 |
|
a = ease_out(min(1.0, max(0.0, (frac - node_start) / 0.05))) |
|
if a>0: |
|
box(ax, x-1.2, y-0.40, 2.4, 0.80, fc=CARD, ec=fg_, alpha=a, r=0.2, lw=1.5) |
|
txt(ax, x, y+0.14, wp.get("label",""), sz=8.5, c=fg_, w="bold", a=a, mono=False) |
|
txt(ax, x, y-0.14, wp.get("sub",""), sz=6.5, c=DM, a=a*0.85) |
|
|
|
# Static edges — chunked: fully drawn well before pre-roll ends |
|
if chunked: |
|
a_e = ease_out(min(1.0, max(0.0, (frac - 0.025) / 0.015))) |
|
else: |
|
a_e = ease_out(min(1.0, max(0.0, (frac - 0.07) / 0.04))) |
|
if a_e>0 and len(positions)>1: |
|
for k in range(len(positions)-1): |
|
x1,y1 = positions[k]; x2,y2 = positions[k+1] |
|
bg_,fg_ = color(wps[k].get("color"), idx=k) |
|
dx = x2-x1; dy = y2-y1 |
|
shrink = 1.25 |
|
arr(ax, |
|
x1+(dx/abs(dx) if dx else 0)*shrink if dx else x1, |
|
y1+(dy/abs(dy) if dy else 0)*shrink if dy else y1, |
|
x2-(dx/abs(dx) if dx else 0)*shrink if dx else x2, |
|
y2-(dy/abs(dy) if dy else 0)*shrink if dy else y2, |
|
c=fg_, lw=1.5) |
|
|
|
# Animated dot |
|
wp_fracs = s.get("_wp_fracs") |
|
chunked = s.get("_wp_chunked", False) |
|
|
|
if wp_fracs and len(wp_fracs) >= len(positions): |
|
if chunked: |
|
# Dot appears at frac 0.04 (~1.9s) — diagram is fully drawn, pre-roll still playing |
|
# Narrator speaks from wp_fracs[0] onward, dot is already at Browser when she starts |
|
dot_start = 0.04 |
|
else: |
|
dot_start = max(0.11, wp_fracs[0]) |
|
dot_end = min(0.97, wp_fracs[-1] + (0.04 if chunked else 0.02)) |
|
else: |
|
dot_start = 0.12 |
|
dot_end = 0.95 |
|
chunked = False |
|
|
|
if dot_start < frac < dot_end and len(positions) > 1: |
|
edges = len(positions) - 1 |
|
|
|
if chunked: |
|
# Dwell-and-snap: dot sits at waypoint k for ~82% of its chunk, |
|
# then smoothly snaps to k+1 in the last 18%. |
|
cur = 0 |
|
for j in range(len(wp_fracs)): |
|
if wp_fracs[j] <= frac: |
|
cur = j |
|
cur = min(cur, len(positions) - 1) |
|
next_f = wp_fracs[cur+1] if cur+1 < len(wp_fracs) else dot_end |
|
span = max(next_f - wp_fracs[cur], 1e-4) |
|
local = min(1.0, (frac - wp_fracs[cur]) / span) |
|
SNAP = 0.18 |
|
if local < (1.0 - SNAP) or cur >= len(positions) - 1: |
|
dx_, dy_ = positions[cur] |
|
else: |
|
t_snap = ease_io((local - (1.0 - SNAP)) / SNAP) |
|
x1,y1 = positions[cur]; x2,y2 = positions[cur+1] |
|
dx_ = lerp(x1,x2,t_snap); dy_ = lerp(y1,y2,t_snap) |
|
for rr,aa in [(0.32,0.08),(0.22,0.15),(0.13,1.0)]: |
|
circ(ax, dx_,dy_, r=rr, c=YL, z=12, a=aa) |
|
circ(ax, positions[cur][0], positions[cur][1], r=0.44, c=YL, z=3, a=0.07) |
|
|
|
elif wp_fracs and len(wp_fracs) >= len(positions): |
|
# Continuous sweep with word-position timing |
|
seg = edges - 1 |
|
seg_t = 1.0 |
|
for k in range(edges): |
|
if frac <= wp_fracs[k+1] or k == edges - 1: |
|
seg = k |
|
span = max(wp_fracs[k+1] - wp_fracs[k], 1e-4) |
|
seg_t = ease_io(min(1.0, max(0.0, (frac - wp_fracs[k]) / span))) |
|
break |
|
x1,y1 = positions[seg]; x2,y2 = positions[seg+1] |
|
dx_ = lerp(x1,x2,seg_t); dy_ = lerp(y1,y2,seg_t) |
|
for rr,aa in [(0.32,0.08),(0.22,0.15),(0.13,1.0)]: |
|
circ(ax, dx_,dy_, r=rr, c=YL, z=12, a=aa) |
|
x2_,y2_ = positions[min(seg+1,len(positions)-1)] |
|
circ(ax, x2_,y2_, r=0.44, c=YL, z=3, a=0.09*seg_t) |
|
|
|
else: |
|
dp = (frac - dot_start) / max(dot_end - dot_start, 1e-4) |
|
seg = min(edges - 1, int(dp * edges)) |
|
seg_t = ease_io((dp * edges) - seg) |
|
x1,y1 = positions[seg]; x2,y2 = positions[seg+1] |
|
dx_ = lerp(x1,x2,seg_t); dy_ = lerp(y1,y2,seg_t) |
|
for rr,aa in [(0.32,0.08),(0.22,0.15),(0.13,1.0)]: |
|
circ(ax, dx_,dy_, r=rr, c=YL, z=12, a=aa) |
|
x2_,y2_ = positions[min(seg+1,len(positions)-1)] |
|
circ(ax, x2_,y2_, r=0.44, c=YL, z=3, a=0.09*seg_t) |
|
|
|
# Success flash — fires after last chunk's narration |
|
success_start = min(0.97, wp_fracs[-1] + (0.03 if chunked else 0.01)) if wp_fracs else 0.93 |
|
a_done = ease_out(min(1.0, max(0.0, (frac - success_start) / 0.04))) |
|
if a_done>0 and success_label and positions: |
|
lx,ly = positions[-1] |
|
sw = max(2.8, len(success_label)*0.09+0.6) |
|
box(ax, lx-sw/2, ly-0.56, sw, 0.88, fc=GL, alpha=0.28*a_done, r=0.25, lw=0) |
|
txt(ax, lx, ly, success_label, sz=10, c=GL, w="bold", a=a_done, mono=False) |
|
|
|
|
|
def render_insights(ax, i, n, s): |
|
"""Scene type: insights — numbered key-takeaway cards.""" |
|
scene_header(ax, s.get("heading",""), i, n) |
|
insights = s.get("insights", []) |
|
ch = min(1.26, 6.8/max(len(insights),1)) |
|
gap = 0.12 |
|
total = len(insights)*ch + (len(insights)-1)*gap |
|
start_y = (8.15-total)/2 + total - ch |
|
|
|
for j, ins in enumerate(insights): |
|
bg_, fg_ = color(ins.get("color"), idx=j) |
|
a = av(i, n, j*0.13, 0.18) |
|
if a<=0: continue |
|
cy = start_y - j*(ch+gap) |
|
box(ax, 0.32, cy, 15.36, ch, fc=CARD, ec=fg_, alpha=a, r=0.25, lw=1.5) |
|
box(ax, 0.32, cy, 0.84, ch, fc=fg_, alpha=0.28*a, r=0.2, lw=0) |
|
txt(ax, 0.74, cy+ch/2, ins.get("badge",f"[{j+1}]"), sz=11, c=fg_, w="bold", a=a) |
|
txt(ax, 9.0, cy+ch*0.73, ins.get("title",""), sz=10.5, c=fg_, w="bold", |
|
a=a, ha="center", mono=False) |
|
txt(ax, 9.0, cy+ch*0.30, ins.get("body",""), sz=8.5, c=DM, a=a*0.9, |
|
ha="center", mono=True) |
|
|
|
|
|
RENDERERS = { |
|
"title": render_title, |
|
"stack": render_stack, |
|
"flow_table": render_flow_table, |
|
"cards": render_cards, |
|
"route_table": render_route_table, |
|
"trace": render_trace, |
|
"insights": render_insights, |
|
} |
{ "meta": { "title": "Human-readable title", "output_name": "snake_case_filename", // used for work dir + default output "theme": "purple" // informational only }, "scenes": [ /* array of scene objects */ ] }