#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Wafer City (05/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/wafer_city # # A circuit board seen from the air, mistaken for a city. # # 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/wafer_city.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/wafer_city.mp4 # cover: https://genekogan.com/player_computer/media/wafer_city.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 wafer_city.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_final — "WAFER CITY" (round 2 of night_watch 15 "SOLDER CITY": same board, new score) F-Zero-style racing funk-rock, 155 bpm, A minor (dorian IV, borrowed E7). 42 bars. boot(4) commute(6) drop1(8) toll(4) drop2(10) blow(4) reroute(6) A printed circuit board read as a city seen from above. Solder-mask green over copper, gold ENIG pads as plazas, copper traces as roads with their 45-degree bends, silkscreen white as street names, through-hole components as monuments, the ground pour as parkland. Macro photography: a tilted focal plane, most of the frame out of focus, solder blobs glinting. A signal's morning commute: it leaves J1, takes the trace roads, waits at the R47 toll, gets bussed with seven others, hits congestion at a via — and then C12 lets go. Bright burst, scorch mark, the neighbourhood goes dark. The router runs again with the hole marked impassable and traffic reroutes around it forever after. The last shot is the crater, the traces politely curving around it, and the silkscreen footprint that still says its name. Composition: engine : audio-first x shot-parallel (tier 4-P), world-camera with rotation sampled out of a per-shot world raster by affine transform content: audio-groove (slap bass on unbroken 16ths, synth-brass stacks with a lip on the attack, 2-op FM lead with pitch-scoop, written rock kit) x effects-post x tts-voices (Zarvox as the board) FINAL CUT (player_computer_final): * 1920x1080 is now the *default* and only delivery. `--720p` still exists as a quick proof render; RS = H/720 as before. * The renderer-debug strip (section name bottom-left, running timecode bottom-right) is gone. The CARDS remain — they are the board's own signage, part of the fiction, not the renderer talking. * The opening WAFER CITY card carries "PLAYER COMPUTER" as a silkscreen subtitle beneath it, in the same white-on-green stencil register. Run from repo root: python3 renders/player_computer_final/wafer_city/render.py --sheet python3 renders/player_computer_final/wafer_city/render.py --jobs 3 python3 renders/player_computer_final/wafer_city/render.py --shots 30,31 --force python3 renders/player_computer_final/wafer_city/render.py --mux-only """ import argparse, datetime, hashlib, heapq, math, os, subprocess, sys, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "wafer_city" TITLE = "WAFER CITY" SETDIR = "player_computer_final" SETNUM = "15" # ---- resolution ------------------------------------------------------------ # The piece is authored at 1280x720. `--1080p` renders the *same* film at # 1920x1080 by multiplying every pixel-space constant — stroke widths, blur # radii, font sizes, HUD layout, raster budget, noise cell sizes — by # RS = H/720. World-space geometry is untouched: the board, the router, the # camera plan and the shot list are all resolution-independent, so both # resolutions are frame-for-frame the same movie at different densities. HD = ("--720p" not in sys.argv) # 1080p is the delivery; --720p is a proof W, H, FPS = ((1920, 1080) if HD else (1280, 720)) + (30,) RS = H / 720.0 # the global pixel-space scale factor MINW = max(1, int(round(RS))) # minimum stroke width in pixels def PS(v): return v*RS # scale a pixel length (float) def PSi(v): return int(v*RS + 0.5) # scale a pixel length (int) def FL(n): return max(1, int(n*RS + 0.5)) # a scaled pixel floor BPM = 155.0 BEAT = 60.0 / BPM BAR = 4 * BEAT ST16 = BEAT / 4 SR = 44100 OUT = Path(__file__).resolve().parent FRAMES = OUT / ("frames" if HD else "frames_720p"); FRAMES.mkdir(exist_ok=True) SUF = "" if HD else "_720p" AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = OUT # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" SECTIONS = [ ("boot", 0, 4), ("commute", 4, 10), ("drop1", 10, 18), ("toll", 18, 22), ("drop2", 22, 32), ("blow", 32, 36), ("reroute", 36, 42), ] N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 1.9 N_FRAMES = int(DUR * FPS) BLOW_BAR = 32 BLOW_T = BLOW_BAR * BAR BLOW_F = int(BLOW_T * FPS) MUSIC_DESC = (f"F-Zero-style racing funk-rock, {BPM:.0f}bpm, A minor with the " f"dorian IV and a borrowed E7, {N_BARS} bars") ENGINE_DESC = "generated PCB city: octile router, solder mask, ENIG gold, tilted-plane macro DOF" # ════════════════════════════════════════════════════════════════════════════ # 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 — no raw full-band blasts anywhere in the kit (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 moving_band(x, lo_curve, hi_curve, blk=512): """Block-wise band shaping with a *moving* cutoff — the cheap way to get a filter that actually sweeps, which is the whole personality of a hoover.""" n = len(x); out = np.zeros(n) if n < 16: return x win = np.hanning(blk*2) for i in range(0, n, blk): j = min(n, i + blk*2) seg = x[i:j] if len(seg) < 16: out[i:j] += seg; continue u = i / max(1, n) k = int(u*(len(lo_curve)-1)) y = bandshape(seg*win[:len(seg)], lo=float(lo_curve[k]), hi=float(hi_curve[k])) out[i:j] += y return out def nsaw(f_arr): """Naive (aliasing) sawtooth from a per-sample frequency array. The aliasing is wanted here — 1992 hardware aliased gloriously.""" ph = np.cumsum(f_arr)/SR return 2.0*(ph - np.floor(ph)) - 1.0 def npulse(f_arr, width=0.5): ph = np.cumsum(f_arr)/SR return np.where((ph - np.floor(ph)) < width, 1.0, -1.0) def chorus(x, depth=0.004, rate=0.7, voices=3, mix=0.5, seed=3): n = len(x); t = np.arange(n)/SR; out = x*(1-mix*0.5) rng = np.random.RandomState(seed) for v in range(voices): ph = rng.uniform(0, 2*np.pi) dl = (0.006 + depth*(0.5+0.5*np.sin(2*np.pi*rate*(1+0.21*v)*t + ph)))*SR idx = np.clip(np.arange(n) - dl, 0, n-1) out = out + np.interp(idx, np.arange(n), x)*(mix/voices) return out def kick(dur=.30, f0=170, f1=50, punch=34, click=.55, seed=1, drive=1.9): 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*11.0) ck = bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=7000) \ * np.exp(-t*240) * click return np.tanh((body + ck) * drive)*.95 def snare(dur=.22, tone=214, bright=1.0, seed=2, snap=1.0): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=340, hi=7600) body = np.sin(2*np.pi*tone*t) + .55*np.sin(2*np.pi*tone*1.63*t) y = nz*np.exp(-t*17)*.9*bright + body*np.exp(-t*30)*.5 crack = bandshape(rng.randn(n), lo=2600, hi=9000)*np.exp(-t*130)*.5*snap return y + crack 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=6200, hi=11500) return nz * np.exp(-t*(13 if openh else 92)) * .40 def ride(dur=.55, seed=9): n = int(dur*SR); t = np.arange(n)/SR bell = sum(np.sin(2*np.pi*f*t) for f in (612, 928, 1310, 1744, 2311)) nz = bandshape(np.random.RandomState(seed).randn(n), lo=4200, hi=9600) return bell*np.exp(-t*11)*.09 + nz*np.exp(-t*5.4)*.15 def crash(dur=1.5, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1500, hi=9000) return nz * (np.exp(-t*2.8) + .3*np.exp(-t*.65)) * .55 def revcrash(dur=1.4, seed=131): return crash(dur, seed)[::-1].copy() * np.linspace(.25, 1.0, int(dur*SR)) def riser(dur=2.0, seed=17, f_lo=200, f_hi=5200): n = int(dur*SR); t = np.arange(n)/SR env = (t/dur) ** 1.6 sweep = np.sin(2*np.pi*np.cumsum(150 + 2600*(t/dur)**2)/SR) rng = np.random.RandomState(seed) nz = np.zeros(n); blk = 2048 for i in range(0, n, blk): u = (i/max(1, n)) ** 1.35 fc = f_lo + (f_hi-f_lo)*u m = min(blk, n-i) seg = rng.randn(m + 256) nz[i:i+m] = bandshape(seg, lo=fc*.75, hi=fc*1.6)[:m] return (nz*env*.55 + sweep*env*.22) * .8 def impact(dur=2.0, seed=139): n = int(dur*SR); t = np.arange(n)/SR f = 30 + 90*np.exp(-t*18) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*2.2) nz = bandshape(np.random.RandomState(seed).randn(n), lo=50, hi=2400)*np.exp(-t*14) return (body + nz*0.45)*0.9 def zap(dur=0.5, seed=57): """The capacitor letting go: a crack, a dielectric tear, and a hiss.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) crack = bandshape(rng.randn(n), lo=1200, hi=12000)*np.exp(-t*46) tear = np.sin(2*np.pi*np.cumsum(2400*np.exp(-t*24) + 90)/SR)*np.exp(-t*10) hiss = bandshape(rng.randn(n), lo=2200, hi=6800)*np.exp(-t*3.2)*.5 return np.tanh((crack*1.1 + tear*.8 + hiss)*1.6)*.9 _HOOV = {} def hoover(freq, dur, sweep=0.62, drive=2.6, seed=11): """The Alpha Juno 'What The' stab: a detuned aliasing saw stack whose pitch falls out of a sixth, through a filter that closes as it lands, chorused until it smears. This is the single most 1992 sound there is.""" key = (round(freq, 3), round(dur, 4), sweep) if key in _HOOV: return _HOOV[key] n = int(dur*SR); t = np.arange(n)/SR bend = 2.0 ** (sweep * np.exp(-t*8.5)) out = np.zeros(n) for det in (-0.030, -0.017, -0.006, 0.0, 0.008, 0.019, 0.033): out += nsaw(freq*bend*(1+det)) out += 0.7*npulse(freq*0.5*bend, 0.36) out += 0.4*nsaw(freq*2*bend*1.004) out /= 8.0 lo = np.full(48, 90.0) hi = np.linspace(6400, 1500, 48) out = moving_band(out, lo, hi) out = chorus(out, depth=0.006, rate=0.9, voices=3, mix=0.65, seed=seed) out = np.tanh(out*drive)/np.tanh(drive) y = out * adsr(n, .004, .10, .78, .13) _HOOV[key] = y return y _PIANO = {} def piano(freq, dur, bright=1.0, seed=5): """Rave piano — bright, slightly stretched, two strings a hair apart.""" key = (round(freq, 3), round(dur, 4), round(bright, 2)) if key in _PIANO: return _PIANO[key] n = int(dur*SR); t = np.arange(n)/SR out = np.zeros(n) B = 0.00045 for s, dt in ((0, 1.0), (1, 1.0016)): for k in range(1, 15): fk = freq*k*math.sqrt(1 + B*k*k)*dt if fk > SR*0.45: break amp = (1.0/(k**1.25)) * (1.0 if k < 5 else bright) out += amp*np.sin(2*np.pi*fk*t + s*1.1)*np.exp(-t*(2.6 + 1.15*k)) ham = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=7000)*np.exp(-t*120)*.25 y = (out/6.0 + ham) * adsr(n, .002, .5, .35, .18) _PIANO[key] = y return y def organstab(freq, dur, seed=19): """Stabby organ chord voice — square-ish drawbars, hard gate.""" n = int(dur*SR); t = np.arange(n)/SR out = np.zeros(n) for k, a in ((1, 1.0), (2, .7), (3, .45), (4, .35), (6, .22), (8, .16)): f = freq*k if f > SR*0.45: break out += a*np.sin(2*np.pi*f*t + k*0.4) out = np.tanh(out*1.5)/1.5 return out*adsr(n, .003, .06, .55, .06)*0.5 def airhorn(dur=1.1, f0=392.0, seed=23): """Soundsystem air horn. Bends up, vibratos, and is far too loud.""" n = int(dur*SR); t = np.arange(n)/SR bend = 1 + 0.075*(1-np.exp(-t*22)) + 0.012*np.sin(2*np.pi*6.2*t)*np.clip(t*4, 0, 1) out = np.zeros(n) for mul, g in ((1.0, 1.0), (1.5, .55), (2.0, .45), (3.0, .22)): for det in (-0.006, 0.0, 0.007): out += g*nsaw(f0*mul*bend*(1+det)) out /= 9.0 out = bandshape(out, lo=260, hi=5200) out = np.tanh(out*3.4)/np.tanh(3.4) env = np.clip(t*30, 0, 1) * np.clip((dur-t)*9, 0, 1) return out*env*0.7 def siren(dur=2.0, f0=520, rate=3.0, seed=29): n = int(dur*SR); t = np.arange(n)/SR f = f0*2**(0.55*np.sin(2*np.pi*rate*t)) y = nsaw(f)*0.5 + np.sin(2*np.pi*np.cumsum(f)/SR)*0.5 y = bandshape(y, lo=300, hi=4200) return y*np.clip(t*6, 0, 1)*np.clip((dur-t)*4, 0, 1)*0.5 def reverb(x, rt=1.6, 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=.38, mix=.25, taps=8): 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 def limit(x, ceil=0.9): p = np.max(np.abs(x)) + 1e-9 return np.tanh(x/p*1.5)/np.tanh(1.5)*ceil # ════════════════════════════════════════════════════════════════════════════ # THE F-ZERO VOICES # # Everything below exists to do one job: a 1990 racing-game soundtrack played # by a pretend sound chip. Three things carry that idiom and nothing else # does — # * a slap bass running unbroken sixteenths, with octave pops and a # chromatic approach note into every chord change; # * synth-brass stacks whose upper partials fade IN across the attack, so a # chord stab has a lip on it instead of a click; # * a two-operator FM lead that scoops up into its notes and shakes at the # top, because the hardware bent pitch and could not do anything else. # The drums are a rock kit, written out as one character per sixteenth, not # a chopped break — the break was the rave record; this is the arcade. # ════════════════════════════════════════════════════════════════════════════ def fbass(freq, dur, g=1.0, bright=1.0, growl=2.3, seed=0): """Slap bass. The filter sweep is free: every harmonic gets its own decay rate, faster the higher it sits — which IS a lowpass closing on a pluck.""" n = int(dur*SR) if n < 8: return np.zeros(0) t = np.arange(n)/SR x = 0.95*np.sin(2*np.pi*freq*t)*np.exp(-t*2.4) for k in range(2, 17): fk = freq*k if fk > 7200: break amp = (1.0/k)*(1.0 if k % 2 else 0.50) x += amp*np.sin(2*np.pi*fk*t + k*0.61) * \ np.exp(-t*(4.0 + 13.0*(k-1)/max(.35, bright))) ck = bandshape(np.random.RandomState(seed+7).randn(n), lo=800, hi=5400) x = np.tanh((x + ck*np.exp(-t*300)*0.22*bright)*growl) return x*adsr(n, .0015, .030, .82, .035)*g*0.50 def synbrass(freq, dur, g=1.0, seed=0, det=0.009, atk=0.030, vib=5.4, vdep=0.005): """Detuned saw stack whose top opens across the attack — the lip of a brass section, faked the way a 1990 chip faked it.""" n = int(dur*SR) if n < 8: return np.zeros(0) t = np.arange(n)/SR lfo = 1.0 + vdep*np.sin(2*np.pi*vib*t)*np.clip((t-0.10)*5.0, 0, 1) x = np.zeros(n) for d in (-det, 0.0, det*1.15): x += nsaw(freq*(1+d)*lfo) x += 0.55*npulse(freq*lfo, 0.30) k = 24 u = np.clip(np.linspace(0, dur, k)/max(1e-3, atk), 0, 1) x = moving_band(x/3.6, np.full(k, 70.0), 700 + 4500*u**0.7) return np.tanh(x*1.6)*adsr(n, atk*0.8, .10, .78, .10)*g*0.42 def fmlead(freq, dur, g=1.0, bend=0.0, bendt=0.055, ratio=2.0, index=3.4, idec=5.5, vib=6.2, vdep=0.010, seed=0): """Two-operator FM lead; `bend` semitones scooped into the note.""" n = int(dur*SR) if n < 8: return np.zeros(0) t = np.arange(n)/SR sc = 2.0**((bend*np.exp(-t/max(1e-3, bendt)))/12.0) vb = 1.0 + vdep*np.sin(2*np.pi*vib*t)*np.clip((t-0.13)*4.5, 0, 1) ph = 2*np.pi*np.cumsum(freq*sc*vb)/SR mod = np.sin(ph*ratio)*index*np.exp(-t*idec) x = np.sin(ph + mod) + 0.22*np.sin(2*ph + mod*0.6) return x*adsr(n, .004, .12, .70, .09)*g*0.42 def powerchord(freq, dur, g=1.0, seed=0): """Root + fifth + octave through a hot amp — the mode-mixture riff voice.""" n = int(dur*SR) if n < 8: return np.zeros(0) x = np.zeros(n) for mul, det in ((1.0, -0.004), (1.0, 0.005), (1.4983, 0.0), (2.0, 0.003)): x += nsaw(np.full(n, freq*mul*(1+det))) x = np.tanh(bandshape(x/4.0, lo=150, hi=3800)*4.2)/np.tanh(4.2) return x*adsr(n, .004, .16, .62, .10)*g*0.36 def tom(freq=180.0, dur=0.26, seed=3, g=1.0): n = int(dur*SR); t = np.arange(n)/SR f = freq*(1 + 0.55*np.exp(-t*22)) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*9.0) nz = bandshape(np.random.RandomState(seed).randn(n), lo=200, hi=3600) \ * np.exp(-t*40)*.22 return (body + nz)*0.8*g # ── the kit, one character per sixteenth ──────────────────────────────────── # 1 e + a 2 e + a 3 e + a 4 e + a KIT = { "boot": {"h": "x...x...x...x..x", "K": "x.............x.", "H": "..............x."}, "drive": {"K": "x..x..x...x.....", "S": "....x.......x...", "g": "..x....x..x...x.", "h": "xxxxxxxxxxxxxxxx", "H": "..........x....."}, "drive2": {"K": "x..x..x...x..x..", "S": "....x.......x...", "g": "..x.......x.x...", "h": "xxxxxxxxxxxxxxxx", "H": "..............x."}, "hard": {"K": "x..x..x.x.x..x..", "S": "....x.......x...", "g": "..x....x......x.", "h": "xxxxxxxxxxxxxxxx", "r": "x.x.x.x.x.x.x.x.", "H": "......x........."}, "half": {"K": "x.......x.......", "S": "........x.......", "r": "x...x...x...x...", "g": "..............x."}, "shuffle":{"K": "x.....x.....x...", "S": "......x.....x...", "r": "x..x..x..x..x..x", "g": "...x.....x......"}, "sparse": {"K": "x.............x.", "r": "x.......x.......", "g": ".......x........"}, "fill": {"K": "x..x............", "S": "....x...........", "T": "........x.x.....", "t": "............x.x.", "h": "xxxxxxxx........"}, "rollup": {"S": "x.x.x.xxx.xxxxxx", "K": "x.......x......."}, } # ════════════════════════════════════════════════════════════════════════════ # VOICE # ════════════════════════════════════════════════════════════════════════════ def _h(*parts): return hashlib.md5("|".join(str(p) for p in parts).encode()).hexdigest()[:16] def read_wav(p): with wave.open(str(p)) as w: ch = w.getnchannels() x = np.frombuffer(w.readframes(w.getnframes()), " 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, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, voice, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def resample(x, factor): """Pitch and speed together — the chipmunk, not a pitch shift.""" n = max(2, int(len(x)/factor)) return fit(x, n) _SAYC = {} def diva(text, pitch=1.55, voice="Samantha", rate=190): """The ridiculous pitched-up rave vocal: say it, then run the tape fast.""" key = (text, voice, rate, pitch) if key in _SAYC: return _SAYC[key] raw = say_wav(text, voice, rate, AUD/("say_"+_h(text, voice, rate)+".wav")) y = resample(raw, pitch) y = bandshape(y, lo=180, hi=9000) y = y/(np.max(np.abs(y))+1e-9) _SAYC[key] = y return y def robot(text, voice="Zarvox", rate=170): raw = say_wav(text, voice, rate, AUD/("say_"+_h(text, voice, rate)+".wav")) return raw/(np.max(np.abs(raw))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # SONG # ════════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0, swing=0.0): sw = swing*ST16 if (step % 2) else 0.0 return bar*BAR + step*ST16 + sw 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.30): 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=.34, 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) 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.3)/np.tanh(1.3) 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("= 2: brass_chord(bar, (0,), ln=14, g=.55, atk=0.34) elif sec == "commute": kit_bar(bar, "drive" if bar % 2 == 0 else "drive2", g=.80, seed=bar) bass_bar(bar, cell, g=.95, bright=.85) brass_chord(bar, (2, 6, 10, 14), ln=1.4, g=.42) if bar >= 7: # the lead sticks its head up lead_bar(bar, THEME[(bar-6) % 4], g=.34, oct_=8.0, pan=.16) if bar == 9: kit_bar(bar, "rollup", g=.60) elif sec == "drop1": j = bar-10 kit_bar(bar, "hard" if j >= 4 else "drive2", g=1.0, seed=bar) bass_bar(bar, cell, g=1.0, bright=1.15) lead_bar(bar, THEME[j % 4], g=.72, oct_=8.0, pan=.12) lead_bar(bar, THEME[j % 4], g=.34, oct_=4.0, voice="brass", pan=-.20) brass_chord(bar, (0, 6, 10) if j % 2 == 0 else (0, 3, 8, 14), ln=1.3, g=.40) if j >= 4: riff(bar, [(0, 4), (6, 2), (10, 4), (14, 2)], g=.42) if j == 7: kit_bar(bar, "rollup", g=.62) elif sec == "toll": # the only place the car stops moving: a half-time shuffle sw = 0.30 kit_bar(bar, "shuffle" if bar < 21 else "rollup", g=.62, swing=sw, seed=bar) bass_bar(bar, [(0,0,3),(3,7,2),(6,10,2),(8,0,3),(11,7,2),(14,12,2)], g=.72, swing=sw, bright=.55) brass_chord(bar, (0,), ln=15, g=.50, atk=0.22) if bar in (19, 21): lead_bar(bar, [(4,19,4,-2),(8,17,2,0),(10,15,2,0),(12,12,4,0)], g=.44, oct_=8.0, pan=-.10, swing=sw) elif sec == "drop2": j = bar-22 kit_bar(bar, "hard" if j % 4 != 3 else "drive2", g=1.05, seed=bar) bass_bar(bar, cell, g=1.05, bright=1.3) lead_bar(bar, THEME_B[j % 4], g=.76, oct_=8.0, pan=.12) lead_bar(bar, THEME_B[j % 4], g=.30, oct_=4.0, voice="brass", pan=-.24) brass_chord(bar, (0, 3, 6, 10, 14), ln=1.2, g=.44) riff(bar, [(0, 4), (4, 2), (6, 2), (10, 4), (14, 2)], g=.46) if j == 9: kit_bar(bar, "rollup", g=.72) elif sec == "blow": j = bar-32 if j == 0: # the capacitor lets go: nothing pass elif j == 1: kit_bar(bar, "sparse", g=.34) riff(bar, [(0, 12)], g=.30, oct_=1.0) elif j == 2: kit_bar(bar, "half", g=.48) bass_bar(bar, [(0,0,6),(8,0,4),(12,7,4)], g=.55, bright=.45) brass_chord(bar, (0,), ln=15, g=.34, atk=0.40) else: kit_bar(bar, "drive", g=.62, seed=bar) bass_bar(bar, B_CELL[0], g=.72, bright=.7) brass_chord(bar, (0, 8), ln=3.0, g=.38) else: # reroute j = bar-36 kit_bar(bar, "drive2" if j < 3 else "hard", g=.86+0.05*j, seed=bar) bass_bar(bar, cell, g=.92, bright=.95) lead_bar(bar, THEME[j % 4], g=.52+0.06*j, oct_=8.0, pan=.10) brass_chord(bar, (0, 6, 10, 14), ln=1.4, g=.42) if j >= 3: riff(bar, [(0, 4), (8, 4), (12, 4)], g=.40) if j == 5: # the landing riff(bar, [(0, 16)], g=.62) brass_chord(bar, (0,), ln=17, g=.66, atk=0.05) s.put("lead", fmlead(ROOT_A*16, 2.6, g=.62, bend=-4, bendt=.10, index=3.6, seed=777), s.t(bar, 0), g=.86, pan=.08) # ── the board powering up ─────────────────────────────────────────────── n = int(3.6*BAR*SR); t = np.arange(n)/SR hum = sum((1.0/k)*np.sin(2*np.pi*50*k*t + k*0.7) for k in (1, 2, 3, 5)) hum *= np.clip(t*2.4, 0, 1)*np.clip((3.6*BAR-t)*0.9, 0, 1)*0.10 s.put("fx", hum, 0.0, g=1.15) rail = np.sin(2*np.pi*np.cumsum(np.linspace(48, 220, n))/SR) * \ (np.linspace(0, 1, n)**2)*0.10 s.put("fx", bandshape(rail, lo=40, hi=1800), 0.0, g=.5, pan=.15) for q, at in enumerate((0.9, 1.35, 1.6, 2.4, 2.9, 3.15, 3.3)): s.put("fx", hat(.028, seed=91+q)*0.5, at, g=.30, pan=-.4+.13*q) # ── transitions ───────────────────────────────────────────────────────── for b in (10, 22): s.put("fx", riser(BAR*3.2), (b-3.2)*BAR, g=.26) s.put("fx", crash(1.5), b*BAR, g=.34, pan=.08) s.put("fx", riser(BAR*2.6), (18-2.6)*BAR, g=.18) s.put("fx", revcrash(1.3), 22*BAR - 1.3, g=.26) s.put("fx", crash(1.3), 4*BAR, g=.24) s.put("fx", crash(1.3), 14*BAR, g=.20, pan=-.1) s.put("fx", crash(1.3), 26*BAR, g=.20, pan=.12) s.put("fx", riser(BAR*2.0, f_lo=300, f_hi=7000), 30*BAR, g=.30) s.put("fx", crash(1.4), 36*BAR, g=.26) s.put("fx", crash(2.2), 41*BAR, g=.34, pan=.05) # the checkered-flag fanfare answering the last chord for q, (st, semi) in enumerate(((0, 12), (2, 19), (4, 24), (6, 28))): s.put("brass", synbrass(ROOT_A*8*2**(semi/12.0), (10-2*q)*ST16, g=.52, atk=0.022, seed=880+q), s.t(41, st), g=.62, pan=-.20+.13*q) # ── the blow ──────────────────────────────────────────────────────────── s.put("fx", zap(0.6), BLOW_T, g=1.0) s.put("fx", impact(2.4), BLOW_T, g=.62) s.put("fx", crash(1.8), BLOW_T, g=.30) n = int(1.6*SR); t = np.arange(n)/SR s.put("fx", np.sin(2*np.pi*3120*t)*np.exp(-t*1.6)*.09, BLOW_T+0.05, g=.5, pan=.4) for i, at in enumerate((0.8, 1.35, 2.1, 3.0)): s.put("fx", hat(.03, seed=61+i)*0.8, BLOW_T+at, g=.3, pan=-.4+.27*i) # ── voices ────────────────────────────────────────────────────────────── s.put("vox", robot("wafer city.")*.7, 0.55, g=.30, pan=-.1) s.put("vox", robot("all lanes clear.")*.6, 2.0*BAR, g=.24, pan=.12) s.put("vox", robot("toll. four kay seven.")*.7, 18*BAR + 2*ST16, g=.30, pan=.05) s.put("vox", robot("bus zero through seven.")*.65, 22*BAR + 8*ST16, g=.26, pan=-.08) s.put("vox", robot("congestion at via nine.")*.7, 29*BAR, g=.28, pan=.1) s.put("vox", robot("see twelve is gone.")*.75, BLOW_T + 2.6, g=.34, pan=0.0) s.put("vox", robot("rerouting. permanently.")*.7, 36*BAR + 6*ST16, g=.30, pan=-.05) # carve the low end so the slap bass owns 80-250 alone and the brass and # lead own the register above it — the whole point of the idiom is that # you can hear the tune over the engine s.bus("bass", lambda x: bandshape(x, lo=42, hi=3400)) s.bus("brass", lambda x: reverb(bandshape(x, lo=180), rt=1.5, mix=.20, seed=71)) s.bus("lead", lambda x: reverb(delay(bandshape(x, lo=280), BEAT*.75, .28, .17), rt=1.9, mix=.24, seed=73)) s.bus("riff", lambda x: reverb(bandshape(x, lo=150), rt=1.1, mix=.14, seed=75)) s.bus("vox", lambda x: reverb(delay(limit(x, .85), BEAT*.75, .36, .30), rt=2.2, mix=.36, seed=77)) s.bus("fx", lambda x: reverb(x, rt=2.4, mix=.30, seed=79)) s.bus("kit", lambda x: reverb(x, rt=0.8, mix=.11, seed=83)) mix = s.mixdown(dict(kit=1.0, bass=0.92, brass=1.12, lead=1.22, riff=1.0, fx=1.0, vox=1.0), pump_depth=.22, pump_rel=.11, levels=dict(boot=.58, commute=.84, drop1=1.0, toll=.66, drop2=1.04, blow=.44, reroute=.92)) 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) 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: 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 BOARD — a generated PCB. This is the new substrate. # # Layout lives on a 24-unit grid. Components claim cells; an octile A* router # lays copper between pads with genuine 45-degree bends, avoiding footprints # and (softly) each other. The ground pour is everything left over, minus a # clearance ring around every conductor — which is what makes a board look # like a board. After the blast the router simply runs again with the crater # marked impassable, so the reroute is generated, not drawn. # ════════════════════════════════════════════════════════════════════════════ G = 24.0 GX, GY = 70, 46 BW, BH = GX*G, GY*G CLR = 8.5 # pour clearance, world units def cx_(i): return i*G + G/2 def cy_(j): return j*G + G/2 DIRS = [(1,0),(1,1),(0,1),(-1,1),(-1,0),(-1,-1),(0,-1),(1,-1)] def astar(start, goal, blocked, cost, turn_pen=1.1, cap=200000): if start == goal: return [start] sx, sy = start; gx, gy = goal def hh(i, j): dx, dy = abs(i-gx), abs(j-gy) return (max(dx, dy) + 0.4142*min(dx, dy)) openh = [(hh(sx, sy), 0.0, sx, sy, -1)] best = {} par = {} seen = 0 while openh: f, g, i, j, d = heapq.heappop(openh) seen += 1 if seen > cap: return None key = (i, j, d) if key in best and best[key] <= g: continue best[key] = g if (i, j) == goal: path = [(i, j)]; k = key while k in par: k = par[k]; path.append((k[0], k[1])) path.reverse() return path for nd, (dx, dy) in enumerate(DIRS): ni, nj = i+dx, j+dy if not (0 <= ni < GX and 0 <= nj < GY): continue if blocked[nj, ni] and (ni, nj) != goal: continue step = 1.4142 if (dx and dy) else 1.0 ng = g + step*(1.0 + cost[nj, ni]) + (turn_pen if (d >= 0 and nd != d) else 0.0) nk = (ni, nj, nd) if nk in best and best[nk] <= ng: continue par[nk] = (i, j, d) heapq.heappush(openh, (ng + hh(ni, nj), ng, ni, nj, nd)) return None def collapse(cells): """Cell path -> polyline, merging collinear runs. What survives is long straights joined by 45-degree turns.""" if len(cells) < 2: return [(cx_(c[0]), cy_(c[1])) for c in cells] out = [cells[0]] for k in range(1, len(cells)-1): a, b, c = cells[k-1], cells[k], cells[k+1] if (b[0]-a[0], b[1]-a[1]) != (c[0]-b[0], c[1]-b[1]): out.append(b) out.append(cells[-1]) return [(cx_(i), cy_(j)) for (i, j) in out] STREETS = ["VCC AVE", "GND PARK", "CLK LANE", "MISO ST", "MOSI ROW", "SDA WAY", "SCL WAY", "RESET BLVD", "XTAL SQ", "TX HILL", "RX HILL", "IRQ ALLEY", "EN LOOP", "A0 ROW", "A1 ROW", "A2 ROW", "D+ RISE", "D- RISE", "SENSE ST", "OE LANE", "WR CRES", "RD CRES", "CS COURT", "NC MEWS", "VREF WALK", "AGND GRN"] class Board: """Everything geometric. Built once at import; deterministic.""" def __init__(self, seed=1992): rng = np.random.RandomState(seed) self.rng = rng self.blocked = np.zeros((GY, GX), bool) self.cost = np.zeros((GY, GX), np.float64) self.comps = [] self.pads = [] # (x, y, r, kind) kind: 'th'|'smd'|'via' self.silk = [] # (text, x, y, size, angle) self.outlines = [] # (kind, args) self.free_pads = [] # routable cell endpoints self._layout(rng) self._route(rng) self._blast() # ---------------------------------------------------------------- layout def _claim(self, i0, j0, w, h, pad=1): if i0-pad < 0 or j0-pad < 0 or i0+w+pad > GX or j0+h+pad > GY: return False if self.blocked[max(0, j0-pad):j0+h+pad, max(0, i0-pad):i0+w+pad].any(): return False self.blocked[j0:j0+h, i0:i0+w] = True return True def _layout(self, rng): dips, caps, res, misc = [], [], [], [] ic_n = 0; r_n = 0; c_n = 0; d_n = 0; q_n = 0 # --- big ICs, the downtown blocks for _ in range(400): if len(dips) >= 7: break w = int(rng.choice([6, 8, 10])); h = 5 i0 = int(rng.randint(4, GX-w-4)); j0 = int(rng.randint(3, GY-h-3)) if not self._claim(i0, j0, w, h, pad=2): continue ic_n += 1 pads = [] for i in range(i0+1, i0+w-1): pads.append((i, j0)); pads.append((i, j0+h-1)) self.comps.append(dict(kind="dip", i0=i0, j0=j0, w=w, h=h, pads=pads, label=f"U{ic_n}", seed=int(rng.randint(1e6)))) dips.append(self.comps[-1]) # --- electrolytic monuments for _ in range(400): if len(caps) >= 6: break i0 = int(rng.randint(3, GX-6)); j0 = int(rng.randint(3, GY-6)) if not self._claim(i0, j0, 4, 4, pad=1): continue c_n += 1 pads = [(i0, j0+2), (i0+3, j0+2)] self.blocked[j0+2, i0] = True; self.blocked[j0+2, i0+3] = True self.comps.append(dict(kind="cap", i0=i0, j0=j0, w=4, h=4, pads=pads, label=f"C{c_n}", seed=int(rng.randint(1e6)), rad=1.55*G)) caps.append(self.comps[-1]) # --- resistors, everywhere, like utility poles for _ in range(900): if len(res) >= 26: break hor = rng.rand() < .6 w, h = (4, 1) if hor else (1, 4) i0 = int(rng.randint(2, GX-w-2)); j0 = int(rng.randint(2, GY-h-2)) if not self._claim(i0, j0, w, h, pad=1): continue r_n += 1 pads = [(i0, j0), (i0+w-1, j0+h-1)] self.comps.append(dict(kind="res", i0=i0, j0=j0, w=w, h=h, pads=pads, hor=hor, label=f"R{r_n}", val=rng.choice( ["10K", "4K7", "220", "1K", "47R", "100K"]), seed=int(rng.randint(1e6)))) res.append(self.comps[-1]) # --- crystals / LEDs / transistors / headers for _ in range(600): if len(misc) >= 14: break k = rng.choice(["xtal", "led", "q", "smd"]) w, h = dict(xtal=(4, 2), led=(3, 1), q=(2, 2), smd=(3, 1))[k] i0 = int(rng.randint(2, GX-w-2)); j0 = int(rng.randint(2, GY-h-2)) if not self._claim(i0, j0, w, h, pad=1): continue if k == "xtal": pads = [(i0, j0), (i0+w-1, j0+h-1)]; lab = "Y1" elif k == "led": d_n += 1; pads = [(i0, j0), (i0+w-1, j0)]; lab = f"D{d_n}" elif k == "q": q_n += 1; pads = [(i0, j0), (i0+1, j0), (i0, j0+1)]; lab = f"Q{q_n}" else: r_n += 1; pads = [(i0, j0), (i0+w-1, j0)]; lab = f"R{r_n}" self.comps.append(dict(kind=k, i0=i0, j0=j0, w=w, h=h, pads=pads, label=lab, seed=int(rng.randint(1e6)))) misc.append(self.comps[-1]) # --- the header on the left edge: where the commute starts j0 = GY//2 - 4 for j in range(j0, j0+8): self.blocked[j, 1] = True self.comps.append(dict(kind="hdr", i0=1, j0=j0, w=1, h=8, pads=[(1, j) for j in range(j0, j0+8)], label="J1", seed=7)) self.home = (1, j0+4) # --- the BGA pad field: a plaza self.bga_pads = [] placed = None for _ in range(600): bi = int(rng.randint(6, GX-11)); bj = int(rng.randint(4, GY-11)) if self.blocked[bj-1:bj+8, bi-1:bi+8].any(): continue placed = (bi, bj); break if placed is None: placed = (GX-12, GY-11) bi, bj = placed self.blocked[bj:bj+7, bi:bi+7] = True self.comps.append(dict(kind="bga", i0=bi, j0=bj, w=7, h=7, pads=[], label="U9", seed=11)) for j in range(7): for i in range(7): if (i in (3,) and j in (3,)): continue self.bga_pads.append((cx_(bi+i), cy_(bj+j))) self.bga = (cx_(bi)+3.0*G, cy_(bj)+3.0*G) # --- the toll: rename a resistor near the middle-left to R47 / 4K7 target = min(res, key=lambda c: (c["i0"]-GX*0.30)**2 + (c["j0"]-GY*0.5)**2) target["label"] = "R47"; target["val"] = "4K7"; target["toll"] = True self.toll = target # --- the hero capacitor hero = min(caps, key=lambda c: (c["i0"]-GX*0.66)**2 + (c["j0"]-GY*0.40)**2) hero["label"] = "C12"; hero["hero"] = True; hero["rad"] = 1.9*G self.c12 = hero self.c12_xy = (cx_(hero["i0"])+1.5*G, cy_(hero["j0"])+1.5*G) # --- vias: the plazas / roundabouts self.vias = [] for _ in range(900): if len(self.vias) >= 46: break i = int(rng.randint(2, GX-2)); j = int(rng.randint(2, GY-2)) if self.blocked[j-1:j+2, i-1:i+2].any(): continue self.blocked[j, i] = True self.vias.append((i, j)) # the congestion via — nearest to a third of the way past the bus self.hub = min(self.vias, key=lambda v: (v[0]-GX*0.55)**2 + (v[1]-GY*0.62)**2) # routable endpoints for c in self.comps: for p in c["pads"]: self.free_pads.append(p) self.free_pads += self.vias # pad geometry for painting for c in self.comps: for (i, j) in c["pads"]: kind = "smd" if c["kind"] in ("smd",) else "th" r = 9.5 if kind == "th" else 7.5 if c["kind"] == "dip": r = 9.0 if c["kind"] == "hdr": r = 10.0 self.pads.append((cx_(i), cy_(j), r, kind, c["kind"])) for (i, j) in self.vias: self.pads.append((cx_(i), cy_(j), 6.2, "via", "via")) for (px, py) in self.bga_pads: self.pads.append((px, py, 8.0, "smd", "bga")) # ---------------------------------------------------------------- routing def _pathcost(self, path, bump=7.0): for (i, j) in path: self.cost[j, i] += bump for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): ni, nj = i+dx, j+dy if 0 <= ni < GX and 0 <= nj < GY: self.cost[nj, ni] = max(0.0, self.cost[nj, ni] - 0.10) def _route_pair(self, a, b, turn_pen=1.1): bl = self.blocked.copy() bl[a[1], a[0]] = False; bl[b[1], b[0]] = False return astar(a, b, bl, self.cost, turn_pen=turn_pen) def _route(self, rng): self.nets = [] def add(path, cls, name=None, width=None): pts = collapse(path) w = width if width else dict(pwr=15.0, clk=9.5, data=8.0)[cls] self.nets.append(dict(cells=path, pts=pts, cls=cls, w=w, name=name, dead=False)) self._pathcost(path) return self.nets[-1] # 1. the hero commute, routed leg by leg toll_a, toll_b = self.toll["pads"] legs = [(self.home, toll_a)] # bus source/dest: a DIP pad row -> another DIP pad row dips = [c for c in self.comps if c["kind"] == "dip"] dips.sort(key=lambda c: c["i0"]) src = dips[len(dips)//3]; dst = dips[-2] bus_src = [(i, src["j0"]+src["h"]-1) for i in range(src["i0"]+1, src["i0"]+9)] bus_dst = [(i, dst["j0"]) for i in range(dst["i0"]+1, dst["i0"]+9)] n_bus = min(len(bus_src), len(bus_dst), 8) bus_src, bus_dst = bus_src[:n_bus], bus_dst[:n_bus] legs.append((toll_b, bus_src[0])) legs.append((bus_src[0], bus_dst[0])) legs.append((bus_dst[0], self.hub)) legs.append((self.hub, self.c12["pads"][0])) hero_pts = [] self.hero_legs = [] for (a, b) in legs: p = self._route_pair(a, b, turn_pen=0.7) if p is None: p = [a, b] net = add(p, "clk", name="hero", width=11.0) net["hero"] = True pl = net["pts"] if hero_pts and pl and hero_pts[-1] == pl[0]: pl = pl[1:] self.hero_legs.append(len(hero_pts)) hero_pts += pl self.hero_pts = hero_pts self.bus_lane_pts = self.nets[2]["pts"] # 2. the rest of the bus — seven more, they bundle naturally self.bus_nets = [self.nets[2]] for k in range(1, n_bus): p = self._route_pair(bus_src[k], bus_dst[k], turn_pen=0.8) if p: self.bus_nets.append(add(p, "data", name=f"DATA {k}")) # 3. general traffic pool = [p for p in self.free_pads] rng.shuffle(pool) made = 0 for k in range(0, len(pool)-1, 2): if made >= 96: break a, b = pool[k], pool[k+1] d = math.hypot(a[0]-b[0], a[1]-b[1]) if d < 5 or d > 34: continue p = self._route_pair(a, b) if p is None or len(p) < 3: continue cls = "pwr" if rng.rand() < 0.14 else ("clk" if rng.rand() < .18 else "data") add(p, cls) made += 1 # short local hops so the map has capillaries too for k in range(len(pool)-1): if made >= 150: break a, b = pool[k], pool[(k*7+3) % len(pool)] d = math.hypot(a[0]-b[0], a[1]-b[1]) if not (3 <= d <= 9): continue p = self._route_pair(a, b) if p is None or len(p) < 3: continue add(p, "data"); made += 1 # 4. street names on the longest roads cand = sorted([n for n in self.nets if len(n["pts"]) >= 3], key=lambda n: -_plen(n["pts"]))[:len(STREETS)] for n, name in zip(cand, STREETS): n["name"] = name self.nets[0]["name"] = "COMMUTE" # ------------------------------------------------------------------ blast def _blast(self): bi = int(self.c12["i0"]+1.5); bj = int(self.c12["j0"]+1.5) self.blast_cell = (bi, bj) self.blast_r = 4.4 bl = self.blocked.copy() for j in range(GY): for i in range(GX): if math.hypot(i-bi, j-bj) <= self.blast_r: bl[j, i] = True self.nets_post = [] self.reroutes = [] for n in self.nets: hit = any(math.hypot(i-bi, j-bj) <= self.blast_r for (i, j) in n["cells"]) ends_here = any((i, j) in self.c12["pads"] for (i, j) in (n["cells"][0], n["cells"][-1])) if not hit: self.nets_post.append(n); continue if ends_here: m = dict(n); m["dead"] = True self.nets_post.append(m); continue a, b = n["cells"][0], n["cells"][-1] b2 = bl.copy(); b2[a[1], a[0]] = False; b2[b[1], b[0]] = False p = astar(a, b, b2, self.cost*0.3, turn_pen=1.0) if p is None: m = dict(n); m["dead"] = True self.nets_post.append(m); continue m = dict(n); m["cells"] = p; m["pts"] = collapse(p); m["new"] = True self.nets_post.append(m) self.reroutes.append(m) # the hero's own detour, for the last shot's traffic self.hero_post = [n for n in self.nets_post if n.get("hero")] def _plen(pts): return sum(math.dist(pts[i], pts[i+1]) for i in range(len(pts)-1)) BOARD = Board(1992) # ---- the hero's schedule: distance along its route, per 16th note ----------- HERO_PTS = BOARD.hero_pts HERO_LEN = _plen(HERO_PTS) HERO_CUM = [0.0] for i in range(len(HERO_PTS)-1): HERO_CUM.append(HERO_CUM[-1] + math.dist(HERO_PTS[i], HERO_PTS[i+1])) def _hero_keys(): L = HERO_LEN lg = BOARD.hero_legs + [len(HERO_PTS)] def at(idx): return HERO_CUM[min(idx, len(HERO_CUM)-1)] k = [(0*16, 0.0), (4*16, 0.0), (10*16, at(lg[1])*0.92), # arrives near the toll (18*16, at(lg[1])), # at the toll (21*16, at(lg[1])), # waiting (the gag) (22*16, at(lg[2])*0.55 + at(lg[1])*0.45), (26*16, at(lg[3])*0.62 + at(lg[2])*0.38), (29*16, at(lg[3])), # bus ends (30*16, at(lg[4])*0.55 + at(lg[3])*0.45), (32*16, at(lg[4])), # stuck at the via when it blows (35*16, at(lg[4])), (42*16, L*0.985)] return k HERO_KEYS = _hero_keys() def hero_s(t): """Arc length at time t — quantised to 16ths, with a small ease inside the step so it *snaps* along the trace on the grid instead of gliding.""" i = t/ST16 i0 = math.floor(i); frac = i - i0 def at(idx): ks = HERO_KEYS if idx <= ks[0][0]: return ks[0][1] for a in range(len(ks)-1): if ks[a][0] <= idx <= ks[a+1][0]: u = (idx-ks[a][0])/max(1, ks[a+1][0]-ks[a][0]) u = u*u*(3-2*u) return ks[a][1] + (ks[a+1][1]-ks[a][1])*u return ks[-1][1] a, b = at(i0), at(i0+1) e = min(1.0, frac*2.6); e = e*e*(3-2*e) return a + (b-a)*e def pt_at(pts, cum, s): s = max(0.0, min(cum[-1]-1e-6, s)) lo, hi = 0, len(cum)-1 while lo < hi-1: m = (lo+hi)//2 if cum[m] <= s: lo = m else: hi = m u = (s-cum[lo])/max(1e-9, cum[lo+1]-cum[lo]) x = pts[lo][0] + (pts[lo+1][0]-pts[lo][0])*u y = pts[lo][1] + (pts[lo+1][1]-pts[lo][1])*u return x, y def hero_xy(t): return pt_at(HERO_PTS, HERO_CUM, hero_s(t)) # ---- ambient commuters ------------------------------------------------------ def _traffic(): rng = np.random.RandomState(4242) out = [] pool = [n for n in BOARD.nets if _plen(n["pts"]) > 200] for k in range(96): n = pool[rng.randint(len(pool))] pts = n["pts"] cum = [0.0] for i in range(len(pts)-1): cum.append(cum[-1]+math.dist(pts[i], pts[i+1])) out.append(dict(pts=pts, cum=cum, L=cum[-1], ph=float(rng.rand()), sp=float(rng.uniform(48, 130)), cls=n["cls"], rev=bool(rng.rand() < .5), cells=n["cells"])) return out TRAFFIC = _traffic() BLAST_XY = (cx_(BOARD.blast_cell[0]), cy_(BOARD.blast_cell[1])) BLAST_R = BOARD.blast_r*G def _busy_vias(): use = {} for n in BOARD.nets: for c in (n["cells"][0], n["cells"][-1]): use[c] = use.get(c, 0) + 1 out = [v for v in BOARD.vias if use.get(v, 0) >= 2] return out or BOARD.vias VIA_BUSY = _busy_vias() # somewhere worth pointing a wide lens at: the middle of a built-up district DISTRICTS = [(cx_(c["i0"])+c["w"]*G/2, cy_(c["j0"])+c["h"]*G/2) for c in BOARD.comps if c["kind"] in ("dip", "bga", "cap")] # ════════════════════════════════════════════════════════════════════════════ # PAINTING THE BOARD # ════════════════════════════════════════════════════════════════════════════ # ── 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="Helvetica.ttc"): size = max(FL(9), int(size)) key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] LIGHT = (-0.55, -0.72) # world-space light direction (upper left) def _sprite_dome(n=None, hole=0.0, gold=(214, 172, 92), shine=52.0, rim=0.55): # the sprite is a resolution: it gets resized on paste, so it has to grow # with the frame or every solder blob softens at 1080p if n is None: n = PSi(96) """A domed solder blob / plated fillet, lit once and reused everywhere.""" yy, xx = np.mgrid[0:n, 0:n] u = (xx-(n-1)/2)/((n-1)/2); v = (yy-(n-1)/2)/((n-1)/2) r = np.sqrt(u*u+v*v) inside = r <= 1.0 z = np.sqrt(np.clip(1-r*r, 0, 1)) lx, ly, lz = LIGHT[0], LIGHT[1], 0.72 ln = math.sqrt(lx*lx+ly*ly+lz*lz); lx, ly, lz = lx/ln, ly/ln, lz/ln hx, hy, hz = lx, ly, lz+1.0 hn = math.sqrt(hx*hx+hy*hy+hz*hz); hx, hy, hz = hx/hn, hy/hn, hz/hn ndl = np.clip(u*lx + v*ly + z*lz, 0, 1) ndh = np.clip(u*hx + v*hy + z*hz, 0, 1) diff = 0.34 + 0.66*ndl spec = ndh**shine base = np.array(gold, np.float32) rgb = base[None, None, :]*diff[..., None] + 255.0*spec[..., None]*1.15 rgb += base[None, None, :]*0.35*(r**3)[..., None]*rim a = inside.astype(np.float32) if hole > 0: a *= (r > hole).astype(np.float32) rgb = rgb*0.98 # soften the silhouette by one pixel a = np.asarray(Image.fromarray((a*255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(PS(1.1))), np.float32)/255.0 return np.clip(rgb, 0, 255), a SPR_BLOB, SPR_BLOB_A = _sprite_dome(None, 0.0, (206, 176, 132), 46.0) SPR_GOLD, SPR_GOLD_A = _sprite_dome(None, 0.0, (208, 168, 84), 30.0, rim=0.9) SPR_RING, SPR_RING_A = _sprite_dome(None, 0.42, (198, 160, 82), 34.0) def _paste_sprite(arr, rgb, alpha, cx, cy, rad): n = max(FL(3), int(rad*2)) if n < 3: return Hh, Ww = arr.shape[:2] x0, y0 = int(round(cx-n/2)), int(round(cy-n/2)) x1, y1 = x0+n, y0+n if x1 <= 0 or y1 <= 0 or x0 >= Ww or y0 >= Hh: return r = np.asarray(Image.fromarray(rgb.astype(np.uint8)).resize((n, n), Image.BILINEAR), np.float32) a = np.asarray(Image.fromarray((alpha*255).astype(np.uint8)).resize((n, n), Image.BILINEAR), np.float32)[..., None]/255.0 sx0, sy0 = max(0, -x0), max(0, -y0) dx0, dy0 = max(0, x0), max(0, y0) dx1, dy1 = min(Ww, x1), min(Hh, y1) if dx1 <= dx0 or dy1 <= dy0: return rr = r[sy0:sy0+(dy1-dy0), sx0:sx0+(dx1-dx0)] aa = a[sy0:sy0+(dy1-dy0), sx0:sx0+(dx1-dx0)] arr[dy0:dy1, dx0:dx1] = arr[dy0:dy1, dx0:dx1]*(1-aa) + rr*aa def silk_text(dr, img, text, x, y, px, ang=0.0, fill=255, name="Helvetica.ttc"): px = int(px) # NOTE: this cull threshold decides which silkscreen survives. It has to # scale, or 1080p shows labels the 720p cut used to drop. if px < FL(9) or px > PS(900): return f = font(px, name) bb = dr.textbbox((0, 0), text, font=f) pad = FL(3) tw, th = bb[2]-bb[0]+2*pad, bb[3]-bb[1]+2*pad if tw < 2 or th < 2: return tmp = Image.new("L", (tw, th), 0) ImageDraw.Draw(tmp).text((pad-bb[0], pad-bb[1]), text, font=f, fill=255) if abs(ang) > 0.5: tmp = tmp.rotate(-ang, expand=True, resample=Image.BICUBIC) X, Y = int(x-tmp.width/2), int(y-tmp.height/2) if isinstance(fill, tuple): img.paste(Image.new(img.mode, tmp.size, fill), (X, Y), tmp) else: if fill != 255: tmp = tmp.point(lambda v, g=fill: v*g//255) img.paste(tmp, (X, Y), tmp) def paint_board(state, x0, y0, x1, y1, sr, seed=1992): """Render a world rectangle of the board at `sr` pixels per world unit.""" Wp = max(8, int((x1-x0)*sr)); Hp = max(8, int((y1-y0)*sr)) def P(x, y): return ((x-x0)*sr, (y-y0)*sr) def S(v): return v*sr nets = BOARD.nets_post if state == "post" else BOARD.nets post = (state == "post") pour_i = Image.new("L", (Wp, Hp), 0); dpour = ImageDraw.Draw(pour_i) trc = Image.new("L", (Wp, Hp), 0); dtrc = ImageDraw.Draw(trc) clr = Image.new("L", (Wp, Hp), 0); dclr = ImageDraw.Draw(clr) silk = Image.new("L", (Wp, Hp), 0); ds = ImageDraw.Draw(silk) silkk = Image.new("L", (Wp, Hp), 0); dsk = ImageDraw.Draw(silkk) hole = Image.new("L", (Wp, Hp), 0); dh = ImageDraw.Draw(hole) pad_margin = 40 def vis(x, y, m=pad_margin): return (x0-m) <= x <= (x1+m) and (y0-m) <= y <= (y1+m) # ---- the pour: parkland. Copper everywhere, then clearance cut out of it dpour.rectangle([P(26, 26)[0], P(26, 26)[1], P(BW-26, BH-26)[0], P(BW-26, BH-26)[1]], fill=255) # ---- traces: the roads for n in nets: pts = n["pts"] if len(pts) < 2: continue if not any(vis(px, py, 60) for (px, py) in pts): continue sp = [P(px, py) for (px, py) in pts] wpx = max(FL(1), int(S(n["w"]))) cpx = max(FL(3), int(S(n["w"]+2*CLR))) dclr.line(sp, fill=255, width=cpx, joint="curve") for (px, py) in sp: dclr.ellipse([px-cpx/2, py-cpx/2, px+cpx/2, py+cpx/2], fill=255) col = 70 if n.get("dead") else 255 dtrc.line(sp, fill=col, width=wpx, joint="curve") for (px, py) in sp: dtrc.ellipse([px-wpx/2, py-wpx/2, px+wpx/2, py+wpx/2], fill=col) # ---- pads: clearance + copper for (px, py, r, kind, owner) in BOARD.pads: if not vis(px, py): continue rr = S(r); cc = S(r+CLR) a, b = P(px, py) dclr.ellipse([a-cc, b-cc, a+cc, b+cc], fill=255) dtrc.ellipse([a-rr, b-rr, a+rr, b+rr], fill=255) clr_a = np.asarray(clr, np.float32)/255.0 trc_a = np.asarray(trc, np.float32)/255.0 pour = np.clip(np.asarray(pour_i, np.float32)/255.0 - clr_a, 0, 1) # ---- board colour ------------------------------------------------------ rng = np.random.RandomState(seed) # laminate weave + fine noise: the divisors are *cell sizes in pixels*, so # they scale, or the fibreglass gets finer (= flatter) at 1080p cw, cf = PS(9.0), PS(3.0) tw = max(8, int(Wp/cw)); th = max(8, int(Hp/cw)) weave = rng.rand(th, tw).astype(np.float32) weave = np.asarray(Image.fromarray((weave*255).astype(np.uint8)).resize( (Wp, Hp), Image.BICUBIC), np.float32)/255.0 fine = np.asarray(Image.fromarray( (rng.rand(max(8, int(Hp/cf)), max(8, int(Wp/cf)))*255).astype(np.uint8)).resize( (Wp, Hp), Image.BILINEAR), np.float32)/255.0 GREEN_BARE = np.array([7, 38, 22], np.float32) # mask over bare laminate GREEN_POUR = np.array([15, 70, 39], np.float32) # mask over the pour GREEN_TRACE = np.array([46, 138, 74], np.float32) # mask over a trace blurpx = max(PS(0.5), sr*0.9) pour_s = np.asarray(Image.fromarray((pour*255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(blurpx)), np.float32)/255.0 trc_s = np.asarray(Image.fromarray((trc_a*255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(blurpx)), np.float32)/255.0 base = GREEN_BARE[None, None, :]*(1-pour_s[..., None]) + \ GREEN_POUR[None, None, :]*pour_s[..., None] base = base*(1-trc_s[..., None]) + GREEN_TRACE[None, None, :]*trc_s[..., None] base *= (0.90 + 0.20*weave)[..., None] base += (fine-0.5)[..., None]*6.0 # mask ridge: the mask conforms to the copper, so every road has a lit edge # and a dark edge depending on which way the light comes from gy_, gx_ = np.gradient(trc_s) ridge = -(gx_*LIGHT[0] + gy_*LIGHT[1]) base += np.clip(ridge*2.4, -1, 1)[..., None]*np.array([96, 190, 124], np.float32)*2.4 gy2, gx2 = np.gradient(pour_s) ridge2 = -(gx2*LIGHT[0] + gy2*LIGHT[1]) base += np.clip(ridge2*2.4, -1, 1)[..., None]*np.array([50, 105, 70], np.float32)*1.5 # glossy mask sheen — a broad soft highlight, in world space yy, xx = np.mgrid[0:Hp, 0:Wp] wx = x0 + xx/sr; wy = y0 + yy/sr sheen = np.exp(-((wx*0.55 + wy*0.83 - BW*0.62)/(BW*0.46))**2) base += sheen[..., None]*np.array([9, 20, 13], np.float32) # ---- the coastline: mask edge, bare FR4 rim, milled edge, the void ------ RIM = 26.0 inb = ((wx >= 0) & (wx <= BW) & (wy >= 0) & (wy <= BH)) inm = ((wx >= RIM) & (wx <= BW-RIM) & (wy >= RIM) & (wy <= BH-RIM)) if not inm.all(): FR4 = np.array([164, 138, 92], np.float32) fr4 = FR4[None, None, :]*(0.72+0.46*weave)[..., None] fr4 = fr4 + (fine-0.5)[..., None]*16.0 TABLE = np.array([13, 14, 16], np.float32) tbl = TABLE[None, None, :]*(0.6+0.8*weave)[..., None] rimm = inb & (~inm) base = np.where(inm[..., None], base, np.where(rimm[..., None], fr4, tbl)) de = np.minimum(np.minimum(wx, BW-wx), np.minimum(wy, BH-wy)) cham = np.clip(1-np.abs(de-3.0)/5.0, 0, 1)*inb base = base + cham[..., None]*np.array([120, 104, 74], np.float32) dm = np.minimum(np.minimum(wx-RIM, BW-RIM-wx), np.minimum(wy-RIM, BH-RIM-wy)) cut = np.clip(1-np.abs(dm)/3.0, 0, 1)*inb base = base + cut[..., None]*np.array([30, 64, 40], np.float32) # ---- the scorch, painted into the mask itself -------------------------- if post: bx, by = BLAST_XY d = np.sqrt((wx-bx)**2 + (wy-by)**2) ang = np.arctan2(wy-by, wx-bx) lob = 1.0 + 0.11*np.sin(ang*5+1.1) + 0.07*np.sin(ang*9-0.4) \ + 0.05*np.sin(ang*3+2.2) rr = d/(BLAST_R*lob) core = np.clip(1.0-rr*0.82, 0, 1)**1.25 # carbon expo = np.clip(1-abs(rr-0.66)*4.0, 0, 1) # mask burned off, copper ring = np.clip(1-abs(rr-0.92)*2.4, 0, 1) # scorched mask outer = np.clip(1-abs(rr-1.26)*2.0, 0, 1) # tan halo base = base*(1 - np.clip(0.95*core + 0.42*ring + 0.14*outer, 0, .96)[..., None]) base += core[..., None]*np.array([15, 11, 9], np.float32) base += expo[..., None]*np.array([86, 52, 26], np.float32)*(0.6+0.7*fine)[..., None] base += ring[..., None]*np.array([64, 34, 14], np.float32)*(0.7+0.6*fine)[..., None] base += outer[..., None]*np.array([70, 50, 22], np.float32)*(0.5+0.7*fine)[..., None] arr = base # ---- gold pads / solder -------------------------------------------------- bx, by = BLAST_XY for (px, py, r, kind, owner) in BOARD.pads: if not vis(px, py): continue a, b = P(px, py) rr = S(r) if rr < 1.2: continue burned = post and math.hypot(px-bx, py-by) < BLAST_R*1.15 if kind == "via": _paste_sprite(arr, SPR_RING*(0.25 if burned else 1.0), SPR_RING_A, a, b, rr*2) dh.ellipse([a-rr*.42, b-rr*.42, a+rr*.42, b+rr*.42], fill=255) elif kind == "smd": _paste_sprite(arr, SPR_GOLD*(0.22 if burned else 1.0), SPR_GOLD_A, a, b, rr*2) else: _paste_sprite(arr, SPR_BLOB*(0.22 if burned else 1.0), SPR_BLOB_A, a, b, rr*2.05) dh.ellipse([a-rr*.30, b-rr*.30, a+rr*.30, b+rr*.30], fill=255) # ---- components (the monuments) ----------------------------------------- body = Image.new("RGBA", (Wp, Hp), (0, 0, 0, 0)) db = ImageDraw.Draw(body) for c in BOARD.comps: bx0, by0 = cx_(c["i0"])-G/2, cy_(c["j0"])-G/2 bx1, by1 = bx0+c["w"]*G, by0+c["h"]*G if bx1 < x0-60 or bx0 > x1+60 or by1 < y0-60 or by0 > y1+60: continue gone = post and c.get("hero") crng = np.random.RandomState(c["seed"]) p0 = P(bx0, by0); p1 = P(bx1, by1) if c["kind"] == "dip" and not gone: db.rounded_rectangle([p0[0]+S(G*0.55), p0[1]+S(G*0.9), p1[0]-S(G*0.55), p1[1]-S(G*0.9)], radius=max(FL(1), S(4)), fill=(26, 26, 30, 255), outline=(52, 52, 58, 255), width=max(FL(1), int(S(1.5)))) # top face sheen + pin-1 dot + notch db.rounded_rectangle([p0[0]+S(G*0.8), p0[1]+S(G*1.15), p1[0]-S(G*0.8), p0[1]+S(G*1.9)], radius=max(FL(1), S(3)), fill=(42, 42, 48, 90)) dot = P(bx0+G*1.15, by0+G*1.5) rr = S(G*0.28) db.ellipse([dot[0]-rr, dot[1]-rr, dot[0]+rr, dot[1]+rr], fill=(12, 12, 14, 255)) nx, ny = P(bx0+G*0.55, (by0+by1)/2) db.pieslice([nx-S(G*0.42), ny-S(G*0.42), nx+S(G*0.42), ny+S(G*0.42)], -90, 90, fill=(12, 12, 14, 255)) silk_text(db, body, c["label"] + " " + f"{74}LS{138+c['seed']%80}", (p0[0]+p1[0])/2, (p0[1]+p1[1])/2, S(G*0.62), 0, fill=(196, 198, 200, 255)) elif c["kind"] == "cap": cxw = cx_(c["i0"])+1.5*G; cyw = cy_(c["j0"])+1.5*G a, b = P(cxw, cyw); rr = S(c["rad"]) if not gone: db.ellipse([a-rr, b-rr, a+rr, b+rr], fill=(18, 22, 46, 255), outline=(70, 84, 130, 255), width=max(FL(1), int(S(1.6)))) db.ellipse([a-rr*.86, b-rr*.86, a+rr*.86, b+rr*.86], fill=(24, 30, 62, 255)) db.ellipse([a-rr*.72, b-rr*.72, a+rr*.72, b+rr*.72], outline=(150, 160, 190, 210), width=max(FL(1), int(S(1.2)))) # the vent score — the K for ang in (90, 210, 330): t = math.radians(ang) db.line([a, b, a+math.cos(t)*rr*.68, b+math.sin(t)*rr*.68], fill=(120, 130, 168, 220), width=max(FL(1), int(S(2.2)))) # the negative stripe db.pieslice([a-rr*.86, b-rr*.86, a+rr*.86, b+rr*.86], 120, 240, fill=(206, 212, 226, 230)) silk_text(db, body, "-", a-rr*.55, b, S(G*0.9), 0, (30, 34, 60, 255)) if c.get("hero"): silk_text(db, body, "470uF", a, b-rr*.34, S(G*0.36), 0, (210, 216, 232, 255)) silk_text(db, body, "16V", a, b+rr*.12, S(G*0.34), 0, (210, 216, 232, 255)) elif c["kind"] == "res": hor = c.get("hor", True) if hor: rx0, ry0 = P(cx_(c["i0"])+G*0.55, cy_(c["j0"])-G*0.30) rx1, ry1 = P(cx_(c["i0"]+c["w"]-1)-G*0.55, cy_(c["j0"])+G*0.30) else: rx0, ry0 = P(cx_(c["i0"])-G*0.30, cy_(c["j0"])+G*0.55) rx1, ry1 = P(cx_(c["i0"])+G*0.30, cy_(c["j0"]+c["h"]-1)-G*0.55) db.rounded_rectangle([rx0, ry0, rx1, ry1], radius=max(FL(1), S(5)), fill=(196, 176, 132, 255), outline=(146, 126, 92, 255), width=max(FL(1), int(S(1.0)))) bands = {"4K7": [(242, 206, 60), (150, 90, 190), (200, 60, 50), (196, 160, 70)], "10K": [(140, 90, 40), (18, 18, 18), (200, 140, 50), (196, 160, 70)], "220": [(200, 60, 50), (200, 60, 50), (140, 90, 40), (196, 160, 70)], "1K": [(140, 90, 40), (18, 18, 18), (200, 60, 50), (196, 160, 70)], "47R": [(242, 206, 60), (150, 90, 190), (18, 18, 18), (196, 160, 70)], "100K":[(140, 90, 40), (18, 18, 18), (242, 206, 60), (196, 160, 70)] }.get(c.get("val", "10K")) for bi2, col in enumerate(bands): u = 0.18 + bi2*0.20 if hor: xq = rx0 + (rx1-rx0)*u db.rectangle([xq, ry0+S(1), xq+max(FL(1), S(2.6)), ry1-S(1)], fill=col+(255,)) else: yq = ry0 + (ry1-ry0)*u db.rectangle([rx0+S(1), yq, rx1-S(1), yq+max(FL(1), S(2.6))], fill=col+(255,)) elif c["kind"] == "xtal": db.rounded_rectangle([p0[0]+S(G*.4), p0[1]+S(G*.3), p1[0]-S(G*.4), p1[1]-S(G*.3)], radius=max(FL(1), S(G*0.42)), fill=(150, 154, 160, 255), outline=(198, 202, 208, 255), width=max(FL(1), int(S(1.4)))) db.rounded_rectangle([p0[0]+S(G*.6), p0[1]+S(G*.45), p1[0]-S(G*.9), p1[1]-S(G*.75)], radius=max(FL(1), S(G*0.3)), fill=(184, 188, 196, 160)) elif c["kind"] == "led": a, b = P((bx0+bx1)/2, (by0+by1)/2); rr = S(G*0.6) db.ellipse([a-rr, b-rr, a+rr, b+rr], fill=(178, 30, 40, 235), outline=(230, 120, 110, 255), width=max(FL(1), int(S(1.2)))) db.ellipse([a-rr*.4, b-rr*.5, a+rr*.1, b-rr*.1], fill=(255, 190, 180, 200)) elif c["kind"] == "q": db.pieslice([p0[0]+S(G*.2), p0[1]+S(G*.2), p1[0]-S(G*.2), p1[1]-S(G*.2)], 30, 330, fill=(22, 22, 26, 255), outline=(60, 60, 66, 255), width=max(FL(1), int(S(1.2)))) elif c["kind"] == "smd": db.rectangle([p0[0]+S(G*.55), p0[1]+S(G*.28), p1[0]-S(G*.55), p1[1]-S(G*.28)], fill=(24, 24, 28, 255)) elif c["kind"] == "hdr": db.rounded_rectangle([p0[0]+S(G*.12), p0[1]+S(G*.12), p1[0]-S(G*.12), p1[1]-S(G*.12)], radius=max(FL(1), S(3)), fill=(18, 18, 20, 235)) elif c["kind"] == "bga": pass # ---- silkscreen --------------------------------------------------------- def slk(text, x, y, size, ang=0.0): if not vis(x, y, 240): return silk_text(ds, silk, text, *P(x, y), S(size), ang, 255) for c in BOARD.comps: bx0, by0 = cx_(c["i0"])-G/2, cy_(c["j0"])-G/2 bx1, by1 = bx0+c["w"]*G, by0+c["h"]*G cxw, cyw = (bx0+bx1)/2, (by0+by1)/2 if c["kind"] == "dip": slk(c["label"], bx0-G*0.55, by0-G*0.30, G*0.62) # the footprint outline ds.rectangle([*P(bx0+G*0.4, by0+G*0.75), *P(bx1-G*0.4, by1-G*0.75)], outline=190, width=max(FL(1), int(S(1.6)))) elif c["kind"] == "cap": hero_gone = post and c.get("hero") a, b = P(cxw, cyw); rr = S(c["rad"]*1.12) if hero_gone: # the footprint outline survives the part. So does the name. if vis(cxw, cyw, 300): dsk.ellipse([a-rr, b-rr, a+rr, b+rr], outline=118, width=max(FL(1), int(S(2.0)))) dsk.pieslice([a-rr, b-rr, a+rr, b+rr], 118, 242, outline=118, width=max(FL(1), int(S(2.6)))) silk_text(dsk, silkk, c["label"], *P(cxw, by1+G*0.62), S(G*0.86), 0, 226) else: slk(c["label"], cxw, by1+G*0.45, G*0.66) ds.ellipse([a-rr, b-rr, a+rr, b+rr], outline=170, width=max(FL(1), int(S(1.6)))) ds.pieslice([a-rr, b-rr, a+rr, b+rr], 120, 240, outline=170, width=max(FL(1), int(S(2.4)))) if c.get("hero"): slk("470uF 16V", cxw, by1+G*1.05, G*0.42) elif c["kind"] == "res": slk(c["label"], cxw, cyw - G*0.72 if c.get("hor", True) else cyw, G*0.52) if c.get("toll"): slk("TOLL", cxw, cyw + G*0.95, G*0.52) slk("4K7", cxw + G*1.6, cyw - G*0.72, G*0.46) elif c["kind"] in ("xtal", "led", "q", "smd"): slk(c["label"], cxw, by0-G*0.42, G*0.46) elif c["kind"] == "hdr": slk("J1", cx_(c["i0"])+G*1.05, cy_(c["j0"])-G*0.6, G*0.66) slk("HOME", cx_(c["i0"])+G*1.6, cy_(c["j0"])+G*4.2, G*0.52) elif c["kind"] == "bga": ds.rectangle([*P(bx0+G*0.3, by0+G*0.3), *P(bx1-G*0.3, by1-G*0.3)], outline=180, width=max(FL(1), int(S(1.6)))) slk("U9", bx0-G*0.2, by0-G*0.5, G*0.62) slk("PLAZA", cxw, by1+G*0.5, G*0.55) # street names, laid along their roads for n in BOARD.nets: if not n.get("name") or n["name"] == "hero": continue pts = n["pts"] if len(pts) < 2: continue k = len(pts)//2 ax, ay = pts[max(0, k-1)]; bx2, by2 = pts[min(len(pts)-1, k)] mx, my = (ax+bx2)/2, (ay+by2)/2 ang = math.degrees(math.atan2(by2-ay, bx2-ax)) if ang > 90: ang -= 180 if ang < -90: ang += 180 off = 13.0 nx2, ny2 = -math.sin(math.radians(ang)), math.cos(math.radians(ang)) slk(n["name"], mx+nx2*off, my+ny2*off, G*0.44, ang) slk(TITLE, BW*0.30, G*1.4, G*1.7) slk("REV 2.0 FAB PLAYER COMPUTER", BW*0.30, G*3.0, G*0.52) slk("MADE ON EARTH", BW*0.80, BH-G*1.2, G*0.5) slk("45" + chr(176) + " ONLY", BW*0.10, BH-G*1.2, G*0.5) silk_a = np.asarray(silk, np.float32)/255.0 if post: d2 = np.sqrt((wx-bx)**2 + (wy-by)**2) silk_a *= np.clip((d2-BLAST_R*0.72)/(BLAST_R*0.45), 0, 1) silk_a = np.maximum(silk_a, np.asarray(silkk, np.float32)/255.0) hole_a = np.asarray(hole, np.float32)/255.0 # holes first (they sit under the silk and over the pads) arr = arr*(1-hole_a[..., None]*0.94) + \ hole_a[..., None]*np.array([16, 14, 13], np.float32) body_a = np.asarray(body, np.float32) ba = body_a[..., 3:4]/255.0 arr = arr*(1-ba) + body_a[..., :3]*ba SILK = np.array([228, 232, 226], np.float32) arr = arr*(1-silk_a[..., None]*0.94) + SILK[None, None, :]*silk_a[..., None]*0.94 # ---- flux specks and dust, for the macro --------------------------------- # a speck is SPK x SPK pixels; the count drops by the same factor so the # *fraction of the picture* that is dust stays put while each speck grows SPK = FL(1) nsp = max(4, int(Wp*Hp/26000/(SPK*SPK))) sx_ = rng.randint(0, max(1, Wp-(SPK-1)), nsp) sy_ = rng.randint(0, max(1, Hp-(SPK-1)), nsp) sv = rng.rand(nsp).astype(np.float32) for q in range(nsp): v = sv[q] if v < 0.7: continue y_, x_ = sy_[q], sx_[q] arr[y_:y_+SPK, x_:x_+SPK] = np.clip(arr[y_:y_+SPK, x_:x_+SPK] + 90*v, 0, 255) # ---- blast confetti: the capacitor's paper, thrown everywhere ------------ if post: crng = np.random.RandomState(6161) dd = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) dd2 = ImageDraw.Draw(dd) for q in range(190): ang = crng.rand()*math.tau rad = BLAST_R*(0.9 + 2.9*crng.rand()**0.6) px = bx + math.cos(ang)*rad; py = by + math.sin(ang)*rad*0.92 if not vis(px, py, 20): continue a, b2 = P(px, py) ln = S(2.0 + 6.0*crng.rand()); th2 = crng.rand()*math.tau g2 = int(120 + 110*crng.rand()) dd2.line([a, b2, a+math.cos(th2)*ln, b2+math.sin(th2)*ln], fill=(g2, int(g2*0.94), int(g2*0.82)), width=max(FL(1), int(S(1.4)))) arr = np.asarray(dd, np.float32) return np.clip(arr, 0, 255) # ════════════════════════════════════════════════════════════════════════════ # CAMERA + SCENES # ════════════════════════════════════════════════════════════════════════════ NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} class Cam: __slots__ = ("cx", "cy", "vw", "rot") def __init__(self, cx, cy, vw, rot): self.cx, self.cy, self.vw, self.rot = cx, cy, vw, rot @property def s(self): return W/self.vw def proj(self, x, y): s = self.s; a = math.radians(self.rot) ca, sa = math.cos(a), math.sin(a) dx, dy = x-self.cx, y-self.cy return (s*(ca*dx - sa*dy) + W/2, s*(sa*dx + ca*dy) + H/2) def corners(self): s = self.s; a = math.radians(-self.rot) ca, sa = math.cos(a), math.sin(a) out = [] for (sx, sy) in ((0, 0), (W, 0), (W, H), (0, H)): px, py = sx-W/2, sy-H/2 out.append((self.cx + (ca*px - sa*py)/s, self.cy + (sa*px + ca*py)/s)) return out def clampcam(cx, cy, vw): vw = min(vw, BW*1.25) vh = vw*H/W cx = min(max(cx, vw*0.30), BW - vw*0.30) cy = min(max(cy, vh*0.30), BH - vh*0.30) return cx, cy SCENES = ["estab", "follow", "junction", "bus", "chip", "toll", "cap", "grid", "queue", "edge", "signage", "blast", "dark", "reroute", "scorch"] class Scene: def __init__(self, shot, rng): self.shot = shot; self.rng = rng self.rot = float(rng.uniform(-22, 22)) self.drot = float(rng.uniform(-2.0, 2.0)) self.dx = float(rng.uniform(-1, 1)); self.dy = float(rng.uniform(-1, 1)) self.zoomdrift = float(rng.uniform(-0.10, 0.10)) self.k = shot.kind self.noclamp = False self.setup() def setup(self): rng = self.rng k = self.k t0 = self.shot.i0/FPS hx, hy = hero_xy(t0) if k == "estab": m = int(rng.integers(3)) if m == 0: # the whole city self.tgt = (BW*0.5+rng.uniform(-60, 60), BH*0.5+rng.uniform(-40, 40)) self.vw = float(rng.uniform(1720, 1980)); self.rot *= 0.35 elif m == 1: # a district p = DISTRICTS[rng.integers(len(DISTRICTS))] self.tgt = (p[0]+float(rng.uniform(-90, 90)), p[1]+float(rng.uniform(-70, 70))) self.vw = float(rng.uniform(760, 1120)) else: # low over the rooftops p = DISTRICTS[rng.integers(len(DISTRICTS))] self.tgt = (p[0]+float(rng.uniform(-160, 160)), p[1]+float(rng.uniform(-120, 120))) self.vw = float(rng.uniform(1080, 1520)); self.rot *= 0.8 elif k == "follow": self.tgt = (hx, hy); self.vw = float(rng.uniform(190, 340)) elif k == "junction": v = VIA_BUSY[rng.integers(len(VIA_BUSY))] self.tgt = (cx_(v[0])+float(rng.uniform(-30, 30)), cy_(v[1])+float(rng.uniform(-30, 30))) self.vw = float(rng.uniform(260, 470)) elif k == "bus": pts = BOARD.bus_lane_pts p = pts[len(pts)//2] self.tgt = (p[0]+rng.uniform(-60, 60), p[1]+rng.uniform(-60, 60)) self.vw = float(rng.uniform(280, 480)) elif k == "chip": dips = [c for c in BOARD.comps if c["kind"] == "dip"] c = dips[rng.integers(len(dips))] ccx = cx_(c["i0"])+c["w"]*G/2; ccy = cy_(c["j0"])+c["h"]*G/2 m = int(rng.integers(4)) if m == 0: # the monolith, whole self.tgt = (ccx, ccy); self.vw = float(rng.uniform(430, 620)) elif m == 1: # extreme macro, one pin row row = cy_(c["j0"]) if rng.random() < .5 else cy_(c["j0"]+c["h"]-1) self.tgt = (ccx + float(rng.uniform(-1, 1))*c["w"]*G*0.22, row) self.vw = float(rng.uniform(150, 240)) elif m == 2: # a corner, pin 1 self.tgt = (cx_(c["i0"])+G*0.9, cy_(c["j0"])+G*0.6) self.vw = float(rng.uniform(210, 320)) else: # the shadow it casts self.tgt = (ccx + c["w"]*G*0.55, ccy + c["h"]*G*0.5) self.vw = float(rng.uniform(300, 430)) elif k == "toll": c = BOARD.toll self.tgt = (cx_(c["i0"])+c["w"]*G/2, cy_(c["j0"])+c["h"]*G/2) self.vw = float(rng.uniform(200, 320)); self.rot *= 0.6 elif k == "cap": caps = [c for c in BOARD.comps if c["kind"] == "cap"] hero = BOARD.c12 c = hero if (self.shot.section in ("drop2", "blow", "reroute") or rng.random() < 0.45) else caps[rng.integers(len(caps))] ccx = cx_(c["i0"])+1.5*G; ccy = cy_(c["j0"])+1.5*G m = int(rng.integers(3)) if m == 0: self.tgt = (ccx, ccy); self.vw = float(rng.uniform(330, 470)) elif m == 1: # the vent score, macro self.tgt = (ccx+float(rng.uniform(-.4, .4))*G, ccy-float(rng.uniform(.1, .7))*G) self.vw = float(rng.uniform(155, 235)) else: # its foot in the street self.tgt = (ccx+float(rng.uniform(-1.9, 1.9))*G, ccy+float(rng.uniform(1.4, 2.4))*G) self.vw = float(rng.uniform(230, 330)) elif k == "grid": self.tgt = BOARD.bga; self.vw = float(rng.uniform(240, 400)) self.rot *= 0.35 elif k == "queue": self.tgt = (cx_(BOARD.hub[0])-float(rng.uniform(0, 70)), cy_(BOARD.hub[1])+float(rng.uniform(-40, 40))) self.vw = float(rng.uniform(300, 470)) elif k == "edge": # the coastline: half board, half table side = int(rng.integers(4)) self.vw = float(rng.uniform(380, 700)) off = self.vw*0.14 self.tgt = [(off, BH*float(rng.uniform(.2, .8))), (BW-off, BH*float(rng.uniform(.2, .8))), (BW*float(rng.uniform(.2, .8)), off), (BW*float(rng.uniform(.2, .8)), BH-off)][side] self.rot = float(rng.uniform(-14, 14)) + (0 if side < 2 else 0) self.noclamp = True elif k == "signage": named = [n for n in BOARD.nets if n.get("name") and n["name"] != "hero"] n = named[rng.integers(len(named))] pts = n["pts"]; kk = max(1, len(pts)//2) p = pts[kk] self.tgt = p; self.vw = float(rng.uniform(210, 340)) ang = math.degrees(math.atan2(pts[kk][1]-pts[kk-1][1], pts[kk][0]-pts[kk-1][0])) while ang > 90: ang -= 180 while ang < -90: ang += 180 self.rot = -ang + float(rng.uniform(-7, 7)) elif k in ("blast", "dark", "reroute", "scorch"): self.tgt = BLAST_XY self.vw = dict(blast=520.0, dark=980.0, reroute=1250.0, scorch=620.0)[k] self.vw *= float(rng.uniform(0.92, 1.10)) if k == "scorch": self.rot *= 0.4 if k == "reroute": self.rot *= 0.3 else: self.tgt = (BW/2, BH/2); self.vw = 900.0 if not self.noclamp: self.tgt = clampcam(self.tgt[0], self.tgt[1], self.vw) def cam(self, k, u, e): sh = self.shot t = (sh.i0+k)/FPS vw = self.vw*(1.0 + self.zoomdrift*u) if self.k == "follow": hx, hy = hero_xy(t) lead = 0.10 hx2, hy2 = hero_xy(t+lead) cx = hx*0.6 + hx2*0.4; cy = hy*0.6 + hy2*0.4 cx += self.dx*10*math.sin(u*3.0); cy += self.dy*10*math.cos(u*2.4) elif self.k == "scorch": vw = self.vw*(1.16 - 0.34*u) cx = self.tgt[0] + self.dx*14*(1-u); cy = self.tgt[1] + self.dy*14*(1-u) elif self.k == "blast": vw = self.vw*(1.0 - 0.10*u + 0.22*math.exp(-u*14)) cx, cy = self.tgt else: drift = vw*0.075 cx = self.tgt[0] + self.dx*drift*u cy = self.tgt[1] + self.dy*drift*u if not self.noclamp: cx, cy = clampcam(cx, cy, vw) rot = self.rot + self.drot*u return Cam(cx, cy, vw, rot) # --------------------------------------------------------------------------- def _glow_kernel(n=41, sig=7.0): yy, xx = np.mgrid[0:n, 0:n] r2 = (xx-(n-1)/2)**2 + (yy-(n-1)/2)**2 return np.exp(-r2/(2*sig*sig)).astype(np.float32) CLS_COL = {"data": (150, 240, 255), "clk": (255, 214, 130), "pwr": (255, 130, 110)} def draw_lights(cam, t, e, state, shot, u): """Every signal in the frame, as light. Returns a float light buffer.""" L = Image.new("RGB", (W, H), (0, 0, 0)) d = ImageDraw.Draw(L) s = cam.s step16 = math.floor(t/ST16) def seg_light(pts, cum, s0, s1, col, wid): if s1 <= s0: return n = max(2, int((s1-s0)/max(6.0, 26.0/max(s, 0.02)))) n = min(n, PSi(40)) pl = [] for q in range(n+1): ss = s0 + (s1-s0)*q/n pl.append(cam.proj(*pt_at(pts, cum, ss))) m60 = PS(60) if all(p[0] < -m60 or p[0] > W+m60 or p[1] < -m60 or p[1] > H+m60 for p in pl): return d.line(pl, fill=col, width=max(FL(1), int(wid)), joint="curve") post = (state == "post") bx, by = BLAST_XY dark_amt = 0.0 if shot.section in ("blow", "reroute"): dark_amt = 1.0 # --- ambient traffic amp = 0.45 + 0.85*e["rms"] for tr in TRAFFIC: Ln = tr["L"] if Ln < 40: continue q = (tr["ph"] + t*tr["sp"]/Ln) % 1.0 if tr["rev"]: q = 1.0-q s1 = q*Ln tail = min(Ln*0.28, 34.0 + 130.0*e["mid"]) s0 = max(0.0, s1-tail) px, py = pt_at(tr["pts"], tr["cum"], s1) if dark_amt > 0 and math.hypot(px-bx, py-by) < BLAST_R*2.0: continue sx, sy = cam.proj(px, py) if sx < -PS(80) or sx > W+PS(80) or sy < -PS(80) or sy > H+PS(80): continue c = CLS_COL[tr["cls"]] g = 0.26 + 0.44*amp seg_light(tr["pts"], tr["cum"], s0, s1, tuple(int(v*g*0.34) for v in c), max(FL(1), s*4.4)) r = max(PS(1.2), s*3.4*(0.8+0.5*e["kick"])) d.ellipse([sx-r, sy-r, sx+r, sy+r], fill=tuple(int(min(255, v*g*1.5)) for v in c)) # --- the hero hs = hero_s(t) hx, hy = pt_at(HERO_PTS, HERO_CUM, hs) sx, sy = cam.proj(hx, hy) if -PS(200) < sx < W+PS(200) and -PS(200) < sy < H+PS(200): tail = 60 + 220*e["rms"] seg_light(HERO_PTS, HERO_CUM, max(0, hs-tail), hs, (110, 200, 230), max(FL(1), s*6.0)) r = max(PS(2.5), s*7.5*(1.0+0.45*e["kick"])) d.ellipse([sx-r*1.9, sy-r*1.9, sx+r*1.9, sy+r*1.9], fill=(40, 90, 120)) d.ellipse([sx-r, sy-r, sx+r, sy+r], fill=(240, 255, 255)) # --- pad glints, twinkling on the hats gl = 0.4 + 0.9*e["high"] grng = np.random.RandomState(int(step16) % 977) for (px, py, rr, kind, owner) in BOARD.pads: if kind == "smd": continue sx2, sy2 = cam.proj(px, py) if sx2 < 0 or sx2 > W or sy2 < 0 or sy2 > H: continue if post and math.hypot(px-bx, py-by) < BLAST_R*1.2: continue h = ((int(px*7 + py*13) + int(step16)*31) % 23) if h > 4: continue ln = max(PS(2.0), s*rr*(0.9 + 1.6*gl)) c = int(150 + 105*gl) d.line([sx2-ln, sy2, sx2+ln, sy2], fill=(c, c, int(c*0.94)), width=MINW) d.line([sx2, sy2-ln, sx2, sy2+ln], fill=(c, c, int(c*0.94)), width=MINW) return L, (hx, hy) def scene_extra(sc, im, d, cam, k, u, e, t, shot): """Per-scene dynamic overlay drawn into the light buffer `im`.""" s = cam.s kind = sc.k if kind == "grid": # the BGA plaza strobing on the 16ths st = int(t/ST16) for q, (px, py) in enumerate(BOARD.bga_pads): if ((q*3 + st*5) % 7) > 2: continue sx, sy = cam.proj(px, py) if sx < -PS(40) or sx > W+PS(40) or sy < -PS(40) or sy > H+PS(40): continue r = max(FL(2), s*7.0*(0.7+0.7*e["kick"])) c = (170, 235, 255) if q % 2 else (255, 205, 130) d.ellipse([sx-r, sy-r, sx+r, sy+r], fill=c) elif kind == "queue": # signals stacked up behind the via hx, hy = cx_(BOARD.hub[0]), cy_(BOARD.hub[1]) n = int(3 + 9*min(1.0, max(0.0, (t - 29*BAR)/(3*BAR)))) for q in range(n): back = HERO_CUM[-1] ss = hero_s(t) - 22.0*(q+1) - 7*math.sin(t*2.2+q) px, py = pt_at(HERO_PTS, HERO_CUM, max(0, ss)) sx, sy = cam.proj(px, py) r = max(PS(1.6), s*4.6) g = 0.5+0.5*math.sin(t*4+q*1.3) d.ellipse([sx-r, sy-r, sx+r, sy+r], fill=(int(120+90*g), int(200+40*g), 255)) elif kind == "toll": # the barrier: a bar across the resistor that lifts, once, late c = BOARD.toll cxw = cx_(c["i0"])+c["w"]*G/2; cyw = cy_(c["j0"])+c["h"]*G/2 openu = np.clip((t - 21.4*BAR)/(0.9*BAR), 0, 1) ang = -78*openu L2 = G*2.6 a = math.radians(ang) p0 = cam.proj(cxw - L2*0.1, cyw - G*1.1) p1 = cam.proj(cxw - L2*0.1 + math.cos(a)*L2, cyw - G*1.1 + math.sin(a)*L2) col = (255, 90, 70) if openu < 0.5 else (140, 255, 170) if int(t/ST16) % 4 < 2 or openu > 0.5: d.line([p0, p1], fill=col, width=max(FL(2), int(s*4.0))) elif kind == "blast": bt = max(0.0, t - BLOW_T) bx, by = BLAST_XY sx, sy = cam.proj(bx, by) # the core: opaque long enough to hide the cut from part to crater if bt < 0.85: g = math.exp(-bt*5.2) r = s*BLAST_R*(0.55 + 3.6*(1-math.exp(-bt*9.0))) for q in range(9, 0, -1): rr = r*(q/9)**1.25 v = int(255*min(1.0, g*(q/9)**0.45)*(1.0 if q > 6 else 0.9)) d.ellipse([sx-rr, sy-rr, sx+rr, sy+rr], fill=(v, int(v*(0.80+0.20*q/9)), int(v*(0.42+0.36*q/9)))) rrng = np.random.RandomState(1717) for q in range(9): # the vent tearing open aa = rrng.rand()*math.tau r2 = r*(1.2 + 2.4*rrng.rand()**1.6) d.line([sx, sy, sx+math.cos(aa)*r2, sy+math.sin(aa)*r2*0.96], fill=(int(250*g), int(210*g), int(120*g)), width=max(FL(1), int(s*G*0.20*(0.4+rrng.rand())))) # the fireball ring, lobed like the burn it leaves if 0.04 < bt < 1.5: rw = BLAST_R*(0.7 + 2.2*(bt-0.04)) v = int(190*max(0, 1-(bt-0.04)/1.5)**1.4) poly = [] for q in range(28): aa = q*math.tau/28 lob = 1.0 + 0.13*math.sin(aa*5+1.1) + 0.08*math.sin(aa*9-0.4) poly.append(cam.proj(bx+math.cos(aa)*rw*lob, by+math.sin(aa)*rw*lob*0.96)) poly.append(poly[0]) d.line(poly, fill=(v, int(v*0.52), int(v*0.14)), width=max(FL(2), int(s*G*0.5)), joint="curve") # sparks — burning electrolyte, thrown a long way srng = np.random.RandomState(909) for q in range(280): ang = srng.rand()*math.tau sp = 300 + 1250*srng.rand()**1.5 life = 0.35 + 2.1*srng.rand() if bt > life: continue rr = sp*bt*(1-0.42*bt/life) px = bx + math.cos(ang)*rr py = by + math.sin(ang)*rr*0.94 + 130*bt*bt a0 = cam.proj(px, py) a1 = cam.proj(px - math.cos(ang)*rr*0.13, py - math.sin(ang)*rr*0.122 - 30*bt*bt) g = 1.0-bt/life d.line([a0, a1], fill=(int(255*g), int(196*g**1.4), int(70*g**2)), width=max(FL(1), int(s*3.0*(0.5+0.9*g)))) # lit smoke boiling out of the hole if 0.10 < bt < 3.4: srng2 = np.random.RandomState(311) for q in range(40): aa = -math.pi/2 + (srng2.rand()-0.5)*2.1 ph = srng2.rand() age = bt-0.10-ph*0.5 if age <= 0: continue rr = BLAST_R*(0.3 + 1.05*age)*(0.45+0.75*srng2.rand()) px = bx + math.cos(aa)*rr*0.7 - 22*age py = by + math.sin(aa)*rr - 26*age a0 = cam.proj(px, py) r2 = max(FL(2), s*G*(0.55+0.9*age)) v = int(52*max(0.0, 1-age/2.6)**1.3) d.ellipse([a0[0]-r2, a0[1]-r2, a0[0]+r2, a0[1]+r2], fill=(v, int(v*0.82), int(v*0.66))) elif kind == "reroute": # the new traces, drawing themselves around the hole prog = np.clip((t - shot.i0/FPS)/max(0.4, shot.n/FPS*0.72), 0, 1) for q, n in enumerate(BOARD.reroutes): pts = n["pts"] if len(pts) < 2: continue cum = [0.0] for i in range(len(pts)-1): cum.append(cum[-1]+math.dist(pts[i], pts[i+1])) pq = np.clip(prog*1.5 - (q % 5)*0.10, 0, 1) if pq <= 0.02: continue m = pq*cum[-1] pl = [] acc = 0.0 for i in range(len(pts)): if cum[i] > m: break pl.append(cam.proj(*pts[i])) pl.append(cam.proj(*pt_at(pts, cum, m))) if len(pl) >= 2: d.line(pl, fill=(120, 255, 190), width=max(FL(1), int(s*4.0)), joint="curve") elif kind == "scorch": # one lone signal easing past the crater, giving it a wide berth for n in BOARD.reroutes[:6]: pts = n["pts"] cum = [0.0] for i in range(len(pts)-1): cum.append(cum[-1]+math.dist(pts[i], pts[i+1])) q = ((t*0.16) + hash_f(n["pts"][0])) % 1.0 ss = q*cum[-1] px, py = pt_at(pts, cum, ss) sx, sy = cam.proj(px, py) if sx < -PS(40) or sx > W+PS(40) or sy < -PS(40) or sy > H+PS(40): continue r = max(PS(1.6), s*4.6) d.ellipse([sx-r, sy-r, sx+r, sy+r], fill=(180, 235, 255)) def hash_f(pt): return ((pt[0]*7.31 + pt[1]*3.17) % 1.0) # ════════════════════════════════════════════════════════════════════════════ # POST # ════════════════════════════════════════════════════════════════════════════ _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.52*r**2.1, 0, 1)[..., None].astype(np.float32) return _VIG["v"] _DOFW = {} def dof_weights(yf, span): key = (round(yf, 1), round(span, 1)) if key not in _DOFW: if len(_DOFW) > 400: _DOFW.clear() # clear BEFORE inserting — the y = np.arange(H, dtype=np.float32) # old order evicted the new key w = np.clip(np.abs(y-yf)/max(1.0, span), 0, 1) w2 = np.clip((w-0.45)/0.55, 0, 1) w1 = np.clip(w/0.55, 0, 1) - w2 w0 = 1.0 - w1 - w2 _DOFW[key] = (w0[:, None, None], w1[:, None, None], w2[:, None, None]) return _DOFW[key] def blur_np(a, r): return np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .filter(ImageFilter.GaussianBlur(r)), np.float32) CARDS = {} def _cards(): C = {} C[int(0.35*FPS)] = ("WAFER CITY", 2.2) C[int(10*BAR*FPS)] = ("0641 HRS", 1.5) C[int(14*BAR*FPS)] = ("0641 HRS · LANE 3", 1.4) C[int(18*BAR*FPS)] = ("TOLL 4K7", 1.8) C[int(22*BAR*FPS)] = ("BUS 0-7", 1.5) C[int(29*BAR*FPS)] = ("CONGESTION", 1.5) C[BLOW_F+int(0.9*FPS)] = ("C12", 2.4) C[int(36*BAR*FPS)] = ("REROUTING", 1.8) return C CARDS = _cards() def post(arr, i, e, shot, cam, light, extra_tint=None): a = arr.astype(np.float32) t = i/FPS # --- macro depth of field: a tilted plane of focus across the frame. # The wider the framing, the deeper the field — a macro at 200 units is # mostly mush, a whole-board shot is nearly all sharp. wide = min(1.0, cam.vw/1400.0) yf = H*(0.50 + 0.11*math.sin(shot.seed*0.7 + i*0.004)) span = max(H*0.13, H*(0.15 + 0.62*wide)) r1 = PS(1.4 + 1.5*(1-wide)); r2 = PS(4.0 + 6.0*(1-wide)) w0, w1, w2 = dof_weights(yf, span) b1 = blur_np(a, r1); b2 = blur_np(a, r2) a = a*w0 + b1*w1 + b2*w2 Lf = np.asarray(light, np.float32) l1 = blur_np(Lf, r1); l2 = blur_np(Lf, r2) Ld = Lf*w0 + l1*w1 + l2*w2 bloom = blur_np(Lf, PS(18.0)) a = a + Ld*0.92 + bloom*0.30 # --- kick-gated channel tear (picture only; text is composited after) sh = int((1 + 5*e["kick"]*(1.0 if shot.section in ("drop1", "drop2") else 0.3)) * RS) if sh > PS(2): a[..., 0] = np.roll(a[..., 0], sh, axis=1) a[..., 2] = np.roll(a[..., 2], -sh, axis=1) if e["kick"] > 0.86 and shot.section in ("drop1", "drop2"): rng = np.random.RandomState(i) for _ in range(rng.randint(1, 3)): y0 = rng.randint(0, H-PSi(24)); hgt = rng.randint(PSi(5), PSi(18)) a[y0:y0+hgt] = np.roll(a[y0:y0+hgt], rng.randint(-PSi(34), PSi(34)), axis=1) # --- tint: the light of the day this board is having if shot.section == "blow": bt = t - BLOW_T heat = max(0.0, 1.0-max(0.0, bt)/2.4) a *= np.array([1.02+0.20*heat, 0.88-0.06*heat, 0.70-0.10*heat], np.float32) a *= 0.60 + 0.46*max(0.0, 1.0-max(0.0, bt)/1.6) elif shot.section == "reroute": a *= np.array([0.86, 0.96, 1.02], np.float32) # cold afterwards a *= 0.86 else: MOOD = {"boot": (np.array([0.74, 0.86, 1.06], np.float32), 0.66), "commute": (np.array([0.90, 0.99, 1.00], np.float32), 0.86), "drop1": (np.array([1.00, 1.03, 0.95], np.float32), 1.02), "toll": (np.array([0.98, 1.00, 0.96], np.float32), 0.90), "drop2": (np.array([1.06, 1.02, 0.90], np.float32), 1.08)} c, g = MOOD.get(shot.section, (np.array([1, 1, 1], np.float32), 1.0)) a *= c*g if extra_tint is not None: a *= extra_tint # --- vignette a *= vignette() # --- grain. The grain *cell* is RS px, so the film stock looks the same # size on screen at 1080p instead of turning into fine static. rng = np.random.RandomState(5100 + i) if RS == 1.0: a += rng.normal(0, 3.1, a.shape) else: gh, gw = int(round(H/RS)), int(round(W/RS)) g = rng.normal(0, 3.1, (gh, gw, 3)) + 128.0 a += np.asarray(Image.fromarray(np.clip(g, 0, 255).astype(np.uint8)) .resize((W, H), Image.NEAREST), np.float32) - 128.0 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) # --- text, crisp, after everything. # The section/timecode strip that used to sit along the bottom was the # renderer talking, not the board; it is gone in the final cut. The CARDS # stay — they are the city's own signage. for f0, (txt, secs) in CARDS.items(): if f0 <= i < f0 + secs*FPS: age = (i-f0)/FPS al = min(1.0, age*7) * min(1.0, (secs-age)*4) fl = 1.0 if age > 0.25 else (1.0 if int(age*24) % 2 else 0.25) v = int(255*al*fl) fnt = font(PSi(46 if len(txt) < 12 else 38), "Helvetica.ttc") bb = d.textbbox((0, 0), txt, font=fnt) x = (W-(bb[2]-bb[0]))/2 d.text((x+PS(2), PS(62+2)), txt, font=fnt, fill=(0, 40, 20)) d.text((x, PS(62)), txt, font=fnt, fill=(v, v, int(v*0.94))) # the show's name, silkscreened small under the title card only if txt == "WAFER CITY": sub = "PLAYER COMPUTER" sf = font(PSi(17), "Menlo.ttc") sb = d.textbbox((0, 0), sub, font=sf) sx = (W-(sb[2]-sb[0]))/2 sy = PS(62+52) sv = int(214*al*fl) # a stencil rule either side, the way a board legend is set rw = PS(46); ry = sy + PS(9) d.line([sx-PS(14)-rw, ry, sx-PS(14), ry], fill=(int(sv*0.5), int(sv*0.62), int(sv*0.5)), width=MINW) d.line([sx+(sb[2]-sb[0])+PS(14), ry, sx+(sb[2]-sb[0])+PS(14)+rw, ry], fill=(int(sv*0.5), int(sv*0.62), int(sv*0.5)), width=MINW) d.text((sx+PS(1), sy+PS(1)), sub, font=sf, fill=(0, 34, 18)) d.text((sx, sy), sub, font=sf, fill=(sv, sv, int(sv*0.94))) 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 # ════════════════════════════════════════════════════════════════════════════ # SHOTS # ════════════════════════════════════════════════════════════════════════════ PLAN = { "boot": (["estab", "edge", "signage", "chip", "estab"], [4, 4, 2]), "commute": (["follow", "junction", "signage", "estab", "chip", "edge", "grid", "follow"], [2, 2, 4, 1]), "drop1": (["follow", "grid", "junction", "bus", "chip", "edge", "signage", "estab", "follow", "cap", "junction", "bus"], [1, 1, 2, .5, 2]), "toll": (["toll", "follow", "signage", "junction", "cap"], [4, 2, 2]), "drop2": (["bus", "follow", "grid", "queue", "junction", "cap", "chip", "edge", "estab", "queue", "follow", "signage", "junction", "bus"], [1, .5, 1, 2, .5]), "blow": (["blast", "dark", "cap", "queue", "dark", "estab"], [8, 4, 2]), "reroute": (["reroute", "follow", "junction", "estab", "cap", "edge", "signage", "scorch"], [4, 4, 2, 6]), } class Shot: __slots__ = ("idx", "i0", "i1", "n", "kind", "section", "seed", "state") def __init__(self, idx, i0, i1, kind, section): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.kind, self.section = kind, section self.seed = 51000 + idx*7717 # The crater exists from the instant it happens; the flash hides the cut. self.state = "post" if i0 >= BLOW_F else "pre" def build_shots(): R = np.random.RandomState(20255) shots = []; idx = 0; last = None for nm, b0, b1 in SECTIONS: pool, menu = PLAN[nm] 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*0.9: t2 = b1*BAR i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: if nm == "blow" and j == 0: kind = "blast" elif nm == "reroute" and j == 0: kind = "reroute" elif nm == "boot" and j == 0: kind = "estab" elif nm == "toll" and j % 2 == 0: kind = "toll" else: cand = [x for x in pool if x != last and x not in ("blast", "reroute", "scorch")] or list(pool) kind = cand[R.randint(len(cand))] last = kind shots.append(Shot(idx, i0, i1, kind, nm)) idx += 1; j += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES-shots[-1].i0 shots[-1].kind = "scorch"; shots[-1].state = "post" shots[-2].kind = "estab" return shots # ---- per-shot world raster -------------------------------------------------- # pixels, not world units — it has to grow with the *area* of the frame or the # 1080p raster gets down-sampled and the whole picture softens. RASTER_CAP = int(3_400_000 * RS * RS) def shot_raster(shot): rng = np.random.default_rng(shot.seed) sc = Scene(shot, rng) E = env() xs, ys, vws = [], [], [] ks = sorted(set([0, shot.n//4, shot.n//2, (3*shot.n)//4, max(0, shot.n-1)])) for k in ks: i = min(N_FRAMES-1, shot.i0+k) e = {kk: float(E[kk][i]) for kk in E} cam = sc.cam(k, k/max(1, shot.n-1), e) for (px, py) in cam.corners(): xs.append(px); ys.append(py) vws.append(cam.vw) m = min(vws)*0.03 x0, x1 = min(xs)-m, max(xs)+m y0, y1 = min(ys)-m, max(ys)+m sr = (W/min(vws))*1.35 while (x1-x0)*sr*(y1-y0)*sr > RASTER_CAP: sr *= 0.85 arr = paint_board(shot.state, x0, y0, x1, y1, sr, seed=1992) return sc, Image.fromarray(arr.astype(np.uint8)), (x0, y0, sr) def sample_raster(rimg, geom, cam): x0, y0, sr = geom s = cam.s; a = math.radians(cam.rot) ca, sa = math.cos(a), math.sin(a) A = sr*ca/s; B = sr*sa/s C = sr*(cam.cx-x0) - A*W/2 - B*H/2 D = -sr*sa/s; Ee = sr*ca/s F = sr*(cam.cy-y0) - D*W/2 - Ee*H/2 return rimg.transform((W, H), Image.AFFINE, (A, B, C, D, Ee, F), resample=Image.BICUBIC, fillcolor=(9, 34, 22)) def render_frame(sc, rimg, geom, shot, k, E): i = min(N_FRAMES-1, shot.i0+k) e = {kk: float(E[kk][i]) for kk in E} u = k/max(1, shot.n-1) t = (shot.i0+k)/FPS cam = sc.cam(k, u, e) base = np.asarray(sample_raster(rimg, geom, cam), np.float32) light, _ = draw_lights(cam, t, e, shot.state, shot, u) d = ImageDraw.Draw(light) scene_extra(sc, light, d, cam, k, u, e, t, shot) tint = None if shot.section == "blow": bt = max(0.0, t-BLOW_T) if bt < 0.20: tint = np.float32(1.0) + np.float32(3.6)*np.float32(math.exp(-bt*18)) return post(base, i, e, shot, cam, light, tint) def render_shot(job): shot, force = job E = env() need = [k for k in range(shot.n) if force or not (FRAMES/f"f{shot.i0+k:05d}.png").exists()] if not need: return f"shot {shot.idx:02d} {shot.kind:9s} cached" sc, rimg, geom = shot_raster(shot) for k in need: im = render_frame(sc, rimg, geom, shot, k, E) im.save(FRAMES/f"f{shot.i0+k:05d}.png", compress_level=1) return f"shot {shot.idx:02d} {shot.kind:9s} {shot.section:8s} {len(need)}/{shot.n}" def sheet_thumb(shot): E = env() sc, rimg, geom = shot_raster(shot) im = render_frame(sc, rimg, geom, shot, shot.n//2, E) return shot.idx, im.resize((PSi(288), PSi(162)), Image.LANCZOS) def contact_sheet(shots, jobs): import multiprocessing as mp cols = 8 rows = (len(shots)+cols-1)//cols tw, th = PSi(288), PSi(162) cap = PSi(22) sheet = Image.new("RGB", (cols*tw, rows*(th+cap)), (10, 12, 12)) sd = ImageDraw.Draw(sheet) with mp.get_context("fork").Pool(jobs) as pool: for idx, im in pool.imap_unordered(sheet_thumb, shots): sh = shots[idx] cx, cy = (idx % cols)*tw, (idx//cols)*(th+cap) sheet.paste(im, (cx, cy)) sd.text((cx+PS(5), cy+th+PS(3)), f"{idx:02d} {sh.kind} {sh.section} {sh.i0/FPS:.1f}s {sh.state}", font=font(PSi(12), "Menlo.ttc"), fill=(190, 200, 195)) p = OUT/f"contact_sheet{SUF}.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("--jobs", type=int, default=min(12, os.cpu_count() or 4)) ap.add_argument("--720p", dest="sd", action="store_true", help="proof render at 1280x720 (frames_720p/, wafer_city_720p.mp4); " "the delivery is 1920x1080 and is the default") a = ap.parse_args() print(f" {W}x{H} (scale {RS:g})") wav = AUD/"final.wav" # --force is for picture; it only rebuilds the song when the whole piece is # in play, so `--shots 40 --force` stays a two-second operation. if not wav.exists() or not (AUD/"env.npz").exists() or (a.force and not a.shots): 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() print(f" {len(shots)} shots, mean {DUR/len(shots):.2f}s") if a.sheet: contact_sheet(shots, a.jobs); 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"[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) print("[3/3] mux…") out = OUT/f"{NAME}{SUF}.mp4" 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" hdtag = f"res={W}x{H}{' 1080p' if HD else ''}; " stamp = (f"generator=renders/player_computer_final/{NAME}/render.py; git={sha}; " f"branch={br}; {hdtag}music={MUSIC_DESC}; engine={ENGINE_DESC}; " f"built={datetime.datetime.now().astimezone().isoformat()}") 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"title={SETDIR} — {TITLE}" + ("" if HD else " (720p proof)"), "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", "-metadata", "artist=poop / player_computer_final", str(out)], check=True, capture_output=True) (OUT/f"PROVENANCE{SUF}.txt").write_text( f"generator: renders/player_computer_final/{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"engine: {ENGINE_DESC}\n" f"board: {len(BOARD.comps)} components, {len(BOARD.nets)} routed nets, " f"{len(BOARD.vias)} vias, {len(BOARD.reroutes)} nets rerouted after the blast\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()