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