#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Pachinko Saint (09/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/pachinko_saint # # One chrome ball's pilgrimage down the pin field, told as the life of a saint. # # This single file IS the piece: it draws every frame, synthesizes every sound, # and muxes them into the final video with ffmpeg. No other project files are # needed. You (or your agent) are invited to make a VARIATION of it: # # Generate a variation of this music video using only code. # Start from https://genekogan.com/player_computer/code/pachinko_saint.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/pachinko_saint.mp4 # cover: https://genekogan.com/player_computer/media/pachinko_saint.jpg # # Requirements: python3, numpy, pillow, and ffmpeg on PATH. # pip install numpy "pillow<13" # Speech/vocals (in pieces that have them) use the macOS `say` command; on # other platforms swap in espeak-ng / any TTS at the say_wav()/speak() calls, # or mute those lines. git provenance stamps degrade gracefully outside a repo. # Run: python3 pachinko_saint.py (writes frames/, audio/, and the final mp4 # next to the script; writes ~8 GB of frames, # takes 5-15 min on a modern machine) # ═════════════════════════════════════════════════════════════════════════════ """ night_watch 04 — "PACHINKO SAINT" Shibuya-kei, 128bpm, A major (modulating to B for the revelation). 32 bars. rail(2) sparse(4) normal(5) trials(6) wilderness(2) press(3) funnel(2) revelation(3) rain(5) THE IDEA -------- One chrome ball's pilgrimage down a pachinko pin field, told as hagiography: the pins are trials, the tulip catcher is grace, the jackpot lamp is revelation, and the payout is a rain of saints. The central conceit is not a metaphor, it is the architecture: **a real deterministic 2D physics sim of a ball in a brass pin field, where every pin strike is a note.** The ball's horizontal position is the pitch (left = low, right = high, quantised to the chord under it); the impact speed is the velocity; the collision time is quantised to the nearest swung 16th so the bounces land musically. The melody you hear is not written — it is *fallen*. Nobody composed the tune; gravity did, and the pin layout is the score. Two consequences shape the whole piece: * The pin field's density bands ARE the song sections. Sparse field = sparse chimes. Dense field = a shower of 16ths. A band with no pins at all is a literal rest — the wilderness, where the saint falls in silence and misses the trials. * The field is laid out from the musical clock: a descent schedule y(t) is integrated first, then each row is assigned its band by the *time the ball will arrive there*. A governor ("the machine tilts") keeps the ball on that schedule while the pins still deflect it freely, so the collisions are genuine but the arrival at the tulip is on the downbeat of bar 24. NEW SUBSTRATE (nothing in this repo did any of it) * pin-field rigid-body sim with (x,y) pin soup, normal/tangential restitution, wall bounces, per-pin retrigger cooldown, impact gate * collision → quantised note event stream (the score generator) * a second, fully vectorised numpy rain sim: 2600 balls, nearest-lattice-pin collision, whose own collisions ring the finale's chimes * brass shading, chrome-ball shading (dark equator + ground bounce + specular), painted cabinet art, tulip catchers, cascading trays, glass glare Composition: engine : audio-first x shot-parallel x world-camera content: audio-groove (vibraphone/harpsichord/brushes) x tts-voices (Daniel, hushed hagiography) x effects-post Run from repo root: python3 renders/night_watch/pachinko_saint/render.py --sheet python3 renders/night_watch/pachinko_saint/render.py """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "pachinko_saint" TITLE = "PACHINKO SAINT" SETDIR = "player_computer_final" SETNUM = "04" # Final cut: the STAGE was always authored at 1920x1080 and downsampled to # 720p on the way out. Delivering at 1080p simply stops throwing that away — # every stage coordinate, stroke width and font in the engines is already the # right size. Only the post chain (which worked in output pixels) is scaled. W, H, FPS = 1920, 1080, 30 S = H / 720.0 # 1.5 def si(v): return int(round(v*S)) def sf(v): return v*S BPM = 128.0 BEAT = 60.0/BPM BAR = 4*BEAT SR = 44100 SW = 0.11 # swing on odd 16ths OUT = Path(__file__).resolve().parent FRAMES = OUT/"frames"; FRAMES.mkdir(exist_ok=True) AUD = OUT/"audio"; AUD.mkdir(exist_ok=True) ROOT = OUT # standalone: was repo root (used for git provenance) FONTS = ROOT/"fonts" SECTIONS = [("rail", 0, 2), ("sparse", 2, 6), ("normal", 6, 11), ("trials", 11, 17), ("wild", 17, 19), ("press", 19, 22), ("funnel", 22, 24), ("revel", 24, 27), ("rain", 27, 32), ("settle", 32, 36)] CODA_BAR = 32 # from here the harmony parks on the tonic and resolves N_BARS = SECTIONS[-1][2] DUR = N_BARS*BAR + 1.9 N_FRAMES = int(DUR*FPS) MUSIC_DESC = f"shibuya-kei, {BPM:.0f}bpm, A major -> B major, {N_BARS} bars" ENGINE_DESC = ("pin-field physics sim (every collision is a note) / brass+chrome " "cabinet / vectorised 2600-ball rain") # ════════════════════════════════════════════════════════════════════════════ # THE FIELD — geometry and the descent schedule # # World units: the playfield is FW wide and unbounded downward. Rows of brass # pins every ROWH, staggered. The band of each row is decided by *when the ball # will get there*, so the field layout and the song arrangement are one object. # ════════════════════════════════════════════════════════════════════════════ FW, COLW, ROWH, NCOL = 1000.0, 46.0, 48.0, 21 RB, RP = 11.0, 7.0 # ball / pin radii G, KV, KP = 1500.0, 3.0, 13.0 # gravity, velocity + position governor DRAGX, EN, ET, WALL_E = 0.55, 0.58, 0.80, 0.60 VMIN, COOL, VYMAX = 28.0, 0.09, 1000.0 Y0 = -300.0 SUB = 12 NF = 17 # funnel rows # (start_bar, band, target descent u/s) BANDS = [(0.0,"rail",0.0), (2.0,"sparse",175.0), (6.0,"normal",250.0), (11.0,"dense",305.0), (17.0,"void",680.0), (19.0,"dense",335.0), (22.0,"funnel",225.0), (24.0,"cup",0.0)] BSTART = [b[0] for b in BANDS] BVEL = [b[2] for b in BANDS] DENS = {"sparse": 11, "normal": 15, "dense": 21} def band_index(bar): return max(0, int(np.searchsorted(BSTART, bar, "right"))-1) def vtarget(t): """Stepped descent target with a 0.4-bar cosine crossfade at each edge.""" b = t/BAR; i = band_index(b); v = BVEL[i] if i+1 < len(BANDS): e = BSTART[i+1] if b > e-0.4: u = (b-(e-0.4))/0.4 v = v + (BVEL[i+1]-v)*(0.5-0.5*math.cos(math.pi*u)) return v _TS = np.arange(0.0, (N_BARS+2)*BAR, 1.0/120.0) _VS = np.array([vtarget(t) for t in _TS]) _YS = Y0 + np.cumsum(_VS)/120.0 def ytarget(t): return float(np.interp(t, _TS, _YS)) def band_of_y(y): return BANDS[band_index(float(np.interp(y, _YS, _TS))/BAR)][1] F0 = int(math.ceil(ytarget(22.0*BAR)/ROWH)) # first funnel row YT = F0*ROWH YB = (F0+NF)*ROWH CUPY = YB + 46.0 CUPX = FW/2 def _funnel_pins(): """Two converging diagonal rails of nails + the tulip cup at the vertex.""" out = [] for sgn in (-1, 1): x0, x1 = FW/2 + sgn*(FW/2-30), FW/2 + sgn*70 L = math.hypot(x1-x0, YB-YT); n = max(2, int(L/30.0)) for j in range(n+1): u = j/n out.append((x0+(x1-x0)*u, YT+(YB-YT)*u)) for j in range(4): out.append((FW/2+sgn*70, YB+j*22.0)) for j in range(-3, 4): out.append((FW/2+j*22.0, CUPY)) return out _FP = {} for _px, _py in _funnel_pins(): _FP.setdefault(int(_py/ROWH), []).append((_px, _py)) _ROWC = {} def row_pins(row): """(pins, band) for a row — a pure deterministic function of `row`, so the renderer regenerates the exact field the sim collided with.""" if row in _ROWC: return _ROWC[row] y = row*ROWH bd = band_of_y(y) extra = _FP.get(row, []) if bd in ("void", "rail", "funnel", "cup"): _ROWC[row] = (extra, bd); return _ROWC[row] off = 0.5*COLW if row % 2 else 0.0 xs = [off+0.5*COLW+c*COLW for c in range(NCOL)] xs = [x for x in xs if 24 < x < FW-24] n = DENS[bd] if n < len(xs): r = np.random.RandomState(abs(row)*7919+13) xs = [xs[i] for i in sorted(r.choice(len(xs), n, replace=False))] _ROWC[row] = ([(x, y) for x in xs] + extra, bd) return _ROWC[row] def simulate(): """The pilgrimage. Returns (events, per-frame trajectory). events: (t, pin_x, pin_y, impact, ball_x, ball_y, band) traj : (N_FRAMES, 4) of x, y, vx, vy """ dt = 1.0/(FPS*SUB) x, y = FW*0.86, Y0 vx, vy = -120.0, 40.0 t = 0.0 ev, last, traj = [], {}, [] centre_from = 21.6*BAR rr = RB+RP for i in range(N_FRAMES*SUB): vy += (G + KV*(vtarget(t)-vy) + KP*(ytarget(t)-y))*dt vy = max(-VYMAX, min(VYMAX, vy)) xt = FW*0.5 if t > centre_from else FW*0.5 + 430.0*math.sin(t*0.29+0.55) vx += (-DRAGX*vx + 0.85*(xt-x) + 30.0*math.sin(t*2.7+1.3))*dt x += vx*dt; y += vy*dt if x < RB: x = RB; vx = abs(vx)*WALL_E if x > FW-RB: x = FW-RB; vx = -abs(vx)*WALL_E for r in range(int((y-rr)/ROWH)-1, int((y+rr)/ROWH)+2): pins, band = row_pins(r) for px, py in pins: dx, dy = x-px, y-py d2 = dx*dx+dy*dy if 1e-9 < d2 < rr*rr: d = math.sqrt(d2); nx, ny = dx/d, dy/d vn = vx*nx+vy*ny if vn < 0: tx, ty = -ny, nx vt = vx*tx+vy*ty imp = -vn vx = nx*(-vn*EN)+tx*(vt*ET) vy = ny*(-vn*EN)+ty*(vt*ET) k = (int(px), int(py)) if imp > VMIN and t-last.get(k, -9.0) > COOL: last[k] = t ev.append((t, px, py, imp, x, y, band)) x = px+nx*rr; y = py+ny*rr t += dt if i % SUB == 0: traj.append((x, y, vx, vy)) return ev, np.array(traj[:N_FRAMES], np.float32) # ── the rain: 2600 balls, fully vectorised, nearest-lattice-pin collision ──── RAIN_T0 = 26.6*BAR NRAIN = 2600 def simulate_rain(traj): """Payout. Balls pour from the top of the visible field, collide with the same staggered lattice (nearest pin found analytically), and pile into the tray. Returns (positions[F,N,2] float32, hits[list of (t, x)]).""" rng = np.random.RandomState(4041) f0 = int(RAIN_T0*FPS) nf = N_FRAMES - f0 cy0 = float(traj[f0, 1]) x = rng.uniform(30, FW-30, NRAIN).astype(np.float64) y = cy0 - 420 - rng.uniform(0, 2500, NRAIN) vx = rng.uniform(-40, 40, NRAIN) vy = rng.uniform(40, 260, NRAIN) born = rng.uniform(0, 1.0, NRAIN)*0.0 pos = np.zeros((nf, NRAIN, 2), np.float32) hits = [] sub = 4; dt = 1.0/(FPS*sub) rr = RB+RP floor = cy0 + 250.0 for f in range(nf): t = f0/FPS + f/FPS for _ in range(sub): vy += (G*0.55 - 1.3*vy + 0.55*430.0)*dt vx += (-1.1*vx)*dt x += vx*dt; y += vy*dt row = np.rint(y/ROWH) py = row*ROWH off = np.where(row.astype(np.int64) % 2 != 0, 0.5*COLW, 0.0) col = np.rint((x-off-0.5*COLW)/COLW) px = off + 0.5*COLW + col*COLW live = (py > cy0-6000) & (py < floor-40) & (px > 20) & (px < FW-20) dx = x-px; dy = y-py d = np.sqrt(dx*dx+dy*dy)+1e-9 hit = live & (d < rr) if hit.any(): nx = dx/d; ny = dy/d vn = vx*nx+vy*ny m = hit & (vn < 0) if m.any(): tx, ty = -ny, nx vt = vx*tx+vy*ty nvx = nx*(-vn*EN)+tx*(vt*ET) nvy = ny*(-vn*EN)+ty*(vt*ET) vx = np.where(m, nvx, vx); vy = np.where(m, nvy, vy) strong = m & (-vn > 210) ns = int(strong.sum()) if ns: idx = np.nonzero(strong)[0] if ns > 5: idx = idx[::max(1, ns//5)] for j in idx[:5]: hits.append((t, float(x[j]))) x = np.where(hit, px+nx*rr, x); y = np.where(hit, py+ny*rr, y) below = y > floor-RB y = np.where(below, floor-RB, y) vy = np.where(below, -np.abs(vy)*0.18, vy) wl = x < RB; x = np.where(wl, RB, x); vx = np.where(wl, np.abs(vx)*.5, vx) wr = x > FW-RB; x = np.where(wr, FW-RB, x); vx = np.where(wr, -np.abs(vx)*.5, vx) pos[f, :, 0] = x; pos[f, :, 1] = y return pos, hits, f0 # ════════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0*2.0**((m-69)/12.0) _PC = {"C":0,"C#":1,"Db":1,"D":2,"D#":3,"Eb":3,"E":4,"F":5,"F#":6,"Gb":6, "G":7,"G#":8,"Ab":8,"A":9,"A#":10,"Bb":10,"B":11} def nm(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return 12*(int(name[i:])+1) + _PC[name[:i]] def nf(name): return mtof(nm(name)) def adsr(n, a, d, s, r): e = np.zeros(n) ai, di, ri = max(1, int(a*SR)), max(1, int(d*SR)), max(1, int(r*SR)) ai = min(ai, n); e[:ai] = np.linspace(0, 1, ai) if ai < n: dd = min(di, n-ai) e[ai:ai+dd] = np.linspace(1, s, dd); e[ai+dd:] = s if ri < n: e[-ri:] *= np.linspace(1, 0, ri) return e def bandshape(x, lo=0.0, hi=0.0, order=4): n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) g = np.ones_like(fq) if lo: g *= 1.0/np.sqrt(1.0+(lo/fq)**order) if hi: g *= 1.0/np.sqrt(1.0+(fq/hi)**order) return np.fft.irfft(X*g, n) def voice(freq, dur, kind="saw", nh=22, c0=4200, c1=650, ck=7.0, detune=(0.0,), a=.005, d=.09, s=.7, r=.10, seed=0): n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR co = c1 + (c0-c1)*np.exp(-t*ck) rng = np.random.RandomState(seed) out = np.zeros(n) for det in detune: f0 = freq*(1+det*0.006) for k in range(1, nh+1): base = {"saw": 1.0/k, "square": (1.0/k) if k % 2 else 0.0, "tri": (1.0/(k*k)) if k % 2 else 0.0, "sine": 1.0 if k == 1 else 0.0}.get(kind, 1.0/k) if base == 0.0: continue fk = f0*k if fk > SR*0.45: break out += base/np.sqrt(1.0+(fk/co)**4)*np.sin(2*np.pi*fk*t+rng.uniform(0, 2*np.pi)) return out/len(detune)*adsr(n, a, d, s, r) def fm(freq, dur, ratio=2.0, index=4.0, idec=6.0, a=.002, d=.4, s=.0, r=.2): n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR mod = np.sin(2*np.pi*freq*ratio*t)*index*np.exp(-t*idec) return np.sin(2*np.pi*freq*t+mod)*adsr(n, a, d, s, r) def ks(freq, dur, damp=0.9975, seed=0, bright=0.6): """Karplus-Strong. The harpsichord is two of these, detuned (two choirs).""" n = int(dur*SR); L = max(2, int(SR/freq)) rng = np.random.RandomState(seed) buf = rng.uniform(-1, 1, L) buf = np.convolve(buf, [bright, 1-bright], "same") out = np.zeros(n); j = 0 for i in range(n): out[i] = buf[j] buf[j] = damp*0.5*(buf[j]+buf[(j+1) % L]) j = (j+1) % L return out*adsr(n, .0008, .04, .8, .25) def harpsi(freq, dur, seed=0, g=1.0): a = ks(freq, dur, damp=0.9968, seed=seed, bright=0.78) b = ks(freq*1.0035, dur*0.92, damp=0.9960, seed=seed+7, bright=0.70) n = min(len(a), len(b)) x = a[:n]*0.72 + b[:n]*0.46 t = np.arange(n)/SR x += np.random.RandomState(seed+31).randn(n)*np.exp(-t*420)*0.06 # quill return x*g def vibe(freq, dur, g=1.0, trem=5.4, depth=0.34, seed=0): """Vibraphone: FM bar tone + the motor's tremolo + a soft mallet thud.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR x = (np.sin(2*np.pi*freq*t) + 0.30*np.sin(2*np.pi*freq*4.0*t)*np.exp(-t*9) + 0.13*np.sin(2*np.pi*freq*9.8*t)*np.exp(-t*22)) x *= np.exp(-t*2.1)*(1.0-np.exp(-t*300)) x *= (1.0 - depth) + depth*np.sin(2*np.pi*trem*t + seed*0.7) mall = np.random.RandomState(seed+3).randn(n)*np.exp(-t*260)*0.05 return (x + bandshape(mall, lo=900, hi=6000))*g def chime(freq, dur, g=1.0, hard=0.5, seed=0): """A pin strike. Struck-brass partials, brighter the harder it was hit.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR x = np.sin(2*np.pi*freq*t)*np.exp(-t*3.4) for p, gg, dc in ((2.76, .40, 7.0), (5.40, .22, 12.0), (8.93, .12, 19.0)): x += gg*(0.4+hard)*np.sin(2*np.pi*freq*p*t)*np.exp(-t*dc) click = np.random.RandomState(seed+5).randn(n)*np.exp(-t*520)*0.10*hard x = x*(1.0-np.exp(-t*900)) + bandshape(click, lo=2200, hi=11000) return x*g def brush(dur=.30, seed=0, g=1.0): """Brushed snare swirl — a filtered noise sweep, never a full-band blast.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=5200) env = np.sin(np.pi*np.clip(t/dur, 0, 1))**1.5 return nz*env*0.34*g def brushtap(dur=.13, seed=0, g=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=7000) body = np.sin(2*np.pi*210*t)*np.exp(-t*40)*0.30 return (nz*np.exp(-t*30)+body)*0.42*g def kick(dur=.26, f0=120, f1=48, g=1.0): n = int(dur*SR); t = np.arange(n)/SR f = f1+(f0-f1)*np.exp(-t*26) return np.tanh(np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*12)*1.5)*0.80*g def hat(dur=.05, openh=False, seed=0, g=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=6200, hi=11500) return nz*np.exp(-t*(13 if openh else 78))*0.34*g def rimclick(seed=0, g=1.0): n = int(.07*SR); t = np.arange(n)/SR return (np.sin(2*np.pi*1620*t)+.5*np.sin(2*np.pi*2480*t))*np.exp(-t*95)*0.42*g def shaker(dur=.08, seed=0, g=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=4200, hi=9500) return nz*(np.exp(-t*44)*np.clip(t*280, 0, 1))*0.34*g def cymswell(dur=2.2, seed=0, g=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=2400, hi=10500) return nz*(np.clip(t/(dur*0.62), 0, 1)**2.2)*np.exp(-np.clip(t-dur*0.62, 0, 9)*2.2)*0.55*g def gong(dur=3.4, freq=88.0, seed=0, g=1.0): n = int(dur*SR); t = np.arange(n)/SR x = np.zeros(n) r = np.random.RandomState(seed) for p, gg in ((1.0, 1.0), (2.41, .5), (3.11, .35), (4.62, .22), (6.9, .14)): x += gg*np.sin(2*np.pi*freq*p*t + r.uniform(0, 6.28))*np.exp(-t*(1.0+p*0.32)) nz = bandshape(r.randn(n), lo=1800, hi=8000)*np.exp(-t*6)*0.30 return (x*0.34+nz)*g def reverb(x, rt=1.8, mix=.30, seed=29, pre=0.02): n = int(rt*SR); t = np.arange(n)/SR ir = np.random.RandomState(seed).randn(n)*np.exp(-t*(5.0/rt)) ir[:int(pre*SR)] = 0 ir /= np.abs(ir).sum()/40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x)+n))) wet = irfft(rfft(x, L)*rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet))+1e-9 return x*(1-mix) + wet*mix*(np.max(np.abs(x))+1e-9) def delay(x, time=.25, fb=.34, mix=.22, taps=6): d = int(time*SR); out = x.copy() for i in range(1, taps+1): s = d*i if s >= len(x): break out[s:] += x[:len(x)-s]*(mix*(fb**i)) return out def compressor(x, thresh_db=-20.0, ratio=2.5, atk=0.060, rel=0.40, knee_db=10.0, ctl=64): """A real slow-attack bus compressor. The detector is a block-RMS at SR/ctl (~690 Hz control rate) with a soft knee and separate attack/release one-poles; the resulting gain curve is interpolated back up to audio rate. Because the attack is tens of milliseconds, individual chime transients pass untouched and only *sustained* level — the 12-second ball-rain that was the loud moment — gets pulled down. Returns (y, max_gr_db).""" n = x.shape[0] if n < ctl*8: return x, 0.0 det = np.abs(x).max(1) if x.ndim == 2 else np.abs(x) m = n//ctl lv = np.sqrt((det[:m*ctl].reshape(m, ctl)**2).mean(1)) + 1e-9 over = 20*np.log10(lv) - thresh_db gr = np.zeros(m); k = max(1e-6, knee_db) hi = over > k/2; mid = (over >= -k/2) & ~hi gr[hi] = (1.0-1.0/ratio)*over[hi] gr[mid] = (1.0-1.0/ratio)*((over[mid]+k/2)**2)/(2*k) fs = SR/ctl ca, cr = math.exp(-1.0/(atk*fs)), math.exp(-1.0/(rel*fs)) sm = np.empty(m); z = 0.0 for i in range(m): g = gr[i]; c = ca if g > z else cr z = (1-c)*g + c*z; sm[i] = z gain = np.interp(np.arange(n), (np.arange(m)+0.5)*ctl, 10**(-sm/20.0)) return (x*(gain[:, None] if x.ndim == 2 else gain)), float(sm.max()) class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0, swing=SW): sw = swing*(BEAT/4) if (step % 2) else 0.0 return bar*BAR + step*(BEAT/4) + sw def put(self, track, sig, at, g=1.0, pan=0.0): if len(sig) == 0: return b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR); j = min(self.n, i+len(sig)) if i >= self.n or j <= i: return th = (pan*.5+.5)*(np.pi/2) b[i:j] += np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1)*g def bus(self, track, fn): if track in self.tr: b = self.tr[track] self.tr[track] = np.stack([fn(b[:, 0]), fn(b[:, 1])], 1) def sec_env(self, levels, glide=0.4): env = np.ones(self.n) for name, b0, b1 in SECTIONS: i0, i1 = int(b0*BAR*SR), min(self.n, int(b1*BAR*SR)) if i1 > i0: env[i0:i1] = levels.get(name, 1.0) env[int(SECTIONS[-1][2]*BAR*SR):] = levels.get(SECTIONS[-1][0], 1.0) k = max(1, int(glide*SR)) return np.convolve(env, np.ones(k)/k, "same") def mixdown(self, gains, pump_depth=.14, pump_rel=.14, levels=None): mix = np.zeros((self.n, 2)) for k, b in self.tr.items(): mix += b*gains.get(k, 1.0) if levels: mix *= self.sec_env(levels)[:, None] if self.kick_t: env = np.ones(self.n); rl = int(pump_rel*SR) shape = 1 - pump_depth*np.exp(-np.arange(rl)/(pump_rel*SR/4)) for at in self.kick_t: i = int(at*SR); j = min(self.n, i+rl) if i < self.n: env[i:j] = np.minimum(env[i:j], shape[:j-i]) mix *= np.convolve(env, np.ones(320)/320, "same")[:, None] a = math.exp(-2*math.pi*32.0/SR) # DC / rumble trim for c in range(2): col = mix[:, c]; lp = np.empty(self.n); z = 0.0 for i in range(self.n): z = (1-a)*col[i] + a*z; lp[i] = z mix[:, c] = col - lp # unity-peak first so the thresholds below are honest dBFS mix = mix/(np.max(np.abs(mix))+1e-9) mix, gr1 = compressor(mix, thresh_db=-20.0, ratio=1.75, atk=0.120, rel=0.70, knee_db=12.0) # section swells mix, gr2 = compressor(mix, thresh_db=-15.0, ratio=3.2, atk=0.030, rel=0.22, knee_db=8.0) # peak control self.gr = (gr1, gr2) mix = np.tanh(mix*1.02)/np.tanh(1.02) return mix/(np.max(np.abs(mix))+1e-9)*.94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1)*32767).astype(" macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, voice_name, rate): """One narrator line, peak-normalised. Cache path is content-addressed.""" p = AUD/("say_"+_h(text, voice_name, rate)+".wav") if not p.exists(): if not _tts_render(text, voice_name, rate, p): return _tts_silence(text, rate) # already zeros; never divide by 0 x = read_wav(p) return x/(np.max(np.abs(x))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # THE SCORE — collisions become notes # # x -> pitch (left low, right high, snapped to the chord under the bar), # impact -> velocity, time -> nearest swung 16th. Slots are thinned so the # shower stays music instead of mush. # ════════════════════════════════════════════════════════════════════════════ PROG = [("A", (0, 4, 7, 11)), ("C#", (0, 3, 7, 10)), ("F#", (0, 3, 7, 10, 14)),("B", (0, 3, 7, 10)), ("D", (0, 4, 7, 11)), ("C#", (0, 3, 7, 10)), ("B", (0, 3, 7, 10)), ("E", (0, 5, 7, 10, 14))] MODUL_BAR = 24 # the revelation modulates up a whole tone def transpose(bar): return 2 if bar >= MODUL_BAR else 0 def chord_at(bar): if bar >= CODA_BAR: # the coda parks on the tonic root, ivs = PROG[0] # A maj7, +2 => B maj7 else: root, ivs = PROG[int(bar) % 8] return nm(root+"2") + transpose(bar), ivs def chord_pitches(bar, lo=60, hi=98): r, ivs = chord_at(bar) out = [] for k in range(-1, 6): for iv in ivs: p = r + iv + 12*k if lo <= p <= hi: out.append(p) return sorted(set(out)) def bass_note(bar): r, _ = chord_at(bar) return r NARRATION = [ (0.55, "In the ninth machine, on a Tuesday, a saint was loaded into the rail.", "A SAINT IS LOADED INTO THE RAIL"), (6.20, "The pins are not cruel. The pins are only many.", "THE PINS ARE NOT CRUEL.\nTHE PINS ARE ONLY MANY."), (12.20, "Trial forty one. He was moved three centimetres to the left.", "TRIAL 41 — MOVED 3 cm LEFT"), (17.20, "And in the wilderness there were no pins at all, and he missed them.", "IN THE WILDERNESS\nTHERE WERE NO PINS AT ALL"), (22.30, "Then the flowers opened.", "THEN THE FLOWERS OPENED"), (24.60, "And the light came on, and the light was only a light, and it was enough.", "AND IT WAS ENOUGH"), (28.40, "And it rained saints for eleven seconds.", "AND IT RAINED SAINTS\nFOR ELEVEN SECONDS"), (30.55, "He was worth eleven thousand four hundred and sixty balls.", "HE WAS WORTH 11,460 BALLS"), (32.55, "Then the tray was full, and the machine was quiet, and the ninth " "machine went on being the ninth machine.", "AND THE MACHINE WAS QUIET"), ] def quantise(t): """Nearest swung 16th.""" slot = int(round(t/(BEAT/4))) return slot, slot*(BEAT/4) + (SW*(BEAT/4) if slot % 2 else 0.0) def score_from_events(ev, rain_hits): """The whole point: turn real collisions into a playable note list.""" slots = {} for (t, px, py, imp, bx, by, band) in ev: slot, at = quantise(t) slots.setdefault(slot, []).append((imp, bx, at, band)) notes = [] SPB = int(round(BAR/(BEAT/4))) # 16 sixteenths per bar for slot, items in slots.items(): bar = int((slot*(BEAT/4))//BAR) if slot % SPB == SPB-1: bar += 1 # anticipate the next chord keep = 3 if items and items[0][3] in ("funnel", "cup") else 2 items.sort(key=lambda z: -z[0]) ps = chord_pitches(bar) used = set() for imp, bx, at, band in items[:keep]: u = min(0.999, max(0.0, bx/FW)) i = int(u*(len(ps)-1)) while i in used and i+1 < len(ps): i += 1 used.add(i) g = min(1.0, max(0.14, (imp/460.0)))**0.75 notes.append((at, ps[i], g, band, bx)) notes.sort() # the rain is thousands of collisions — thin each 16th to a chord of at most # six distinct pitches so the payout glitters instead of turning into hiss rslots = {} for t, x in rain_hits: slot, at = quantise(t) bar = int((slot*(BEAT/4))//BAR) if slot % SPB == SPB-1: bar += 1 ps = chord_pitches(bar, lo=72, hi=105) u = min(0.999, max(0.0, x/FW)) rslots.setdefault(slot, (at, set(), []))[1].add(ps[int(u*(len(ps)-1))]) rslots[slot][2].append(x) rn = [] for slot, (at, pitches, xs) in rslots.items(): pl = sorted(pitches) if len(pl) > 6: pl = pl[::max(1, len(pl)//6)][:6] for q, p in enumerate(pl): jit = ((slot*37+q*101) % 17-8)/1000.0 # deterministic ±8 ms rn.append((at+jit, p, min(1.0, len(xs)/26.0))) rn.sort() return notes, rn def coda_bar(s, bar, R): """The settle. Four bars parked on the tonic in which the arrangement is taken apart one layer per bar — a ritardando made of subtraction rather than of tempo, so the machine can stop without the tape stopping.""" j = bar - CODA_BAR # 0..3 root = bass_note(bar); _, ivs = chord_at(bar) fade = max(0.0, 1.0-0.22*j) s.put("bass", voice(mtof(root+12), BAR*(1.7-0.25*j), kind="tri", nh=9, c0=700, c1=190, ck=1.1, a=.02, d=.55, s=.68, r=1.5, seed=900+bar), s.t(bar, 0), g=0.34*fade, pan=-.05) if j == 0: s.kick_t.append(s.t(bar, 0)) for k, iv in enumerate(ivs): s.put("vibe", vibe(mtof(root+iv+24), BAR*(2.4-0.35*j), g=0.9, trem=4.4, depth=0.30, seed=940+bar*7+k), s.t(bar, 0), g=0.10*fade, pan=.34-.22*k) for st in ([0, 4, 10, 14], [0, 10], [0], [])[min(3, j)]: for k, iv in enumerate(ivs[:3]): s.put("harp", harpsi(mtof(root+iv+24), BEAT*1.3, seed=bar*53+st+k, g=0.85-0.10*k), s.t(bar, st), g=0.10*fade, pan=-.30+.20*k) for b4 in ([0, 1, 2, 3], [0, 2], [0], [])[min(3, j)]: s.put("kit", brush(0.34, seed=bar*11+b4, g=0.72*fade), s.t(bar, b4*4), g=0.24, pan=0.10) for st in ([2, 6, 10], [6], [], [])[min(3, j)]: s.put("kit", shaker(seed=bar*23+st), s.t(bar, st), g=0.14*fade, pan=-.2) arp = [(0, 16), (4, 11), (8, 7), (12, 4)][:max(1, 4-j)] for st, iv in arp: s.put("chime", chime(mtof(root+iv+24), 1.5, g=0.55*fade, hard=0.40, seed=970+bar*13+st), s.t(bar, st), g=0.24, pan=.18-.12*(st/12)) def build_song(ev, rain_hits): s = Song(DUR) R = np.random.RandomState(31337) notes, rain_notes = score_from_events(ev, rain_hits) def sec_of(bar): for name, a, b in SECTIONS: if a <= bar < b: return name return SECTIONS[-1][0] # ---- the fallen melody: every note is a pin strike --------------------- for at, p, g, band, bx in notes: f = mtof(p) # p already carries transpose() via chord_at pan = (bx/FW - 0.5)*1.3 # the field IS the stereo image dur = 1.15 if p < 74 else 0.92 # shorter low tails = less inter-bar mud s.put("chime", chime(f, dur, g=g, hard=min(1.0, g*1.1), seed=int(at*97) % 9999), at, g=0.27, pan=max(-.95, min(.95, pan))) if g > 0.55: s.put("vibe", vibe(f*0.5, 1.5, g=g*0.5, seed=int(at*13) % 997), at, g=0.16, pan=pan*0.55) # ---- the finale's shower ------------------------------------------------ for at, p, dens in rain_notes: f = mtof(p) # taper the shower through the coda so the payout subsides musically tap = 1.0 if at < CODA_BAR*BAR else max(0.0, 1.0-(at-CODA_BAR*BAR)/(2.6*BAR))**1.6 if tap <= 0.02: continue s.put("shower", chime(f, 0.62, g=(0.42+0.4*dens)*tap, hard=0.72, seed=int(at*211) % 9999), at, g=0.175*tap, pan=R.uniform(-.9, .9)) # ---- band, groove, bossa ------------------------------------------------ for bar in range(N_BARS): sec = sec_of(bar) if sec == "settle": coda_bar(s, bar, R) continue quiet = sec in ("rail",) hush = sec == "wild" big = sec in ("revel", "rain") lvl = 0.5 if quiet else (0.55 if hush else (1.15 if big else 1.0)) root = bass_note(bar) _, ivs = chord_at(bar) ps = chord_pitches(bar, lo=55, hi=84) # bossa bass: root on 1, fifth on the "and of 2" for st, iv in ((0, 0), (6, 7), (8, 0), (14, 7)): if quiet and st > 0: continue if hush and st in (8, 14): continue f = mtof(root+iv+12) s.put("bass", voice(f, BEAT*0.95, kind="tri", nh=9, c0=760, c1=210, ck=9.0, a=.006, d=.16, s=.55, r=.16, seed=bar*17+st), s.t(bar, st), g=0.42*lvl, pan=-.05) if st in (0, 8): s.kick_t.append(s.t(bar, st)) # brushed kit if not quiet: for b4 in range(4): s.put("kit", brush(0.30, seed=bar*11+b4, g=(0.9 if not hush else 0.45)), s.t(bar, b4*4), g=0.30*lvl, pan=0.10) if not hush: for st in (4, 12): s.put("kit", brushtap(seed=bar*19+st, g=1.0), s.t(bar, st), g=0.34*lvl, pan=-.12) for st in (0, 3, 6, 10, 12): s.put("kit", rimclick(seed=bar*7+st, g=0.7), s.t(bar, st), g=0.16*lvl, pan=.28) for st in range(2, 16, 4): s.put("kit", shaker(seed=bar*23+st), s.t(bar, st), g=0.22*lvl, pan=-.30+.6*R.rand()) for st in range(0, 16, 2): if R.rand() < .55: s.put("kit", hat(0.04, seed=bar*29+st), s.t(bar, st), g=0.13*lvl, pan=.34) if big: for st in (0, 8): s.put("kit", kick(g=0.8), s.t(bar, st), g=0.42, pan=0) # harpsichord: the bossa comp, syncopated if not quiet: comp = [0, 3, 6, 10, 11, 14] if not hush else [0, 10] for j, st in enumerate(comp): for k, iv in enumerate(ivs[: (3 if hush else 4)]): f = mtof(root+iv+24+ (12 if (j % 3 == 2) else 0)) s.put("harp", harpsi(f, BEAT*1.15, seed=bar*53+j*7+k, g=0.9-0.10*k), s.t(bar, st), g=0.115*lvl, pan=-.34+.20*k) # vibraphone pads on the maj7 colour if not quiet: for k, iv in enumerate(ivs[:4]): s.put("vibe", vibe(mtof(root+iv+24), BEAT*2.6, g=0.85, trem=5.4, depth=0.40, seed=bar*31+k), s.t(bar, 2 if bar % 2 == 0 else 6), g=0.115*lvl, pan=.30-.18*k) # a lifting counter-melody in the big sections if big: MEL = [0, 4, 7, 11, 14, 11, 7, 4] for j, st in enumerate((0, 4, 7, 10, 14)): f = mtof(root+MEL[(bar*3+j) % 8]+36) s.put("vibe", vibe(f, BEAT*1.5, g=0.85, seed=bar*41+j), s.t(bar, st), g=0.13, pan=-.2+.1*j) # ---- the cadence: the piece lands instead of stopping ------------------- croot = bass_note(CODA_BAR) s.put("fx", gong(6.4, mtof(croot-12), seed=1201), CODA_BAR*BAR, g=0.40, pan=0) s.put("fx", cymswell(3.4, seed=1203), (CODA_BAR-0.55)*BAR, g=0.20, pan=0) for k, iv in enumerate((0, 4, 7, 11, 16)): # the tonic, spread wide s.put("vibe", vibe(mtof(croot+iv+24), 6.0, g=0.9, trem=3.6, depth=0.24, seed=1210+k), CODA_BAR*BAR, g=0.105, pan=.45-.22*k) # the very last note: the tonic, alone, allowed to ring out s.put("chime", chime(mtof(croot+24), 4.2, g=0.85, hard=0.35, seed=1230), (N_BARS-1.0)*BAR, g=0.30, pan=0.0) s.put("chime", chime(mtof(croot+36), 3.4, g=0.42, hard=0.28, seed=1231), (N_BARS-1.0)*BAR + BEAT*0.5, g=0.20, pan=-.25) # ---- one-shot events ---------------------------------------------------- for b in (2, 11, 19, 24, 27): s.put("fx", cymswell(2.0, seed=100+b), (b-1.6)*BAR, g=0.24, pan=0) s.put("fx", gong(3.6, 92.0, seed=77), 24*BAR, g=0.55, pan=0) s.put("fx", gong(3.0, 184.0, seed=79), 24*BAR+BEAT/2, g=0.28, pan=.2) s.put("fx", cymswell(2.6, seed=88), 26.4*BAR, g=0.34) # the wilderness: no pins, so a bare pad and a very low drone for bar in (17, 18): for k, iv in enumerate(chord_at(bar)[1][:3]): s.put("pad", voice(mtof(bass_note(bar)+iv+12), BAR*1.4, kind="tri", nh=7, c0=620, c1=320, ck=.7, detune=(-1.1, 0, 1.2), a=.8, d=.9, s=.75, r=1.1, seed=bar*61+k), bar*BAR, g=0.10, pan=-.35+.3*k) # ---- narration ---------------------------------------------------------- for bar, text, _card in NARRATION: x = say_wav(text, "Daniel", 148) s.put("vox", x, bar*BAR, g=0.42, pan=0.0) s.bus("chime", lambda x: reverb(delay(x, BEAT*0.75, .28, .16), rt=2.6, mix=.34, seed=901)) s.bus("shower", lambda x: reverb(x, rt=3.0, mix=.44, seed=903)) s.bus("vibe", lambda x: reverb(delay(x, BEAT*0.5, .22, .12), rt=2.4, mix=.36, seed=905)) s.bus("harp", lambda x: reverb(x, rt=1.5, mix=.24, seed=907)) s.bus("kit", lambda x: reverb(x, rt=0.9, mix=.14, seed=909)) s.bus("pad", lambda x: reverb(x, rt=4.0, mix=.55, seed=911)) s.bus("fx", lambda x: reverb(x, rt=2.8, mix=.40, seed=913)) s.bus("vox", lambda x: reverb(x, rt=2.2, mix=.34, seed=915)) mix = s.mixdown(dict(chime=1.0, shower=1.0, vibe=1.0, harp=1.0, bass=1.0, kit=1.0, pad=1.0, fx=1.0, vox=1.0), pump_depth=.12, pump_rel=.14, levels=dict(rail=.62, sparse=.86, normal=.94, trials=.97, wild=.60, press=.97, funnel=.99, revel=1.09, rain=1.10, settle=.84)) wav = AUD/"final.wav" s.write(wav, mix) return wav, mix, notes def analyze(mix): x = mix.mean(1) hop = SR/FPS; win = int(hop*1.7) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high")} for f in range(N_FRAMES): i = int(f*hop); seg = x[i:i+win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg**2).mean()) sp = np.abs(np.fft.rfft(seg*np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1/SR) E["low"][f] = sp[fr < 180].sum() E["mid"][f] = sp[(fr >= 180) & (fr < 2600)].sum() E["high"][f] = sp[fr >= 2600].sum() for k in E: E[k] = np.clip(E[k]/(np.percentile(E[k], 96)+1e-9), 0, 1.25) return E _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV _SIM = {} def sim(): if not _SIM: z = np.load(AUD/"sim.npz", allow_pickle=True) _SIM["traj"] = z["traj"]; _SIM["ev"] = z["ev"] _SIM["rain"] = z["rain"]; _SIM["rain_f0"] = int(z["rain_f0"]) return _SIM # ════════════════════════════════════════════════════════════════════════════ # LOOK — brass, chrome, lacquer, glass # ════════════════════════════════════════════════════════════════════════════ SW_S, SH_S = 1920, 1080 ASPECT = W/H BG = (20, 34, 46) BG2 = (13, 22, 32) FELT = (24, 52, 62) BRASS = (206, 160, 78) BRASS_H = (255, 240, 196) BRASS_S = (104, 70, 22) CHROME = (186, 200, 212) CHROME_D= (58, 74, 90) CHROME_H= (252, 254, 255) CREAM = (247, 238, 218) CORAL = (232, 104, 96) TEAL = (74, 178, 170) MUSTARD = (236, 182, 62) PLUM = (74, 46, 74) LACQ = (126, 34, 42) GOLD = (255, 214, 118) INK = (26, 20, 26) def ease_io(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def lerp(a, b, u): return a+(b-a)*u def mixc(a, b, u): u = max(0.0, min(1.0, u)) return tuple(int(a[i]+(b[i]-a[i])*u) for i in range(3)) def scale(c, k): return tuple(max(0, min(255, int(v*k))) for v in c) # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) _FC = {} def font(size, name="Georgia Bold.ttf"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] class Cam: """World (playfield units, y down) -> stage pixels.""" def __init__(self, cx, cy, zoom, xs=1.0): self.cx, self.cy, self.z, self.xs = cx, cy, zoom, xs def px(self, x, y): return (SW_S/2 + (x-self.cx)*self.z*self.xs, SH_S/2 + (y-self.cy)*self.z) def box(self, x, y, r): px, py = self.px(x, y); rr = r*self.z return [px-rr, py-rr, px+rr, py+rr] def rows(self, pad=1.6): h = SH_S/(2*self.z)*pad return int((self.cy-h)/ROWH)-1, int((self.cy+h)/ROWH)+1 def draw_pin(d, cam, x, y, glow=0.0, r=RP): """A brass nail head: shadow, body, rim-light, specular.""" rr = r*cam.z if rr < 1.1: px, py = cam.px(x, y) d.point((px, py), fill=BRASS); return px, py = cam.px(x, y) d.ellipse([px-rr*1.25, py-rr*0.9+rr*0.55, px+rr*1.25, py+rr*1.25+rr*0.55], fill=scale(BG2, 0.75)) body = mixc(BRASS, GOLD, glow*0.9) d.ellipse([px-rr, py-rr, px+rr, py+rr], fill=scale(body, 1.0+glow*0.5)) d.ellipse([px-rr, py-rr*0.15, px+rr, py+rr], fill=scale(BRASS_S, 1.0+glow)) d.ellipse([px-rr*0.92, py-rr*0.92, px+rr*0.92, py+rr*0.45], fill=scale(body, 1.05+glow*0.6)) if rr > 2.4: d.ellipse([px-rr*0.44, py-rr*0.70, px-rr*0.02, py-rr*0.24], fill=BRASS_H) def draw_ball(d, cam, x, y, r=RB, halo=0.0, saint=False): if saint: r = r*1.62 """A chrome ball: dark shell, bright equator, ground bounce below, one hot specular up-left. `saint` adds a warm rim so the protagonist never gets lost among the payout.""" rr = r*cam.z px, py = cam.px(x, y) if halo > 0.02: for k in range(3, 0, -1): a = max(0.0, halo*(0.30-0.062*k)) hr = rr*(1.3+k*0.78) d.ellipse([px-hr, py-hr, px+hr, py+hr], outline=mixc(FELT, GOLD, a), width=max(1, int(rr*0.30))) if rr < 1.6: d.ellipse([px-1.8, py-1.8, px+1.8, py+1.8], fill=CHROME_H); return d.ellipse([px-rr*1.16, py-rr*0.30, px+rr*1.16, py+rr*1.40], fill=scale(BG2, .55)) d.ellipse([px-rr*1.16, py-rr*1.16, px+rr*1.16, py+rr*1.16], fill=mixc((10, 14, 20), GOLD, 0.70 if saint else 0.0)) # terminator: a dark sphere with a bright one riding up-left inside it d.ellipse([px-rr, py-rr, px+rr, py+rr], fill=CHROME_D) d.ellipse([px-rr*0.94, py-rr*0.99, px+rr*0.80, py+rr*0.74], fill=CHROME) d.ellipse([px-rr*0.80, py-rr*0.95, px+rr*0.52, py+rr*0.42], fill=mixc(CHROME, CHROME_H, 0.42)) d.ellipse([px-rr*0.20, py+rr*0.52, px+rr*0.86, py+rr*0.98], fill=mixc(CHROME, CHROME_H, 0.55)) # ground bounce if rr > 2.4: d.ellipse([px-rr*0.62, py-rr*0.80, px-rr*0.06, py-rr*0.24], fill=CHROME_H) def glass(im, t, strength=1.0): """The cabinet glass: two diagonal sheen bands + a soft reflected window.""" ov = Image.new("RGB", im.size, (0, 0, 0)) d = ImageDraw.Draw(ov) for k, (off, wdt, a) in enumerate(((0.10, 150, 0.30), (0.42, 62, 0.20), (0.74, 28, 0.14))): sx = SW_S*(off + 0.03*math.sin(t*0.35+k)) d.polygon([(sx, 0), (sx+wdt, 0), (sx+wdt-460, SH_S), (sx-460, SH_S)], fill=scale((190, 220, 240), a*strength)) d.rectangle([SW_S*0.60, SH_S*0.05, SW_S*0.90, SH_S*0.26], fill=scale((150, 190, 220), 0.10*strength)) ov = ov.filter(ImageFilter.GaussianBlur(26)) return Image.blend(im, Image.blend(im, ov, 0.0), 0.0) if strength <= 0 else \ Image.fromarray(np.clip(np.asarray(im, np.float32) + np.asarray(ov, np.float32)*0.9, 0, 255).astype(np.uint8)) def saint_icon(d, cx, cy, sc, t, blaze=0.0): """The painted cabinet-art saint: a chrome ball with a halo and hands.""" for k in range(3): r = sc*(1.55+k*0.16) d.ellipse([cx-r, cy-r*0.98-sc*0.15, cx+r, cy+r*0.98-sc*0.15], outline=mixc(GOLD, CREAM, blaze), width=max(2, int(sc*0.035))) n = 16 for i in range(n): a = i*math.tau/n + t*0.25 r0, r1 = sc*1.7, sc*(2.25+0.35*math.sin(t*2.2+i)+blaze*0.9) d.line([cx+math.cos(a)*r0, cy+math.sin(a)*r0-sc*0.15, cx+math.cos(a)*r1, cy+math.sin(a)*r1-sc*0.15], fill=mixc(scale(GOLD, .7), CREAM, blaze), width=max(2, int(sc*0.05))) d.ellipse([cx-sc*1.02, cy-sc*1.35, cx+sc*1.02, cy-sc*0.55], outline=GOLD, width=max(3, int(sc*0.07))) d.ellipse([cx-sc, cy-sc, cx+sc, cy+sc], fill=CHROME_D) d.ellipse([cx-sc*0.96, cy-sc*0.24, cx+sc*0.96, cy+sc*0.96], fill=CHROME) d.ellipse([cx-sc*0.5, cy-sc*0.7, cx-sc*0.06, cy-sc*0.24], fill=CHROME_H) for sgn in (-1, 1): # praying hands d.polygon([(cx+sgn*sc*0.95, cy+sc*0.30), (cx+sgn*sc*1.55, cy+sc*0.05), (cx+sgn*sc*1.42, cy+sc*0.62)], fill=mixc(CREAM, CORAL, .35)) def cabinet_art(d, t, blaze=0.0): """Painted side panels + marquee, the machine's own iconography.""" for sgn, x0 in ((-1, 0), (1, SW_S-280)): d.rectangle([x0, 0, x0+280, SH_S], fill=LACQ) d.rectangle([x0+(0 if sgn < 0 else 262), 0, x0+(18 if sgn < 0 else 280), SH_S], fill=scale(LACQ, .6)) for k in range(9): yy = 60+k*118 d.ellipse([x0+58, yy, x0+222, yy+96], outline=mixc(MUSTARD, CREAM, blaze*0.6), width=4) d.ellipse([x0+96, yy+22, x0+184, yy+74], fill=mixc(PLUM, CORAL, .35+.3*math.sin(t+k))) saint_icon(d, x0+140, SH_S*0.5, 74, t, blaze) def tray_of_balls(d, x0, y0, x1, y1, fill_u, t, seed=5): """A cascading payout tray, packed hexagonally, filling with fill_u.""" d.rounded_rectangle([x0, y0, x1, y1], 18, fill=scale(CHROME_D, .55)) d.rounded_rectangle([x0+8, y0+8, x1-8, y1-8], 14, fill=scale(BG2, 1.2)) rr = int(min(34, max(9, (y1-y0)/9.0))) rows = int((y1-y0-24)/(rr*1.72)) nfill = int(rows*fill_u) R = np.random.RandomState(seed) for r in range(rows): if rows-1-r >= nfill: continue yy = y1-16-r*rr*1.72 off = rr if r % 2 else 0 n = int((x1-x0-24)/(rr*2)) for c in range(n): xx = x0+16+off+c*rr*2 + math.sin(t*3+r*0.7+c)*1.2 d.ellipse([xx-rr, yy-rr, xx+rr, yy+rr], fill=CHROME_D) d.ellipse([xx-rr*.94, yy-rr*.99, xx+rr*.80, yy+rr*.74], fill=CHROME) d.ellipse([xx-rr*.62, yy-rr*.80, xx-rr*.06, yy-rr*.24], fill=CHROME_H) NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] def pitch_ruler(d, cam, lit, t, bar): """The mechanism, made visible: the field's x axis IS the keyboard.""" y = 128 d.rectangle([0, y-34, SW_S, y+96], fill=scale(INK, 1.5)) ps = chord_pitches(int(bar)) n = max(2, len(ps)) f = font(26, "Menlo.ttc") for i in range(n): x0 = SW_S*(i/n)+5; x1 = SW_S*((i+1)/n)-5 on = lit.get(i, 0.0) d.rounded_rectangle([x0, y-16, x1, y+50], 7, fill=mixc(scale(TEAL, .40), GOLD, on)) nn = NOTE_NAMES[(ps[i]+transpose(int(bar))) % 12] d.text(((x0+x1)/2-d.textlength(nn, font=f)/2, y+4), nn, font=f, fill=mixc(scale(CREAM, .5), INK, on)) # (final cut) the "PITCH = POSITION · VELOCITY = IMPACT" caption is gone. # The lit keys under the falling ball say it without narrating it. # ════════════════════════════════════════════════════════════════════════════ # ENGINES # ════════════════════════════════════════════════════════════════════════════ def field_backdrop(t, e, dark=0.0): im = Image.new("RGB", (SW_S, SH_S), mixc(BG, BG2, dark)) d = ImageDraw.Draw(im) for k in range(7): # felt gradient d.rectangle([0, SH_S*k/7, SW_S, SH_S*(k+1)/7], fill=mixc(mixc(FELT, BG2, dark), BG2, k/9.0)) return im, d DECOR_C = (CORAL, TEAL, MUSTARD, PLUM) def field_decor(d, cam, t): """The painted playfield under the nails: lacquer medallions, chrome guide rails, decorative tulips, and the numbered trial plates the saint passes. Drawn in world space so it scrolls with the pilgrimage.""" r0, r1 = cam.rows() z = cam.z for r in range(r0, r1+1): if r < 0: continue y = r*ROWH if r % 9 == 0 and z < 1.9: # medallion c = DECOR_C[(r//9) % 4] cx = FW*(0.5+0.34*math.sin(r*0.7)) for q in range(4): rq = 128-q*32 d.ellipse([*cam.px(cx-rq, y-rq), *cam.px(cx+rq, y+rq)], outline=mixc(FELT, c, 0.26+0.16*q), width=max(1, min(9, int((5-q)*z)))) if r % 13 == 4 and z < 3.0: # chrome guide rail x0 = FW*(0.06 if (r//13) % 2 else 0.40) p = [cam.px(x0+j*FW*0.54/16, y-58*math.sin(math.pi*j/16.0)) for j in range(17)] d.line(p, fill=scale(CHROME_D, 1.25), width=max(2, int(9*z)), joint="curve") d.line([(a, b-2*z) for a, b in p], fill=scale(CHROME, .8), width=max(1, int(3*z)), joint="curve") if r % 17 == 6 and 0.55 < z < 2.4: # trial plate f = font(max(12, min(40, int(26*z))), "Menlo.ttc") X, Y = cam.px(FW*0.035, y-16) d.text((X, Y), f"TRIAL {r:03d}", font=f, fill=scale(CREAM, .42)) if r % 11 == 3 and z < 3.0: # decorative tulips for xx in (FW*0.20, FW*0.80): X, Y = cam.px(xx, y+16) w2 = 26*z d.polygon([(X-w2, Y+w2*0.7), (X-w2*0.4, Y-w2*0.6), (X+w2*0.4, Y-w2*0.6), (X+w2, Y+w2*0.7)], fill=mixc(FELT, CHROME_D, .8)) d.polygon([(X-w2*0.7, Y+w2*0.4), (X, Y-w2*0.5), (X+w2*0.7, Y+w2*0.4)], fill=mixc(CHROME_D, CORAL, .45)) def recent_hits(evs, t, win=0.36): return [x for x in evs if 0 <= t-x[0] < win] class Descent: """The pilgrimage proper. Camera tracks the saint down the pin field.""" MODES = ("follow", "lead", "wide", "macro", "ruler") def __init__(self, shot, rng, mode=None): self.i0 = shot.i0 self.mode = mode or str(rng.choice(["follow", "lead", "wide", "macro"])) self.dark = 0.0 self.side = 1 if rng.random() < 0.5 else -1 def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"]; evs = S["ev"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) vy = float(traj[min(i, len(traj)-1), 3]) zoom = {"follow": 1.62, "lead": 1.38, "wide": 1.12, "macro": 3.4, "ruler": 1.18}[self.mode] cx = FW/2 if self.mode in ("wide", "ruler") else lerp(FW/2, bx, 0.72) cy = by + {"follow": 60, "lead": -220, "wide": 120, "macro": 30, "ruler": 140}[self.mode] if self.mode == "macro": cx = bx + self.side*22; cy = by + 26 cam = Cam(cx, cy, zoom) im, d = field_backdrop(t, e, self.dark) field_decor(d, cam, t) r0, r1 = cam.rows() hits = recent_hits(evs, t) hot = {(int(h[1]), int(h[2])): max(0.0, 1.0-(t-h[0])/0.36) for h in hits} for r in range(r0, r1+1): pins, band = row_pins(r) for px, py in pins: g = hot.get((int(px), int(py)), 0.0) draw_pin(d, cam, px, py, glow=g) # collision rings for (ht, px, py, imp, hx, hy, band) in hits: a = 1.0-(t-ht)/0.36 rr = (RP + (1-a)*74)*cam.z X, Y = cam.px(px, py) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], outline=mixc(BG, GOLD, a*0.95), width=max(1, int(3*a*cam.z))) # motion trail for j in range(1, 13): ii = max(0, i-j) tx, ty = float(traj[ii, 0]), float(traj[ii, 1]) a = (1-j/13.0)*0.55 rr = RB*cam.z*(1-j*0.045) X, Y = cam.px(tx, ty) if rr > 0.6: d.ellipse([X-rr, Y-rr, X+rr, Y+rr], fill=mixc(mixc(BG, FELT, .5), CHROME, a)) draw_ball(d, cam, bx, by, halo=min(0.55, 0.12+abs(vy)/2200.0), saint=True) if self.mode == "ruler": ps = chord_pitches(int(t//BAR)) lit = {} for h in hits: uu = min(0.999, max(0.0, h[4]/FW)) lit[int(uu*(len(ps)-1))] = max(0.0, 1.0-(t-h[0])/0.36) pitch_ruler(d, cam, lit, t, t//BAR) im = glass(im, t, 0.7 if self.mode != "macro" else 0.35) return np.asarray(im, np.float32) class Cabinet: """The whole machine, front on: art panels, glass, marquee, tray.""" def __init__(self, shot, rng, blaze=0.0): self.i0 = shot.i0; self.blaze = blaze self.push = rng.random()*0.5 def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"]; evs = S["ev"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) bl = self.blaze*(0.75+0.25*math.sin(t*7.0)) im = Image.new("RGB", (SW_S, SH_S), scale(LACQ, .35)) d = ImageDraw.Draw(im) cabinet_art(d, t, bl) gx0, gx1 = 292, SW_S-292 gy0, gy1 = 132, SH_S-236 d.rounded_rectangle([gx0-14, gy0-14, gx1+14, gy1+14], 20, fill=mixc(scale(BRASS, .8), GOLD, bl)) d.rectangle([gx0, gy0, gx1, gy1], fill=mixc(BG, BG2, .3)) cam = Cam(FW/2, by+130, (gx1-gx0)/FW*0.98) # draw the field into a scratch stage then paste the window fim, fd = field_backdrop(t, e) field_decor(fd, cam, t) r0, r1 = cam.rows() hits = recent_hits(evs, t) hot = {(int(h[1]), int(h[2])): max(0.0, 1.0-(t-h[0])/0.36) for h in hits} for r in range(r0, r1+1): for px, py in row_pins(r)[0]: draw_pin(fd, cam, px, py, glow=max(bl*0.55, hot.get((int(px), int(py)), 0.0))) for (ht, px, py, imp, hx, hy, band) in hits: a = 1.0-(t-ht)/0.36 rr = (RP+(1-a)*70)*cam.z X, Y = cam.px(px, py) fd.ellipse([X-rr, Y-rr, X+rr, Y+rr], outline=mixc(BG, GOLD, a), width=3) if t > RAIN_T0: pos = S["rain"]; f0 = S["rain_f0"] P = pos[min(len(pos)-1, max(0, i-f0))] lo, hi = cam.cy-SH_S/(2*cam.z)-40, cam.cy+SH_S/(2*cam.z)+40 V = P[(P[:, 1] > lo) & (P[:, 1] < hi)] for q in range(len(V)): draw_ball(fd, cam, float(V[q, 0]), float(V[q, 1])) tulip(fd, cam, t, 1.0 if t > 23.4*BAR else 0.0, blaze=bl) draw_ball(fd, cam, bx, by, halo=0.15+bl*0.8, saint=True) win = fim.crop((int(SW_S/2-(gx1-gx0)/2), int(SH_S/2-(gy1-gy0)/2), int(SW_S/2+(gx1-gx0)/2), int(SH_S/2+(gy1-gy0)/2))) im.paste(win, (gx0, gy0)) d = ImageDraw.Draw(im) # marquee d.rounded_rectangle([gx0-14, 18, gx1+14, 118], 14, fill=mixc(PLUM, GOLD, bl*0.7)) f = font(58) s = "P A C H I N K O S A I N T" d.text((SW_S/2-d.textlength(s, font=f)/2, 32), s, font=f, fill=mixc(CREAM, (255, 255, 255), bl)) # the manufacturer's plate, screened onto the marquee glass f9 = font(21, "Menlo.ttc") s9 = "P L A Y E R C O M P U T E R" d.text((SW_S/2-d.textlength(s9, font=f9)/2, 100), s9, font=f9, fill=mixc(scale(MUSTARD, .95), CREAM, 0.30+0.60*bl)) for j in range(14): # bulb chase a = 0.35+0.65*((int(t*7)+j) % 3 == 0) xx = gx0+30+j*((gx1-gx0-40)/13.0) d.ellipse([xx-9, 126, xx+9, 144], fill=mixc(scale(MUSTARD, .4), CREAM, a)) # payout trays fu = 0.0 if t > RAIN_T0: fu = min(1.0, (t-RAIN_T0)/(4.4)) tray_of_balls(d, gx0-14, SH_S-224, SW_S/2-10, SH_S-118, fu, t, seed=5) tray_of_balls(d, SW_S/2+10, SH_S-224, gx1+14, SH_S-118, fu*0.86, t, seed=9) d.rounded_rectangle([gx0-14, SH_S-108, gx1+14, SH_S-16], 16, fill=scale(LACQ, .8)) # launch handle ang = -0.9 + 0.7*math.sin(t*1.6) hx, hy = gx1+70, SH_S-160 d.ellipse([hx-46, hy-46, hx+46, hy+46], fill=mixc(BRASS, GOLD, bl)) d.ellipse([hx-30, hy-30, hx+30, hy+30], fill=CORAL) d.line([hx, hy, hx+math.cos(ang)*38, hy+math.sin(ang)*38], fill=CREAM, width=9) im = glass(im, t, 0.85) return np.asarray(im, np.float32) class Rail: """The launch: the saint in the chrome rail, waiting to be fired.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.tight = rng.random() < 0.5 def frame(self, k, u, e): i = self.i0+k; t = i/FPS im = Image.new("RGB", (SW_S, SH_S), BG2) d = ImageDraw.Draw(im) for j in range(9): d.line([(0, SH_S*0.14+j*24), (SW_S, SH_S*0.06+j*24)], fill=scale(FELT, 1.0+j*0.03), width=10) # the rail: a chrome trough sweeping up and right pts = [(SW_S*0.06+j*(SW_S*0.88/40), SH_S*0.86 - (j/40.0)**2.1*SH_S*0.62) for j in range(41)] d.line(pts, fill=scale(CHROME_D, 1.1), width=int(62 if self.tight else 46), joint="curve") d.line(pts, fill=CHROME, width=int(26 if self.tight else 20), joint="curve") d.line([(x, y-9) for x, y in pts], fill=CHROME_H, width=6, joint="curve") # the ball travelling the rail p = min(0.999, max(0.0, (t-0.55)/2.9)) p = ease_out(p) if p > 0 else 0.0 j = p*40 j0 = int(j); j1 = min(40, j0+1); fr = j-j0 bxp = lerp(pts[j0][0], pts[j1][0], fr) byp = lerp(pts[j0][1], pts[j1][1], fr)-16 cam = Cam(0, 0, 1.0) rr = 40 if self.tight else 28 for q in range(6): a = (1-q/6.0)*0.5*p d.ellipse([bxp-q*22-rr*0.7, byp-rr*0.7, bxp-q*22+rr*0.7, byp+rr*0.7], fill=mixc(BG2, CHROME, a)) d.ellipse([bxp-rr*1.1, byp-rr*0.4, bxp+rr*1.1, byp+rr*1.5], fill=scale(BG2, .6)) d.ellipse([bxp-rr, byp-rr, bxp+rr, byp+rr], fill=CHROME_D) d.ellipse([bxp-rr*.98, byp-rr*.3, bxp+rr*.98, byp+rr*.98], fill=mixc(CHROME, CHROME_H, .35)) d.ellipse([bxp-rr*.9, byp-rr*.05, bxp+rr*.9, byp+rr*.6], fill=CHROME) d.ellipse([bxp-rr*.52, byp-rr*.74, bxp-rr*.04, byp-rr*.26], fill=CHROME_H) # the handle, cranking hx, hy = SW_S*0.16, SH_S*0.80 ang = -1.4 + 2.6*min(1.0, max(0.0, (t-0.2)/1.2)) d.ellipse([hx-96, hy-96, hx+96, hy+96], fill=scale(BRASS, .8)) d.ellipse([hx-72, hy-72, hx+72, hy+72], fill=CORAL) d.ellipse([hx-30, hy-30, hx+30, hy+30], fill=mixc(BRASS, GOLD, .5)) d.line([hx, hy, hx+math.cos(ang)*82, hy+math.sin(ang)*82], fill=CREAM, width=16) saint_icon(d, SW_S*0.82, SH_S*0.24, 66, t, 0.15) im = glass(im, t, 0.6) return np.asarray(im, np.float32) class PinMacro: """Very close: two or three nails and the strike, as brass rings.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.off = float(rng.uniform(-1, 1)) self.z = float(rng.choice([3.2, 4.2, 5.4, 6.8])) self.sx, self.sy = [(0.0, 0.0), (-170.0, -110.0), (190.0, 130.0), (-120.0, 150.0)][int(rng.integers(0, 4))] def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"]; evs = S["ev"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) z = self.z*(1.0+0.05*u) cam = Cam(bx+self.sx/z, by+self.sy/z, z) im, d = field_backdrop(t, e, 0.15) field_decor(d, cam, t) r0, r1 = cam.rows() hits = recent_hits(evs, t, 0.5) hot = {(int(h[1]), int(h[2])): max(0.0, 1.0-(t-h[0])/0.5) for h in hits} for r in range(r0, r1+1): for px, py in row_pins(r)[0]: draw_pin(d, cam, px, py, glow=hot.get((int(px), int(py)), 0.0)) for (ht, px, py, imp, hx, hy, band) in hits: a = 1.0-(t-ht)/0.5 for q in range(3): rr = (RP+(1-a)*46+q*11)*cam.z X, Y = cam.px(px, py) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], outline=mixc(FELT, GOLD, a*(0.9-q*0.25)), width=max(1, int(4*a))) R = np.random.RandomState(int(ht*1000) % 9999) for q in range(9): # sparks ang = R.uniform(0, 6.28); rr = (1-a)*R.uniform(20, 90) X, Y = cam.px(px+math.cos(ang)*rr, py+math.sin(ang)*rr) d.ellipse([X-3*a, Y-3*a, X+3*a, Y+3*a], fill=mixc(FELT, GOLD, a)) draw_ball(d, cam, bx, by, halo=0.20, saint=True) if hits: ht, px, py, imp, hx, hy, band = max(hits, key=lambda z: z[3]) bar = int(ht//BAR) ps = chord_pitches(bar) uu = min(0.999, max(0.0, hx/FW)) pit = ps[int(uu*(len(ps)-1))] + transpose(bar) a = max(0.0, 1.0-(t-ht)/0.5) lab = f"{NOTE_NAMES[pit % 12]}{pit//12-1}" f = font(120) BX, BY = cam.px(bx, by) lw = d.textlength(lab, font=f) lx = (SW_S*0.70-lw/2) if BX < SW_S*0.5 else (SW_S*0.16) ly = SH_S*0.66 if BY < SH_S*0.5 else SH_S*0.16 d.text((lx, ly), lab, font=f, fill=mixc(FELT, GOLD, a*0.92)) # (final cut) the numeric `IMPACT nnn` readout under it is gone; # the note the pin struck is the picture, not the telemetry. im = glass(im, t, 0.3) return np.asarray(im, np.float32) class Wilderness: """The void band: no pins. The saint falls, and misses them.""" def __init__(self, shot, rng): self.i0 = shot.i0; self.zoom = rng.uniform(0.5, 0.8) def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) vy = float(traj[min(i, len(traj)-1), 3]) cam = Cam(lerp(FW/2, bx, .6), by-90, self.zoom*1.7) im = Image.new("RGB", (SW_S, SH_S), (7, 12, 20)) d = ImageDraw.Draw(im) R = np.random.RandomState(808) for q in range(240): # distant pins as stars sx = R.uniform(-400, FW+400); sy = R.uniform(-9000, 9000) wy = by + (sy % 3000) - 1500 X, Y = cam.px(sx, wy) if -20 < X < SW_S+20 and -20 < Y < SH_S+20: rr = R.uniform(1.4, 4.2) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], fill=scale(BRASS, R.uniform(.35, .95))) for j in range(1, 40): # long fall streak ii = max(0, i-j) tx, ty = float(traj[ii, 0]), float(traj[ii, 1]) a = (1-j/40.0)*0.6 rr = max(0.7, RB*cam.z*(1-j*0.018)) X, Y = cam.px(tx, ty) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], fill=mixc((7, 12, 20), CHROME, a)) for q in range(26): # speed lines sx = ((q*137+int(t*40)*13) % 1400)-200 X, Y0 = cam.px(sx, by-1200+((q*331+t*abs(vy)*2.2) % 2400)) d.line([X, Y0, X, Y0+140*cam.z], fill=scale(CHROME_D, .55), width=2) draw_ball(d, cam, bx, by, halo=0.34, saint=True) return np.asarray(im, np.float32) class Funnel: """The rails converge. Grace, rendered as geometry.""" MODES = ("v", "cup", "rail") def __init__(self, shot, rng, mode=None): self.i0 = shot.i0 self.mode = mode or str(rng.choice(list(Funnel.MODES))) def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"]; evs = S["ev"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) if self.mode == "v": cam = Cam(FW/2, (YT+YB)/2+40, 1.02) elif self.mode == "cup": cam = Cam(CUPX, CUPY-150, 2.5) else: cam = Cam(lerp(FW/2, bx, .8), min(max(by+40, YT), YB-120), 2.0) im, d = field_backdrop(t, e, 0.05) field_decor(d, cam, t) r0, r1 = cam.rows() hits = recent_hits(evs, t) hot = {(int(h[1]), int(h[2])): max(0.0, 1.0-(t-h[0])/0.36) for h in hits} # painted funnel guide plates behind the nails for sgn in (-1, 1): p0 = cam.px(FW/2+sgn*(FW/2-30), YT); p1 = cam.px(FW/2+sgn*70, YB) p2 = cam.px(FW/2+sgn*(FW/2+900), YB) d.polygon([p0, p1, p2, (p2[0], p0[1])], fill=scale(LACQ, .34)) d.line([p0, p1], fill=mixc(BRASS, GOLD, .5), width=max(3, int(9*cam.z))) for r in range(r0, r1+1): for px, py in row_pins(r)[0]: draw_pin(d, cam, px, py, glow=hot.get((int(px), int(py)), 0.0)) tulip(d, cam, t, open_u=min(1.0, max(0.0, (t-21.6*BAR)/(1.5*BAR))), blaze=0.0) for (ht, px, py, imp, hx, hy, band) in hits: a = 1.0-(t-ht)/0.36 rr = (RP+(1-a)*60)*cam.z X, Y = cam.px(px, py) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], outline=mixc(FELT, GOLD, a), width=3) for j in range(1, 10): ii = max(0, i-j) X, Y = cam.px(float(traj[ii, 0]), float(traj[ii, 1])) rr = RB*cam.z*(1-j*0.06) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], fill=mixc(FELT, CHROME, (1-j/10.0)*0.5)) draw_ball(d, cam, bx, by, halo=0.30, saint=True) im = glass(im, t, 0.55) return np.asarray(im, np.float32) def tulip(d, cam, t, open_u=1.0, blaze=0.0): """The catcher: two chrome petals on a lacquer cup.""" X, Y = cam.px(CUPX, CUPY) z = cam.z d.rounded_rectangle([X-96*z, Y-14*z, X+96*z, Y+64*z], int(10*z)+1, fill=mixc(scale(LACQ, .9), GOLD, blaze)) for sgn in (-1, 1): a = math.radians(-96 + 66*open_u) # closed = upright, open = out bxp, byp = X+sgn*70*z, Y-10*z L = 88*z tipx = bxp + sgn*math.cos(a)*L*-1 if False else bxp + sgn*(-math.sin(a))*L tipy = byp + math.cos(a)*L*-1 wid = 26*z d.polygon([(bxp-sgn*wid*0.6, byp+16*z), (bxp+sgn*wid*0.6, byp+16*z), (tipx+sgn*wid, tipy), (tipx-sgn*wid*0.2, tipy-wid*0.4)], fill=mixc(CHROME, GOLD, blaze*0.7)) d.line([(bxp, byp+14*z), (tipx, tipy)], fill=CHROME_H, width=max(2, int(5*z))) d.ellipse([X-26*z, Y+8*z, X+26*z, Y+52*z], fill=mixc(PLUM, GOLD, blaze)) class Jackpot: """Revelation. The lamp comes on. It is only a lamp.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.mode = str(rng.choice(["rays", "close", "rays"])) def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) bl = min(1.0, max(0.0, (t-24.0*BAR)/(0.9*BAR))) puls = 0.72+0.28*math.sin(t*9.4) z = 4.4 if self.mode == "close" else 2.55 cam = Cam(lerp(CUPX, bx, .6), by-40.0, z) im = Image.new("RGB", (SW_S, SH_S), mixc(BG2, scale(GOLD, .30), bl*0.55)) d = ImageDraw.Draw(im) X, Y = cam.px(CUPX, CUPY-30) n = 24 for j in range(n): # rotating rays a0 = j*math.tau/n + t*0.55 a1 = a0 + math.tau/(n*2.1) L = 2400*(0.7+0.3*puls) d.polygon([(X, Y), (X+math.cos(a0)*L, Y+math.sin(a0)*L), (X+math.cos(a1)*L, Y+math.sin(a1)*L)], fill=mixc(mixc(BG2, scale(GOLD, .4), bl), mixc(GOLD, CREAM, .3), bl*0.55*puls)) for r in range(*cam.rows()): for px, py in row_pins(r)[0]: draw_pin(d, cam, px, py, glow=bl*0.8*puls) tulip(d, cam, t, 1.0, blaze=bl*puls) draw_ball(d, cam, bx, by, halo=bl*1.0*puls) # the halo, arriving — bright rings, and a flare at the core BX, BY = cam.px(bx, by) for q in range(5): rr = (RB*1.62*z)*(1.5+q*0.85)*(1+0.05*math.sin(t*6+q)) d.ellipse([BX-rr, BY-rr, BX+rr, BY+rr], outline=mixc(mixc(GOLD, CREAM, .45), (255, 255, 255), bl*(0.8-q*0.14)), width=max(3, int(11-q*2))) for q in range(4): rr = (RB*1.62*z)*(0.9+q*0.16) d.ellipse([BX-rr, BY-rr*1.9-rr*0.4, BX+rr, BY-rr*0.5], outline=mixc(GOLD, CREAM, .8), width=max(2, int(6-q))) for j in range(8): # star flare a0 = j*math.tau/8 + t*0.9 L = (RB*1.62*z)*(3.4+1.2*puls) d.line([BX, BY, BX+math.cos(a0)*L, BY+math.sin(a0)*L], fill=mixc(GOLD, (255, 255, 255), .7), width=max(2, int(7-j % 3))) # the plate f = font(104) s = "J A C K P O T" plate_a = min(1.0, max(0.0, (t-24.4*BAR)/0.7)) if plate_a > 0: pw = d.textlength(s, font=f) d.rounded_rectangle([SW_S/2-pw/2-58, SH_S*0.09, SW_S/2+pw/2+58, SH_S*0.09+212], 20, fill=mixc(PLUM, LACQ, .4)) d.rounded_rectangle([SW_S/2-pw/2-44, SH_S*0.09+12, SW_S/2+pw/2+44, SH_S*0.09+200], 14, outline=mixc(scale(GOLD, .6), CREAM, plate_a), width=5) d.text((SW_S/2-pw/2, SH_S*0.09+22), s, font=f, fill=mixc(scale(GOLD, .5), CREAM, plate_a*puls)) f2 = font(38, "Menlo.ttc") s2 = "REVELATION - PAYOUT 11,460" d.text((SW_S/2-d.textlength(s2, font=f2)/2, SH_S*0.09+146), s2, font=f2, fill=mixc(scale(CREAM, .4), GOLD, plate_a)) im = glass(im, t, 0.5) return np.asarray(im, np.float32) class Rain: """The payout: thousands of saints, falling through the same field.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.mode = str(rng.choice(["wide", "wide", "close"])) def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"]; pos = S["rain"]; f0 = S["rain_f0"] by = float(traj[min(i, len(traj)-1), 1]) bx = float(traj[min(i, len(traj)-1), 0]) j = min(len(pos)-1, max(0, i-f0)) z = 1.05 if self.mode == "wide" else 2.3 cam = Cam(FW/2 if self.mode == "wide" else bx, (CUPY-220.0) if self.mode == "wide" else by-120.0, z) im, d = field_backdrop(t, e, 0.0) field_decor(d, cam, t) bl = 0.5+0.5*math.sin(t*8.0) for r in range(*cam.rows()): for px, py in row_pins(r)[0]: draw_pin(d, cam, px, py, glow=0.55*bl) P = pos[j] vis = P[(P[:, 1] > cam.cy-SH_S/(2*z)-40) & (P[:, 1] < cam.cy+SH_S/(2*z)+40)] for q in range(len(vis)): draw_ball(d, cam, float(vis[q, 0]), float(vis[q, 1])) tulip(d, cam, t, 1.0, blaze=bl*0.8) draw_ball(d, cam, bx, by, halo=0.5+0.5*bl, saint=True) for j in range(20): # rays off the tulip a0 = j*math.tau/20 + t*0.5 X, Y = cam.px(CUPX, CUPY-30) L = 900*(0.6+0.4*bl) d.line([X, Y, X+math.cos(a0)*L, Y+math.sin(a0)*L], fill=mixc(FELT, GOLD, 0.16+0.10*bl), width=max(2, int(7*z))) im = glass(im, t, 0.75) return np.asarray(im, np.float32) class Trays: """The cascade. It keeps coming. It does not stop coming.""" def __init__(self, shot, rng): self.i0 = shot.i0; self.seed = int(rng.integers(0, 9999)) self.close = bool((shot.idx // 2) % 2) def frame(self, k, u, e): i = self.i0+k; t = i/FPS im = Image.new("RGB", (SW_S, SH_S), scale(LACQ, .4)) d = ImageDraw.Draw(im) cabinet_art(d, t, 0.6) if self.close: return self._close(im, d, t, u) fu = min(1.0, max(0.0, (t-RAIN_T0)/2.4)) d.rounded_rectangle([300, 60, SW_S-300, SH_S-40], 22, fill=scale(BG2, 1.3)) # a chute of falling balls into the tray R = np.random.RandomState(self.seed) for q in range(150): ph = (t*1.5 + R.uniform(0, 1))*R.uniform(0.7, 1.6) yy = 60 + ((ph % 1.0)*(SH_S-360)) xx = 340 + R.uniform(0, SW_S-680) rr = 15 d.ellipse([xx-rr*1.1, yy-rr*.4, xx+rr*1.1, yy+rr*1.5], fill=scale(BG2, .7)) d.ellipse([xx-rr, yy-rr, xx+rr, yy+rr], fill=CHROME_D) d.ellipse([xx-rr*.96, yy-rr*.24, xx+rr*.96, yy+rr*.96], fill=mixc(CHROME, CHROME_H, .3)) d.ellipse([xx-rr*.5, yy-rr*.72, xx-rr*.06, yy-rr*.28], fill=CHROME_H) tray_of_balls(d, 316, SH_S-470, SW_S-316, SH_S-60, fu, t, seed=self.seed) f = font(46, "Menlo.ttc") s = f"{int(fu*11460):,} BALLS" d.text((SW_S/2-d.textlength(s, font=f)/2, 104), s, font=f, fill=mixc(MUSTARD, CREAM, .5)) im = glass(im, t, 0.8) return np.asarray(im, np.float32) def _close(self, im, d, t, u): """Right down in the tray: the balls are enormous and they keep coming.""" fu = min(1.0, max(0.0, (t-RAIN_T0)/2.2)) d.rounded_rectangle([180, 40, SW_S-180, SH_S-30], 26, fill=scale(CHROME_D, .5)) tray_of_balls(d, 200, 120, SW_S-200, SH_S-60, 0.35+0.65*fu, t, seed=self.seed) R = np.random.RandomState(self.seed+3) for q in range(60): ph = (t*2.2 + R.uniform(0, 1)) yy = 40 + ((ph % 1.0)*(SH_S*0.55)) xx = 240 + R.uniform(0, SW_S-480) rr = 34 d.ellipse([xx-rr*1.1, yy-rr*.3, xx+rr*1.1, yy+rr*1.4], fill=scale(BG2, .55)) d.ellipse([xx-rr, yy-rr, xx+rr, yy+rr], fill=CHROME_D) d.ellipse([xx-rr*.94, yy-rr*.99, xx+rr*.80, yy+rr*.74], fill=CHROME) d.ellipse([xx-rr*.62, yy-rr*.80, xx-rr*.06, yy-rr*.24], fill=CHROME_H) f = font(52, "Menlo.ttc") s = f"{int(fu*11460):,} BALLS" d.text((SW_S/2-d.textlength(s, font=f)/2, 96), s, font=f, fill=mixc(MUSTARD, CREAM, .6)) im = glass(im, t, 0.7) return np.asarray(im, np.float32) class Icon: """A devotional plate. Used for the cards.""" def __init__(self, shot, rng): self.i0 = shot.i0; self.blaze = 0.0 def frame(self, k, u, e): t = (self.i0+k)/FPS im = Image.new("RGB", (SW_S, SH_S), LACQ) d = ImageDraw.Draw(im) for r in range(9): rr = 130+r*96 d.ellipse([SW_S/2-rr*1.5, SH_S/2-rr, SW_S/2+rr*1.5, SH_S/2+rr], outline=scale(MUSTARD, .30+0.05*math.sin(t*1.4+r)), width=3) saint_icon(d, SW_S/2, SH_S*0.46, 168, t, 0.25+0.15*math.sin(t*3)) im = glass(im, t, 0.4) return np.asarray(im, np.float32) class Trace: """The score, being written. Pulled back far enough to see the path the saint has fallen so far — every kink in the ribbon is a note you heard.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.span = int(rng.integers(220, 420)) def frame(self, k, u, e): i = self.i0+k; t = i/FPS S = sim(); traj = S["traj"]; evs = S["ev"] bx, by = float(traj[min(i, len(traj)-1), 0]), float(traj[min(i, len(traj)-1), 1]) cam = Cam(FW/2, by-380, 0.40, xs=3.25) im, d = field_backdrop(t, e, 0.35) r0, r1 = cam.rows() for r in range(r0, r1+1): for px, py in row_pins(r)[0]: X, Y = cam.px(px, py) d.ellipse([X-2, Y-2, X+2, Y+2], fill=scale(BRASS, .55)) d.rectangle([*cam.px(0, cam.cy-4000), *cam.px(6, cam.cy+4000)], fill=scale(CHROME_D, .9)) d.rectangle([*cam.px(FW-6, cam.cy-4000), *cam.px(FW, cam.cy+4000)], fill=scale(CHROME_D, .9)) j0 = max(0, i-self.span) pts = [cam.px(float(traj[q, 0]), float(traj[q, 1])) for q in range(j0, i+1)] if len(pts) > 2: for wdt, col, a in ((13, GOLD, .18), (7, GOLD, .40), (3, CREAM, .95)): d.line(pts, fill=mixc(mixc(BG, FELT, .4), col, a), width=wdt, joint="curve") for (ht, px, py, imp, hx, hy, band) in evs: if not (t-self.span/FPS <= ht <= t): continue X, Y = cam.px(px, py) aa = 0.35+0.65*min(1.0, imp/420.0) rr = 4+7*min(1.0, imp/420.0) d.ellipse([X-rr, Y-rr, X+rr, Y+rr], fill=mixc(FELT, GOLD, aa)) draw_ball(d, cam, bx, by, halo=0.5, saint=True) # (final cut) the "DEPTH nnnn TRIALS PASSED nnn / THE PATH IS THE # MELODY" readout that used to sit along the bottom is gone — it was # the renderer narrating itself over its own best image. im = glass(im, t, 0.4) return np.asarray(im, np.float32) ENGINES = {"descent": Descent, "cabinet": Cabinet, "rail": Rail, "trace": Trace, "macro": PinMacro, "wild": Wilderness, "funnel": Funnel, "jackpot": Jackpot, "rain": Rain, "trays": Trays, "icon": Icon} # ════════════════════════════════════════════════════════════════════════════ # SHOT PLAN # ════════════════════════════════════════════════════════════════════════════ PLAN = { "rail": (["rail", "cabinet"], [4, 8]), "sparse": (["descent", "cabinet", "descent", "macro"], [4, 6, 8]), "normal": (["descent", "macro", "descent", "trace", "cabinet"], [4, 4, 6, 8]), "trials": (["descent", "macro", "descent", "trace", "descent", "icon", "cabinet"], [2, 3, 4, 6]), "wild": (["wild", "wild"], [4, 8]), "press": (["descent", "macro", "trace", "descent", "cabinet"], [2, 3, 4, 6]), "funnel": (["funnel", "funnel", "funnel"], [3, 4, 4]), "revel": (["jackpot", "cabinet", "jackpot"], [4, 6, 8]), "rain": (["rain", "trays", "rain", "cabinet"], [3, 4, 6, 8]), "settle": (["trays", "rain", "cabinet", "icon"], [6, 8, 10]), } DESCENT_MODES = ["follow", "lead", "wide", "macro", "ruler", "follow", "lead", "ruler"] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "card", "sub") def __init__(self, idx, i0, i1, engine, section, card=None, sub=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.section = engine, section self.seed = 70707 + idx*104729 self.card, self.sub = card, sub def build_shots(): R = np.random.RandomState(2718) shots = []; idx = 0; last = None; dm = 0; fm = 0 cards = {b: c for b, _txt, c in NARRATION} card_bars = sorted(cards.keys()) used_cards = set() for name, b0, b1 in SECTIONS: engs, menu = PLAN[name] t = b0*BAR; j = 0 while t < b1*BAR-1e-6: step = menu[R.randint(len(menu))]*BEAT t2 = min(t+step, b1*BAR) if (b1*BAR-t2) < BEAT*1.4: t2 = b1*BAR i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: pool = [x for x in engs if x != last] or list(engs) eng = "rail" if idx == 0 else pool[R.randint(len(pool))] last = eng sub = None if eng == "descent": sub = DESCENT_MODES[dm % len(DESCENT_MODES)]; dm += 1 if eng == "funnel": sub = Funnel.MODES[fm % len(Funnel.MODES)]; fm += 1 card = None for cb in card_bars: if cb in used_cards: continue if t/BAR <= cb < t2/BAR: card = cards[cb]; used_cards.add(cb); break shots.append(Shot(idx, i0, i1, eng, name, card, sub)) idx += 1; j += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES-shots[-1].i0 shots[-1].engine, shots[-1].sub = "icon", None # the last image settles return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> letterbox (text composited crisp, last) # ════════════════════════════════════════════════════════════════════════════ _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.42*r**2.1, 0, 1)[..., None] return _VIG["v"] def post(arr, i, e, shot): a = np.asarray(arr, np.float32) if a.shape[0] != H or a.shape[1] != W: a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((W, H), Image.LANCZOS), np.float32) # tint: warm brass in the highlights, cool lacquer in the shadows lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([-8, 0, 12], np.float32) + lum*np.array([14, 4, -12], np.float32) im = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) sm = im.resize((W//4, H//4), Image.BILINEAR).filter( ImageFilter.GaussianBlur(sf(7))).resize((W, H), Image.BILINEAR) a = np.clip(a*0.90+np.asarray(sm, np.float32)*0.17, 0, 255) a *= vignette() # grain at 720p, blown up NEAREST: the GRAIN SIZE scales with the frame rng = np.random.RandomState(52100+i) if S == 1.0: a += rng.normal(0, 1.9, a.shape) else: g = rng.normal(0, 1.9, (int(H/S), int(W/S), 3)).astype(np.float32) a += np.stack([np.asarray(Image.fromarray(g[..., c], "F") .resize((W, H), Image.NEAREST), np.float32) for c in range(3)], -1) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) if shot.card: age = i-shot.i0 life = FPS*3.4 if age < life: al = min(1.0, age/7.0)*min(1.0, (life-age)/10.0) f = font(si(40)) lines = shot.card.split("\n") bh = si(26)+len(lines)*si(50) y0 = H*0.985-bh-int(H*0.045) d.rectangle([0, y0, W, y0+bh], fill=tuple(int(c*al) for c in (14, 10, 16))) d.rectangle([0, y0, W, y0+si(3)], fill=tuple(int(c*al*0.7) for c in GOLD)) for q, ln in enumerate(lines): lw = d.textlength(ln, font=f) d.text((W/2-lw/2, y0+si(14)+q*si(50)), ln, font=f, fill=tuple(int(c*al) for c in CREAM)) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=INK); d.rectangle([0, H-bh, W, H], fill=INK) return out # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) kw = {} if shot.engine in ("descent", "funnel"): kw["mode"] = shot.sub if shot.engine == "cabinet" and shot.section in ("revel", "rain"): kw["blaze"] = 1.0 eng = ENGINES[shot.engine](shot, rng, **kw) made = 0 for k in range(shot.n): i = shot.i0+k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue arr = eng.frame(k, k/max(1, shot.n-1), e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {str(shot.sub or ''):7s} {shot.section:7s} {made}/{shot.n}" def contact_sheet(shots): cols = 6 rows = (len(shots)+cols-1)//cols tw, th = 320, 180 sheet = Image.new("RGB", (cols*tw, rows*(th+26)), (10, 10, 14)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) kw = {} if sh.engine in ("descent", "funnel"): kw["mode"] = sh.sub if sh.engine == "cabinet" and sh.section in ("revel", "rain"): kw["blaze"] = 1.0 eng = ENGINES[sh.engine](sh, rng, **kw) mid = sh.n//2 e = {kk: float(E[kk][min(sh.i0+mid, N_FRAMES-1)]) for kk in E} arr = eng.frame(mid, mid/max(1, sh.n-1), e) im = post(arr, sh.i0+mid, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+26) sheet.paste(im, (cx, cy)) sd.text((cx+5, cy+th+5), f"{sh.idx:02d} {sh.engine}{'/'+sh.sub if sh.sub else ''} · " f"{sh.section} · {sh.i0/FPS:.1f}s", font=font(13, "Menlo.ttc"), fill=(190, 195, 205)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def build_audio(verbose=True): if verbose: print(f"[1/4] sim… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") ev, traj = simulate() rain, rain_hits, rain_f0 = simulate_rain(traj) if verbose: import collections c = collections.Counter(int(x[0]) for x in ev) print(f" {len(ev)} pin strikes = {len(ev)/DUR:.2f}/s " f"rain {NRAIN} balls, {len(rain_hits)} sampled strikes") print(" strikes/s:", "".join(str(min(9, c.get(s, 0))) for s in range(int(DUR)))) np.savez_compressed(AUD/"sim.npz", traj=traj, ev=np.array(ev, dtype=object), rain=rain.astype(np.float32), rain_f0=np.array(rain_f0)) if verbose: print("[2/4] song… (collisions -> notes)") wav, mix, notes = build_song(ev, rain_hits) if verbose: print(f" {len(notes)} quantised chime notes") E = analyze(mix) np.savez(AUD/"env.npz", **E) return wav def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--sim-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if a.sim_only: ev, traj = simulate() import collections c = collections.Counter(int(x[0]) for x in ev) print(f"{len(ev)} strikes = {len(ev)/DUR:.2f}/s") print("strikes/s:", "".join(str(min(9, c.get(s, 0))) for s in range(int(DUR)))) print(f"F0={F0} YT={YT:.0f} YB={YB:.0f} CUPY={CUPY:.0f}") for b in (2, 6, 11, 17, 19, 22, 24, 27): k = min(len(traj)-1, int(b*BAR*FPS)) print(f" bar {b:3d} y={traj[k,1]:8.0f} x={traj[k,0]:6.0f} vy={traj[k,3]:7.0f}") return if not wav.exists() or not (AUD/"env.npz").exists() or not (AUD/"sim.npz").exists() \ or a.force: wav = build_audio() if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[3/4] frames… {len(jobs)} shots / {N_FRAMES} frames on {a.jobs} workers") import multiprocessing as mp with mp.get_context("fork").Pool(a.jobs) as pool: for r in pool.imap_unordered(render_shot, jobs): print(" ", r) print("[4/4] mux…") out = OUT/f"{NAME}.mp4" subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"generator=renders/{SETDIR}/{NAME}/render.py", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", "-metadata", f"comment={MUSIC_DESC}; {ENGINE_DESC}", str(out)], check=True, capture_output=True) try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel)\n" f"voice: macOS say -v Daniel -r 148 (hushed hagiography)\n" f"determinism: seeded RandomState/default_rng only; no hash(); " f"sim is a fixed-timestep integrator\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()