#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Tuva Transit (14/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/tuva_transit # # A night drive across the steppe with nothing in it but light trails. # # 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/tuva_transit.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/tuva_transit.mp4 # cover: https://genekogan.com/player_computer/media/tuva_transit.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 tuva_transit.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) # ═════════════════════════════════════════════════════════════════════════════ """ player_computer_2 / B — "TUVA TRANSIT" (round 2: the steppe is populated) Tuvan throat singing (kargyraa growl + khoomei overtone whistle) over motorik krautrock, 140bpm, A. 38 bars, instrumental-but-sung. night(4) -> motorik(8) -> steppe(6) -> mirage(8) -> horizon(4) -> sunrise(8) A driver crossing the steppe through the night. The horizon never arrives, mile markers blur, shapes stand at the roadside that are not there. Then the sun comes up and the trails resolve into an ordinary empty road. LOOK — long-exposure light painting. There are no objects in this film. There is a float32 light-accumulation buffer, and there are moving emitters that deposit energy into it over simulated exposure time. Everything you see is a TRAIL: headlights smeared into ribbons, star arcs from a wheeling sky, mile numerals dragged into ribbons of their own. The exposure constant tau breathes with the music — long tau at the hypnotic middle, and at sunrise tau collapses toward zero, which is the only reason the world "resolves": short exposure, not new geometry. Same substrate all the way through. Composition: engine : audio-first x shot-parallel (tier 4-P; the exposure buffer is recursive within a shot and thrown away at every cut) content: audio-groove (motorik kit, bass ostinato, source-filter throat singing) x effects-post Run from repo root: python3 renders/player_computer_2/tuva_transit/render.py --sheet python3 renders/player_computer_2/tuva_transit/render.py """ import argparse, datetime, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont NAME = "tuva_transit" TITLE = "TUVA TRANSIT" SETDIR = "player_computer_final" SETNUM = "B5" W, H, FPS = 1920, 1080, 30 # final cut: native 1080p. One global S multiplies every pixel-space quantity. # Three things scale here and they are not the same thing: # * the FRAME and the PROJECTION (W, H, WREF) — the road keeps its scale; # * SAMPLE COUNTS along anything that projects to a screen LINE (the z-ramp, # a pole, an outline) — a 1.5x longer line needs 1.5x the samples or it # comes out dotted; # * the FILM SPEED. A moving emitter crosses 1.5x more pixels in the same # time, so flux-per-pixel drops by S. EXPO *= S restores it exactly, and # leaves the exposure-normalised emitters (`unit`, `ambient`) untouched, # because their 1/EXPO cancels against develop()'s gain. S = H / 720.0 # 1.5 def ns(n): return max(2, int(round(n*S))) # scaled sample count def si(v): return int(round(v*S)) def sf(v): return v*S WREF = si(1120) # the width this was framed at; the road keeps its scale # and 16:9 simply shows more steppe on either side BPM = 140.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 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 = [ ("night", 0, 4), ("motorik", 4, 12), ("steppe", 12, 18), ("mirage", 18, 26), ("horizon", 26, 30), ("sunrise", 30, 38), ] N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 2.6 N_FRAMES = int(DUR * FPS) MUSIC_DESC = (f"Tuvan throat singing (kargyraa + khoomei overtone whistle) over " f"motorik krautrock, {BPM:.0f}bpm, A minor -> A major, {N_BARS} bars") ENGINE_DESC = "long-exposure light accumulation: road / sky / lamps / posts / dash / ghost / above / hold" # ════════════════════════════════════════════════════════════════════════════ # 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 nf(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return mtof(12 * (int(name[i:]) + 1) + _PC[name[:i]]) 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): """Exact FFT band shaping. Every noise source goes through this so nothing in the kit is a raw full-band blast (AESTHETIC 13a).""" 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=24, c0=4200, c1=700, ck=8.0, res=0.0, detune=(0.0,), a=.005, d=.09, s=.7, r=.10, vib=(0.0, 0.0), seed=0): """Additive voice through a *moving* emulated filter (cutoff array).""" 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); vd, vr = vib for det in detune: f0 = freq * (1 + det * 0.006) for k in range(1, nh + 1): if kind == "saw": base = 1.0 / k elif kind == "square": base = (1.0 / k) if k % 2 else 0.0 elif kind == "tri": base = (1.0 / (k*k)) if k % 2 else 0.0 elif kind == "sine": base = 1.0 if k == 1 else 0.0 else: base = 1.0 / k if base == 0.0: continue fk = f0 * k if fk > SR * 0.45: break g = base / np.sqrt(1.0 + (fk / co) ** 4) if res: g = g + res * base * np.exp(-((fk - co) / (0.3 * co + 1)) ** 2) phase = 2*np.pi*fk*t + rng.uniform(0, 2*np.pi) if vd: phase = phase + vd * np.sin(2*np.pi*vr*t) out += g * np.sin(phase) out /= len(detune) return out * adsr(n, a, d, s, r) def kick(dur=.34, f0=150, f1=44, punch=26, click=.42, seed=1): n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*punch) body = np.sin(2*np.pi*np.cumsum(f)/SR) * np.exp(-t*9.0) ck = np.random.RandomState(seed).randn(n) * np.exp(-t*320) * click return np.tanh((body + ck) * 1.6) * .95 def snare(dur=.20, tone=196, bright=1.0, seed=2): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=300, hi=6800) body = np.sin(2*np.pi*tone*t) + .55*np.sin(2*np.pi*tone*1.58*t) return nz*np.exp(-t*21)*.80*bright + body*np.exp(-t*28)*.45 def hat(dur=.05, openh=False, seed=7): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=6000, hi=10500) return nz * np.exp(-t*(13 if openh else 92)) * .40 def tom(dur=.30, f0=210, f1=95, seed=11): n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*11) nz = bandshape(np.random.RandomState(seed).randn(n), lo=180, hi=2400) return (np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*7.5) + nz*np.exp(-t*30)*.30) * .8 def shaker(dur=.075, seed=5): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=4200, hi=9200) return nz * (np.exp(-t*44) * np.clip(t*300, 0, 1)) * .40 def cymbal(dur=2.2, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1200, hi=8600) return nz * (np.exp(-t*2.1) + .28*np.exp(-t*.5)) * .52 def engine_hum(dur, rpm=44.0, seed=101, bright=1.0): """The car itself. A low buzzy drone with a couple of orders above it and a band of road noise on top — never a static full-band hiss.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) wob = 1.0 + 0.010*np.sin(2*np.pi*0.13*t) + 0.006*np.sin(2*np.pi*0.41*t) f = rpm*wob ph = 2*np.pi*np.cumsum(f)/SR y = np.zeros(n) for k, g in ((1, 1.0), (2, .52), (3, .30), (4, .16), (6, .09), (8, .05)): y += g*np.sin(ph*k + k*0.7) road = bandshape(rng.randn(n), lo=220*bright, hi=1500*bright) return (y*0.55 + road*0.30*bright) * 0.5 def wind(dur, seed=203, lo=380, hi=2600): n = int(dur*SR) rng = np.random.RandomState(seed) x = bandshape(rng.randn(n), lo=lo, hi=hi) t = np.arange(n)/SR swell = 0.55 + 0.45*np.sin(2*np.pi*0.07*t + 1.1) return x*swell*0.5 def reverb(x, rt=2.0, mix=.3, seed=29, pre=0.02): n = int(rt*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) ir = rng.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=.42, mix=.28, taps=9): d = int(time*SR); out = x.copy() for i in range(1, taps+1): g = mix * (fb ** i); s = d*i if s >= len(x): break out[s:] += x[:len(x)-s] * g return out # ════════════════════════════════════════════════════════════════════════════ # THROAT SINGING — source/filter synthesis, built additively # # The whole centerpiece. A glottal source with a full harmonic series (plus # half-integer harmonics: the ventricular folds of *kargyraa* vibrate at f0/2, # which is what makes the growl sound an octave below the pitch), run through a # vocal-tract filter of two broad formants — and then one extremely narrow, # very high-gain resonance that is SWEPT along the harmonic series to pick out # one partial at a time. That swept peak is the whistle. Because it can only # land on real harmonics of f0, the melody it plays is the natural overtone # scale — h6=E5, h7 (septimal, the "sour" one Tuvans keep), h8=A5, h9=B5, # h10=C#6 (a *just* major third), h12=E6, h16=A6. That constraint is not a # limitation, it is the sound. # # Everything is computed at control rate (hop 128) and interpolated, so a # 14-second phrase with 50 partials is a couple of seconds of numpy. # ════════════════════════════════════════════════════════════════════════════ HOP = 128 def _formants(f, peaks, floor=0.22): g = np.full_like(f, floor) for fc, q, a in peaks: g = g + a / (1.0 + ((f - fc) / (fc / q)) ** 2) return g def throat(dur, f0=110.0, mel=None, whistle=0.0, kargy=0.55, amp=1.0, nh=34, nsub=16, bw=0.55, seed=0, gliss=0.075, attack=0.30, release=0.55, breath=0.030, vib=(0.010, 4.9)): """`mel` = [(harmonic_number, beats), …] — the overtone melody.""" n = int(dur*SR) if n <= 0: return np.zeros(0) m = n // HOP + 2 tc = np.arange(m) * HOP / SR ts = np.arange(n) / SR rng = np.random.RandomState(seed) # --- source: f0 with human drift + vibrato ------------------------------ drift = (1.0 + 0.0045*np.sin(2*np.pi*0.19*tc + seed*0.7) + 0.0026*np.sin(2*np.pi*1.37*tc + seed*1.9) + vib[0]*np.sin(2*np.pi*vib[1]*tc)) f0s = np.interp(ts, tc, f0*drift) ph = 2*np.pi*np.cumsum(f0s)/SR # --- the swept resonance track (harmonic-number space) ------------------ hc = np.zeros(m); artic = np.ones(m) if mel: tot = sum(b for _, b in mel) or 1.0 at = 0.0 for (hh, b) in mel: ln = dur * b / tot i0, i1 = int(at/dur*m), min(m, int((at+ln)/dur*m)) if i1 > i0: hc[i0:i1] = hh # a small re-articulation dip at each note onset k = min(i1-i0, max(2, int(0.020*SR/HOP))) artic[i0:i0+k] = np.linspace(0.35, 1.0, k) at += ln hc[hc == 0] = mel[0][0] kk = max(3, int(gliss*SR/HOP)) hc = np.convolve(hc, np.ones(kk)/kk, "same") hc[:kk] = hc[kk]; hc[-kk:] = hc[-kk-1] else: hc[:] = 8.0 # --- amplitude envelope ------------------------------------------------- env = np.ones(m) ai, ri = max(2, int(attack*SR/HOP)), max(2, int(release*SR/HOP)) ai = min(ai, m//2); ri = min(ri, m//2) env[:ai] = np.linspace(0, 1, ai); env[-ri:] = np.linspace(1, 0, ri) env = env * (0.90 + 0.10*np.sin(2*np.pi*0.29*tc)) env_s = np.interp(ts, tc, env) PEAKS = [(300.0, 6.0, 1.00), (760.0, 3.6, 0.44), (2500.0, 4.5, 0.20)] wamp = np.interp(ts, tc, artic) * env_s hc_s = np.interp(ts, tc, hc) out = np.zeros(n) # integer harmonics — the buzz, plus the swept whistle riding on it for k in range(1, nh+1): fk = k*f0 if fk > SR*0.45: break src = (1.0/(k**0.72)) * float(_formants(np.array([fk]), PEAKS)[0]) wg = whistle * np.exp(-((hc_s - k)/bw)**2) if whistle else 0.0 g = src + wg * (0.55 + 0.45*np.exp(-((k-10.0)/9.0)**2)) if np.max(g) < 0.0016: continue out += np.sin(ph*k + rng.uniform(0, 2*np.pi)*0.0) * g # half-integer harmonics — kargyraa's subharmonic growl if kargy > 0: for k in range(1, nsub+1): hk = k - 0.5 fk = hk*f0 src = (1.0/(hk**0.60)) * float(_formants(np.array([fk]), PEAKS)[0]) g = kargy * src if g < 0.0016: continue out += np.sin(ph*hk) * g out *= env_s # breath: a narrow noise band riding the whistle, so it sounds blown if breath: nz = bandshape(rng.randn(n), lo=f0*np.mean(hc)*0.72, hi=f0*np.mean(hc)*1.45) out += nz * wamp * breath * (0.4 + 0.6*whistle) return out / (np.max(np.abs(out)) + 1e-9) * amp # ════════════════════════════════════════════════════════════════════════════ # SONG # ════════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0): return bar*BAR + step*(BEAT/4) def put(self, track, sig, at, g=1.0, pan=0.0): 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.40): env = np.ones(self.n) for nm, 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(nm, 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=.26, 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]) env = np.convolve(env, np.ones(300)/300, "same") mix *= env[:, None] a = math.exp(-2*math.pi*28.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 mix = np.tanh(mix*1.20)/np.tanh(1.20) 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(" D -> A -> E: it opens up } def build_song(): s = Song(DUR) R = np.random.RandomState(1408) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "sunrise" for bar in range(N_BARS): sec = sec_of(bar) drive = sec in ("motorik", "steppe", "mirage") last2 = bar >= N_BARS - 2 # ---- motorik kit: relentless straight eighths, no swing at all ----- if drive or (sec == "sunrise" and not last2): g = .92 if drive else .58 for st in range(0, 16, 2): jit = (R.rand()-0.5)*0.0035 # micro-timing only at = s.t(bar, st) + jit v = 1.0 if st % 4 == 0 else .84 s.put("drums", kick(dur=.30 if st % 4 == 0 else .24, f0=152, f1=45, punch=28)*v, at, g=g) if st % 4 == 0: s.kick_t.append(at) for st in (4, 12): s.put("drums", snare(dur=.19, tone=205, bright=1.05 if drive else .8), s.t(bar, st), g=.62 if drive else .38, pan=-.05) for st in range(16): # relentless 16th shimmer v = .24 if st % 4 == 0 else (.15 if st % 2 == 0 else .095) s.put("drums", hat(dur=.036, openh=(st == 14)), s.t(bar, st) + (R.rand()-.5)*0.003, g=v*(0.85+0.3*R.rand()), pan=-.30+.6*R.rand()) for st in (1, 5, 9, 13): s.put("drums", shaker(), s.t(bar, st+2), g=.13, pan=.34) if sec == "mirage": for st in (7, 11, 15): s.put("drums", snare(dur=.08, tone=250, bright=.6), s.t(bar, st), g=.16, pan=.22) if bar % 8 == 7 and drive: # Liebezeit tom fill for j, st in enumerate((8, 10, 12, 13, 14, 15)): s.put("drums", tom(dur=.26, f0=250-j*22, f1=110-j*7, seed=11+j), s.t(bar, st), g=.30+.05*j, pan=-.4+.16*j) elif sec == "night" and bar >= 2: for st in (0, 8): at = s.t(bar, st) s.put("drums", kick(dur=.36, f0=140, f1=42, punch=20), at, g=.44) s.kick_t.append(at) elif sec == "horizon": at = s.t(bar, 0) s.put("drums", kick(dur=.42, f0=136, f1=40, punch=16), at, g=.52) s.kick_t.append(at) s.put("drums", tom(dur=.5, f0=190, f1=86), s.t(bar, 10), g=.20, pan=.3) # ---- bass ostinato ------------------------------------------------- pat = OSTI[sec] for j, iv in enumerate(pat): if iv is None: continue if last2 and j % 4 != 0: continue at = s.t(bar, j*2) ln = BEAT*(0.46 if drive else 1.4) s.put("bass", voice(BASSA*2**(iv/12.0), ln, kind="saw", nh=16, c0=760+260*(1 if drive else 0), c1=190, ck=9.0, res=.45, detune=(-0.9, 0.0, 0.9), a=.004, d=.10, s=.62, r=.07, seed=bar*17+j), at, g=.30 if drive else .22) s.put("sub", voice(BASSA*2**(iv/12.0), ln*1.25, kind="sine", nh=2, c0=180, c1=90, ck=4, a=.006, d=.15, s=.85, r=.10, seed=bar*5+j), at, g=.30 if drive else .20) # ---- igil drone (bowed horse-head fiddle) -------------------------- if sec in ("night", "steppe", "horizon", "sunrise"): iv = 0 if sec != "sunrise" else (0 if bar % 4 < 2 else 5) s.put("igil", voice(F0V*2**(iv/12.0)/2, BAR*1.2, kind="saw", nh=22, c0=1500, c1=560, ck=.8, detune=(-1.6, 0, 1.7), a=.7, d=.7, s=.72, r=.9, vib=(.010, 5.1), seed=bar*29), s.t(bar, 0), g=.070, pan=-.32) # ---- shimmer: 16th arpeggio through tape echo ---------------------- if sec in ("steppe", "mirage", "sunrise"): SCL = ([0, 3, 5, 7, 10] if sec != "sunrise" else [0, 4, 7, 9, 12]) for st in range(0, 16, 2): iv = SCL[(bar*3 + st//2) % len(SCL)] oc = 4 if (st//2) % 3 else 8 s.put("shim", voice(F0V*oc/2*2**(iv/12.0), BEAT*.30, kind="tri", nh=9, c0=5200, c1=2000, ck=16, res=.4, a=.002, d=.05, s=.16, r=.06, seed=bar*7+st), s.t(bar, st), g=.055, pan=-.45+.9*((st//2) % 2)) # ---- texture ------------------------------------------------------- if bar % 8 == 0 and sec in ("motorik", "mirage", "sunrise"): s.put("fx", cymbal(dur=2.4, seed=13+bar), s.t(bar, 0), g=.16, pan=.12) # ---- the car, continuous under everything ------------------------------ s.put("hum", engine_hum(DUR, rpm=42.0, seed=101), 0.0, g=.30, pan=-.10) s.put("hum", engine_hum(DUR, rpm=42.0*1.005, seed=137), 0.0, g=.24, pan=.14) s.put("hum", wind(DUR, seed=203), 0.0, g=.075, pan=.0) # ════════════════════════════════════════════════════════════════════ # THE VOICE # ════════════════════════════════════════════════════════════════════ def vput(bar0, bars, **kw): sig = throat(bars*BAR, f0=F0V, **kw) s.put("throat", sig, bar0*BAR, g=kw.pop("g", 1.0)) return sig # night — the growl arrives from far off, no whistle yet s.put("throat", throat(2*BAR, f0=F0V, mel=None, whistle=0.0, kargy=0.85, amp=.55, attack=1.1, release=1.0, breath=.02, seed=3), 2*BAR, g=.34, pan=-.06) # motorik — kargyraa locks to the drone, the whistle only hints s.put("throat", throat(8*BAR, f0=F0V, mel=[(8, 1)], whistle=0.20, kargy=1.0, amp=.85, attack=.9, release=1.2, breath=.025, seed=5), 4*BAR, g=.46, pan=.04) # steppe — the overtone melody proper s.put("throat", throat(6*BAR, f0=F0V, mel=MEL_STEPPE, whistle=1.05, kargy=.62, amp=1.0, attack=.35, release=.8, breath=.035, seed=7), 12*BAR, g=.60, pan=-.03) # mirage — higher, faster, more agitated, growl back underneath s.put("throat", throat(8*BAR, f0=F0V, mel=MEL_MIRAGE, whistle=1.25, kargy=.80, amp=1.0, attack=.20, release=.6, breath=.045, seed=11), 18*BAR, g=.64, pan=.05) # horizon — alone, almost nothing else playing s.put("throat", throat(4*BAR, f0=F0V, mel=MEL_HORIZ, whistle=1.15, kargy=.45, amp=1.0, attack=.8, release=1.4, breath=.030, seed=13), 26*BAR, g=.58, pan=.0) # sunrise — resolving onto h10 (the just major third) and home to h8 s.put("throat", throat(8*BAR, f0=F0V, mel=MEL_SUN, whistle=1.0, kargy=.34, amp=1.0, attack=.9, release=2.6, breath=.028, seed=17), 30*BAR, g=.52, pan=.0) s.bus("throat", lambda x: reverb(delay(x, BEAT*.75, .30, .16), rt=3.2, mix=.34, seed=421)) s.bus("igil", lambda x: reverb(x, rt=4.0, mix=.55, seed=431)) s.bus("shim", lambda x: reverb(delay(x, BEAT*.75, .46, .34), rt=2.8, mix=.42, seed=433)) s.bus("fx", lambda x: reverb(x, rt=3.4, mix=.42, seed=439)) s.bus("hum", lambda x: x*1.0) mix = s.mixdown(dict(drums=1.0, bass=1.0, sub=1.0, igil=1.0, shim=1.0, fx=1.0, hum=1.0, throat=1.0), pump_depth=.24, pump_rel=.12, levels=dict(night=.46, motorik=.95, steppe=1.0, mirage=1.0, horizon=.72, sunrise=.86)) wav = AUD / "final.wav" s.write(wav, mix) return wav, mix def analyze(mix): x = mix.mean(1) hop = SR / FPS; win = int(hop*1.7) keys = ("rms", "low", "mid", "high", "voice") E = {k: np.zeros(N_FRAMES) for k in keys} 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 < 170].sum() E["mid"][f] = sp[(fr >= 170) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].sum() # the overtone whistle lives here — it drives the brightest light E["voice"][f] = sp[(fr >= 600) & (fr < 1900)].sum() for k in E: p = np.percentile(E[k], 96) + 1e-9 E[k] = np.clip(E[k]/p, 0, 1.25) lo = E["low"] flux = np.maximum(0, lo - np.concatenate([[0], lo[:-1]])) E["kick"] = np.clip(np.convolve(flux, [.25, .5, .25], "same") / (np.percentile(flux, 97)+1e-9), 0, 1) np.savez(AUD/"env.npz", **E) 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 # ════════════════════════════════════════════════════════════════════════════ # THE SUBSTRATE — a light-accumulation buffer # # There is no "draw a thing" call anywhere below this line. There is `deposit`, # which adds energy at a set of points, and there is time. A frame is what the # buffer has accumulated since it last decayed away — decay constant tau, which # IS the exposure length. Long tau: ribbons. Short tau: objects. # ════════════════════════════════════════════════════════════════════════════ def _kernel(rad, sigma): o = np.arange(-rad, rad+1) yy, xx = np.meshgrid(o, o, indexing="ij") w = np.exp(-(yy**2 + xx**2)/(2*sigma**2)) w /= w.sum() return yy.ravel().astype(np.int64), xx.ravel().astype(np.int64), w.ravel() def _mkkern(r): """The deposit footprint scales with the frame: a 1-px splat at 720p is a 1.5-px splat at 1080p, so nothing gets thinner. The kernel is normalised, so widening it never changes how much energy a point deposits.""" if r <= 0: return _kernel(0 if S < 1.4 else 1, 0.50*S) return _kernel(max(1, int(round(r*S))), max(0.62, r*0.60)*S) KERNELS = {r: _mkkern(r) for r in (0, 1, 2, 3, 4)} def deposit(buf, x, y, col, w, rad=1): """Add energy at N points. col is (3,) or (N,3); w is (N,). No geometry, no edges, no fill — only accumulated light.""" if len(x) == 0: return ky, kx, kw = KERNELS[rad] Hh, Ww = buf.shape[0], buf.shape[1] xi = np.rint(x).astype(np.int64); yi = np.rint(y).astype(np.int64) N, K = len(xi), len(kw) yy = (yi[None, :] + ky[:, None]).ravel() xx = (xi[None, :] + kx[:, None]).ravel() ww = (w[None, :] * kw[:, None]).ravel() cc = np.repeat(col[None, :], N*K, 0) if col.ndim == 1 else np.tile(col, (K, 1)) m = (yy >= 0) & (yy < Hh) & (xx >= 0) & (xx < Ww) & (ww > 1e-7) if not m.any(): return flat = yy[m]*Ww + xx[m] v = cc[m] * ww[m][:, None] fb = buf.reshape(-1, 3) nb = Hh*Ww for c in range(3): fb[:, c] += np.bincount(flat, weights=v[:, c], minlength=nb) def _box(a, r, axis): if r < 1: return a a = np.moveaxis(a, axis, 0) p = np.concatenate([np.repeat(a[:1], r, 0), a, np.repeat(a[-1:], r, 0)], 0) c = np.cumsum(p, 0) c = np.concatenate([np.zeros_like(c[:1]), c], 0) k = 2*r+1 out = (c[k:] - c[:-k]) / k return np.moveaxis(out, 0, axis) def blur(a, r, passes=2): for _ in range(passes): a = _box(_box(a, r, 0), r, 1) return a # Exposure gain. A moving emitter only illuminates a given pixel for the # instant it is over it, so raw deposited energy per pixel is ~flux/velocity — # tiny. EXPO is the film speed that turns that into a picture. Area emitters # (`ambient`) are specified in final linear-light units and divided back out, # so the sky still brightens with longer exposure but doesn't blow out. EXPO = 34.0 * S SMALL = (W//4, H//4) # 280 x 180 bloom scratch def develop(buf, gain=1.0, bloom=0.85, chroma=(6, 4, 3), halo=0.30): """Tone-map the accumulator into a picture. Per-channel bloom radii — a real lens is not achromatic, and red haloes wider than blue.""" chroma = tuple(max(1, int(round(c*S))) for c in chroma) lin = buf * gain sm = np.asarray(Image.fromarray(np.clip(lin*40, 0, 255).astype(np.uint8)) .resize(SMALL, Image.BILINEAR), np.float32)/40.0 bl = np.empty_like(sm) for c in range(3): bl[..., c] = blur(sm[..., c], chroma[c], passes=2) big = np.asarray(Image.fromarray(np.clip(bl*40, 0, 255).astype(np.uint8)) .resize((W, H), Image.BILINEAR), np.float32)/40.0 wide = np.empty_like(sm) for c in range(3): wide[..., c] = blur(sm[..., c], chroma[c]*4, passes=2) big2 = np.asarray(Image.fromarray(np.clip(wide*40, 0, 255).astype(np.uint8)) .resize((W, H), Image.BILINEAR), np.float32)/40.0 lin = lin + big*bloom + big2*halo return (1.0 - np.exp(-lin)) * 255.0 # ════════════════════════════════════════════════════════════════════════════ # ENGINES — every one of them is an emitter rig, never a renderer # ════════════════════════════════════════════════════════════════════════════ C_HEAD = np.array([0.72, 0.80, 1.00], np.float32) C_TAIL = np.array([1.00, 0.10, 0.06], np.float32) C_SODI = np.array([1.00, 0.52, 0.13], np.float32) C_STAR = np.array([0.72, 0.78, 1.00], np.float32) C_MARK = np.array([0.95, 0.92, 0.62], np.float32) C_GHOST = np.array([0.28, 1.00, 0.72], np.float32) C_DASH = np.array([1.00, 0.34, 0.10], np.float32) C_SUN = np.array([1.00, 0.62, 0.24], np.float32) HZ = H*0.420 # default horizon; each shot may move it FOC = 0.72 # default focal length EYE = 1.20 # camera height above the road, metres # ── the cyber traffic ─────────────────────────────────────────────────────── # Somewhere past the second lamp the traffic stops being lorries. These cars # are the only things on the steppe that were BUILT to be light-painted: a low # wedge whose entire surface treatment is edge-lighting, so what the exposure # records is not a car with lights on it but a moving wire drawing of one — # rocker line, beltline, roof spine, a lateral bar at each end, and two wheel # arches burning through. Every one of them is a set of world-space polylines # fed through the same projection as the poles; nothing new is drawn. # # They pass ON THE GRID. Each car's pass time is quantised to the beat, so the # whole lane is playing the motorik pulse: at 140 bpm a car crosses the frame # every two beats, and the exposure hands you the interval as a ribbon. C_CYAN = np.array([0.16, 0.94, 1.00], np.float32) C_MAGE = np.array([1.00, 0.14, 0.72], np.float32) C_ICE = np.array([0.86, 0.96, 1.00], np.float32) C_LIME = np.array([0.52, 1.00, 0.40], np.float32) CYBER_PALETTE = [(C_CYAN, C_MAGE), (C_MAGE, C_CYAN), (C_ICE, C_MAGE), (C_LIME, C_CYAN), (C_CYAN, C_ICE)] CAR_L = 4.70 # metres, nose to tail def _car_edges(): """Car-local ribbon geometry, built once. Returns a list of (dz, lat, drop) polylines — dz metres from the car's centre toward the tail, lat metres from its centreline, drop metres above the road.""" u = np.linspace(0.0, 1.0, ns(46)) # 0 = nose, 1 = tail dz = (u - 0.5)*CAR_L hw = 0.96*(0.60 + 0.40*np.sin(np.pi*u**0.92)) # half width rock = np.full_like(u, 0.28) # rocker line belt = 0.72 + 0.36*np.exp(-((u - 0.56)/0.26)**2) # beltline / cabin E = [] for sgn in (-1.0, 1.0): E.append(("edge", dz, sgn*hw, rock, 1.00)) E.append(("hot", dz, sgn*hw*0.92, belt, 1.00)) cab = (u > 0.36) & (u < 0.84) E.append(("edge", dz[cab], np.zeros(cab.sum()), belt[cab] + 0.11, 0.62)) # lateral bars: the nose bar and the tail bar q = np.linspace(-0.80, 0.80, ns(16)) E.append(("nose", np.full(len(q), -CAR_L*0.49), q, np.full(len(q), 0.66), 1.0)) E.append(("tail", np.full(len(q), CAR_L*0.49), q, np.full(len(q), 0.74), 1.0)) # wheel arches — a half-circle standing in the (dz, height) plane th = np.linspace(0.10*np.pi, 0.90*np.pi, ns(18)) for sgn in (-1.0, 1.0): for zc, tag in ((-1.42, "nose"), (1.44, "tail")): E.append((tag, zc - np.cos(th)*0.36, np.full(len(th), sgn*0.97), 0.34 + np.sin(th)*0.34, 0.85)) return E CAR_EDGES = _car_edges() class LightEngine: RAD = 1 SUB = 12 EG = 1.0 # per-engine film speed: emitters differ by orders of # magnitude in how long they dwell on a given pixel def __init__(self, shot, rng): self.s, self.rng, self.p = shot, rng, shot.params self.buf = np.zeros((H, W, 3), np.float32) self.gain = float(self.p.get("gain", 1.0)) self.tau0 = float(self.p.get("tau", 0.60)) self.cam = Cam(curv=float(self.p.get("curv", rng.uniform(-2.6, 2.6))), vxo=float(self.p.get("vxo", 0.0)), hz=float(self.p.get("hz", 0.42)), foc=float(self.p.get("foc", 0.72)), hump=float(self.p.get("hump", rng.uniform(-0.5, 0.5)))) # An emitter that barely moves in screen space accumulates for the whole # exposure, so it must be specified in FINAL linear-light units and have # the film speed divided back out — same convention as `ambient`. # Fast-moving emitters stay in flux units, where brightness = flux/speed. self.unit = 1.0/(EXPO*self.EG*max(1e-6, self.gain)) self._tau = self.tau0 self.setup() def setup(self): pass def tau(self, k, u, e): """Exposure length. This breathes with the music — that is the whole instrument. Bass energy shortens it (a hard hit reads as a snapshot), the overtone whistle lengthens it (the voice smears the world).""" return self.tau0 * (0.72 + 0.80*e["voice"]) / (0.82 + 0.70*e["kick"]) def emit(self, tt, e): return None def ambient(self, k, u, e): return None def _step(self, k, e, dt): S = self.SUB tt = (k + np.arange(S)/S)/FPS # shot-local: a cut is a new frame got = self.emit(tt, e) if got is not None: x, y, col, w = got deposit(self.buf, x, y, col, w*(dt/S), rad=self.RAD) def frame(self, k, u, e): dt = 1.0/FPS self._tau = max(0.035, self.tau(k, u, e)) if k == 0: # A cut opens the shutter on an empty film. Without a pre-roll the # first second of every long exposure is an under-formed picture and # the cut lands on nothing — so run the exposure backwards from # before the cut and hand the first frame a film that is already # holding an image. pre = min(int(self._tau*FPS*1.15), 46) for j in range(-pre, 0): self.buf *= math.exp(-dt/self._tau) self._step(j, e, dt) amb0 = self.ambient(0, 0.0, e) if amb0 is not None: self.buf += amb0*(dt/(EXPO*self.EG*max(1e-6, self.gain))) self.buf *= math.exp(-dt/self._tau) self._step(k, e, dt) amb = self.ambient(k, u, e) if amb is not None: # area emitters are specified in FINAL linear-light units, so the # engine's film speed is divided back out: the sky sits where it is # told to sit and still brightens with a longer exposure self.buf += amb*(dt/(EXPO*self.EG*max(1e-6, self.gain))) return develop(self.buf, self.gain*EXPO*self.EG, bloom=float(self.p.get("bloom", 0.85)), chroma=self.p.get("chroma", (6, 4, 3)), halo=float(self.p.get("halo", 0.30))) # --------------------------------------------------------------------------- # projection — one pseudo-3D road shared by every engine that stands on it # --------------------------------------------------------------------------- class Cam: """Where the camera is. Framing is the cheapest variety there is, so every shot gets its own horizon height, focal length and curvature.""" __slots__ = ("curv", "vx", "hz", "foc", "hump") def __init__(self, curv=0.0, vxo=0.0, hz=0.42, foc=0.72, hump=0.0): self.curv, self.vx = curv, W*(0.5 + vxo) self.hz, self.foc, self.hump = H*hz, foc, hump def _road_x(z, lat, cam): """Lateral world offset / z, plus a curvature term that grows linearly on screen — that term is what bends the ribbons.""" return (cam.vx + cam.foc*WREF*(lat/np.maximum(z, 0.34)) + cam.curv*z*WREF*0.0016) def _road_y(z, hgt, cam): return (cam.hz + cam.foc*H*(hgt/np.maximum(z, 0.34)) + cam.hump*z*H*0.0006) # z sampled uniformly in 1/z, i.e. uniformly in screen height — the only way a # receding line comes out continuous instead of dotted near the camera ZINV = np.linspace(1.0/104.0, 1.0/1.05, ns(520)) ZLIN = 1.0/ZINV WHITE_LINE = np.array([1.00, 0.95, 0.82], np.float32) ASPHALT = np.array([0.62, 0.60, 0.66], np.float32) def _push(acc, z, lat, hgt, cam, col, flux, zmax=150.0, falloff=1.75): X, Y, C, Wt = acc z = np.maximum(np.asarray(z, np.float32), 0.34) x = _road_x(z, lat, cam); y = _road_y(z, hgt, cam) keep = ((x > -1000) & (x < W+1000) & (y > cam.hz-460) & (y < H+800) & (z < zmax) & (z > 0.55)) if not keep.any(): return n = int(keep.sum()) X.append(x[keep]); Y.append(y[keep]) C.append(np.repeat(col[None, :], n, 0) if col.ndim == 1 else col[keep]) f = flux[keep] if np.ndim(flux) else flux Wt.append(np.clip(f*((1.0/z[keep])**falloff), 0, 900.0)) def _surface(acc, t, sp, cam, fl_edge, fl_dash, fl_eye=0.0, reach=26.0, unit=1.0): """The road itself, and it is not an object: it is what our own headlights are currently writing — two edge lines, a broken centre line and the reflectors, all streaming toward the camera and all fading out at the end of the beams, so the road never converges to a hard vertex.""" z = ZLIN fall = np.exp(-z/reach) for lat in (-1.92, 1.92): _push(acc, z, lat, EYE, cam, WHITE_LINE, fl_edge*unit*fall, zmax=104.0, falloff=0.0) dash = ((z + sp*t) % 9.0) < 3.3 if dash.any(): _push(acc, z[dash], 0.0, EYE, cam, WHITE_LINE, fl_dash*unit*fall[dash], zmax=104.0, falloff=0.0) if fl_eye > 0: ze = (np.arange(0.0, 104.0, 3.6) - sp*t) % 102.0 + 1.2 side = np.where(np.arange(len(ze)) % 2 == 0, -2.28, 2.28) _push(acc, ze, side, EYE-0.06, cam, C_MARK, fl_eye*np.exp(-ze/(reach*1.5)), zmax=104.0) _POOLZ = {} def road_pool(cam, reach=34.0, spread=3.1): """Irradiance on the asphalt from our own beams. An area emitter, inverted through the same projection so it curves and tilts with the road.""" key = (round(cam.hz, 2), round(cam.foc, 3)) if key not in _POOLZ: dy = np.arange(H, dtype=np.float32) - cam.hz _POOLZ[key] = np.where(dy > 1.2, cam.foc*H*EYE/np.maximum(dy, 1e-3), 1e6) z = _POOLZ[key][:, None] cx = cam.vx + cam.curv*z*WREF*0.0016 lat = (np.arange(W, dtype=np.float32)[None, :] - cx)*z/(cam.foc*WREF) m = np.exp(-np.abs(lat/spread)**2.6)*np.exp(-z/reach)/np.maximum(z, 2.2)**0.75 return np.where(z < 1e5, m, 0.0).astype(np.float32) _DITH = {} def dither(k): """Area emitters get broken up so nothing in this film has a clean edge.""" key = k % 8 if key not in _DITH: r = np.random.RandomState(9000+key).random_sample((H//5, W//5)) _DITH[key] = (np.asarray(Image.fromarray((r*255).astype(np.uint8)) .resize((W, H), Image.BILINEAR), np.float32)/255.0)[..., None] return _DITH[key] _SKYC = {} def _skyc(hz): key = round(hz, 2) if key not in _SKYC: y = np.arange(H, dtype=np.float32) _SKYC[key] = (np.clip((hz - y)/max(1.0, hz), 0, 1)[:, None, None], np.exp(-((y-hz)/26.0)**2)[:, None, None].astype(np.float32), (y > hz)[:, None, None].astype(np.float32)) return _SKYC[key] def night_sky(cam, glow=0.0, voice=0.0): sky, hor, _ = _skyc(cam.hz) a = np.array([0.013, 0.017, 0.037], np.float32)*(sky**1.7) a = a + np.array([0.028+0.20*glow, 0.017+0.070*glow, 0.010+0.016*glow], np.float32)*hor*(0.55 + 0.85*voice) return a def dawn_sky(cam, d, sunx=0.62): """The sun as an area emitter — a disc that has to be *exposed*, not drawn.""" sky, _, gnd = _skyc(cam.hz) above = 1.0 - gnd # the hot band belongs ABOVE the horizon only — otherwise the steppe gets # painted the same orange as the sky and the horizon stops existing glow = np.clip(1.0 - sky, 0, 1)**3.4 * above a = (np.array([1.00, 0.40, 0.14], np.float32)*glow + np.array([0.11, 0.21, 0.54], np.float32)*(sky**0.70)) * (0.07 + 0.66*d) gy = np.clip((np.arange(H, dtype=np.float32) - cam.hz) / max(1.0, H - cam.hz), 0, 1)[:, None, None] ground = (np.array([0.52, 0.34, 0.22], np.float32)*(1.0-gy)**2.2 + np.array([0.15, 0.13, 0.14], np.float32)*gy**0.7) a = a + gnd*ground*(0.05 + 0.50*d)*(0.93 + 0.14*_steppe()) sr = 5 + 26*np.clip(d*1.5-0.20, 0, 1) xx, yy = np.meshgrid(np.arange(W, dtype=np.float32), np.arange(H, dtype=np.float32)) rr = np.sqrt((xx - W*sunx)**2 + ((yy - (cam.hz - sr*1.6))*1.04)**2) a = a + (np.exp(-(rr/max(3.0, sr))**2.2)[..., None]*C_SUN)*(2.6*d) return a.astype(np.float32) _STEP = {} def _steppe(): """Low-frequency ground texture, so the daylight steppe is a place and not a flat fill. Deterministic.""" if "g" not in _STEP: r = np.random.RandomState(4711).random_sample((90, 140)) _STEP["g"] = (np.asarray(Image.fromarray((r*255).astype(np.uint8)) .resize((W, H), Image.BICUBIC), np.float32)/255.0)[..., None] return _STEP["g"] # --------------------------------------------------------------------------- class Road(LightEngine): """First person. Oncoming headlights, taillights running away, cat's eyes, and the road our own beams keep writing. `mode="rear"` turns the camera around — same rig, red instead of white, the country leaving instead of arriving. `day` brings the sky up; the shot's `tau` is what decides whether any of it holds still long enough to be a thing rather than a smear.""" SUB = 26 RAD = 1 EG = 26.0 def setup(self): r, p = self.rng, self.p self.day = float(p.get("day", 0.0)) self.dens = float(p.get("dens", 1.0)) self.speed = float(p.get("speed", 24.0)) self.rear = (p.get("mode", "fwd") == "rear") self.sunx = float(p.get("sunx", 0.62)) self.reach = float(p.get("reach", 26.0)) n = max(2, int(15*self.dens)) self.on_z = r.uniform(2.0, 96.0, n) self.on_l = r.uniform(-4.4, -2.9, n) self.on_v = r.uniform(30, 48, n) m = max(1, int(7*self.dens)) self.aw_z = r.uniform(4.0, 96.0, m) self.aw_l = r.uniform(1.2, 2.6, m) self.aw_v = r.uniform(1.5, 7.0, m) # ---- ROUND 2: the roadside. A kilometre of steppe is not empty; it # is poles and wires and posts and the eyes of things standing off # the shoulder, and all of them are written by our own beams. self.poles = float(p.get("poles", 1.0)) self.polegap = float(p.get("polegap", 34.0)) self.polelat = float(p.get("polelat", 9.2)) self.deline = float(p.get("deline", 1.0)) self.signs = float(p.get("signs", 1.0)) self.trucks = int(p.get("trucks", 1 if self.dens > 0.5 else 0)) self.town = float(p.get("town", 0.0)) self.rain = float(p.get("rain", 0.0)) self.snow = float(p.get("snow", 0.0)) self.eyes = int(p.get("eyes", 0)) self.tk_z = r.uniform(6.0, 98.0, max(1, self.trucks)) self.tk_v = r.uniform(22, 34, max(1, self.trucks)) self.sg_z = r.uniform(8.0, 96.0, 3) self.ey_z = r.uniform(14.0, 90.0, max(1, self.eyes)) self.ey_l = r.choice([-1.0, 1.0], max(1, self.eyes)) * \ r.uniform(6.5, 15.0, max(1, self.eyes)) self.tw_x = r.uniform(-42.0, 42.0, 90) self.tw_h = r.uniform(0.4, 4.6, 90) self.tw_z = r.uniform(0.0, 1.0, 90) self.rn_x = r.uniform(-9.0, 9.0, 240) self.rn_z = r.uniform(1.4, 26.0, 240) self.rn_p = r.uniform(0.0, 1.0, 240) # ---- the cyber traffic. `cyber` is brightness, `cybn` how many are # scheduled across the shot, `cybgap` the spacing IN BEATS. self.cyber = float(p.get("cyber", 0.0)) self.t0 = self.s.i0/FPS if self.cyber > 0.01: n = int(p.get("cybn", 4)) gap = float(p.get("cybgap", 2.0)) # quantise to the beat: the lane plays the pulse b0 = math.ceil((self.t0 - 1.2)/BEAT) self.cy_tp = np.array([(b0 + j*gap)*BEAT for j in range(n)]) # alternate oncoming / overtaking, but let the shot bias it ov = float(p.get("cybover", 0.34)) self.cy_on = r.random(n) > ov # True = oncoming self.cy_lat = np.where(self.cy_on, r.uniform(-4.10, -3.15, n), r.uniform(2.55, 3.35, n)) self.cy_v = np.where(self.cy_on, r.uniform(52, 68, n), r.uniform(13, 20, n)) self.cy_pal = r.integers(0, len(CYBER_PALETTE), n) self.cy_wob = r.uniform(0, math.tau, n) def _cyber(self, acc, t, cam, night): """Each car is CAR_EDGES pushed through the road projection at its own depth. falloff=1.0 keeps a polyline's brightness per PIXEL constant as it grows, exactly as the ghost outline does.""" gt = self.t0 + t for j in range(len(self.cy_tp)): if self.cy_on[j]: z = (self.cy_tp[j] - gt)*self.cy_v[j] # closing zmax, flip = 112.0, -1.0 else: z = (gt - self.cy_tp[j])*self.cy_v[j] # pulling ahead zmax, flip = 74.0, 1.0 if not (1.6 < z < zmax): continue hz_ = math.exp(-z/62.0)*self.cyber*(0.40 + 0.60*night) if hz_ < 0.004: continue lat0 = self.cy_lat[j] + 0.10*math.sin(gt*0.9 + self.cy_wob[j]) acc_c, rear_c = CYBER_PALETTE[int(self.cy_pal[j])] for tag, dz, dlat, drop, w in CAR_EDGES: zz = z + flip*dz if tag == "hot": col, fl = acc_c, 8.0 elif tag == "nose": col, fl = (C_ICE if self.cy_on[j] else rear_c), 30.0 elif tag == "tail": col, fl = (rear_c if self.cy_on[j] else C_MAGE), 24.0 else: col, fl = acc_c*0.55 + 0.22, 3.6 _push(acc, zz, lat0 + flip*dlat, EYE - drop, cam, col, fl*w*hz_, zmax=zmax, falloff=1.0) # what the car writes on the asphalt beside it rz = z + np.linspace(-CAR_L*0.6, CAR_L*0.6, ns(12)) _push(acc, rz, lat0, EYE, cam, acc_c*0.7, 6.0*hz_*math.exp(-z/26.0), zmax=54.0, falloff=1.0) def _roadside(self, acc, t, sp, cam, night): """Poles, wires, delineators, signs, eyes, a town, and the weather.""" if night <= 0.02 and self.day > 0.9: night = 0.35 # daylight still writes the posts # ---- power line: poles on the right, three wires sagging between if self.poles > 0.01: g = self.polegap zp = (np.arange(0.0, 108.0, g) - sp*t) % (108.0) + 1.2 hs = np.linspace(EYE, EYE - 7.4, ns(16)) ZP = np.repeat(zp, len(hs)); HS = np.tile(hs, len(zp)) _push(acc, ZP, self.polelat, HS, cam, C_MARK*0.40, 5.2*self.poles*night*np.exp(-ZP/46.0), zmax=104.0, falloff=0.9) for arm in (-1.0, 1.0): _push(acc, zp, self.polelat + arm*1.35, EYE - 7.1, cam, C_MARK*0.5, 6.0*self.poles*night*np.exp(-zp/46.0), zmax=104.0, falloff=0.9) zw = ZLIN ph = ((zw - sp*t) % g)/g sag = 0.55*np.sin(np.pi*ph) for wl, hh in ((-1.35, 7.1), (0.0, 7.35), (1.35, 7.1)): _push(acc, zw, self.polelat + wl, EYE - hh + sag, cam, C_MARK*0.34, 2.1*self.poles*night*np.exp(-zw/40.0), zmax=104.0, falloff=0.0) # ---- delineator posts, both shoulders, reflector on top if self.deline > 0.01: zd = (np.arange(0.0, 104.0, 26.0) - sp*t) % 102.0 + 1.4 hs = np.linspace(EYE, EYE - 1.05, ns(5)) for lat in (-3.4, 3.4): ZD = np.repeat(zd, len(hs)); HS = np.tile(hs, len(zd)) _push(acc, ZD, lat, HS, cam, C_MARK*0.55, 7.0*self.deline*np.exp(-ZD/22.0), zmax=104.0, falloff=1.1) _push(acc, zd, lat, EYE - 1.10, cam, C_MARK, 36.0*self.deline*np.exp(-zd/17.0), zmax=104.0) # ---- a reflective sign, now and then if self.signs > 0.01: zs = (self.sg_z - sp*t) % 190.0 + 1.6 gx = np.linspace(-0.85, 0.85, ns(7)) gy = np.linspace(0.0, -1.60, ns(7)) for i in range(len(zs)): if zs[i] > 88.0: continue XX = np.repeat(gx, len(gy)); YY = np.tile(gy, len(gx)) _push(acc, np.full(len(XX), zs[i]), 5.4 + XX, EYE - 2.3 + YY, cam, C_MARK, 26.0*self.signs*math.exp(-zs[i]/15.0), zmax=104.0) _push(acc, np.full(ns(4), zs[i]), 5.4, np.linspace(EYE, EYE - 1.6, ns(4)), cam, C_MARK*0.4, 6.0*self.signs*math.exp(-zs[i]/15.0), zmax=104.0) # ---- oncoming trucks: a cab, a roof cluster, trailer markers if self.trucks > 0: zt = (self.tk_z - (sp + self.tk_v)*t) % 96.0 + 1.0 for off in (-0.62, 0.62): _push(acc, zt, -3.6 + off, EYE - 0.72, cam, C_HEAD, 96.0*night*self.dens) roof = np.linspace(-0.55, 0.55, ns(5)) ZT = np.repeat(zt, len(roof)); RF = np.tile(roof, len(zt)) _push(acc, ZT, -3.6 + RF, EYE - 3.5, cam, C_SODI, 16.0*night*self.dens) side = np.linspace(0.0, 13.0, ns(7)) ZT2 = (np.repeat(zt, len(side)) + np.tile(side, len(zt))) _push(acc, ZT2, -2.6, EYE - 2.2, cam, C_SODI*0.8, 9.0*night*self.dens) # ---- animal eyes at the shoulder: two dots, and then gone if self.eyes > 0: ze = (self.ey_z - sp*t) % 120.0 + 1.0 for off in (-0.09, 0.09): _push(acc, ze, self.ey_l + off, EYE - 0.62, cam, np.array([1.00, 0.86, 0.42], np.float32), 34.0*night*np.exp(-ze/13.0), zmax=60.0) # ---- a town, passed through and gone if self.town > 0.01: zt = 6.0 + 46.0*((self.tw_z + t*0.05) % 1.0) _push(acc, zt, self.tw_x, EYE - self.tw_h, cam, C_SODI, 22.0*self.town*night*np.exp(-zt/34.0), zmax=104.0) zl = (np.arange(0.0, 70.0, 17.0) - sp*t*0.5) % 68.0 + 3.0 hs = np.linspace(EYE, EYE - 6.0, ns(10)) ZL = np.repeat(zl, len(hs)); HS = np.tile(hs, len(zl)) _push(acc, ZL, -11.0, HS, cam, C_SODI*0.35, 4.0*self.town*night, zmax=104.0, falloff=0.9) _push(acc, zl, -11.0, EYE - 6.4, cam, C_SODI, 70.0*self.town*night, zmax=104.0) # ---- weather, only where the beams reach it if self.rain > 0.01 or self.snow > 0.01: fall = 34.0 if self.rain > self.snow else 3.2 amt = max(self.rain, self.snow) ph = (self.rn_p + t*(0.9 if self.rain > self.snow else 0.22)) % 1.0 zz = self.rn_z hh = EYE - 5.6 + ph*5.6 drift = 0.0 if self.rain > self.snow else 0.9*np.sin(t*1.7 + zz) _push(acc, zz, self.rn_x*0.34 + drift, hh, cam, WHITE_LINE, 2.6*amt*np.exp(-zz/16.0), zmax=40.0, falloff=1.2) def emit(self, tt, e): acc = ([], [], [], []) cam = self.cam sp = self.speed*(0.55 + 0.85*e["rms"]) night = 1.0 - self.day cfar, cnear = ((C_TAIL, C_HEAD) if self.rear else (C_HEAD, C_TAIL)) for t in tt: if night > 0.02: # traffic closing on us (or, looking back, falling away) z = (self.on_z - (sp + self.on_v)*t) % 94.0 + 1.0 for off in (-0.44, 0.44): _push(acc, z, self.on_l+off, EYE-0.68, cam, cfar, 72.0*night*self.dens) _push(acc, z, self.on_l, EYE-0.26, cam, cfar*0.45, 9.0*night*self.dens) za = (self.aw_z + self.aw_v*t) % 94.0 + 1.0 for off in (-0.42, 0.42): _push(acc, za, self.aw_l+off, EYE-0.78, cam, cnear, 42.0*night*self.dens) # sunlit surfaces are exposure-NORMALISED (the sun is bright, so a # short exposure still sees them); beam-lit ones are not (a longer # exposure really does gather more of our own headlights) sun = self.day/max(0.12, self._tau) self._roadside(acc, t, sp, cam, night) if self.cyber > 0.01: self._cyber(acc, t, cam, night) _surface(acc, t, sp, cam, fl_edge=0.40 + 1.55*sun, fl_dash=0.85 + 3.20*sun, fl_eye=11.0*night, reach=self.reach + 26.0*self.day, unit=self.unit) if not acc[0]: return None return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): d = self.day base = night_sky(self.cam, 0.0, e["voice"])*(1.0 - d) if d > 0.01: base = base + dawn_sky(self.cam, d, self.sunx) \ * (1.60/max(0.14, self._tau)) pool = road_pool(self.cam, reach=self.reach + 30.0*d)[..., None] beam = (WHITE_LINE*0.16*(1-d) + ASPHALT*0.055*d*(1.10/max(0.14, self._tau))) return (base + pool*beam)*(0.88 + 0.24*dither(k)) class Sky(LightEngine): """Star trails. The pole sits off the left edge, so every star is an arc whose length IS the exposure. A satellite goes straight through and a plane's strobe writes a dashed line — both real long-exposure artefacts.""" SUB = 12 RAD = 0 EG = 1.0 def setup(self): r, p = self.rng, self.p self.n = int(p.get("stars", 900) * S * S) self.px = W*float(p.get("polex", -0.16)) self.py = self.cam.hz - H*float(p.get("poley", 0.06)) self.r = r.uniform(0.004, 1.0, self.n)**0.48 * W*1.65 self.a0 = r.uniform(0, math.tau, self.n) self.br = (r.uniform(0.10, 1.0, self.n)**2.2)*58 + 3.2 # a star close to the pole crawls, so it would deposit for the whole # exposure onto one pixel — weight by how fast its arc actually moves self.br = self.br*np.clip(self.r/(0.34*W), 0.30, 1.0) tint = r.uniform(0, 1, self.n)[:, None] self.col = (C_STAR[None, :]*(1-tint) + np.array([1.0, .70, .46], np.float32)[None, :]*tint).astype(np.float32) self.om = float(p.get("om", 0.055)) self.sat = bool(p.get("sat", r.random() < 0.45)) self.plane = bool(p.get("plane", r.random() < 0.45)) self.meteor = float(p.get("meteor", -1.0)) self.roadsp = float(p.get("roadsp", 20.0)) self.glow = float(p.get("glow", 0.0)) self.road = bool(p.get("road", True)) def emit(self, tt, e): acc = ([], [], [], []) X, Y, C, Wt = acc om = self.om*(0.55 + 1.7*e["rms"]) for t in tt: a = self.a0 + om*t x = self.px + np.cos(a)*self.r y = self.py + np.sin(a)*self.r*0.64 keep = (y < self.cam.hz-1) & (x > -40) & (x < W+40) & (y > -40) if keep.any(): X.append(x[keep]); Y.append(y[keep]) C.append(self.col[keep]); Wt.append(self.br[keep]*(0.5+1.0*e["high"])) if self.sat: q = (t*0.075 + 0.1) % 1.0 X.append(np.array([-60 + q*(W+120)])) Y.append(np.array([self.cam.hz*0.18 + q*self.cam.hz*0.46])) C.append(C_STAR[None, :]); Wt.append(np.array([34.0])) if self.plane: q = (t*0.045 + 0.3) % 1.0 if math.sin(t*math.tau*1.15) > 0.5: X.append(np.array([W+40 - q*(W+120)])) Y.append(np.array([self.cam.hz*0.60 - math.sin(q*3.1)*sf(26)])) C.append(np.array([[1.0, 0.16, 0.10]], np.float32)) Wt.append(np.array([80.0])) if self.meteor > 0: q = t - self.meteor if 0 < q < 0.5: X.append(np.array([W*0.24 + q*W*0.95])) Y.append(np.array([self.cam.hz*0.14 + q*self.cam.hz*0.80])) C.append(np.array([[1.0, 0.93, 0.82]], np.float32)) Wt.append(np.array([380.0*(1-q/0.5)])) if self.road: _surface(acc, t, self.roadsp, self.cam, fl_edge=0.45, fl_dash=1.00, fl_eye=7.0, reach=20.0, unit=self.unit) if not X: return None return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): return night_sky(self.cam, self.glow*u, e["voice"])*(0.88 + 0.24*dither(k)) class Lamps(LightEngine): """Sodium lights spaced on the eighth-note grid, so one goes over the roof on every kick, and each drops a pool of orange onto the road that rushes at the camera. This is the motorik shot: nothing but repetition, arriving.""" SUB = 30 RAD = 1 EG = 30.0 def setup(self): r, p = self.rng, self.p self.spacing = float(p.get("spacing", 9.0)) self.speed = self.spacing/(BEAT/2)*float(p.get("rate", 1.0)) self.z0 = np.arange(0.0, 150.0, self.spacing) self.side = float(p.get("side", r.choice([-1.0, 1.0]))) self.arm = float(p.get("arm", 4.6)) self.hgt = float(p.get("lamph", 6.1)) self.double = bool(p.get("double", r.random() < 0.45)) ring = np.linspace(0, math.tau, ns(26), endpoint=False) self.rz = np.cos(ring)*3.0 self.rl = np.sin(ring)*2.2 def emit(self, tt, e): acc = ([], [], [], []) cam = self.cam sides = (-1.0, 1.0) if self.double else (self.side,) for t in tt: z = (self.z0 - self.speed*t) % 150.0 + 0.5 for sd in sides: lat = sd*self.arm _push(acc, z, lat, EYE-self.hgt, cam, C_SODI, 118.0*(0.5+0.8*e["rms"])) for q in (0.26, 0.52, 0.78, 1.0): # the mast, dimly lit _push(acc, z, lat, EYE-self.hgt*(1-q), cam, C_SODI*0.26, 8.0) for zz in z: # the pool it throws if not (1.6 < zz < 58): continue _push(acc, zz + self.rz, lat*0.42 + self.rl, EYE, cam, C_SODI*0.55, 5.4, zmax=70.0) _surface(acc, t, self.speed*0.85, cam, fl_edge=0.50, fl_dash=1.15, fl_eye=8.0, reach=30.0, unit=self.unit) if not acc[0]: return None return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): sky, hor, _ = _skyc(self.cam.hz) a = (np.array([0.011, 0.010, 0.018], np.float32)*(sky**1.9) + np.array([0.030, 0.017, 0.006], np.float32)*hor) pool = road_pool(self.cam, reach=44.0, spread=5.0)[..., None] return (a + pool*C_SODI*0.13)*(0.88 + 0.24*dither(k)) _TEXTPTS = {} def text_points(txt, size=150, step=2): """Sample the lit pixels of a numeral so it can be *painted with light* rather than drawn. Cached per string.""" key = (txt, size, step) if key in _TEXTPTS: return _TEXTPTS[key] f = font(size, "Impact.ttf") im = Image.new("L", (size*len(txt)+40, int(size*1.6)), 0) ImageDraw.Draw(im).text((20, 10), txt, font=f, fill=255) a = np.asarray(im) ys, xs = np.nonzero(a > 96) if len(xs) == 0: _TEXTPTS[key] = (np.zeros(0, np.float32), np.zeros(0, np.float32)) return _TEXTPTS[key] sel = np.arange(0, len(xs), step) xs = xs[sel].astype(np.float32); ys = ys[sel].astype(np.float32) _TEXTPTS[key] = ((xs - xs.mean())/size, (ys - ys.mean())/size) return _TEXTPTS[key] class Posts(LightEngine): """Delineator posts whipping past, and kilometre markers whose numerals are themselves only light — so at speed the number smears into a ribbon of its own and stops being a number.""" SUB = 34 RAD = 1 EG = 44.0 def setup(self): r, p = self.rng, self.p self.speed = float(p.get("speed", 30.0)) self.gap = float(p.get("gap", 4.0)) self.z0 = np.arange(0.0, 104.0, self.gap) self.sd = np.where(np.arange(len(self.z0)) % 2 == 0, -3.1, 3.1) self.num = str(p.get("num", 412)) self.mgap = float(p.get("mgap", 27.0)) self.mz = np.arange(0.0, 120.0, self.mgap) + 12.0 self.mside = float(p.get("mside", r.choice([-1.0, 1.0]))) self.tx, self.ty = text_points(self.num, si(150), int(p.get("tstep", 3))) def emit(self, tt, e): acc = ([], [], [], []) X, Y, C, Wt = acc cam = self.cam sp = self.speed*(0.6 + 0.8*e["rms"]) for t in tt: z = (self.z0 - sp*t) % 102.0 + 0.6 fall = np.exp(-z/34.0) for j, dy in enumerate((0.0, -0.36, -0.72, -1.02)): _push(acc, z, self.sd, EYE+dy, cam, C_MARK, (26.0 if j == 0 else 10.0)*fall) for mz0 in self.mz: mz = (mz0 - sp*t) % 118.0 + 1.4 if not (1.6 < mz < 46) or not len(self.tx): continue pr = 1.0/mz cx = _road_x(np.array([mz]), self.mside*5.8, cam)[0] cy = _road_y(np.array([mz]), EYE-2.70, cam)[0] sc = cam.foc*W*pr*3.0 xs = cx + self.tx*sc; ys = cy + self.ty*sc keep = (xs > -300) & (xs < W+300) & (ys > -300) & (ys < H+300) if not keep.any(): continue X.append(xs[keep]); Y.append(ys[keep]) C.append(np.repeat(C_MARK[None, :], int(keep.sum()), 0)) # ∝ 1/z² — a surface of constant radiance holds its brightness Wt.append(np.full(int(keep.sum()), float(1400.0*pr*pr*math.exp(-mz/26.0) * self.unit/max(0.12, self._tau)))) _surface(acc, t, sp, cam, fl_edge=0.50, fl_dash=1.12, fl_eye=8.0, reach=28.0, unit=self.unit) if not X: return None return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): pool = road_pool(self.cam, reach=28.0)[..., None] return (night_sky(self.cam, 0.0, e["voice"]) + pool*WHITE_LINE*0.15) \ * (0.88 + 0.24*dither(k)) class Dash(LightEngine): """Inside. The instruments are the only things emitting, so the car is a constellation. The needle is a swept arc — the light-painting classic — and it is driven by the mix RMS, so the speedometer reads the music.""" SUB = 22 RAD = 1 EG = 1.1 def setup(self): r, p = self.rng, self.p self.cx = W*float(p.get("cx", 0.32)) self.cy = H*float(p.get("cy", 0.60)) self.rr = H*float(p.get("rr", 0.29)) self.refl = int(p.get("refl", 6)) self.ro = r.uniform(0, 1, max(1, self.refl)) self.rv = r.uniform(0.10, 0.45, max(1, self.refl)) self.tick = np.linspace(-math.pi*0.84, math.pi*0.24, 13) def _dial(self, acc, cx, cy, rr, ang, col, hot, t): X, Y, C, Wt = acc q = np.linspace(0.12, 1.0, ns(96)) X.append(cx + np.cos(ang)*rr*q); Y.append(cy + np.sin(ang)*rr*q) C.append(np.repeat(col[None, :], len(q), 0)) Wt.append(np.linspace(0.3, 1.0, len(q))*hot) for i, a in enumerate(self.tick): big = (i % 3 == 0) qq = np.linspace(0.88 if big else 0.94, 1.04, ns(22)) X.append(cx + np.cos(a)*rr*qq); Y.append(cy + np.sin(a)*rr*qq) C.append(np.repeat((col*0.8+0.12)[None, :], len(qq), 0)) Wt.append(np.full(len(qq), 2.6 if big else 1.1)) aa = np.linspace(self.tick[0], self.tick[-1], ns(200)) X.append(cx + np.cos(aa)*rr*1.06); Y.append(cy + np.sin(aa)*rr*1.06) C.append(np.repeat((col*0.7)[None, :], len(aa), 0)) Wt.append(np.full(len(aa), 0.9)) def emit(self, tt, e): acc = ([], [], [], []) X, Y, C, Wt = acc sp = 0.16 + 0.80*e["rms"] for t in tt: ang = -math.pi*0.84 + (math.pi*1.08)*(sp*(0.9+0.14*math.sin(t*5.1))) self._dial(acc, self.cx, self.cy, self.rr, ang, C_DASH, 13.0, t) a2 = -math.pi*0.84 + (math.pi*1.08)*float(np.clip(0.20+0.9*e["low"], 0, 1)) self._dial(acc, W*0.68, self.cy, self.rr*0.74, a2, np.array([0.32, 1.0, 0.52], np.float32), 9.0, t) for j in range(self.refl): q = (self.ro[j] + t*self.rv[j]) % 1.0 X.append(np.array([-90 + q*(W+180)])) Y.append(np.array([H*0.19 + math.sin(q*3.4 + j)*H*0.07])) C.append(C_HEAD[None, :]*0.95) Wt.append(np.array([90.0*(0.3+1.0*e["high"])])) for j, (lx, ly, cc) in enumerate( ((0.50, 0.90, (1.0, .30, .06)), (0.545, 0.90, (0.25, .9, .35)), (0.455, 0.90, (1.0, .72, .10)))): if (j == 1) or (math.sin(t*3.1 + j*2) > 0.2): X.append(np.array([W*lx])); Y.append(np.array([H*ly])) C.append(np.array([cc], np.float32)); Wt.append(np.array([26.0])) if not X: return None return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): g = np.exp(-((np.arange(H)-H*0.90)/54.0)**2)[:, None, None].astype(np.float32) w = np.exp(-((np.arange(H)-H*0.16)/90.0)**2)[:, None, None].astype(np.float32) return (np.array([0.048, 0.016, 0.004], np.float32)*g*(0.5+0.8*e["low"]) + np.array([0.006, 0.008, 0.016], np.float32)*w) \ * (0.88 + 0.24*dither(k)) SHAPES = { "horse": [(0.10,0.55),(0.16,0.40),(0.24,0.34),(0.34,0.32),(0.46,0.33),(0.56,0.30), (0.62,0.22),(0.66,0.10),(0.73,0.05),(0.78,0.12),(0.75,0.24),(0.70,0.34), (0.64,0.41),(0.60,0.52),(0.63,0.72),(0.60,0.94),(0.55,0.94),(0.56,0.70), (0.48,0.56),(0.36,0.57),(0.31,0.75),(0.29,0.94),(0.24,0.94),(0.26,0.72), (0.20,0.58),(0.12,0.68),(0.07,0.62)], "argali":[(0.14,0.60),(0.20,0.44),(0.30,0.38),(0.44,0.37),(0.56,0.34),(0.64,0.26), (0.70,0.18),(0.80,0.14),(0.88,0.22),(0.86,0.34),(0.76,0.38),(0.70,0.30), (0.66,0.22),(0.62,0.38),(0.58,0.52),(0.60,0.94),(0.55,0.94),(0.55,0.62), (0.44,0.58),(0.34,0.60),(0.33,0.94),(0.28,0.94),(0.29,0.62),(0.20,0.72), (0.13,0.70)], "figure":[(0.50,0.06),(0.57,0.11),(0.57,0.19),(0.51,0.24),(0.64,0.30),(0.70,0.52), (0.66,0.55),(0.60,0.38),(0.60,0.62),(0.64,0.94),(0.56,0.94),(0.52,0.68), (0.46,0.94),(0.38,0.94),(0.41,0.62),(0.40,0.38),(0.34,0.55),(0.30,0.52), (0.36,0.30),(0.48,0.24),(0.43,0.19),(0.43,0.11)], "yurt": [(0.08,0.94),(0.10,0.52),(0.20,0.34),(0.36,0.24),(0.50,0.20),(0.64,0.24), (0.80,0.34),(0.90,0.52),(0.92,0.94),(0.60,0.94),(0.60,0.62),(0.40,0.62), (0.40,0.94)], "hand": [(0.30,0.94),(0.26,0.62),(0.20,0.40),(0.25,0.37),(0.32,0.54),(0.31,0.22), (0.37,0.20),(0.40,0.50),(0.43,0.12),(0.49,0.12),(0.50,0.50),(0.55,0.18), (0.61,0.20),(0.59,0.54),(0.66,0.34),(0.72,0.38),(0.66,0.66),(0.66,0.94)], "signpost":[(0.46,0.94),(0.46,0.32),(0.18,0.32),(0.18,0.08),(0.82,0.08),(0.82,0.32), (0.54,0.32),(0.54,0.94)], } def _resample(pts, n, closed=True): p = np.array(pts, np.float32) if closed: p = np.concatenate([p, p[:1]], 0) seg = np.sqrt(((p[1:]-p[:-1])**2).sum(1)) cum = np.concatenate([[0], np.cumsum(seg)]) q = np.linspace(0, cum[-1], n) return (np.interp(q, cum, p[:, 0]).astype(np.float32), np.interp(q, cum, p[:, 1]).astype(np.float32)) class Ghost(LightEngine): """Something is standing at the roadside. It is drawn by one light pen running its outline once, in the wrong colour, at the wrong size, while it slides past — which is what a hallucination at 140 km/h is: an outline your eye finished on its own.""" SUB = 20 RAD = 0 EG = 30.0 def setup(self): r, p = self.rng, self.p self.kind = p.get("kind", "horse") self.gx, self.gy = _resample(SHAPES[self.kind], ns(1400)) self.z0 = float(p.get("z0", 40.0)) self.speed = float(p.get("speed", 9.0)) self.side = float(p.get("side", r.choice([-1.0, 1.0]))) self.lat = self.side*float(p.get("lat", 5.4)) self.big = float(p.get("big", 13.0)) # apparitions are oversized self.pen = float(p.get("pen", 0.95)) # laps of the outline per second self.roadsp = float(p.get("roadsp", 26.0)) self.day = float(p.get("day", 0.0)) self.col = (C_GHOST if self.day < 0.5 else np.array([1.00, 0.84, 0.58], np.float32)) self.sunx = float(p.get("sunx", 0.62)) def emit(self, tt, e): acc = ([], [], [], []) X, Y, C, Wt = acc cam = self.cam NP = len(self.gx) for t in tt: z = self.z0 - self.speed*t if z > 1.4: pr = 1.0/z cx = _road_x(np.array([z]), self.lat, cam)[0] cy = _road_y(np.array([z]), EYE, cam)[0] sc = cam.foc*W*pr*self.big q = float(np.clip(t*self.pen, 0, 1.0)) m = max(8, int(NP*q)) xs = cx + (self.gx[:m]-0.5)*sc ys = cy + (self.gy[:m]-0.94)*sc keep = (xs > -320) & (xs < W+320) & (ys > -320) & (ys < H+320) if keep.any(): # per-point flux ∝ 1/z keeps the outline's brightness per # PIXEL constant as it grows — otherwise a distant shape is a # thousand points stacked on ten pixels, i.e. a white blob ww = np.full(m, 170.0*pr*self.unit/max(0.12, self._tau), np.float32) ww[max(0, m-ns(110)):] *= 4.5 # the pen tip is hot X.append(xs[keep]); Y.append(ys[keep]) C.append(np.repeat(self.col[None, :], int(keep.sum()), 0)) Wt.append(ww[keep]) sun = self.day/max(0.12, self._tau) _surface(acc, t, self.roadsp, cam, fl_edge=0.40 + 1.55*sun, fl_dash=0.85 + 3.20*sun, fl_eye=10.0*(1-self.day), reach=26.0 + 26.0*self.day, unit=self.unit) if not X: return None return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): base = night_sky(self.cam, 0.0, e["voice"])*(1.0 - self.day) if self.day > 0.01: base = base + dawn_sky(self.cam, self.day, self.sunx) \ * (1.60/max(0.14, self._tau)) pool = road_pool(self.cam, reach=26.0 + 30.0*self.day)[..., None] beam = (WHITE_LINE*0.16*(1-self.day) + ASPHALT*0.055*self.day*(1.10/max(0.14, self._tau))) return (base + pool*beam)*(0.88 + 0.24*dither(k)) class Above(LightEngine): """Straight down. The road becomes a set of parallel ribbons and the whole crossing becomes a pattern — the shot where the driving stops being a journey and starts being a loop.""" SUB = 30 RAD = 1 EG = 1.0 def setup(self): r, p = self.rng, self.p self.rot = float(p.get("rot", r.uniform(-0.26, 0.26))) self.speed = float(p.get("speed", 620.0)) n = int(p.get("cars", 16)) self.cy = r.uniform(-0.2, 1.4, n) self.lane = r.integers(0, 4, n) self.dirn = np.where(self.lane < 2, 1.0, -1.0) self.cv = r.uniform(0.75, 1.35, n)*self.speed self.tail = self.dirn < 0 self.wob = r.uniform(0, math.tau, n) def emit(self, tt, e): X, Y, C, Wt = [], [], [], [] lanes = np.array([-0.185, -0.062, 0.062, 0.185]) for t in tt: th = self.rot + 0.05*math.sin(t*0.23) ca, sa = math.cos(th), math.sin(th) def place(lx, ly, col, w): lx = np.atleast_1d(lx); ly = np.atleast_1d(ly) x0 = lx*W; y0 = (ly % 1.4 - 0.2)*H x = W*0.5 + x0*ca - (y0-H*0.5)*sa y = H*0.5 + x0*sa + (y0-H*0.5)*ca X.append(x); Y.append(y) C.append(np.repeat(col[None, :], len(x), 0)) Wt.append(np.atleast_1d(w)*np.ones(len(x))) dash = np.arange(0, 30)*0.052 - (t*0.34) % 0.052 for lx in (-0.124, 0.0, 0.124): place(np.full(len(dash), lx), dash, WHITE_LINE, 6.0) dd = np.arange(0, 140)*0.011 for lx in (-0.244, 0.244): place(np.full(len(dd), lx), dd, C_MARK, 2.4) ly = (self.cy + self.dirn*self.cv*t/H) % 1.4 for j in range(len(ly)): col = C_TAIL if self.tail[j] else C_HEAD lx = lanes[self.lane[j]] + 0.006*math.sin(t*2.1 + self.wob[j]) place(np.array([lx-0.013, lx+0.013]), np.array([ly[j], ly[j]]), col, 52.0*(0.5+0.9*e["rms"])) if not X: return None return (np.concatenate(X), np.concatenate(Y), np.concatenate(C), np.concatenate(Wt)) def ambient(self, k, u, e): return (np.full((1, 1, 3), 0.007, np.float32) + np.zeros((H, W, 3), np.float32))*(0.88 + 0.24*dither(k)) class Hold(LightEngine): """The long one. Exposure stretched until the road is a single unmoving smear and the sky has visibly turned. One car comes toward us for eleven seconds and does not arrive.""" SUB = 14 RAD = 1 EG = 1.0 def setup(self): r, p = self.rng, self.p self.sky = Sky(self.s, np.random.default_rng(self.s.seed ^ 0x5f3a)) self.sky.om = float(p.get("om", 0.030)) self.sky.glow = 0.0 self.sky.road = False self.sky.cam = self.cam self.sky.py = self.cam.hz - H*0.06 self.approach = float(p.get("approach", 100.0)) self.roadsp = float(p.get("roadsp", 14.0)) self.glow = float(p.get("glow", 0.0)) def emit(self, tt, e): got = self.sky.emit(tt, e) acc = ([got[0]], [got[1]], [got[2]], [got[3]*0.85]) for t in tt: z = self.approach - t*3.2 if z > 2.0: for off in (-0.44, 0.44): _push(acc, np.array([z]), -3.3+off, EYE-0.68, self.cam, C_HEAD, 30.0) _surface(acc, t, self.roadsp, self.cam, fl_edge=0.40, fl_dash=0.95, fl_eye=6.0, reach=18.0, unit=self.unit) return tuple(np.concatenate(a) for a in acc) def ambient(self, k, u, e): pool = road_pool(self.cam, reach=20.0)[..., None] return (night_sky(self.cam, self.glow*(0.35+0.65*u), e["voice"]) + pool*WHITE_LINE*0.11)*(0.88 + 0.24*dither(k)) ENGINES = {"road": Road, "sky": Sky, "lamps": Lamps, "posts": Posts, "dash": Dash, "ghost": Ghost, "above": Above, "hold": Hold} # ════════════════════════════════════════════════════════════════════════════ # THE SCORE — shot table # ════════════════════════════════════════════════════════════════════════════ KM = [512, 498, 487, 471, 460, 444, 431, 418, 402, 391, 377, 366, 350, 341, 341, 341, 341, 341, 329, 318, 302, 291, 277, 264, 250, 241, 233, 220] PLAN = { "night": ([("sky", dict(tau=2.8, stars=1100, om=0.028, sat=True, hz=0.58, polex=-0.20, roadsp=12.0)), ("hold", dict(tau=4.2, approach=124.0, glow=0.0, hz=0.46, foc=0.86)), ("road", dict(tau=1.7, dens=0.40, speed=15.0, hz=0.40, curv=2.2, poles=1.2, eyes=2, trucks=1, cyber=0.85, cybn=3, cybgap=4.0, cybover=0.2)), ("sky", dict(tau=3.4, stars=900, om=0.048, poley=-0.02, plane=True, hz=0.62, polex=1.14, roadsp=10.0))], [8, 12, 16]), "motorik": ([("lamps", dict(tau=0.75, rate=1.0, arm=4.6, hz=0.36, curv=1.4)), ("road", dict(tau=0.85, dens=1.1, speed=26.0, hz=0.44, curv=-2.0, poles=1.0, trucks=2, signs=1.4, cyber=1.15, cybn=6, cybgap=2.0, cybover=0.30)), ("posts", dict(tau=0.60, speed=30.0, hz=0.34, foc=0.60, curv=0.6)), ("above", dict(tau=0.70, cars=16)), ("dash", dict(tau=0.55, cx=0.32, rr=0.29)), ("lamps", dict(tau=0.55, rate=1.0, double=True, arm=5.4, hz=0.30, foc=0.90, curv=-0.8, lamph=6.8)), ("road", dict(tau=1.10, dens=1.5, speed=30.0, vxo=0.11, hz=0.50, foc=0.58, curv=2.5, poles=1.3, trucks=2, town=0.9, cyber=1.30, cybn=7, cybgap=1.5, cybover=0.45)), ("road", dict(tau=0.80, dens=1.2, speed=28.0, mode="rear", hz=0.38, curv=-1.2, poles=1.1, town=0.6, trucks=1, cyber=1.10, cybn=5, cybgap=2.0, cybover=0.55))], [2, 2, 3, 4, 4]), "steppe": ([("sky", dict(tau=3.0, stars=1200, om=0.072, hz=0.66, polex=-0.24, roadsp=16.0)), ("above", dict(tau=1.4, cars=13, speed=520.0, rot=0.20)), ("road", dict(tau=1.7, dens=0.9, speed=26.0, hz=0.32, foc=0.94, curv=-2.4, poles=1.4, polegap=28.0, eyes=3, snow=0.9, cyber=0.95, cybn=3, cybgap=4.0, cybover=0.25)), ("hold", dict(tau=3.6, approach=108.0, hz=0.50)), ("posts", dict(tau=1.3, speed=28.0, hz=0.44, mside=1.0, mgap=34.0)), ("lamps", dict(tau=1.5, rate=0.5, arm=3.6, hz=0.52, foc=0.58, lamph=5.4))], [4, 6, 6, 8]), "mirage": ([("ghost", dict(tau=1.5, kind="horse", z0=30.0, speed=6.0, hz=0.52, big=11.0)), ("road", dict(tau=1.3, dens=1.9, speed=34.0, hz=0.30, foc=0.96, curv=2.6, poles=1.2, trucks=3, signs=1.6, rain=1.0, cyber=1.35, cybn=8, cybgap=1.0, cybover=0.40)), ("ghost", dict(tau=1.2, kind="argali", z0=27.0, speed=5.5, side=1.0, hz=0.54, big=11.0)), ("posts", dict(tau=1.7, speed=42.0, gap=3.0, hz=0.36, mgap=30.0)), ("lamps", dict(tau=0.9, rate=2.0, double=True, hz=0.40, lamph=6.4)), ("ghost", dict(tau=1.6, kind="figure", z0=24.0, speed=5.0, hz=0.56, big=13.0, lat=4.4)), ("dash", dict(tau=1.1, refl=11, cx=0.26, rr=0.36)), ("above", dict(tau=1.8, cars=22, speed=780.0, rot=-0.22)), ("ghost", dict(tau=2.2, kind="hand", z0=22.0, speed=4.0, side=1.0, hz=0.62, big=12.0, lat=4.0)), ("road", dict(tau=1.0, dens=1.4, speed=32.0, mode="rear", hz=0.48, foc=0.62, curv=-2.2, poles=1.0, trucks=2, eyes=2, cyber=1.20, cybn=6, cybgap=1.5, cybover=0.60)), ("ghost", dict(tau=1.4, kind="yurt", z0=32.0, speed=6.5, hz=0.50, big=13.0))], [1, 2, 2, 2, 3, 4]), "horizon": ([("hold", dict(tau=5.0, approach=116.0, om=0.022, glow=0.30, hz=0.54, foc=0.84)), ("sky", dict(tau=4.6, stars=1400, om=0.028, meteor=1.9, glow=0.35, hz=0.70, polex=-0.10, roadsp=8.0)), ("hold", dict(tau=6.0, approach=130.0, om=0.018, glow=0.75, hz=0.44, foc=1.05))], [8, 12, 16]), } # The landing is authored, not shuffled: one exposure collapsing shot by shot # from a three-second smear to a sixth of a second, which is the only thing # that changes. Same road, same rig, shorter and shorter exposure — until the # world stops being a trail and is simply there. SUNRISE = [ (5, "hold", dict(tau=3.4, approach=136.0, om=0.016, glow=1.0, hz=0.46, foc=0.90)), (5, "road", dict(tau=2.4, day=0.30, dens=0.55, speed=22.0, curv=1.8, hz=0.42, sunx=0.70, poles=1.1, trucks=1, town=0.5, cyber=0.80, cybn=3, cybgap=3.0, cybover=0.35)), (4, "ghost", dict(tau=1.1, kind="signpost", z0=26.0, speed=5.0, pen=1.8, day=0.55, lat=6.6, side=1.0, curv=-0.6, hz=0.52, big=7.0, sunx=0.30)), (6, "road", dict(tau=0.85, day=0.80, dens=0.26, speed=20.0, curv=-1.4, hz=0.36, foc=0.86, sunx=0.24, poles=1.0, signs=1.2)), (6, "road", dict(tau=0.36, day=0.94, dens=0.10, speed=17.0, curv=0.8, hz=0.50, foc=0.60, sunx=0.72, poles=0.9, deline=1.2)), (6, "road", dict(tau=0.16, day=1.00, dens=0.00, speed=14.0, curv=-0.20, hz=0.44, sunx=0.50, bloom=0.55, halo=0.18, poles=0.8, deline=1.0, signs=0.8)), ] CARDS = {"night": ("TUVA TRANSIT", "impact"), "steppe": ("THE HORIZON DOES NOT ARRIVE", "menlo"), "mirage": (None, None)} LAST_CARD = "it was only ever a road" # The lines. Same register and same typography as the last card — one thought # at a time, low left, gone before you finish agreeing with it. Placed to sit # in the gaps between the section cards, never over one. LINES = [ (4.30, 3.0, "night, and the steppe has no edges"), (9.60, 3.0, "a hundred kilometres between two lamps"), (14.20, 3.0, "the engine holds one note all night"), (18.10, 3.0, "two voices, one throat"), (26.00, 3.0, "the markers count down to nothing"), (31.40, 3.0, "something stands where nothing stands"), (36.20, 3.4, "the low voice is ground, the high voice is sky"), (41.10, 3.0, "we have passed this hour already"), (46.40, 3.2, "the horizon keeps its distance"), (52.40, 3.0, "no arriving, only heading"), (57.20, 3.0, "the ghosts turn back into posts"), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "params", "card", "cardfont", "km") def __init__(self, idx, i0, i1, engine, section, params, card, cardfont, km): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.section = engine, section self.seed = 71042 + idx*7919 self.params = dict(params) self.card, self.cardfont, self.km = card, cardfont, km def build_shots(): R = np.random.RandomState(9091) shots = []; idx = 0; last = None for nm, b0, b1 in SECTIONS: if nm == "sunrise": # authored, not shuffled t = b0*BAR tot = sum(b for b, _, _ in SUNRISE) for j, (beats, eng, par) in enumerate(SUNRISE): t2 = min(t + beats*BEAT, b1*BAR) i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: shots.append(Shot(idx, i0, i1, eng, nm, par, None, None, KM[idx % len(KM)])) idx += 1 t = t2 continue pool, menu = PLAN[nm] t = b0*BAR; j = 0 order = list(range(len(pool))) R.shuffle(order) oi = 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.2: t2 = b1*BAR i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: cand = [q for q in order if pool[q][0] != last] or order pick = cand[oi % len(cand)]; oi += 1 eng, par = pool[pick] last = eng card, cf = (CARDS.get(nm, (None, None)) if j == 0 else (None, None)) shots.append(Shot(idx, i0, i1, eng, nm, par, card, cf, KM[idx % len(KM)])) if eng == "posts": shots[-1].params = dict(par, num=str(KM[idx % len(KM)])) idx += 1; j += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 shots[-1].card = LAST_CARD; shots[-1].cardfont = "georgia" return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> (crisp text) -> letterbox # ════════════════════════════════════════════════════════════════════════════ # ── 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="Menlo.ttc"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] _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.46*r**2.0, 0, 1)[..., None].astype(np.float32) return _VIG["v"] SUN_I0 = int(SECTIONS[-1][1]*BAR*FPS) def warmth(i): """Night is cold. The sunrise warms the whole grade, slowly.""" return float(np.clip((i - SUN_I0)/(6.0*BAR*FPS), 0, 1))**0.85 def post(arr, i, e, shot): a = np.asarray(arr, np.float32) # 1. tint wm = warmth(i) cool = np.array([0.86, 0.94, 1.14], np.float32) warm = np.array([1.14, 1.00, 0.86], np.float32) a = a * (cool*(1-wm) + warm*wm) # 2. vignette a = a * vignette() # 3. grain — film, and it is a NIGHT film, so the shadows are noisy. # Rolled at 720p and blown up NEAREST so the GRAIN SIZE scales with the # frame; the shadow-weighting stays at full resolution. rng = np.random.RandomState(4400 + i) lum = a.mean(2, keepdims=True)/255.0 if S == 1.0: g = rng.normal(0, 1.0, a.shape) else: g0 = rng.normal(0, 1.0, (int(H/S), int(W/S), 3)).astype(np.float32) g = np.stack([np.asarray(Image.fromarray(g0[..., c], "F") .resize((W, H), Image.NEAREST), np.float32) for c in range(3)], -1) a = a + g*(3.4 + 6.0*(1-lum)**2) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) # crisp text last — never through the channel work d = ImageDraw.Draw(out) hud = (168, 196, 214) if wm < 0.5 else (206, 176, 140) # (final cut) the dashboard keeps its clock and its distance-to-go. The # frame timecode and the section name were the renderer talking; they are # gone. clock = 4*3600 + 12*60 + int(i/FPS*47) d.text((sf(30), H-sf(52)), "%02d:%02d" % ((clock//3600) % 24, (clock//60) % 60), font=font(si(15)), fill=hud) d.text((sf(104), H-sf(52)), f"km {shot.km:03d}", font=font(si(15)), fill=hud) t = i/FPS for (t0, dt_, ln) in LINES: if t0 <= t < t0+dt_: al = min(1.0, (t-t0)/0.30)*min(1.0, (t0+dt_-t)/0.55) d.text((sf(34), H-sf(132)), ln, font=font(si(30), "Georgia Italic.ttf"), fill=tuple(int(c*al) for c in (244, 234, 220))) break if shot.card: age = i - shot.i0 hold = FPS*(3.4 if shot.cardfont == "georgia" else 2.4) if age < hold: al = min(1.0, age/6.0)*min(1.0, (hold-age)/14.0) if shot.cardfont == "impact": f, xy, col = font(si(56), "Impact.ttf"), (sf(34), sf(36)), (255, 255, 255) elif shot.cardfont == "georgia": f, xy, col = (font(si(34), "Georgia Italic.ttf"), (sf(34), H-sf(132)), (250, 240, 226)) else: f, xy, col = font(si(22)), (sf(34), sf(40)), (196, 226, 236) d.text(xy, shot.card, font=f, fill=tuple(int(c*al) for c in col)) if shot.cardfont == "impact": # the show mark, under the title f2 = font(si(17)) d.text((sf(38), sf(36) + si(64)), "P L A Y E R C O M P U T E R", font=f2, fill=tuple(int(c*al) for c in (146, 200, 224))) # 4. letterbox bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(0, 0, 0)) d.rectangle([0, H-bh, W, H], fill=(0, 0, 0)) return out # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() eng = ENGINES[shot.engine](shot, np.random.default_rng(shot.seed)) 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" u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) # ALWAYS step — exposure is stateful if p.exists() and not force: continue post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:6s} {shot.section:8s} {made}/{shot.n}" def contact_sheet(shots): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = 300, 193 sheet = Image.new("RGB", (cols*tw, rows*(th+26)), (10, 10, 14)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): eng = ENGINES[sh.engine](sh, np.random.default_rng(sh.seed)) mid = int(sh.n*0.62) arr = None; e = {kk: 0.4 for kk in E} for k in range(mid+1): i = sh.i0+k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(k, k/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.section} · {sh.i0/FPS:.1f}s " f"· t{sh.params.get('tau', 0.6)}", font=font(12), fill=(190, 195, 205)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") 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("--audio", default=None) ap.add_argument("--jobs", type=int, default=min(12, os.cpu_count())) a = ap.parse_args() wav = Path(a.audio) if a.audio else AUD/"final.wav" if a.audio is None and (not wav.exists() or not (AUD/"env.npz").exists() or a.force): print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") wav, mix = build_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots); return want = None if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") want = sel or None jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] 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) if want is not None: print("partial render — rerun with --mux-only to reassemble"); return missing = [i for i in range(N_FRAMES) if not (FRAMES/f"f{i:05d}.png").exists()] if missing: raise SystemExit(f"{len(missing)} frames missing, first={missing[0]}") print("[3/3] 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", "slow", "-crf", "21", "-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=generator renders/{SETDIR}/{NAME}/render.py | " f"{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, exposure buffer stateful per shot)\n" f"shots: {len(shots)}\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()