Skip to content

Commit 97d99b8

Browse files
committed
Add headless server-side reel generator (tools/gen_reel.py)
Renders the live-plot engine into vertical 1080x1920 H.264 MP4s for Instagram without a browser — parses data/editions.js path data, strokes progressively with smoothstep pacing, draws the HUD (bed label, progress, ink meters, pen-head crosshair) and a branded end card, encodes via imageio-ffmpeg/libx264. Companion to the browser-based reel.html. Usage: python3 tools/gen_reel.py --all red 15 https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS
1 parent 141336e commit 97d99b8

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

tools/gen_reel.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/env python3
2+
"""
3+
PLOTFLOW · Server-side Reel generator
4+
Renders the live-plot engine into vertical 1080x1920 H.264 MP4s for Instagram,
5+
mirroring reel.html but headless (no browser). Reads paths from data/editions.js.
6+
7+
Usage:
8+
python3 tools/gen_reel.py # all editions, red, 15s, 30fps
9+
python3 tools/gen_reel.py zaku red 15 # one edition
10+
python3 tools/gen_reel.py --all black 20 # all editions, black ink, 20s
11+
Output: <key>-<color>-reel.mp4 in the current directory.
12+
13+
Deps: pillow, numpy, imageio-ffmpeg (bundles ffmpeg w/ libx264).
14+
Fonts: uses Liberation Sans + IPA Gothic (JP). Swap BOLD/REG to Archivo locally
15+
for the exact site typeface.
16+
"""
17+
import json, re, sys, math, subprocess, os
18+
from PIL import Image, ImageDraw, ImageFont
19+
import numpy as np
20+
import imageio_ffmpeg
21+
22+
# ---------- load editions data ----------
23+
src = open('/home/user/plotflow.github.io/data/editions.js').read()
24+
# strip the JS wrapper -> JSON
25+
src = src[src.index('{'):]
26+
src = src[:src.rindex('}')+1]
27+
DATA = json.loads(src)
28+
SUITS = DATA['suits']
29+
ORDER = DATA.get('plotterOrder', list(SUITS.keys()))
30+
31+
# ---------- config ----------
32+
W, H = 1080, 1920
33+
PAPER = (246, 243, 236)
34+
INK_HEX = {'black': (23, 21, 15), 'red': (216, 52, 42), 'blue': (31, 74, 160)}
35+
HUD = (21, 22, 15)
36+
FEED = 1100
37+
ART = (60, 280, 960, 1300) # x,y,w,h
38+
39+
BOLD = '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf'
40+
REG = '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf'
41+
JP = '/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf'
42+
43+
# ---------- parse path 'd' into polyline subpaths ----------
44+
def parse_path(d):
45+
# tokens like "M 744,398" or "L 736,382"
46+
subpaths, cur = [], []
47+
for cmd, xs, ys in re.findall(r'([ML])\s*(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)', d):
48+
x, y = float(xs), float(ys)
49+
if cmd == 'M':
50+
if len(cur) > 1: subpaths.append(cur)
51+
cur = [(x, y)]
52+
else:
53+
cur.append((x, y))
54+
if len(cur) > 1: subpaths.append(cur)
55+
return subpaths
56+
57+
def seg_lengths(subpaths):
58+
segs = [] # (x0,y0,x1,y1, cumlen_start)
59+
total = 0.0
60+
for sp in subpaths:
61+
for i in range(len(sp)-1):
62+
x0,y0 = sp[i]; x1,y1 = sp[i+1]
63+
L = math.hypot(x1-x0, y1-y0)
64+
segs.append((x0,y0,x1,y1,total,L))
65+
total += L
66+
return segs, total
67+
68+
def bbox(subpaths):
69+
xs = [p[0] for sp in subpaths for p in sp]
70+
ys = [p[1] for sp in subpaths for p in sp]
71+
return min(xs), min(ys), max(xs), max(ys)
72+
73+
# ---------- easing ----------
74+
def ease_out_cubic(t): return 1 - (1-t)**3
75+
def smoothstep(t): return t*t*(3-2*t)
76+
77+
def fmt(sec):
78+
s = max(0, round(sec))
79+
return f"{s//60:02d}:{s%60:02d}"
80+
81+
# ---------- render one reel ----------
82+
def render(key, color, dur=15, fps=30, out=None):
83+
suit = SUITS[key]
84+
ink = INK_HEX[color]
85+
subpaths = parse_path(suit['d'])
86+
minx, miny, maxx, maxy = bbox(subpaths)
87+
pad = max(maxx-minx, maxy-miny) * 0.06
88+
vbx, vby = minx-pad, miny-pad
89+
vbw, vbh = (maxx-minx)+2*pad, (maxy-miny)+2*pad
90+
segs, total_len = seg_lengths(subpaths)
91+
92+
ax, ay, aw, ah = ART
93+
sc = min(aw/vbw, ah/vbh)
94+
ox = ax + (aw - vbw*sc)/2
95+
oy = ay + (ah - vbh*sc)/2
96+
def mx(x): return ox + (x-vbx)*sc
97+
def my(y): return oy + (y-vby)*sc
98+
99+
mm_per_unit = 420 / max(vbw, vbh)
100+
ink_total_m = (total_len * mm_per_unit) / 1000
101+
total_sec = (total_len * mm_per_unit) / FEED * 60
102+
103+
total_frames = dur * fps
104+
plot_end = int(total_frames * 0.80)
105+
hold_end = int(total_frames * 0.92)
106+
107+
# fonts
108+
f_logo = ImageFont.truetype(BOLD, 56)
109+
f_bed = ImageFont.truetype(BOLD, 26)
110+
f_name = ImageFont.truetype(REG, 22)
111+
f_jp = ImageFont.truetype(JP, 26)
112+
f_stat = ImageFont.truetype(BOLD, 22)
113+
f_small= ImageFont.truetype(REG, 17)
114+
f_end_logo = ImageFont.truetype(BOLD, 88)
115+
f_end_name = ImageFont.truetype(BOLD, 32)
116+
f_end_jp = ImageFont.truetype(JP, 30)
117+
f_end_tag = ImageFont.truetype(REG, 20)
118+
119+
out = out or f'/tmp/{key}-{color}-reel.mp4'
120+
ff = imageio_ffmpeg.write_frames(
121+
out, (W, H), fps=fps, codec='libx264', quality=None,
122+
bitrate='10M', pix_fmt_in='rgb24', pix_fmt_out='yuv420p',
123+
macro_block_size=1,
124+
output_params=['-preset','medium','-profile:v','high','-movflags','+faststart']
125+
)
126+
ff.send(None)
127+
128+
def hud_rgba(a=0.30): return HUD + (int(255*a),)
129+
130+
for f in range(total_frames):
131+
if f < plot_end:
132+
prog = smoothstep(f/plot_end); phase='draw'; alpha=0
133+
elif f < hold_end:
134+
prog = 1.0; phase='hold'; alpha=0
135+
else:
136+
prog = 1.0; phase='end'
137+
alpha = (f-hold_end)/(total_frames-hold_end)
138+
139+
img = Image.new('RGB', (W,H), PAPER)
140+
d = ImageDraw.Draw(img, 'RGBA')
141+
142+
# ---- strokes up to prog ----
143+
target = prog * total_len
144+
pen = None
145+
for (x0,y0,x1,y1,cs,L) in segs:
146+
if cs >= target: break
147+
if cs+L <= target:
148+
d.line([(mx(x0),my(y0)),(mx(x1),my(y1))], fill=ink, width=2)
149+
pen = (x1,y1)
150+
else:
151+
frac = (target-cs)/L
152+
xe = x0+(x1-x0)*frac; ye=y0+(y1-y0)*frac
153+
d.line([(mx(x0),my(y0)),(mx(xe),my(ye))], fill=ink, width=2)
154+
pen = (xe,ye)
155+
break
156+
157+
# ---- HUD ----
158+
hc = hud_rgba(0.30)
159+
d.text((60,60), f'BED 01 · {suit["code"]}', font=f_bed, fill=hc)
160+
lw = d.textlength('PLOTFLOW*', font=f_bed)
161+
d.text((W-60-lw,60), 'PLOTFLOW*', font=f_bed, fill=hc)
162+
d.text((60,96), suit['name'], font=f_name, fill=hc)
163+
d.text((60,122), suit.get('jp',''), font=f_jp, fill=hc)
164+
fr = f'F{FEED} mm/min'
165+
d.text((W-60-d.textlength(fr,font=f_small),98), fr, font=f_small, fill=hc)
166+
167+
bar_y = H-160
168+
d.rectangle([60,bar_y,W-60,bar_y+3], fill=HUD+(26,))
169+
d.rectangle([60,bar_y,60+(W-120)*prog,bar_y+3], fill=ink)
170+
ink_drawn = (target*mm_per_unit)/1000
171+
elapsed = total_sec*prog
172+
d.text((60,bar_y+16), f'{fmt(elapsed)} / {fmt(total_sec)}', font=f_stat, fill=hc)
173+
pct = f'{round(prog*100)}%'
174+
d.text((W/2-d.textlength(pct,font=f_stat)/2,bar_y+16), pct, font=f_stat, fill=hc)
175+
inkstr = f'{ink_drawn:.1f}m / {ink_total_m:.1f}m ink'
176+
d.text((W-60-d.textlength(inkstr,font=f_stat),bar_y+16), inkstr, font=f_stat, fill=hc)
177+
178+
if pen and 0 < prog < 1:
179+
px,py = mx(pen[0]),my(pen[1]); r=14
180+
d.ellipse([px-r,py-r,px+r,py+r], outline=(232,53,31,160), width=2)
181+
d.line([px-r*1.8,py,px+r*1.8,py], fill=(232,53,31,160), width=2)
182+
d.line([px,py-r*1.8,px,py+r*1.8], fill=(232,53,31,160), width=2)
183+
d.text((60,bar_y+44), f'X {round(pen[0]*mm_per_unit)} Y {round(pen[1]*mm_per_unit)}', font=f_small, fill=hc)
184+
185+
# ---- end card crossfade ----
186+
if phase=='end':
187+
ov = Image.new('RGBA',(W,H),(246,243,236,int(255*min(1,alpha))))
188+
img = Image.alpha_composite(img.convert('RGBA'), ov).convert('RGB')
189+
if alpha>0.15:
190+
a2 = min(1,(alpha-0.15)/0.6)
191+
d2 = ImageDraw.Draw(img,'RGBA')
192+
def ctext(y,txt,fnt,col):
193+
w=d2.textlength(txt,font=fnt); d2.text((W/2-w/2,y),txt,font=fnt,fill=col+(int(255*a2),))
194+
ctext(H/2-90,'PLOTFLOW*',f_end_logo,(21,22,15))
195+
ctext(H/2+10,f'{suit["code"]} {suit["name"]}',f_end_name,(21,22,15))
196+
ctext(H/2+60,suit.get('jp',''),f_end_jp,(91,94,84))
197+
ctext(H-110,'DRAWN BY MACHINE · マシンドロー',f_end_tag,(143,145,132))
198+
199+
ff.send(np.asarray(img))
200+
201+
ff.close()
202+
sz = os.path.getsize(out)/1024/1024
203+
print(f'{key}-{color}: {out} {sz:.1f} MB ({total_frames} frames @ {fps}fps, {dur}s)')
204+
return out
205+
206+
if __name__ == '__main__':
207+
a = sys.argv[1:]
208+
if not a or a[0] in ('--all','-a','all'):
209+
color = a[1] if len(a)>1 else 'red'
210+
dur = int(a[2]) if len(a)>2 else 15
211+
for k in ORDER:
212+
render(k, color, dur=dur)
213+
else:
214+
key = a[0]
215+
color = a[1] if len(a)>1 else 'red'
216+
dur = int(a[2]) if len(a)>2 else 15
217+
render(key, color, dur=dur)

0 commit comments

Comments
 (0)