#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Vending (21/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/vending # # A bag of chips hangs by one corner in coil C3, and the machine has no opinion about it. # # 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/vending.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/vending.mp4 # cover: https://genekogan.com/player_computer/media/vending.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 vending.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 — "VENDING" (final delivery cut) Hyperpop, 160bpm, F# minor. 42 bars. The most aggressive music in the set: clipped supersaws, 808 slides, chipmunked vocal chops, gratuitous risers, a hard full stop at bar 24, and a beat switch at bar 26. insert(4) drop1(8) stuck(4) rage(8) stop(2) switch(8) hero(6) outro(2) A bag of chips is stuck in coil C3, hanging by one corner over the shelf edge. The buyer's escalating attempts — mash the keypad, feed it another 1.25, shake it, shoulder-charge it — all fail. The machine is indifferent: the LED never changes, the other four coils never move. So he buys B3, the bag directly above. B3 falls, hits C3 on the way down, and knocks it loose. Both land. The second bag was the hero. THE SUBSTRATE — A VENDING MACHINE FACE. New to this repo. Everything inside the cabinet lives in a real perspective frustum: one vanishing point at the middle of the glass, a focal divide, back panel and side walls as trapezoids running 520 world units deep. The coils are actual 3D helices — X = R·cos(a), Y = R·sin(a), Z = u·DEPTH — projected through that divide and drawn back-to-front as tapering polylines, shaded per-vertex by the dot product of the ring normal with the fluorescent tube's direction, so each turn has a chrome top and a dark underside. A bag sits *inside* the helix at its own depth: the turns behind it are drawn, then the bag, then the turns in front. Rotation is quantised to the 16th-note grid, so the coil visibly steps on the beat instead of creeping. On top of the whole thing sits a separate additive glass pass — the diagonal specular streak, seeded fingerprint whorls in world space, reflected ceiling strips, and the buyer himself, who only ever appears as a soft reflection in the glass. The LED price display is a real seven-segment renderer (its own additive layer, blurred, so it glows). The falls are integrated: gravity, tumble, restitution off the shelf lip and the tray floor. Composition: engine : audio-first x shot-parallel (tier 4-P) x world-camera through a perspective frustum (one universal scene painter, ~50 framings) content: audio-groove (supersaw / 808 slides / chipmunk chops / risers) x tts-voices (Samantha chopped, Superstar sung, Zarvox as the machine) x effects-post FINAL CUT (player_computer_final). The film is unchanged; the delivery is: * 1920x1080 native. The cabinet is a world, and Cam turns world units into pixels with s = pw/vw, so raising the delivery frame raises s and every coil turn, seam, price label and reflection is re-rasterised rather than upscaled — the internal 1.30x supersample goes with it (2496x1404). The framing table is authored as (cx, cy, view-width) and the aspect is unchanged at 16:9, so VWK stays exactly where round two put it and no shot is recomposed. What did need telling separately is the delivery-side post: the RGB-shift offset, the shadow and LED-bloom blur clamps, the burst type sizes and their drop shadows all carry S = H/720 = 1.5, and film grain is drawn at 1280x720 and NEAREST-blown-up so a speck keeps its size on screen. * No renderer-debug overlays. The seven-segment LED price/selection readout is the MACHINE's own display — diegetic, and the whole point of the stand-off — so it stays, as do the shelf labels, USE EXACT CHANGE, INSERT COIN and the shouted bursts, which are the film speaking. * Title moment: the opening "VENDING" burst now carries PLAYER COMPUTER under it, cut from the same Impact and the same hard drop shadow. Run from repo root: python3 renders/player_computer_final/vending/render.py --sheet python3 renders/player_computer_final/vending/render.py --jobs 3 """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "vending" TITLE = "VENDING" SUBT = "PLAYER COMPUTER" SETDIR = "player_computer_final" SETNUM = "14" W, H, FPS = 1920, 1080, 30 # ── delivery scale ────────────────────────────────────────────────────────── # Nearly everything here is world-space and rides on Cam.s = pw/vw, which # grows with the frame on its own. S is only needed for the handful of # quantities measured in DELIVERY pixels: the chroma offset, the two blur # clamps, the burst type, the film grain. S = H/720.0 def PX(v): return int(round(v*S)) def PF(v): return v*S SS = 1.30 PW, PH = int(W*SS), int(H*SS) BPM = 160.0 BEAT = 60.0 / BPM # 0.375 BAR = 4 * BEAT # 1.5 S16 = BEAT / 4 # 0.09375 SR = 44100 OUT = Path(__file__).resolve().parent FRAMES = OUT / "frames"; FRAMES.mkdir(exist_ok=True) AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = Path(__file__).resolve().parent # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" SECTIONS = [ ("insert", 0, 4), ("drop1", 4, 12), ("stuck", 12, 16), ("rage", 16, 24), ("stop", 24, 26), ("switch", 26, 34), ("hero", 34, 40), ("outro", 40, 42), ] N_BARS = SECTIONS[-1][2] TAIL = 2.4 DUR = N_BARS * BAR + TAIL N_FRAMES = int(DUR * FPS) MUSIC_DESC = (f"hyperpop, {BPM:.0f}bpm, F# minor, {N_BARS} bars, clipped supersaw / " "808 slides / chipmunk chops / beat switch at bar 26") ENGINE_DESC = ("vending-machine face — perspective frustum, 3D helical coils, " "additive glass pass, seven-segment LED, integrated drops") # ════════════════════════════════════════════════════════════════════════════ # 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 — nothing in the kit is a raw full-band blast (AESTHETIC 13a).""" n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) g = np.ones_like(fq) if lo: g *= 1.0/np.sqrt(1.0 + (lo/fq)**order) if hi: g *= 1.0/np.sqrt(1.0 + (fq/hi)**order) return np.fft.irfft(X*g, n) def clip(x, drive): """The hyperpop verb. Hard-ish soft clip, gain-compensated.""" return np.tanh(x*drive) / np.tanh(drive) def supersaw(freq, dur, det=2.2, nv=7, drive=7.0, hi=7200, a=.003, d=.09, s=.72, r=.07, seed=0, sub=0.0, wob=0.0): """7 naive saws, wide detune, hard clipped, then band-limited. Naive (aliasing) saws are the point: this is a supersaw that has been slammed into a limiter, and the fold-back is half the timbre. The FFT band shape afterwards keeps the very top from turning to sand. """ n = int(dur*SR) if n <= 2: return np.zeros(max(0, n)) t = np.arange(n)/SR rng = np.random.RandomState(seed | 1) out = np.zeros(n) for i in range(nv): c = (i - (nv-1)/2) / ((nv-1)/2) f = freq * (1 + c*det*0.01) if wob: f = f * (1 + wob*np.sin(2*np.pi*5.5*t + i)) ph = (np.cumsum(np.full(n, f))/SR + rng.rand()) % 1.0 out += 2*ph - 1 out /= nv if sub: out += sub*np.sin(2*np.pi*freq*0.5*t) out = clip(out, drive) out = bandshape(out, lo=90, hi=hi) return out * adsr(n, a, d, s, r) def pluck808(f0, dur, slide_from=None, slide_t=0.075, drive=3.4, dec=2.4, seed=0): """808 sub with a real portamento slide and a pitch blip for the knock.""" n = int(dur*SR) if n <= 2: return np.zeros(max(0, n)) t = np.arange(n)/SR f = np.full(n, float(f0)) if slide_from: u = np.clip(t/slide_t, 0, 1)**0.55 f = slide_from + (f0 - slide_from)*u f = f * (1 + 2.1*np.exp(-t*95)) x = np.sin(2*np.pi*np.cumsum(f)/SR) * np.exp(-t*dec) x = clip(x, drive) return x * adsr(n, .001, .02, .95, min(.08, dur*0.3)) * 0.95 def kick(dur=.26, f0=210, f1=47, punch=42, click=.55, seed=1, drive=2.6): 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*13.0) ck = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=7000) \ * np.exp(-t*380) * click return clip(body + ck, drive) * .96 def clap(dur=.30, seed=2, bright=1.0): """4 offset noise bursts + a short body — the hyperpop clap.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=900*bright, hi=8200) out = np.zeros(n) for j, off in enumerate((0.0, .010, .019, .027)): i = int(off*SR) seg = nz[:n-i] * np.exp(-np.arange(n-i)/SR*(58 if j < 3 else 15)) out[i:] += seg * (0.7 + 0.3*j/3) return clip(out*0.5, 2.2) * .62 def snare(dur=.24, tone=205, seed=3): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=340, hi=7600) body = np.sin(2*np.pi*tone*t) + .6*np.sin(2*np.pi*tone*1.6*t) return clip(nz*np.exp(-t*17)*.9 + body*np.exp(-t*24)*.55, 2.0)*.8 def hat(dur=.04, openh=False, seed=7, bright=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=6400*bright, hi=13500) return nz * np.exp(-t*(13 if openh else 120)) * .42 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=11000) return nz * (np.exp(-t*2.9) + .28*np.exp(-t*.7)) * .55 def riser(dur=1.5, seed=17, f0=180, f1=5200, gain=1.0): """A gratuitous pitch riser: a real exponential sweep plus a noise band that climbs with it (never a static hiss).""" n = int(dur*SR); t = np.arange(n)/SR; u = t/max(dur, 1e-6) env = u ** 1.5 f = f0 * (f1/f0) ** (u**1.6) sweep = np.sin(2*np.pi*np.cumsum(f)/SR) rng = np.random.RandomState(seed) nz = np.zeros(n); blk = 4096 for i in range(0, n, blk): m = min(blk, n-i) uu = (i/max(1, n)) ** 1.4 fc = 420 + 7200*uu nz[i:i+m] = bandshape(rng.randn(m+256), lo=fc*.7, hi=fc*1.7)[:m] return clip(nz*env*.55 + sweep*env*.30, 2.4) * .85 * gain def downlift(dur=1.2, seed=19): n = int(dur*SR); t = np.arange(n)/SR; u = t/max(dur, 1e-6) f = 3600 * (70/3600) ** (u**0.8) x = np.sin(2*np.pi*np.cumsum(f)/SR) * (1-u)**0.6 return clip(x, 2.0) * .55 def revcym(dur=1.6, seed=23): x = crash(dur, seed=seed) return x[::-1] * np.linspace(0, 1, len(x))**1.4 * 0.9 def hum(dur, seed=29): """The machine: 60 Hz compressor drone + a filtered tube whine.""" n = int(dur*SR); t = np.arange(n)/SR x = (np.sin(2*np.pi*60*t)*0.55 + np.sin(2*np.pi*120*t)*0.22 + np.sin(2*np.pi*15720*t)*0.05) nz = bandshape(np.random.RandomState(seed).randn(n), lo=180, hi=1100)*0.10 return (x + nz) * 0.11 def coinclunk(seed=31): """A quarter going into the mech: a ring, a rattle down the rails, a stop.""" n = int(.55*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) ring = sum(np.sin(2*np.pi*f*t)*g for f, g in ((2450, 1.0), (3720, .55), (5140, .32), (6980, .18))) ring *= np.exp(-t*11) out = ring*0.32 for j, off in enumerate((.09, .16, .21, .28, .33)): i = int(off*SR); m = n - i tk = bandshape(rng.randn(m), lo=1800, hi=9000)*np.exp(-np.arange(m)/SR*180) out[i:] += tk * (0.30 - j*0.04) return out*0.85 def keybeep(seed=33, f=1760, dur=.09): n = int(dur*SR); t = np.arange(n)/SR return np.sin(2*np.pi*f*t)*adsr(n, .002, .02, .5, .03)*.5 def keyclick(seed=35): n = int(.05*SR); t = np.arange(n)/SR return bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=5200) \ * np.exp(-t*160) * .45 def bodyhit(seed=37, big=1.0): """A shoulder into sheet metal: low boom + a shell of rattling glass.""" n = int(.9*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) boom = np.sin(2*np.pi*(52 + 90*np.exp(-t*26))*t)*np.exp(-t*7.5) shell = bandshape(rng.randn(n), lo=260, hi=2600)*np.exp(-t*16) glassr = np.zeros(n) for j in range(14): i = int((0.02 + j*0.021 + rng.rand()*0.012)*SR) if i >= n: break m = n-i glassr[i:] += bandshape(rng.randn(m), lo=3000, hi=11000) \ * np.exp(-np.arange(m)/SR*90) * (0.25*rng.rand()+0.08) return clip(boom*1.1 + shell*.5 + glassr*.7, 2.2)*.8*big def coilwhir(dur, seed=39): """The vend motor: a geared whine with tooth-rate ripple.""" n = int(dur*SR); t = np.arange(n)/SR base = 118.0 x = (np.sin(2*np.pi*base*t)*0.5 + np.sin(2*np.pi*base*2*t)*0.3 + np.sin(2*np.pi*base*3.02*t)*0.16) x *= (0.75 + 0.25*np.sin(2*np.pi*10.6666*t)) # 16ths at 160bpm nz = bandshape(np.random.RandomState(seed).randn(n), lo=700, hi=3400)*0.22 env = np.clip(t/0.05, 0, 1) * np.clip((dur-t)/0.08, 0, 1) return clip((x + nz)*env, 1.6)*.30 def bagland(seed=41, big=1.0): """A foil bag hitting a steel tray: a crackle burst with a hollow thump.""" n = int(.45*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) crk = bandshape(rng.randn(n), lo=2200, hi=12000)*np.exp(-t*26) thud = np.sin(2*np.pi*(88 + 60*np.exp(-t*40))*t)*np.exp(-t*17) return clip(crk*0.55 + thud*0.8, 2.0)*.7*big def flapswing(seed=43): n = int(.6*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) sq = bandshape(rng.randn(n), lo=500, hi=3000)*np.exp(-t*9) return sq*0.30 def reverb(x, rt=1.2, mix=.24, seed=47, pre=0.014): 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=.1875, fb=.42, mix=.22, 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 # ════════════════════════════════════════════════════════════════════════════ # VOICE — chipmunk chops, a sung hook, and the machine # ════════════════════════════════════════════════════════════════════════════ def _h(*parts): """Stable cache key. Python's str hash is salted per process — hash() here would resynthesise every line on every run.""" 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 or n < 2: return np.zeros(max(0, n)) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def syllables(x, min_s=0.045, gap_s=0.030): """Split a `say` line into voiced islands. Crude, deterministic, and exactly what a chop needs — we want syllables, not words.""" k = max(1, int(0.014*SR)) env = np.convolve(np.abs(x), np.ones(k)/k, "same") thr = env.max()*0.13 on = env > thr segs = []; i = 0; n = len(on) while i < n: if on[i]: j = i while j < n and on[j]: j += 1 if segs and (i - segs[-1][1])/SR < gap_s: segs[-1] = (segs[-1][0], j) elif (j-i)/SR >= min_s: segs.append((i, j)) i = j else: i += 1 return segs or [(0, len(x))] def chop_grain(seg, semis, dur, up=1.62, seed=0): """One chipmunk grain: resample the syllable (pitch AND speed together — that is the chipmunk), then window it hard to the grid slot.""" ratio = up * 2**(semis/12.0) m = max(4, int(len(seg)/ratio)) g = fit(seg, m) n = int(dur*SR) if n <= 4: return np.zeros(max(0, n)) if m > n: g = g[:n] out = np.zeros(n); out[:len(g)] = g out *= adsr(n, .004, .05, .55, min(.05, dur*0.4)) return out / (np.max(np.abs(out)) + 1e-9) def carrier(f_per_sample, nh=26, detune=(0.0, -0.7, 0.8)): n = len(f_per_sample); out = np.zeros(n) for d in detune: f = f_per_sample*(1 + d*0.006) ph = 2*np.pi*np.cumsum(f)/SR for k in range(1, nh+1): live = (f*k) < SR*0.45 if not live.any(): break out += np.sin(ph*k)/k * live return out/len(detune) def vocode(mod, car, nfft=1024, hop=256, bands=24, lo=140, hi=7000, gmax=12.0, rel=0.5, sib=0.09, tilt=4600.0): n = max(len(mod), len(car)) mod = np.pad(mod, (0, n-len(mod))); car = np.pad(car, (0, n-len(car))) win = np.hanning(nfft); nfr = 1 + max(0, (n-nfft))//hop fr = np.fft.rfftfreq(nfft, 1/SR) edges = np.geomspace(lo, hi, bands+1) idx = [np.where((fr >= edges[b]) & (fr < edges[b+1]))[0] for b in range(bands)] keep = np.zeros(len(fr), bool) for ii in idx: keep[ii] = True out = np.zeros(n); wsum = np.zeros(n)+1e-9 prev = np.zeros(bands) for f in range(nfr): s = f*hop M = np.fft.rfft(mod[s:s+nfft]*win); C = np.fft.rfft(car[s:s+nfft]*win) am = np.abs(M); ac = np.abs(C) g = np.zeros(len(fr)) for b, ii in enumerate(idx): if not len(ii): continue em = np.sqrt((am[ii]**2).mean()); ec = np.sqrt((ac[ii]**2).mean()) gb = np.clip(em/(ec+1e-4), 0, gmax) gb = prev[b]*rel + gb*(1-rel) prev[b] = gb; g[ii] = gb out[s:s+nfft] += np.fft.irfft(C*g*keep)*win wsum[s:s+nfft] += win**2 ws = np.maximum(wsum, 0.35*np.median(wsum[nfft:max(nfft+1, n-nfft)])) y = out/ws y[:hop] = 0.0; y[-hop:] = 0.0 y /= (np.max(np.abs(y))+1e-9) hp = np.zeros_like(mod); hp[1:] = mod[1:]-mod[:-1] for _ in range(2): hp = np.convolve(hp, [1, -0.93], "same") hp = np.clip(hp/(np.percentile(np.abs(hp), 99.5)+1e-9), -1, 1) a = math.exp(-2*math.pi*tilt/SR); z = 0.0; lp = np.empty_like(y) for i in range(len(y)): z = (1-a)*y[i] + a*z; lp[i] = z y = 0.6*y + 0.8*lp + sib*hp return y/(np.max(np.abs(y))+1e-9) def sing(text, notes, dur, voice="Superstar", rate=200, cache=None, gliss=0.010, **vk): """The hook. A `say` modulator carries the words; a detuned additive carrier up in the fifth octave carries the pitch, so it comes out as the hyperpop diva instead of a robot reading.""" n = int(dur*SR) key = cache/("say_"+_h(text, voice, rate)+".wav") mod = fit(say_wav(text, voice, rate, key), n) tot = sum(w for _, w in notes) or 1.0 f = np.zeros(n); at = 0 for i, (fq, w) in enumerate(notes): ln = int(n*w/tot) if i < len(notes)-1 else n-at f[at:at+ln] = fq; at += ln if gliss: k = max(3, int(gliss*SR)); f = np.convolve(f, np.ones(k)/k, "same") f[:k] = f[k]; f[-k:] = f[-k-1] return vocode(mod, carrier(f), **vk) def speak(text, voice="Zarvox", rate=170, cache=None, pitch=1.0, dur=None): key = cache/("say_"+_h(text, voice, rate)+".wav") x = say_wav(text, voice, rate, key) if pitch != 1.0: x = fit(x, max(4, int(len(x)/pitch))) if dur: n = int(dur*SR) x = x[:n] if len(x) > n else np.pad(x, (0, n-len(x))) return x/(np.max(np.abs(x))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # SONG CANVAS # ════════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0): return bar*BAR + step*S16 def put(self, track, sig, at, g=1.0, pan=0.0): if len(sig) == 0: return b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR); j = min(self.n, i+len(sig)) if i >= self.n or j <= i: return if i < 0: sig = sig[-i:]; i = 0; j = min(self.n, len(sig)) 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.10): 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=.45, pump_rel=.13, levels=None, master_drive=1.5): 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(220)/220, "same") mix *= env[:, None] a = math.exp(-2*math.pi*32.0/SR) for c in range(2): col = mix[:, c]; lp = np.empty(self.n); z = 0.0 for i in range(0, self.n, 8192): blk = col[i:i+8192] for j in range(len(blk)): z = (1-a)*blk[j] + a*z; lp[i+j] = z mix[:, c] = col - lp mix = clip(mix, master_drive) return mix/(np.max(np.abs(mix))+1e-9)*.95 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: for st in (0, 8): at = s.t(bar, st) s.put("drums", kick(dur=.22, drive=2.0), at, g=.45) s.kick_t.append(at) for st in range(2, 16, 4): s.put("drums", hat(seed=bar*16+st), s.t(bar, st), g=.20) elif sec == "outro": if bar == 40: for st in (0,): s.put("drums", kick(dur=.26), s.t(bar, st), g=.5) elif sw: # BEAT SWITCH — half-time stomp, wider, heavier for st in (0, 3, 10): at = s.t(bar, st) s.put("drums", kick(dur=.34, f0=250, f1=42, punch=34, drive=3.4), at, g=.95) s.kick_t.append(at) s.put("drums", snare(dur=.32, tone=190, seed=bar*3+5), s.t(bar, 8), g=.72, pan=.05) s.put("drums", clap(seed=bar*5+2), s.t(bar, 8), g=.42, pan=-.05) for st in range(0, 16, 2): s.put("drums", hat(dur=.055, seed=bar*16+st, bright=.85), s.t(bar, st), g=.18 + .10*(st % 4 == 0), pan=-.28+.56*((st//2) % 2)) if bar % 4 == 3: for j in range(6): s.put("drums", hat(dur=.028, seed=bar*40+j, bright=1.15), s.t(bar, 12) + j*S16/3, g=.10+.030*j, pan=.2) else: KPAT = {"drop1": (0, 6, 10), "stuck": (0, 10), "rage": (0, 5, 8, 11), "hero": (0, 3, 6, 8, 11, 14)}[sec] for st in KPAT: at = s.t(bar, st) s.put("drums", kick(dur=.24, drive=2.9 if hard else 2.2), at, g=.90 if hard else .55) s.kick_t.append(at) s.put("drums", clap(seed=bar*7+3), s.t(bar, 8), g=.62 if hard else .34, pan=.04) if hard: s.put("drums", snare(dur=.16, tone=240, seed=bar*9+1), s.t(bar, 8), g=.30, pan=-.06) dens = 1 if sec == "stuck" else 0 for st in range(0, 16, 1 + dens): v = .13 + .10*(st % 4 == 0) + .05*R.rand() s.put("drums", hat(dur=.036, seed=bar*16+st), s.t(bar, st), g=v*(1.0 if hard else .7), pan=-.32+.64*((st//1) % 2)) # 32nd + triplet rolls if hard and bar % 2 == 1: nroll = 6 if sec == "rage" else 4 for j in range(nroll): s.put("drums", hat(dur=.024, seed=bar*70+j, bright=1.2), s.t(bar, 14) + j*(S16*2/nroll), g=.09+.028*j, pan=-.2) if sec == "rage" and bar % 4 == 2: for j in range(4): s.put("drums", clap(dur=.14, seed=bar*11+j), s.t(bar, 12) + j*S16/2, g=.16+.05*j, pan=-.1+.07*j) # ---- 808 ---------------------------------------------------------- if not quiet: base = nf(rootn + "1") prev = nf(PROG[(bar-1) % 4][0] + "1") if sw: s.put("808", pluck808(base, BEAT*2.2, slide_from=prev*1.42, dec=1.5, drive=4.0, seed=bar), s.t(bar, 0), g=1.05) s.put("808", pluck808(base*1.5, BEAT*1.1, slide_from=base, dec=2.6, drive=3.6, seed=bar+1), s.t(bar, 10), g=.75) else: pat = [(0, 0, 3.0), (6, 0, 2.0), (10, 7, 2.4), (14, 5, 3.4)] \ if sec != "stuck" else [(0, 0, 2.2), (10, 0, 2.6)] for st, semi, dc in pat: tgt = base*2**(semi/12.0) sl = prev if st == 0 else base*2**((semi+7)/12.0) s.put("808", pluck808(tgt, BEAT*1.4, slide_from=sl, dec=dc, drive=3.2, seed=bar*16+st), s.t(bar, st), g=.95 if hard else .60) # ---- supersaw chords ---------------------------------------------- if sec == "insert": if bar >= 1: for iv in ivs[:3]: s.put("saw", supersaw(rf*4*2**(iv/12.0), BAR*0.9, det=1.6, drive=2.2, hi=3400, a=.25, d=.5, s=.5, r=.5, seed=bar*13+iv), s.t(bar, 0), g=.10, pan=-.3+.2*iv/7) elif sec == "outro": for iv in ivs[:3]: s.put("saw", supersaw(rf*4*2**(iv/12.0), BAR*1.6, det=1.4, drive=1.8, hi=2600, a=.4, d=.9, s=.4, r=1.0, seed=bar*17+iv), s.t(bar, 0), g=.13, pan=-.25+.16*iv/7) else: stabs = {"drop1": (0, 3, 6, 10, 12), "stuck": (0, 8), "rage": (0, 2, 3, 6, 8, 10, 11, 14), "switch": (0, 3, 10), "hero": (0, 2, 4, 6, 8, 10, 12, 14)}[sec] oct_ = 2 if sw else 4 for st in stabs: ln = BEAT*(1.6 if sw else 0.62) for k2, iv in enumerate(ivs): s.put("saw", supersaw(rf*oct_*2**(iv/12.0), ln, det=2.6, drive=8.5 if hard else 5.0, hi=8200 if hard else 5200, a=.002, d=.10, s=.55, r=.09, seed=bar*64+st*4+k2, wob=.004 if sec == "rage" else 0.0), s.t(bar, st), g=(.17 if hard else .12)*(1.25 if sw else 1.0), pan=-.42+.28*k2) # screaming lead over the loud parts if sec in ("rage", "hero"): for j, st in enumerate((0, 4, 8, 12)): iv = LEAD_MEL[(bar*4+j) % len(LEAD_MEL)] s.put("lead", supersaw(rf*8*2**(iv/12.0), BEAT*.9, det=1.4, nv=5, drive=9.0, hi=11000, a=.002, d=.16, s=.35, r=.10, seed=bar*31+j), s.t(bar, st), g=.11, pan=.15-.3*(j % 2)) # ---- chipmunk chops ------------------------------------------------- if sec in ("drop1", "rage", "hero", "switch"): bank = banks[bar % len(banks)] grid = {"drop1": (0, 2, 4, 7, 10, 12, 14), "rage": (0, 1, 2, 4, 6, 7, 8, 10, 12, 13, 14), "hero": (0, 2, 4, 6, 8, 10, 12, 14), "switch": (0, 4, 6, 10, 12)}[sec] for j, st in enumerate(grid): seg = bank[(bar*5 + j) % len(bank)] semi = CHOP_MEL[(bar*7 + j) % len(CHOP_MEL)] + (0 if sw else 0) dd = S16*(2.0 if sw else 1.15) s.put("chop", chop_grain(seg, semi, dd, up=1.45 if sw else 1.72, seed=bar*32+j)*0.9, s.t(bar, st), g=.30 if sw else .24, pan=-.35+.7*((bar*5+j) % 3)/2) # ---- the sung hook ------------------------------------------------------ hk = nf("F#5") s.put("hook", sing(HOOK_LINES[0][0], [(hk, 1), (hk*2**(3/12), 1), (hk*2**(-2/12), 1), (hk, 1.4)], HOOK_LINES[0][1], cache=cache), 8*BAR, g=.30, pan=-.06) s.put("hook", sing(HOOK_LINES[1][0], [(hk*2**(3/12), 1), (hk*2**(5/12), 1), (hk, 1.5)], HOOK_LINES[1][1], cache=cache), 20*BAR + BEAT*2, g=.32, pan=.08) s.put("hook", sing(HOOK_LINES[2][0], [(hk*2**(-2/12), 1), (hk, 1), (hk*2**(3/12), 1), (hk*2**(-4/12), 1.6)], HOOK_LINES[2][1], cache=cache), 25*BAR, g=.62, pan=0.0) s.put("hook", sing(HOOK_LINES[3][0], [(hk*2**(3/12), 1), (hk*2**(7/12), 1.2), (hk*2**(5/12), 1), (hk*2**(3/12), 1), (hk, 2.0)], HOOK_LINES[3][1], cache=cache), 40*BAR + BEAT*0.5, g=.52, pan=0.0) # ---- the machine speaks (indifferently) --------------------------------- s.put("voxm", speak(MACHINE_LINES[0], "Zarvox", 190, cache, pitch=1.0)*.6, 24*BAR + BEAT*0.4, g=.60, pan=.22) s.put("voxm", speak(MACHINE_LINES[1], "Zarvox", 190, cache, pitch=1.0)*.6, 38*BAR + BEAT*2, g=.34, pan=.22) s.put("voxm", speak(MACHINE_LINES[2], "Zarvox", 200, cache, pitch=1.0)*.5, 1*BAR + BEAT*1, g=.22, pan=.2) # ---- risers, stops, foley ---------------------------------------------- for b, ln, g in ((4, 1.6, 1.0), (12, 1.2, .7), (16, 1.6, 1.1), (20, 0.9, .55), (26, 1.8, 1.15), (30, 0.9, .5), (34, 1.5, 1.2), (38, 0.8, .5)): s.put("fx", riser(ln, seed=17+b, gain=g), b*BAR - ln, g=.30) s.put("fx", downlift(1.1, seed=71), 24*BAR, g=.42) s.put("fx", revcym(1.7, seed=73), 26*BAR - 1.7, g=.30) for b in (4, 16, 26, 34): s.put("fx", crash(seed=13+b), b*BAR, g=.34, pan=-.1) # coins, keys, motor, body, landings — the story, as foley s.put("fx", coinclunk(31), 1*BAR, g=.85, pan=.25) s.put("fx", coinclunk(32), 18*BAR, g=.80, pan=.25) s.put("fx", coinclunk(34), 21*BAR + BEAT, g=.80, pan=.25) s.put("fx", coinclunk(36), 26*BAR, g=.95, pan=.25) for at in (4*BAR, 4*BAR+BEAT*1.5, 28.5*BAR, 28.5*BAR+BEAT*1.5): s.put("fx", keyclick(35), at, g=.55, pan=.3) s.put("fx", keybeep(33, f=1760 if at < 20*BAR else 1976), at, g=.40, pan=.3) for j, at in enumerate(MASH): # the mash s.put("fx", keyclick(35+j), at, g=.34, pan=.3) s.put("fx", keybeep(33, f=1760*2**((j % 5)/12.0), dur=.05), at, g=.22, pan=.3) s.put("fx", coilwhir(5.6, 39), 5*BAR, g=.75, pan=-.15) s.put("fx", coilwhir(4.4, 40), 29.5*BAR, g=.85, pan=-.15) for at, big in ((20*BAR, .8), (20*BAR+BEAT*2, .8), (22*BAR, .9), (22*BAR+BEAT*2, .9), (23*BAR+BEAT*2, 1.35)): s.put("fx", bodyhit(37+int(at*7), big=big), at, g=.55, pan=-.05) s.put("fx", bagland(41, 1.15), T_STRIKE, g=.85, pan=-.1) # B3 hits C3 s.put("fx", bagland(42, 1.05), T_LAND1, g=.80, pan=-.15) # C3 -> tray s.put("fx", bagland(43, 1.10), T_LAND2, g=.85, pan=-.05) # B3 -> tray s.put("fx", flapswing(43), 38*BAR, g=.50, pan=-.2) # ---- buses --------------------------------------------------------------- # the vocoder and the chops both come out very peaky — a hard limit is # what makes a whispered `say` modulator sit on top of a wall of saws s.bus("chop", lambda x: reverb(delay(clip(x*2.4, 2.2), BEAT*0.75, .32, .26), rt=1.1, mix=.20, seed=901)) s.bus("hook", lambda x: reverb(delay(clip(x*3.4, 2.6), BEAT*1.5, .34, .28), rt=2.2, mix=.34, seed=903)) s.bus("lead", lambda x: reverb(delay(x, BEAT*0.5, .30, .22), rt=1.4, mix=.24, seed=905)) s.bus("saw", lambda x: reverb(x, rt=1.0, mix=.14, seed=907)) s.bus("fx", lambda x: reverb(x, rt=1.3, mix=.20, seed=909)) s.bus("voxm", lambda x: reverb(x, rt=1.8, mix=.30, seed=911)) mix = s.mixdown(dict(drums=1.0, **{"808": 1.0}, saw=1.0, lead=1.0, chop=1.0, hook=1.0, fx=1.0, voxm=1.0, hum=1.0), pump_depth=.42, pump_rel=.12, levels=dict(insert=.62, drop1=1.0, stuck=.62, rage=1.05, stop=.34, switch=1.0, hero=1.10, outro=.64)) 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 MACHINE — world geometry # # World units. The cabinet face is 1000 wide; everything inside it is drawn # through a single perspective divide toward one vanishing point in the middle # of the glass, so shelves and side walls recede honestly and the coils are # real helices rather than drawn spirals. # ════════════════════════════════════════════════════════════════════════════ CAB_X0, CAB_X1 = 0.0, 1000.0 CAB_Y0, CAB_Y1 = 40.0, 1720.0 GL_X0, GL_X1 = 45.0, 690.0 GL_Y0, GL_Y1 = 70.0, 1180.0 RP_X0, RP_X1 = 700.0, 992.0 LED_BOX = (730.0, 108.0, 958.0, 214.0) KEY_X0, KEY_Y0 = 748.0, 282.0 KEY_W, KEY_H, KEY_G = 60.0, 66.0, 14.0 KEY_LABELS = [["A", "B", "C"], ["D", "E", "F"], ["1", "2", "3"], ["4", "5", "6"]] COIN_SLOT = (786.0, 752.0, 906.0, 792.0) COIN_RET = (786.0, 836.0, 906.0, 884.0) CUP = (760.0, 916.0, 900.0, 1010.0) PLATE = (726.0, 1046.0, 962.0, 1108.0) FLAP = (86.0, 1236.0, 424.0, 1428.0) BRAND = (60.0, 1470.0, 940.0, 1622.0) DEPTH = 520.0 TURNS = 8.0 COIL_R = 52.0 FOCAL = 780.0 VPX, VPY = 368.0, 620.0 ROW_Y = {"A": 250.0, "B": 430.0, "C": 610.0, "D": 790.0, "E": 970.0} COL_X = {1: 146.0, 2: 270.0, 3: 394.0, 4: 518.0, 5: 642.0} TUBE_X, TUBE_Y0, TUBE_Y1 = 76.0, 96.0, 1150.0 ROOM = (9, 9, 12) WALLC = (23, 21, 27) FLOORC = (15, 14, 18) CAB = (33, 37, 48) CAB2 = (21, 24, 32) CABHI = (58, 65, 82) STRIPE = (176, 30, 48) BACKP = (60, 66, 60) SIDEP = (43, 48, 45) SHELFC = (128, 134, 138) COILC_ = (206, 214, 222) LEDR = (255, 62, 40) LEDOFF = (52, 14, 12) TRAYD = (5, 6, 8) BAGS = { # slot -> (color, band color, label, tilt) "A1": ((44, 96, 188), (240, 232, 60), "SALT"), "A2": ((22, 132, 84), (240, 240, 230), "LIME"), "A4": ((176, 40, 132), (250, 220, 90), "BEET"), "A5": ((40, 46, 60), (230, 96, 40), "COAL"), "B1": ((222, 108, 24), (250, 240, 220), "PAP"), "B2": ((30, 120, 176), (250, 250, 240), "SEA"), "B4": ((120, 46, 168), (240, 230, 90), "GRAPE"), "B5": ((198, 176, 30), (60, 50, 40), "CORN"), "C1": ((28, 148, 108), (250, 246, 226), "DILL"), "C2": ((196, 46, 40), (250, 224, 70), "HOT"), "C4": ((60, 62, 78), (220, 226, 236), "ASH"), "C5": ((228, 148, 40), (60, 46, 36), "CHEZ"), "D1": ((166, 32, 60), (246, 238, 214), "BBQ"), "D2": ((44, 96, 188), (240, 232, 60), "SALT"), "D3": ((28, 148, 108), (250, 246, 226), "DILL"), "D4": ((222, 108, 24), (250, 240, 220), "PAP"), "D5": ((30, 120, 176), (250, 250, 240), "SEA"), "E1": ((196, 46, 40), (250, 224, 70), "HOT"), "E2": ((198, 176, 30), (60, 50, 40), "CORN"), "E3": ((120, 46, 168), (240, 230, 90), "GRAPE"), "E4": ((60, 62, 78), (220, 226, 236), "ASH"), "E5": ((166, 32, 60), (246, 238, 214), "BBQ"), } VICTIM = ((214, 44, 38), (250, 226, 70), "HOT") # C3 — the stuck one HERO = ((250, 186, 32), (40, 40, 48), "GOLD") # B3 — the one that jumps _FPRNG = np.random.RandomState(80808) FINGERPRINTS = [(GL_X0+30 + _FPRNG.rand()*(GL_X1-GL_X0-60), GL_Y0+40 + _FPRNG.rand()*(GL_Y1-GL_Y0-80), 16 + _FPRNG.rand()*22, _FPRNG.rand()*math.pi, 5 + int(_FPRNG.rand()*4)) for _ in range(34)] _SMRNG = np.random.RandomState(90909) SMUDGES = [(GL_X0+20 + _SMRNG.rand()*(GL_X1-GL_X0-40), GL_Y0+30 + _SMRNG.rand()*(GL_Y1-GL_Y0-60), 40 + _SMRNG.rand()*130, 18 + _SMRNG.rand()*60, _SMRNG.rand()*math.pi) for _ in range(22)] def proj(x, y, z): """One-point perspective toward (VPX, VPY). Returns (x, y, scale).""" k = FOCAL/(FOCAL + z) return VPX + (x-VPX)*k, VPY + (y-VPY)*k, k class Cam: """World -> pixel. (cx, cy, view width) with the 16:9 height implied.""" __slots__ = ("s", "x0", "y0", "vw", "vh", "pw", "ph") def __init__(self, cx, cy, vw, pw=PW, ph=PH, dx=0.0, dy=0.0): self.pw, self.ph = pw, ph self.vw = vw; self.vh = vw*ph/pw self.s = pw/vw self.x0 = cx - vw/2 + dx self.y0 = cy - self.vh/2 + dy def p(self, x, y): return ((x-self.x0)*self.s, (y-self.y0)*self.s) def l(self, v): return v*self.s def box(self, x0, y0, x1, y1): a = self.p(x0, y0); b = self.p(x1, y1) return [a[0], a[1], b[0], b[1]] def vis(self, x0, y0, x1, y1, pad=60.0): return not (x1 < self.x0-pad or x0 > self.x0+self.vw+pad or y1 < self.y0-pad or y0 > self.y0+self.vh+pad) def shade(col, f): return tuple(int(max(0, min(255, c*f))) for c in col) def mixc(a, b, u): return tuple(int(a[i] + (b[i]-a[i])*u) for i in range(3)) # ════════════════════════════════════════════════════════════════════════════ # STORY CLOCK — every visual state is a pure function of t # ════════════════════════════════════════════════════════════════════════════ T_COIN1 = 1*BAR T_KEY_C = 4*BAR T_KEY_3 = 4*BAR + BEAT*1.5 T_VEND1_0 = 5*BAR T_VEND1_1 = 10.6*BAR T_COIN2 = 26*BAR T_KEY_B = 28.5*BAR T_KEY_32 = 28.5*BAR + BEAT*1.5 T_VEND2_0 = 29.5*BAR T_VEND2_1 = 34*BAR T_DROP = 34*BAR T_STRIKE = T_DROP + BEAT # B3 lands on C3 — one beat after release T_LAND1 = T_DROP + BEAT*2.75 # C3 hits the tray T_LAND2 = T_DROP + BEAT*3.0 # B3 hits the tray T_HAND = 38*BAR GRAV = 2361.0 # tuned so the strike falls ON the beat SHAKES = [(20*BAR, .45, 1.0), (20*BAR+BEAT*2, .45, 1.0), (22*BAR, .5, 1.1), (22*BAR+BEAT*2, .5, 1.1), (23*BAR+BEAT*2, .8, 1.9)] # the mash: a burst of jabs per bar of `rage`, so the keypad is alive for the # whole section instead of for the first five seconds of it MASH = [] for _b, _n in ((16, 5), (17, 4), (18, 6), (19, 4), (20, 6), (21, 5), (22, 7), (23, 5)): for _j in range(_n): MASH.append(_b*BAR + _j*BEAT*(0.50 if _n > 4 else 0.70)) PRESSES = [(T_KEY_C, "C"), (T_KEY_3, "3"), (T_KEY_B, "B"), (T_KEY_32, "3")] PRESSES += [(_at, "ABCDEF123456"[(_k*5) % 12]) for _k, _at in enumerate(MASH)] # a couple of hesitant jabs while he waits out the second vend PRESSES += [(30.0*BAR, "b"), (31.5*BAR + BEAT, "3"), (33.0*BAR, "b")] def vend_phase(t, t0, t1, turns): """Coil rotation, quantised to the 16th grid — the coil steps on the beat.""" if t <= t0: return 0.0 total = max(1, int(round((t1-t0)/S16))) steps = min(total, int((min(t, t1)-t0)/S16) + 1) return 2*math.pi*turns*steps/total def shake_at(t): dx = dy = 0.0; rot = 0.0 for at, ln, amp in SHAKES: if at <= t < at+ln: u = (t-at)/ln d = math.exp(-u*5.0) ph = (t-at)*62.0 dx += math.sin(ph)*16*amp*d dy += math.sin(ph*1.37+1.1)*11*amp*d rot += math.sin(ph*0.9)*0.012*amp*d return dx, dy, rot # ---- the falls: integrated once, deterministically ------------------------- def simulate_drops(): """B3 is released, tumbles, strikes C3, both continue to the tray.""" g = GRAV dt = 1.0/FPS n = N_FRAMES hero = np.zeros((n, 4)) # x, y, rot, alive vic = np.zeros((n, 4)) hx, hy = COL_X[3], ROW_Y["B"] - 62 hvx, hvy, hrot, hvr = 6.0, 0.0, 0.0, 2.1 cx0, cy0 = COL_X[3] + 26, ROW_Y["C"] - 46 vx, vy, vrot, vvx, vvy, vvr = cx0, cy0, -1.05, 0.0, 0.0, 0.0 vic_free = False struck = False floor_y = FLAP[1] + 128 f0 = int(T_DROP*FPS) for i in range(n): if i >= f0: hvy += g*dt; hx += hvx*dt; hy += hvy*dt; hrot += hvr*dt if not struck and hy >= cy0 - 30: struck = True hvy *= 0.55; hvr = 5.4; hvx = -34.0 vic_free = True vvy = 320.0; vvx = -190.0; vvr = 4.4 if vic_free: vvy += g*dt; vx += vvx*dt; vy += vvy*dt; vrot += vvr*dt # shelf-lip deflection on the way down for rw in ("D", "E"): ly = ROW_Y[rw] if abs(hy-ly) < 12 and hvy > 0 and hx > COL_X[3]-70: hvy *= 0.72; hvx -= 26.0; hvr *= 1.3 lo, hi = FLAP[0]+82, FLAP[2]-82 # they can't leave the tray if hy > floor_y: hy = floor_y; hvy = -hvy*0.30; hvx *= 0.55; hvr *= -0.35 hx = min(max(hx, lo), hi) if abs(hvy) < 40: hvy = 0.0; hvr *= 0.4 if vic_free and vy > floor_y - 24: vy = floor_y - 24; vvy = -vvy*0.28; vvx *= 0.5; vvr *= -0.3 vx = min(max(vx, lo), hi) if abs(vvy) < 40: vvy = 0.0; vvr *= 0.4 hvx *= 0.995 hero[i] = (hx, hy, hrot, 1.0 if i >= f0 else 0.0) vic[i] = (vx, vy, vrot, 1.0 if vic_free else 0.0) return hero, vic DROP_H, DROP_V = simulate_drops() def state(i, t, e): """Everything the painter needs, for frame i at time t.""" st = {} st["t"] = t; st["i"] = i st["shake"] = shake_at(t) st["ph_c3"] = vend_phase(t, T_VEND1_0, T_VEND1_1, TURNS - 0.32) st["ph_b3"] = vend_phase(t, T_VEND2_0, T_VEND2_1, TURNS + 0.4) st["idle"] = (e["kick"]*0.10 + 0.02*math.sin(t*1.3)) # LED text if t < T_COIN1: led = "0.00" elif t < T_KEY_C: led = "1.25" elif t < T_VEND1_1: led = "C-3" elif t < T_COIN2: led = "1.25" elif t < T_KEY_B: led = "1.25" elif t < T_DROP: led = "b-3" elif t < 38*BAR: led = "---" else: led = "0.00" if 24*BAR <= t < 26*BAR: led = "1.25" if int((t-24*BAR)/BEAT) % 2 == 0 else " " st["led"] = led st["led_on"] = 1.0 if t > 0.6 else max(0.0, t/0.6) # pressed key st["press"] = None for at, lab in PRESSES: if at <= t < at+0.30: st["press"] = lab # coin animation 0..1 (falling into the slot) st["coin"] = -1.0 for at in (T_COIN1, 18*BAR, 21*BAR+BEAT, T_COIN2): if at-0.42 <= t < at+0.10: st["coin"] = (t-(at-0.42))/0.52 # fluorescent flicker fl = 1.0 rs = np.random.RandomState(int(t*FPS) + 4242) for at, ln, amp in SHAKES: if at <= t < at+ln+0.25: fl *= (0.35 + 0.65*rs.rand()) if rs.rand() < 0.55 else 1.0 if 24*BAR <= t < 26*BAR: fl *= 0.80 + 0.20*math.sin(t*7.0) if t < 0.7: fl *= 0.25 + 0.75*(t/0.7) st["flick"] = fl # drops st["hero"] = DROP_H[min(i, N_FRAMES-1)] st["vic"] = DROP_V[min(i, N_FRAMES-1)] st["c3_stuck"] = st["vic"][3] < 0.5 and t >= T_VEND1_1 st["c3_vending"] = T_VEND1_0 <= t < T_VEND1_1 st["b3_gone"] = t >= T_DROP # hand into the tray st["hand"] = float(np.clip((t - T_HAND)/1.1, 0, 1)) if t >= T_HAND else 0.0 st["taken"] = st["hand"] > 0.72 # reflection pose if t < T_KEY_C: pose = "idle" elif t < T_VEND1_1: pose = "watch" elif t < 16*BAR: pose = "lean" elif t < 20*BAR: pose = "press" elif t < 24*BAR: pose = "shake" elif t < T_COIN2: pose = "still" elif t < T_DROP: pose = "watch" elif t < T_HAND: pose = "lean" else: pose = "reach" st["pose"] = pose return st # ════════════════════════════════════════════════════════════════════════════ # DRAWING — the universal scene painter # ════════════════════════════════════════════════════════════════════════════ def helix_pts(cx, cy, phase, turns=TURNS, npts=132, r=COIL_R, depth=DEPTH): """The coil, as a real 3D helix run through the perspective divide.""" out = [] for i in range(npts): u = i/(npts-1.0) z = u*depth a = phase + u*turns*2*math.pi X = cx + r*math.cos(a) Y = cy + r*math.sin(a) px, py, k = proj(X, Y, z) out.append((px, py, k, a, u)) return out def draw_coil(d, cam, cx, cy, phase, bags, light=1.0): """Back-to-front polyline with per-vertex chrome shading, bags interleaved at their own depth so the wire genuinely passes in front of them.""" pts = helix_pts(cx, cy, phase) bags = sorted(bags, key=lambda b: -b[0]) # deepest first bi = 0 prev = None for idx in range(len(pts)-1, -1, -1): px, py, k, a, u = pts[idx] while bi < len(bags) and u <= bags[bi][0]: bags[bi][1]() # draw the bag bi += 1 if prev is not None: nx, ny = math.cos(a), math.sin(a) lam = max(0.0, -(nx*0.42 + ny*0.91)) # tube is above-left f = (0.24 + 0.86*lam) * (0.42 + 0.58*k) * light w = max(2, int(cam.l(6.2*k))) p0 = cam.p(prev[0], prev[1]); p1 = cam.p(px, py) d.line([p0, p1], fill=shade(COILC_, f), width=w) if lam > 0.55 and w > 3: d.line([(p0[0], p0[1]-w*0.28), (p1[0], p1[1]-w*0.28)], fill=shade((255, 255, 255), 0.55*lam*light), width=max(2, int(w*0.36))) prev = (px, py) while bi < len(bags): bags[bi][1](); bi += 1 def bag_shape(w, h, seed): """Pillow-bag silhouette: crimped top and bottom seams, bulging middle.""" rng = np.random.RandomState(seed) pts = [] n = 18 for i in range(n+1): # right side, top -> bottom v = i/n bulge = math.sin(v*math.pi)**0.7 x = w*(0.18 + 0.32*bulge) + rng.uniform(-0.012, 0.012)*w pts.append((x, -h/2 + v*h)) for i in range(n+1): # left side, bottom -> top v = 1 - i/n bulge = math.sin(v*math.pi)**0.7 x = -w*(0.18 + 0.32*bulge) + rng.uniform(-0.012, 0.012)*w pts.append((x, -h/2 + v*h)) return pts def draw_bag(d, cam, cx, cy, w, h, rot, col, band, label, seed, k=1.0, light=1.0, crinkle=True): """A foil bag: silhouette, crinkle creases (paired highlight/shadow lines), a specular sheen, a printed band, crimped seams.""" if not cam.vis(cx-w, cy-h, cx+w, cy+h): return ca, sa = math.cos(rot), math.sin(rot) def wp(x, y): return cam.p(cx + x*ca - y*sa, cy + x*sa + y*ca) base = shade(col, (0.45 + 0.55*light)*(0.55 + 0.45*k)) poly = [wp(x, y) for x, y in bag_shape(w, h, seed)] d.polygon(poly, fill=base) rng = np.random.RandomState(seed*7 + 11) if crinkle and cam.l(w) > 14: # creases: each drawn twice, a lit edge and its shadow for j in range(11): y0 = -h/2 + h*(j+0.5)/11 + rng.uniform(-0.02, 0.02)*h amp = rng.uniform(0.06, 0.22)*h ph = rng.uniform(0, math.tau) pts_hi, pts_lo = [], [] for q in range(7): v = q/6.0 xx = -w*0.46 + v*w*0.92 yy = y0 + math.sin(v*3.1 + ph)*amp*0.34 pts_hi.append(wp(xx, yy - h*0.012)) pts_lo.append(wp(xx, yy + h*0.014)) d.line(pts_lo, fill=shade(col, 0.42*(0.5+0.5*light)), width=max(2, int(cam.l(w*0.020))), joint="curve") d.line(pts_hi, fill=mixc(base, (255, 255, 255), 0.30), width=max(2, int(cam.l(w*0.016))), joint="curve") # sheen down the left third sh = [wp(-w*0.30 + math.sin(v*2.2)*w*0.05, -h*0.42 + v*h*0.84) for v in np.linspace(0, 1, 8)] d.line(sh, fill=mixc(base, (255, 255, 255), 0.42), width=max(2, int(cam.l(w*0.055))), joint="curve") # printed band + label if cam.l(h) > 26: bp = [wp(-w*0.46, -h*0.10), wp(w*0.46, -h*0.10), wp(w*0.46, h*0.14), wp(-w*0.46, h*0.14)] d.polygon(bp, fill=shade(band, 0.55 + 0.45*light)) fs = int(cam.l(h*0.17)) if fs >= 8: f = font(fs, "Impact.ttf") tw = d.textlength(label, font=f) cxp, cyp = wp(0, h*0.015) d.text((cxp-tw/2, cyp-fs*0.62), label, font=f, fill=shade(col, 0.75)) # crimped seams for sy in (-h/2, h/2): d.line([wp(-w*0.20, sy), wp(w*0.20, sy)], fill=shade(col, 0.30), width=max(2, int(cam.l(h*0.05)))) SEG7 = { "0": "abcdef", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgedc", "7": "abc", "8": "abcdefg", "9": "abfgcd", "-": "g", " ": "", ".": "", "A": "abcefg", "b": "fedcg", "C": "adef", "d": "bcdeg", "E": "adefg", "F": "aefg", "H": "bcefg", "L": "def", "P": "abefg", "S": "afgcd", "O": "abcdef", "U": "bcdef", "r": "eg", "t": "defg", "n": "ceg", "o": "cdeg", "3": "abgcd", } def seg7_cells(text): """'0.00' -> [('0', True), ('0', False), ('0', False)] — a decimal point rides on the digit before it instead of eating a whole cell.""" cells = [] for ch in text: if ch == "." and cells: cells[-1] = (cells[-1][0], True) else: cells.append((ch, False)) return cells def seg7_polys(x, y, w, h, th): """Segment quads for one digit cell at (x, y) top-left.""" t = th return { "a": [(x+t, y), (x+w-t, y), (x+w-t*1.5, y+t), (x+t*1.5, y+t)], "b": [(x+w, y+t*0.5), (x+w, y+h/2-t*0.4), (x+w-t, y+h/2-t*0.8), (x+w-t, y+t*1.2)], "c": [(x+w, y+h/2+t*0.4), (x+w, y+h-t*0.5), (x+w-t, y+h-t*1.2), (x+w-t, y+h/2+t*0.8)], "d": [(x+t*1.5, y+h-t), (x+w-t*1.5, y+h-t), (x+w-t, y+h), (x+t, y+h)], "e": [(x, y+h/2+t*0.4), (x+t, y+h/2+t*0.8), (x+t, y+h-t*1.2), (x, y+h-t*0.5)], "f": [(x, y+t*0.5), (x+t, y+t*1.2), (x+t, y+h/2-t*0.8), (x, y+h/2-t*0.4)], "g": [(x+t*1.4, y+h/2-t*0.5), (x+w-t*1.4, y+h/2-t*0.5), (x+w-t*1.0, y+h/2), (x+w-t*1.4, y+h/2+t*0.5), (x+t*1.4, y+h/2+t*0.5), (x+t*1.0, y+h/2)], } def draw_seg7(d, cam, gd, box, text, on=1.0): """Seven-segment readout. Lit segments also go into the glow layer.""" x0, y0, x1, y1 = box if not cam.vis(x0-10, y0-10, x1+10, y1+10): return d.rounded_rectangle(cam.box(x0, y0, x1, y1), radius=max(2, int(cam.l(8))), fill=(12, 8, 9), outline=(46, 44, 50), width=max(2, int(cam.l(2)))) pad = (x1-x0)*0.055 cl = seg7_cells(text) n = max(1, len(cl)) cw = ((x1-x0) - pad*2) / n dh = (y1-y0) - pad*2 dw = cw*0.60 th = dh*0.115 for i, (ch, dot) in enumerate(cl): cx = x0 + pad + i*cw + (cw-dw)/2 cy = y0 + pad polys = seg7_polys(cx, cy, dw, dh, th) lit = SEG7.get(ch, SEG7.get(ch.upper(), "")) for k, poly in polys.items(): pts = [cam.p(px, py) for px, py in poly] if k in lit: d.polygon(pts, fill=shade(LEDR, 0.55 + 0.45*on)) gd.polygon(pts, fill=int(190*on)) else: d.polygon(pts, fill=LEDOFF) if dot: bb = cam.box(cx+dw+th*0.4, cy+dh-th*1.5, cx+dw+th*1.6, cy+dh-th*0.3) d.ellipse(bb, fill=shade(LEDR, 0.55+0.45*on)) gd.ellipse(bb, fill=int(190*on)) def draw_room(d, cam): d.rectangle([0, 0, cam.pw, cam.ph], fill=ROOM) hz = cam.p(0, CAB_Y1 + 120)[1] if hz > 0: d.rectangle([0, 0, cam.pw, min(hz, cam.ph)], fill=WALLC) if hz < cam.ph: d.rectangle([0, max(0.0, hz), cam.pw, cam.ph], fill=FLOORC) if -40 < hz < cam.ph + 40: # the pool of light the machine throws on the corridor floor — without # it a wide shot is a black rectangle with a lit slot in the middle for j, (sp, g) in enumerate(((1.00, 24), (0.72, 34), (0.44, 46))): d.ellipse(cam.box(CAB_X0 - 900*sp, CAB_Y1 + 40, CAB_X1 + 700*sp, CAB_Y1 + 120 + 780*sp), fill=(g, g+3, g+7)) d.line([(0, hz), (cam.pw, hz)], fill=(46, 44, 52), width=max(2, int(cam.l(6)))) for j in range(-2, 4): x = cam.p(-900 + j*760, 0)[0] d.line([(x, hz), (x + cam.l(300), cam.ph)], fill=(19, 18, 22), width=max(2, int(cam.l(4)))) # spill up the wall behind the cabinet for j, (sp, g) in enumerate(((1.0, 20), (0.6, 28))): d.ellipse(cam.box(CAB_X0 - 760*sp, CAB_Y0 - 400*sp, CAB_X1 + 760*sp, CAB_Y1), fill=(g, g+2, g+6)) def draw_cabinet(d, cam, st): if not cam.vis(CAB_X0-40, CAB_Y0-40, CAB_X1+40, CAB_Y1+120): return r = max(2, int(cam.l(16))) # body shadow / foot d.rectangle(cam.box(CAB_X0-18, CAB_Y1, CAB_X1+18, CAB_Y1+62), fill=(12, 12, 15)) d.rounded_rectangle(cam.box(CAB_X0, CAB_Y0, CAB_X1, CAB_Y1), radius=r, fill=CAB) # brushed vertical striping on the metal if cam.l(CAB_X1-CAB_X0) > 200: rng = np.random.RandomState(1717) for j in range(56): x = CAB_X0 + rng.rand()*(CAB_X1-CAB_X0) g = 0.90 + 0.16*rng.rand() d.line([cam.p(x, CAB_Y0+8), cam.p(x, CAB_Y1-8)], fill=shade(CAB, g), width=max(2, int(cam.l(2)))) # bezel around the glass d.rounded_rectangle(cam.box(GL_X0-22, GL_Y0-22, GL_X1+22, GL_Y1+22), radius=max(2, int(cam.l(10))), fill=CAB2) d.rounded_rectangle(cam.box(GL_X0-22, GL_Y0-22, GL_X1+22, GL_Y0-6), radius=max(2, int(cam.l(4))), fill=CABHI) # brand stripe down the right panel d.rectangle(cam.box(RP_X0-8, CAB_Y0+16, RP_X0+2, CAB_Y1-16), fill=STRIPE) d.rectangle(cam.box(RP_X0+4, CAB_Y0+16, RP_X1+6, CAB_Y1-16), fill=CAB2) # brand plate if cam.vis(*BRAND): d.rounded_rectangle(cam.box(*BRAND), radius=max(2, int(cam.l(10))), fill=(16, 18, 24)) fs = int(cam.l((BRAND[3]-BRAND[1])*0.40)) if fs >= 9: f = font(fs, "Impact.ttf") tw = d.textlength("VEND-O-MAT", font=f) cx = (BRAND[0]+BRAND[2])/2 px, py = cam.p(cx, BRAND[1] + (BRAND[3]-BRAND[1])*0.24) d.text((px-tw/2, py), "VEND-O-MAT", font=f, fill=STRIPE) f2 = font(max(PX(7), int(fs*0.30)), "Menlo.ttc") t2 = "MODEL 7 · SERIES C" tw2 = d.textlength(t2, font=f2) px2, py2 = cam.p(cx, BRAND[1] + (BRAND[3]-BRAND[1])*0.74) d.text((px2-tw2/2, py2), t2, font=f2, fill=(88, 92, 104)) def draw_interior(d, cam, t, e, st): """Back panel, side walls, shelves, coils, bags — all through the divide.""" if not cam.vis(GL_X0-30, GL_Y0-30, GL_X1+30, GL_Y1+30): return bx0, by0, _ = proj(GL_X0+40, GL_Y0+30, DEPTH) bx1, by1, _ = proj(GL_X1-40, GL_Y1-30, DEPTH) d.rectangle(cam.box(GL_X0, GL_Y0, GL_X1, GL_Y1), fill=(10, 12, 11)) # side / top / bottom walls as trapezoids for quad in ( [(GL_X0, GL_Y0), (bx0, by0), (bx0, by1), (GL_X0, GL_Y1)], [(GL_X1, GL_Y0), (bx1, by0), (bx1, by1), (GL_X1, GL_Y1)], [(GL_X0, GL_Y0), (GL_X1, GL_Y0), (bx1, by0), (bx0, by0)], [(GL_X0, GL_Y1), (GL_X1, GL_Y1), (bx1, by1), (bx0, by1)], ): d.polygon([cam.p(*q) for q in quad], fill=SIDEP) d.rectangle(cam.box(bx0, by0, bx1, by1), fill=BACKP) # back panel: a faint printed grid, so depth reads for j in range(1, 7): yy = by0 + (by1-by0)*j/7 d.line([cam.p(bx0, yy), cam.p(bx1, yy)], fill=shade(BACKP, 0.86), width=max(2, int(cam.l(2)))) # the fluorescent tube, front-left, vertical tw_ = 13.0 d.rounded_rectangle(cam.box(TUBE_X-tw_, TUBE_Y0, TUBE_X+tw_, TUBE_Y1), radius=max(2, int(cam.l(tw_))), fill=shade((236, 244, 232), 0.55 + 0.45*st["flick"])) for cap in (TUBE_Y0, TUBE_Y1-26): d.rectangle(cam.box(TUBE_X-tw_-3, cap, TUBE_X+tw_+3, cap+26), fill=(60, 66, 62)) rows = list(ROW_Y.items()) for rw, ry in rows: if not cam.vis(GL_X0, ry-190, GL_X1, ry+30): continue # shelf: a trapezoid from the front lip back to the wall fx0, fy0, _ = proj(GL_X0+18, ry, 0.0) fx1, _, _ = proj(GL_X1-18, ry, 0.0) rx0, ry0, _ = proj(GL_X0+18, ry, DEPTH) rx1, _, _ = proj(GL_X1-18, ry, DEPTH) d.polygon([cam.p(fx0, fy0), cam.p(fx1, fy0), cam.p(rx1, ry0), cam.p(rx0, ry0)], fill=shade(SHELFC, 0.36)) d.line([cam.p(fx0, fy0), cam.p(fx1, fy0)], fill=SHELFC, width=max(2, int(cam.l(7)))) # price rail d.line([cam.p(fx0, fy0+11), cam.p(fx1, fy0+11)], fill=(28, 30, 34), width=max(2, int(cam.l(13)))) if cam.l(GL_X1-GL_X0) > 420: fs = max(7, int(cam.l(15))) f = font(fs, "Menlo.ttc") for col, cxw in COL_X.items(): lab = f"{rw}{col} 1.25" twl = d.textlength(lab, font=f) px, py = cam.p(cxw, ry+5) d.text((px-twl/2, py), lab, font=f, fill=(196, 200, 206)) # coils + their bags for col, cxw in COL_X.items(): slot = f"{rw}{col}" cy_ = ry - COIL_R - 6 if slot == "C3": ph = st["ph_c3"] + st["idle"]*0.4 elif slot == "B3": ph = st["ph_b3"] + st["idle"]*0.4 else: ph = 0.30*(col*1.7 + ord(rw)) + st["idle"]*0.8 bags = [] if slot == "C3": if not st["c3_stuck"] and st["vic"][3] < 0.5: u = max(0.0, 0.80 - st["ph_c3"]/(2*math.pi*TURNS)*0.80) bags.append(_bag_job(d, cam, cxw, ry, u, VICTIM, slot, st, rot=0.0)) elif slot == "B3": if not st["b3_gone"]: u = max(0.0, 0.80 - st["ph_b3"]/(2*math.pi*(TURNS+0.4))*0.80) bags.append(_bag_job(d, cam, cxw, ry, u, HERO, slot, st, rot=0.0)) elif slot in BAGS: for q, u in enumerate((0.30, 0.58, 0.84)): bags.append(_bag_job(d, cam, cxw, ry, u, BAGS[slot], slot+str(q), st, rot=0.0)) draw_coil(d, cam, cxw, cy_, ph, bags, light=0.55 + 0.45*st["flick"]) # the stuck bag hangs OFF the front lip, outside the coil if rw == "C" and st["c3_stuck"]: sw = math.sin(t*3.1)*0.045*math.exp(-max(0, t-T_VEND1_1)*0.10) dxs, dys, _ = st["shake"] draw_bag(d, cam, COL_X[3]+26+dxs*0.5, ry-46+dys*0.4, 84, 128, -1.05 + sw + dxs*0.004, *VICTIM, seed=333, k=1.0, light=0.55+0.45*st["flick"]) def _bag_job(d, cam, cxw, ry, u, spec, seed_s, st, rot=0.0): """Defer a bag draw until the coil walk reaches its depth.""" col, band, label = spec z = u*DEPTH px, py, k = proj(cxw, ry - 66, z) seed = (abs(int(sum(ord(c)*(j+3) for j, c in enumerate(seed_s)))) % 9973) + 7 def go(): draw_bag(d, cam, px, py, 84*k, 128*k, rot, col, band, label, seed, k=k, light=(0.42 + 0.58*st["flick"])*(0.5+0.5*k)) return (u, go) def draw_falls(d, cam, st): if st["taken"]: return hx, hy, hr, ha = st["hero"] vx, vy, vr, va = st["vic"] if va > 0.5: draw_bag(d, cam, vx, vy, 84, 128, vr, *VICTIM, seed=333, k=1.0, light=0.55+0.45*st["flick"]) if ha > 0.5: draw_bag(d, cam, hx, hy, 84, 128, hr, *HERO, seed=555, k=1.0, light=0.60+0.40*st["flick"]) def draw_flap(d, cam, st): if not cam.vis(FLAP[0]-30, FLAP[1]-30, FLAP[2]+30, FLAP[3]+140): return x0, y0, x1, y1 = FLAP d.rounded_rectangle(cam.box(x0-16, y0-16, x1+16, y1+16), radius=max(2, int(cam.l(12))), fill=CAB2) d.rectangle(cam.box(x0, y0, x1, y1+150), fill=TRAYD) # the rubber flap, hinged at the top, kicked open by landings op = 0.0 t = st["t"] for at, amp in ((T_LAND1, 1.0), (T_LAND2, 0.8), (T_HAND, 0.9)): if at <= t < at+0.9: op = max(op, amp*math.exp(-(t-at)*4.2)*abs(math.cos((t-at)*11))) fy = y0 + (y1-y0)*(0.30 + 0.62*(1-op)) d.polygon([cam.p(x0+6, y0+6), cam.p(x1-6, y0+6), cam.p(x1-6-op*22, fy), cam.p(x0+6+op*22, fy)], fill=(30, 30, 34)) d.line([cam.p(x0+6+op*22, fy), cam.p(x1-6-op*22, fy)], fill=(58, 60, 66), width=max(2, int(cam.l(5)))) fs = max(7, int(cam.l(26))) f = font(fs, "Impact.ttf") tw = d.textlength("PUSH", font=f) px, py = cam.p((x0+x1)/2, y0 + (y1-y0)*0.14) d.text((px-tw/2, py), "PUSH", font=f, fill=(84, 88, 98)) def draw_hand(d, cam, st): x0, y0, x1, y1 = FLAP if st["hand"] > 0.01 and cam.vis(x0-60, y0-60, x1+60, y1+240): u = st["hand"] hxp = (x0+x1)/2 + 40 hyp = y0 + (y1-y0)*(0.24 + 0.66*min(1.0, u*1.6)) + 40 if u > 0.72: hyp -= (u-0.72)*420 sc = 1.0 d.ellipse(cam.box(hxp-52*sc, hyp-46*sc, hxp+52*sc, hyp+52*sc), fill=(56, 44, 40)) for j in range(4): fx = hxp - 34 + j*23 d.rounded_rectangle(cam.box(fx-10, hyp-78, fx+10, hyp-8), radius=max(2, int(cam.l(9))), fill=(64, 50, 45)) d.rectangle(cam.box(hxp-42, hyp+40, hxp+42, hyp+220), fill=(40, 42, 52)) def draw_rightpanel(d, cam, st, gd): if not cam.vis(RP_X0-20, CAB_Y0, RP_X1+20, CAB_Y1): return draw_seg7(d, cam, gd, LED_BOX, st["led"], on=st["led_on"]*st["flick"]) if cam.vis(LED_BOX[0], LED_BOX[3], LED_BOX[2], LED_BOX[3]+40): f = font(max(PX(7), int(cam.l(17))), "Menlo.ttc") d.text(cam.p(LED_BOX[0]+4, LED_BOX[3]+8), "PRICE / SELECTION", font=f, fill=(120, 126, 140)) # keypad for r, row in enumerate(KEY_LABELS): for c, lab in enumerate(row): x0 = KEY_X0 + c*(KEY_W+KEY_G); y0 = KEY_Y0 + r*(KEY_H+KEY_G) x1, y1 = x0+KEY_W, y0+KEY_H if not cam.vis(x0, y0, x1, y1): continue down = (st["press"] == lab) off = 3.0 if down else 0.0 d.rounded_rectangle(cam.box(x0+2, y0+6, x1+2, y1+8), radius=max(2, int(cam.l(8))), fill=(10, 11, 14)) fillc = (96, 104, 120) if down else (52, 57, 70) d.rounded_rectangle(cam.box(x0+off, y0+off, x1+off, y1+off), radius=max(2, int(cam.l(8))), fill=fillc) d.line([cam.p(x0+off+6, y0+off+4), cam.p(x1+off-6, y0+off+4)], fill=shade(fillc, 1.5), width=max(2, int(cam.l(3)))) fs = max(7, int(cam.l(KEY_H*0.52))) f = font(fs, "Impact.ttf") tw = d.textlength(lab, font=f) px, py = cam.p((x0+x1)/2+off, y0+KEY_H*0.18+off) d.text((px-tw/2, py), lab, font=f, fill=(250, 250, 250) if down else (176, 182, 196)) if down: # a real finger, on the outside of the machine fx, fy = (x0+x1)/2 + 8, (y0+y1)/2 + 6 d.rounded_rectangle( cam.box(fx-27, fy-16, fx+27, fy+180), radius=max(2, int(cam.l(26))), fill=(78, 58, 50)) d.ellipse(cam.box(fx-27, fy-30, fx+27, fy+22), fill=(96, 72, 62)) d.ellipse(cam.box(fx-15, fy-22, fx+13, fy+6), fill=(178, 152, 140)) # coin slot + coin d.rounded_rectangle(cam.box(*COIN_SLOT), radius=max(2, int(cam.l(6))), fill=(66, 72, 86)) d.rectangle(cam.box(COIN_SLOT[0]+14, COIN_SLOT[1]+13, COIN_SLOT[2]-14, COIN_SLOT[3]-13), fill=(6, 6, 8)) if st["coin"] >= 0.0: u = st["coin"] cxw = COIN_SLOT[0]+18 + (COIN_SLOT[2]-COIN_SLOT[0]-36)*0.5 cyw = COIN_SLOT[1] - 190*(1-min(1.0, u*1.4))**2 rr = 26 * (1.0 if u < 0.8 else max(0.05, 1-(u-0.8)*5)) sq = abs(math.cos(u*13.0))*0.75 + 0.25 d.ellipse(cam.box(cxw-rr*sq, cyw-rr, cxw+rr*sq, cyw+rr), fill=(198, 198, 190), outline=(120, 120, 116), width=max(2, int(cam.l(2)))) f = font(max(PX(7), int(cam.l(15))), "Menlo.ttc") d.text(cam.p(COIN_SLOT[0]-2, COIN_SLOT[1]-24), "INSERT COIN", font=f, fill=(140, 146, 160)) # coin return d.rounded_rectangle(cam.box(*COIN_RET), radius=max(2, int(cam.l(8))), fill=(58, 30, 34)) fs = max(7, int(cam.l(17))) f2 = font(fs, "Impact.ttf") d.text(cam.p(COIN_RET[0]+12, COIN_RET[1]+10), "COIN RETURN", font=f2, fill=(212, 176, 176)) d.rounded_rectangle(cam.box(*CUP), radius=max(2, int(cam.l(8))), fill=(8, 8, 10)) d.rounded_rectangle(cam.box(PLATE[0], PLATE[1], PLATE[2], PLATE[3]), radius=max(2, int(cam.l(6))), fill=(20, 22, 28)) f3 = font(max(PX(7), int(cam.l(21))), "Impact.ttf") d.text(cam.p(PLATE[0]+12, PLATE[1]+10), "USE EXACT", font=f3, fill=(150, 156, 172)) d.text(cam.p(PLATE[0]+12, PLATE[1]+34), "CHANGE", font=f3, fill=(150, 156, 172)) REFL_BASE = 150 def draw_glass(gd, dd, cam, t, st): """The glass pass. Two layers, because one doesn't work: `gd` adds light (specular, prints, rim), `dd` takes it away (the buyer's body, which is a dark mass over a lit cabinet). Additive-only reflections just fog the picture — the figure only reads once it occludes.""" if not cam.vis(GL_X0-40, GL_Y0-40, GL_X1+40, GL_Y1+40): return # reflected ceiling strips for j, (ax, ay) in enumerate(((-260, 40), (120, -60))): p0 = cam.p(GL_X0+ax, GL_Y0+ay) p1 = cam.p(GL_X0+ax+520, GL_Y0+ay+700) gd.line([p0, p1], fill=15 + j*5, width=max(2, int(cam.l(40 - j*12)))) # the big diagonal specular streak swp = math.sin(t*0.30)*90 quad = [(GL_X0-120+swp, GL_Y1+150), (GL_X0+150+swp, GL_Y1+150), (GL_X0+700+swp, GL_Y0-120), (GL_X0+430+swp, GL_Y0-120)] gd.polygon([cam.p(*q) for q in quad], fill=9) quad2 = [(GL_X0+180+swp, GL_Y1+150), (GL_X0+236+swp, GL_Y1+150), (GL_X0+786+swp, GL_Y0-120), (GL_X0+730+swp, GL_Y0-120)] gd.polygon([cam.p(*q) for q in quad2], fill=15) # smudges for sx, sy, sw2, sh2, ang in SMUDGES: if not cam.vis(sx-sw2, sy-sh2, sx+sw2, sy+sh2): continue ca, sa = math.cos(ang), math.sin(ang) pts = [] for q in range(14): a = q/14*math.tau xx, yy = math.cos(a)*sw2*0.5, math.sin(a)*sh2*0.5 pts.append(cam.p(sx + xx*ca - yy*sa, sy + xx*sa + yy*ca)) gd.polygon(pts, fill=8) # fingerprints — actual whorls, only worth drawing when close enough if cam.l(1.0) > 0.9: for fx, fy, fr, ang, nw in FINGERPRINTS: if not cam.vis(fx-fr, fy-fr*1.3, fx+fr, fy+fr*1.3): continue for q in range(nw): rr = fr*(0.22 + 0.78*q/max(1, nw-1)) bb = cam.box(fx-rr*0.78, fy-rr*1.05, fx+rr*0.78, fy+rr*1.05) gd.arc(bb, int(math.degrees(ang)) + 24, int(math.degrees(ang)) + 336, fill=26, width=max(2, int(cam.l(1.6)))) draw_reflection(gd, dd, cam, t, st) def draw_reflection(gd, dd, cam, t, st): pose = st["pose"] bob = math.sin(t*1.7)*8 cx, cy, sc = 320.0, 640.0, 1.30 arm_r = 0.0; shk = 0.0 if pose == "idle": cy += bob elif pose == "watch": cx += 30; cy += bob*0.5 elif pose == "lean": cx += 60; cy += 50 + bob*0.4; sc *= 1.14 elif pose == "press": arm_r = 1.0; cx += 40 elif pose == "shake": shk = 1.0; cx += math.sin(t*22)*30; cy += 20 + math.sin(t*19)*14 sc *= 1.18 elif pose == "still": cy += bob*0.3; cx += 10 elif pose == "reach": cx += 10; cy += 150; sc *= 1.26; arm_r = -1.0 dxs, dys, _ = st["shake"] cx += dxs*0.9; cy += dys*0.9 if 23*BAR+BEAT*2 <= t < 23*BAR+BEAT*2+0.55: sc *= 1.0 + math.exp(-(t-(23*BAR+BEAT*2))*5.0)*0.55 if not cam.vis(cx-460*sc, cy-460*sc, cx+460*sc, cy+700*sc): return # A reflection is a broad, soft thing. Push the camera in far enough and # you'd see a gradient, not a face — so it fades out below a wide framing # instead of turning two eyes into two dinner plates. near = float(np.clip((cam.vw - 340.0)/420.0, 0.0, 1.0)) if near <= 0.02: return boost = st.get("refl_boost", 1.0) * near dk = int(min(255, REFL_BASE*boost)) rim = int(min(255, 52*boost)) detail = cam.vw > 620 def P(x, y): return cam.p(cx + x*sc, cy + y*sc) def B(x0, y0, x1, y1): return cam.box(cx+x0*sc, cy+y0*sc, cx+x1*sc, cy+y1*sc) # torso + shoulders — occluding mass torso = [P(-250, 520), P(-190, 130), P(-120, 66), P(120, 66), P(190, 130), P(250, 520)] dd.polygon(torso, fill=dk) gd.line(torso[:-1], fill=rim, width=max(2, int(cam.l(4*sc)))) # neck + head dd.polygon([P(-42, 90), P(-42, 10), P(42, 10), P(42, 90)], fill=dk) dd.ellipse(B(-96, -178, 96, 56), fill=dk) # hair mass, a little darker still dd.chord(B(-102, -196, 102, 10), 180, 360, fill=min(255, int(dk*1.20))) # a rim of light down one side of the face only — a lit edge, not an outline gd.arc(B(-96, -178, 96, 56), 300, 60, fill=int(rim*1.4), width=max(2, int(cam.l(6*sc)))) if not detail: return # the face reads through: brow, eyes, mouth, all faint gd.line([P(-56, -62), P(-18, -68)], fill=int(rim*0.8), width=max(2, int(cam.l(5*sc)))) gd.line([P(18, -68), P(56, -62)], fill=int(rim*0.8), width=max(2, int(cam.l(5*sc)))) for ex, ew in ((-40, 12), (40, 10)): gd.ellipse(B(ex-ew, -44, ex+ew, -28), fill=int(min(255, rim*1.25))) gd.line([P(-6, -26), P(-2, 4)], fill=int(rim*0.7), width=max(2, int(cam.l(4*sc)))) if pose in ("shake", "press"): gd.ellipse(B(-24, 6, 24, 40), fill=int(rim*1.0)) # shouting else: gd.line([P(-26, 20), P(26, 20)], fill=int(rim*0.8), width=max(2, int(cam.l(5*sc)))) # arms def arm(p0, p1, w): dd.line([P(*p0), P(*p1)], fill=dk, width=max(2, int(cam.l(w*sc)))) gd.line([P(*p0), P(*p1)], fill=int(rim*0.7), width=max(2, int(cam.l(w*0.20*sc)))) if arm_r > 0.5: arm((150, 190), (430, -20), 56) elif arm_r < -0.5: arm((-30, 210), (-110, 600), 56) if shk > 0.5: for sgn in (-1, 1): arm((sgn*150, 190), (sgn*330, -40 + math.sin(t*21+sgn)*40), 54) def paint(cam, t, e, st): im = Image.new("RGB", (cam.pw, cam.ph), ROOM) d = ImageDraw.Draw(im) gl = Image.new("L", (cam.pw, cam.ph), 0); gd = ImageDraw.Draw(gl) dk = Image.new("L", (cam.pw, cam.ph), 0); dd = ImageDraw.Draw(dk) led = Image.new("L", (cam.pw, cam.ph), 0); ld = ImageDraw.Draw(led) draw_room(d, cam) draw_cabinet(d, cam, st) draw_interior(d, cam, t, e, st) draw_flap(d, cam, st) draw_falls(d, cam, st) draw_hand(d, cam, st) draw_rightpanel(d, cam, st, ld) draw_glass(gd, dd, cam, t, st) return im, gl, dk, led def light_map(cam, flick): """The interior tube's falloff, resolved coarsely and upsampled. This is what makes the shot look lit from inside rather than drawn.""" blk = max(1, PX(8)) # keeps the map's cost and its softness qw, qh = cam.pw//blk, cam.ph//blk xs = cam.x0 + (np.arange(qw)+0.5)/cam.s*blk ys = cam.y0 + (np.arange(qh)+0.5)/cam.s*blk X, Y = np.meshgrid(xs, ys) dx = np.abs(X - TUBE_X) dy = np.maximum(0.0, np.maximum(TUBE_Y0 - Y, Y - TUBE_Y1)) dist = np.sqrt(dx*dx + dy*dy*1.4) L = 1.0/(1.0 + (dist/620.0)**1.55) L = L*flick im = Image.fromarray(np.clip(L*255, 0, 255).astype(np.uint8)) im = im.resize((cam.pw, cam.ph), Image.BILINEAR) return np.asarray(im, np.float32)[..., None]/255.0 def compose(cam, im, gl, dk, led, flick): a = np.asarray(im, np.float32) L = light_map(cam, flick) a = a*(0.52 + 1.15*L) dn = np.asarray(dk.filter(ImageFilter.GaussianBlur( float(np.clip(cam.s*1.4, PF(1.0), PF(7.0))))), np.float32)[..., None]/255.0 a = a*(1.0 - dn*0.52) g = np.asarray(gl, np.float32)[..., None]/255.0 a = a + g*np.array([178.0, 192.0, 216.0])*1.10 # the LED bloom is a fixed WORLD size, so it doesn't turn into a smear # the moment the camera goes macro glow_r = float(np.clip(cam.s*2.0, PF(1.5), PF(11.0))) lg = led.filter(ImageFilter.GaussianBlur(glow_r)) lgn = np.asarray(lg, np.float32)[..., None]/255.0 a = a + lgn*np.array([255.0, 62.0, 40.0])*1.35 return a # ════════════════════════════════════════════════════════════════════════════ # SHOTS — framings, moves, engines # ════════════════════════════════════════════════════════════════════════════ FRAMING = { "wide": (430, 800, 1600), "wide2": (400, 690, 1360), "full": (368, 620, 1320), "rows": (394, 620, 840), "rowsA": (394, 268, 800), "rowsB": (394, 440, 800), "rowsD": (394, 830, 840), "rowsE": (394, 1000, 840), "col1": (150, 640, 660), "col5": (640, 640, 660), "c3": (400, 578, 420), "c3m": (404, 572, 205), "coil": (394, 596, 268), "coilmac": (352, 604, 128), "coilA2": (270, 228, 262), "coilD5": (642, 776, 262), "coilE1": (146, 956, 262), "coilC5": (642, 596, 200), "coilB1": (146, 416, 200), "b3": (394, 398, 400), "b3m": (398, 392, 210), "led": (844, 161, 330), "ledmac": (844, 161, 200), "keys": (846, 468, 440), "keysmac": (846, 420, 330), "keyslow": (846, 600, 360), "coin": (846, 790, 330), "coinmac": (846, 772, 190), "flap": (255, 1300, 660), "tray": (300, 1330, 470), "refl": (330, 500, 1000), "reflmac": (300, 560, 520), "chute": (394, 900, 900), "landing": (300, 1180, 900), "brand": (500, 1546, 760), "cornerhi": (210, 320, 700), "corner5": (600, 820, 700), # the drop, authored "release": (394, 430, 640), "strike": (394, 560, 540), "fall": (394, 980, 980), "settle": (310, 1320, 600), } REFL_SHOTS = {"refl", "reflmac"} # round 2: the delivery frame went 14:9 -> 16:9. Every framing below is a # (cx, cy, view-width) authored against the old aspect; scaling the view WIDTH # by 8/7 holds the vertical framing exactly where it was and spends the extra # 160px on more room either side of the machine — never on a tighter crop. VWK = (W / H) / (1120 / 720) def framing(name): cx, cy, vw = FRAMING[name] return (cx, cy, vw * VWK) def cam_for(move, u, shake, jseed, push=0.05, drift=1.0): if ">" in move: a, b = move.split(">", 1) A = framing(a.strip()); B = framing(b.strip()) ee = u*u*(3-2*u) cx = A[0]+(B[0]-A[0])*ee; cy = A[1]+(B[1]-A[1])*ee vw = A[2]*(B[2]/A[2])**ee else: cx, cy, vw = framing(move) vw *= (1.0 - push*u) dx, dy, _ = shake jx = math.sin(u*2.1 + jseed)*vw*0.006*drift + dx jy = math.cos(u*1.7 + jseed*1.3)*vw*0.004*drift + dy return Cam(cx, cy, vw, dx=jx, dy=jy) POOL = { "insert": ["wide", "wide>wide2", "led", "coinmac", "brand", "full", "refl", "rowsA"], "drop1": ["full", "rows", "c3", "coil", "coilmac", "led", "keys", "rows>c3", "wide2>full", "c3m", "refl", "keysmac", "cornerhi", "full>coil", "coilA2", "col5", "rowsE", "coilD5", "corner5", "coilB1"], "stuck": ["c3", "c3m", "coil", "rows", "full", "reflmac", "c3>c3m", "coilC5", "rowsD"], "rage": ["keysmac", "keys", "c3m", "coil", "led", "refl", "reflmac", "wide2", "rows", "coin", "c3", "full", "ledmac", "coilmac", "corner5", "cornerhi", "keys>keysmac", "rows>c3", "wide>full", "coinmac", "b3", "coilA2", "coilD5", "coilE1", "rowsA", "rowsE", "col1", "col5", "keyslow", "coilC5", "rowsB", "coilB1", "wide"], "stop": ["c3m", "wide", "led", "full"], "switch": ["coinmac", "keysmac", "led", "b3", "b3m", "rowsB", "full", "coil", "rows", "refl", "b3>b3m", "wide2>rowsB", "ledmac", "coilA2", "col5", "cornerhi", "rowsE", "coilD5", "wide2", "col1"], "hero": ["b3", "rows", "chute", "landing", "flap", "tray", "full", "c3", "rowsD", "wide2", "coil", "rows>chute", "landing>tray", "rowsE", "refl", "col1", "settle", "wide"], "outro": ["tray", "flap>tray", "wide2", "brand", "wide"], } MENU = { "insert": [8, 12, 12, 16], "drop1": [4, 6, 8, 8, 12], "stuck": [8, 8, 12, 16], "rage": [2, 3, 3, 4, 4, 6, 8], "stop": [8, 16], "switch": [4, 6, 8, 8, 12], "hero": [4, 4, 6, 8, 12], "outro": [16, 16], } # The landing is authored, not shuffled: (start seconds, length, framing). SCRIPT = [ (T_DROP, BEAT*1.0, "release"), (T_DROP+BEAT*1.0, BEAT*0.8, "strike"), (T_DROP+BEAT*1.8, BEAT*1.1, "fall"), (T_DROP+BEAT*2.9, BEAT*2.1, "settle"), (T_DROP+BEAT*5.0, BEAT*3.0, "tray"), ] DRIFT = {"insert": .6, "drop1": 1.0, "stuck": .7, "rage": 2.2, "stop": .3, "switch": 1.1, "hero": 1.5, "outro": .5} SHIFT = {"insert": 0.0, "drop1": 1.0, "stuck": 0.4, "rage": 2.6, "stop": 0.2, "switch": 1.2, "hero": 1.8, "outro": 0.2} # big on-screen text — hyperpop wants words BURSTS = [ (0.35, 2.4, "VENDING", "title"), (7.6, 0.9, "C3", "big"), (9.6, 0.7, "1.25", "big"), (12.0, 0.9, "GIVE IT", "big"), (20.7, 1.0, "STUCK", "huge"), (24.2, 0.6, "AGAIN", "big"), (25.4, 0.5, "AGAIN", "big"), (26.6, 0.5, "AGAIN", "big"), (28.5, 0.8, "PLEASE", "huge"), (31.0, 0.7, "SHAKE IT", "big"), (33.5, 0.8, "HARDER", "huge"), (35.4, 0.9, "NOTHING", "huge"), (36.2, 2.0, "THE MACHINE DOES NOT CARE", "small"), (39.2, 1.0, "ONE MORE", "big"), (42.9, 0.8, "B3", "big"), (46.0, 0.8, "1.25", "big"), (T_DROP+0.05, 0.55, "GO", "huge"), (T_LAND2+0.05, 1.10, "BOTH", "huge"), (54.4, 1.70, "TWO BAGS", "big"), (60.4, 4.00, "THE SECOND ONE WAS THE HERO", "card"), ] BURST_STYLE = { "title": (150, "Impact.ttf", (250, 246, 240), 0.44), "huge": (128, "Impact.ttf", (255, 240, 90), 0.46), "big": (84, "Impact.ttf", (250, 250, 250), 0.16), "small": (44, "Impact.ttf", (255, 130, 150), 0.80), "card": (58, "Georgia Bold.ttf", (246, 240, 236), 0.46), } class Shot: __slots__ = ("idx", "i0", "i1", "n", "move", "section", "seed") def __init__(self, idx, i0, i1, move, section): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.move, self.section = move, section self.seed = 160160 + idx*7919 def build_shots(): """Random within a section's pool (no repeats inside a window of 3), with the authored SCRIPT spans punched in verbatim where they overlap.""" R = np.random.RandomState(1425) spans = []; recent = [] for nm, b0, b1 in SECTIONS: pool, menu = POOL[nm], MENU[nm] t = b0*BAR while t < b1*BAR - 1e-6: step = menu[R.randint(len(menu))]*S16 t2 = min(t+step, b1*BAR) if (b1*BAR - t2) < S16*2: t2 = b1*BAR cand = [x for x in pool if x not in recent] or list(pool) mv = cand[R.randint(len(cand))] recent = (recent + [mv])[-3:] spans.append([t, t2, mv, nm]) t = t2 # punch the authored spans over whatever the shuffle put there for sa, ln, mv in SCRIPT: sb = sa + ln out = [] for a, b, m, nm in spans: if b <= sa or a >= sb: out.append([a, b, m, nm]); continue if a < sa: out.append([a, sa, m, nm]) if b > sb: out.append([sb, b, m, nm]) sec = sec_of(int(sa/BAR)) out.append([sa, sb, mv, sec]) spans = sorted(out, key=lambda s: s[0]) shots = [] for idx, (a, b, m, nm) in enumerate( [s for s in spans if s[1] - s[0] > 1.5/FPS]): shots.append(Shot(idx, int(a*FPS), int(b*FPS), m, nm)) for j in range(len(shots)-1): shots[j].i1 = shots[j+1].i0 shots[j].n = shots[j].i1 - shots[j].i0 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) _FC = {} def font(size, name="Menlo.ttc"): key = (int(size), name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, int(size)) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx*nx + ny*ny)/1.42 _VIG["v"] = np.clip(1.0 - 0.52*r**2.0, 0, 1)[..., None] return _VIG["v"] def rgb_shift(a, px): """Channel offset on the PICTURE only. Text is composited after this.""" if px < 0.5: return a p = int(round(px*S)) # px is quoted in 1280x720 authoring pixels out = a.copy() out[:, p:, 0] = a[:, :-p, 0] out[:, :-p, 2] = a[:, p:, 2] return out def post(a, i, e, shot, t): # ---- picture-only glitch ------------------------------------------------- sh = SHIFT[shot.section]*(0.5 + 1.4*e["high"])*(1.0 + 2.4*e["kick"]) if 23*BAR+BEAT*2 <= t < 23*BAR+BEAT*2+0.35: sh += 7 if T_STRIKE <= t < T_STRIKE+0.20: sh += 6 # capped: past ~10px the fringing stops reading as an accent and starts # reading as a broken render, and it eats the shelf price labels a = rgb_shift(a, min(sh, 10.0)) # flash frames on the section downbeats and the big hits for at in (4*BAR, 16*BAR, 26*BAR, 34*BAR, 23*BAR+BEAT*2, T_STRIKE, T_LAND2): if 0 <= t-at < 2.2/FPS: a = a*0.42 + np.array([255.0, 244.0, 210.0])*0.72 # ---- fixed post order: tint -> vignette -> grain -> (text) -> letterbox -- lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([-16.0, -4.0, 22.0], np.float32) \ + lum*np.array([12.0, 2.0, -8.0], np.float32) a *= vignette() rng = np.random.RandomState(760760 + i) sig = 2.4 + 3.0*e["high"] if S == 1.0: a += rng.normal(0, sig, a.shape) else: # grain is a look, not a resolution: drawn at 1280x720 and blown up # nearest-neighbour so a speck covers the same fraction of the frame. gn = rng.normal(0, sig, (720, 1280, 3))*8.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a += (np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) for at, ln, txt, sty in BURSTS: if not (at <= t < at+ln): continue size, fname, col, ypos = BURST_STYLE[sty] age = t-at al = min(1.0, age/0.05)*min(1.0, (ln-age)/0.18) if sty in ("big", "huge"): al *= 0.7 + 0.3*abs(math.sin(age*26)) f = font(PX(size), fname) lines = txt.split("\n") if "\n" in txt else [txt] if sty == "small" or sty == "card": words = txt.split() if len(words) > 4: mid = (len(words)+1)//2 lines = [" ".join(words[:mid]), " ".join(words[mid:])] for li, line in enumerate(lines): tw = d.textlength(line, font=f) yy = H*ypos + li*PF(size)*1.08 jx = 0.0 if sty == "huge": jx = math.sin(age*40)*PF(4) d.text((W/2-tw/2+jx+PF(3), yy+PF(3)), line, font=f, fill=tuple(int(c*al*0.25) for c in (0, 0, 0))) d.text((W/2-tw/2+jx, yy), line, font=f, fill=tuple(int(c*al) for c in col)) if sty == "title": # THE TITLE MOMENT — the show's name cut from the same Impact, # hung under the card with the same hard shadow. fs2 = font(PX(22), fname) tr = PF(11.0) sw2 = sum(d.textlength(ch, font=fs2) + tr for ch in SUBT) - tr x2 = W/2 - sw2/2 # no rule: Impact at this size already ends the block, and a bar # under it cuts through the bottoms of the title's own letters y2 = H*ypos + PF(size)*1.16 for ch in SUBT: d.text((x2+PF(2), y2+PF(2)), ch, font=fs2, fill=tuple(int(c*al*0.25) for c in (0, 0, 0))) d.text((x2, y2), ch, font=fs2, fill=tuple(int(c*al) for c in (250, 246, 240))) x2 += d.textlength(ch, font=fs2) + tr bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(8, 8, 11)) d.rectangle([0, H-bh, W, H], fill=(8, 8, 11)) return out def render_frame(shot, k, E): i = shot.i0 + k ii = min(i, N_FRAMES-1) t = i/FPS e = {kk: float(E[kk][ii]) for kk in E} st = state(i, t, e) if shot.move.split(">")[-1].strip() in REFL_SHOTS: st["refl_boost"] = 2.0 u = k/max(1, shot.n-1) cam = cam_for(shot.move, u, st["shake"], shot.seed % 97, push=0.05, drift=DRIFT[shot.section]*(0.6+1.2*e["kick"])) im, gl, dk, led = paint(cam, t, e, st) a = compose(cam, im, gl, dk, led, st["flick"]) a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((W, H), Image.LANCZOS), np.float32) return post(a, i, e, shot, t) def render_shot(job): shot, force = job E = env() made = 0 for k in range(shot.n): i = shot.i0 + k p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue render_frame(shot, k, E).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.move:16s} {shot.section:7s} {made}/{shot.n}" def _sheet_one(job): shot, tw, th = job E = env() im = render_frame(shot, shot.n//2, E).resize((tw, th), Image.LANCZOS) return shot.idx, np.asarray(im) def contact_sheet(shots, jobs=8): cols = 8 rows = (len(shots)+cols-1)//cols tw, th = PX(288), PX(162) # 16:9 thumbs sheet = Image.new("RGB", (cols*tw, rows*(th+PX(22))), (10, 10, 14)) sd = ImageDraw.Draw(sheet) import multiprocessing as mp payload = [(s, tw, th) for s in shots] with mp.get_context("fork").Pool(jobs) as pool: for idx, arr in pool.imap_unordered(_sheet_one, payload): sh = shots[idx] cx, cy = (idx % cols)*tw, (idx//cols)*(th+PX(22)) sheet.paste(Image.fromarray(arr), (cx, cy)) sd.text((cx+PX(5), cy+th+PX(3)), f"{sh.idx:02d} {sh.move} · {sh.section} · {sh.i0/FPS:.1f}s", font=font(PX(12)), fill=(190, 195, 205)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() or a.force: print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") wav, mix = build_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots, jobs=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 " f"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}.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" stamp = (f"generator=renders/{SETDIR}/{NAME}/render.py | git={sha} | " f"branch={br} | {MUSIC_DESC} | {ENGINE_DESC}") subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SUBT} — {TITLE}", "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", "-metadata", "artist=poop / night_watch", str(out)], check=True, capture_output=True) (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"scale: S={S} — native re-rasterisation, internal raster {PW}x{PH}\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engine: {ENGINE_DESC}\n" f"shots: {len(shots)} supersample: {SS}\n" f"voices: Samantha + Karen (chopped, chipmunked), " f"Superstar (vocoded hook), Zarvox (the machine)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()