#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Lighthouse (15/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/lighthouse # # A signal answered, then not, in moiré and three primaries. # # 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/lighthouse.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/lighthouse.mp4 # cover: https://genekogan.com/player_computer/media/lighthouse.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 lighthouse.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 — "LIGHTHOUSE" (final delivery cut) Psytrance, 145bpm, E minor. 38 bars, instrumental (~66s). A lighthouse sends. Long, long, short. Nothing for a while. Then, from somewhere out in the dark, the same rhythm comes back — long, long, short — and the two of them spend the middle of the piece talking, faster and faster, in a language with two words in it. Then the answer stops. The lighthouse carries on sending, at exactly the same speed it always did, which is the only thing it knows how to do. Look: op-art. Hard black and white with three accent colours, concentric wave interference, moire line grids that beat against each other, rotating radials. No shading, no texture, no depth — flat optical noise that resolves into a rhythm if you let it. Recut vs the side_quests original: 66 bars -> 38 (sending/reply trimmed, the long exchange+fast middle halved, silence+still tightened), and ALL picture motion made continuous per-frame at 30fps — the original quantised its motion to the 16th-note grid (~3 identical frames per step at 145bpm), which read as dropped frames. The beat-strobes (polarity flips, downbeat inverts) are kept as rhythm ON TOP of continuously sliding patterns. FINAL CUT (player_computer_final). This file is forked from the ROUND-ONE `renders/player_computer/lighthouse/render.py` — the original op-art visuals with the frame-pacing fix and the approved psytrance score. The round-two night-sea rebuild is discarded. Three changes on top of round one: 1. 14:9 -> 16:9, by *extending the field*, never stretching it. The engines paint into a small op-art buffer that post() blows up with LANCZOS; the buffer goes 400x258 (1.55:1) -> SWL x SHL at exactly 16:9, and because every engine normalises by SHL/2 the vertical framing is untouched and the extra width is new picture, not smeared picture. 2. 1920x1080 native. S = H/720 = 1.5 multiplies every pixel-space quantity: the op-art buffer itself (so the blow-up ratio, and therefore the softness of the blow-up, is identical), the tower's shapes, stroke widths, font sizes, the chroma offset, the letterbox. Film grain is generated at 1280x720 and NEAREST-upscaled so a speck stays the same size on screen. 3. Title moment: the "LIGHTHOUSE" card gains the show subtitle PLAYER COMPUTER, set in the piece's own hard flat op-art idiom — accent rule, tracked-out Menlo caps, no antialiased niceties. There are no debug-metadata overlays to strip: the only text this piece ever burnt into the picture is the title card itself. Composition: engine : audio-first x shot-parallel content: audio-groove (psy kit, rolling 16th bass, acid line, gated pads) x generative-art (wave interference, moire) x effects-post Run from repo root: python3 renders/player_computer_final/lighthouse/render.py --sheet python3 renders/player_computer_final/lighthouse/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 = "lighthouse" TITLE = "LIGHTHOUSE" SUBT = "PLAYER COMPUTER" SETDIR = "player_computer_final" W, H, FPS = 1920, 1080, 30 # ── delivery scale ────────────────────────────────────────────────────────── # S is the single number the whole look scales by. Authoring units are the # 1280x720 delivery frame of the 16:9 recompose; S = H/720 = 1.5 turns them # into real 1080p pixels. Nothing is upscaled after the fact — the op-art # buffer, the tower, the type and the letterbox are all re-rasterised at S. S = H / 720.0 def P(v): return int(round(v*S)) # pixel length -> int def PF(v): return v*S # pixel length -> float def PW(v): return max(2, int(round(v*S))) # stroke width, floor 2 at 1080p BPM = 145.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 OUT = Path(__file__).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 = [ ("sending", 0, 6), ("reply", 6, 12), ("exchange",12, 20), ("fast", 20, 28), ("silence", 28, 32), ("still", 32, 38), ] N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 3.0 N_FRAMES = int(DUR * FPS) MUSIC_DESC = f"psytrance, {BPM:.0f}bpm, E minor, {N_BARS} bars, instrumental" ENGINE_DESC = "op-art — interference / moire / radial / bars / beacon" 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 voice(freq, dur, kind="saw", nh=26, c0=5200, c1=700, ck=8.0, res=0.0, detune=(0.0,), a=.005, d=.09, s=.7, r=.10, vib=(0.0, 0.0), seed=0): """Additive voice through a *moving* emulated filter (cutoff array). The cutoff glides c0->c1 at rate ck; `res` bumps harmonics near the cutoff. This is what gives plucks, reeses and stabs their motion. """ n = int(dur * SR) if n <= 0: return np.zeros(0) t = np.arange(n) / SR co = c1 + (c0 - c1) * np.exp(-t * ck) rng = np.random.RandomState(seed) out = np.zeros(n) vd, vr = vib for det in detune: f0 = freq * (1 + det * 0.006) for k in range(1, nh + 1): if kind == "saw": base = 1.0 / k elif kind == "square": base = (1.0 / k) if k % 2 else 0.0 elif kind == "tri": base = (1.0 / (k*k)) if k % 2 else 0.0 elif kind == "sine": base = 1.0 if k == 1 else 0.0 else: base = 1.0 / k if base == 0.0: continue fk = f0 * k if fk > SR * 0.45: break g = base / np.sqrt(1.0 + (fk / co) ** 4) if res: g = g + res * base * np.exp(-((fk - co) / (0.3 * co + 1)) ** 2) ph = rng.uniform(0, 2*np.pi) phase = 2*np.pi*fk*t + ph if vd: phase = phase + vd * np.sin(2*np.pi*vr*t) out += g * np.sin(phase) out /= len(detune) return out * adsr(n, a, d, s, r) def fm(freq, dur, ratio=2.0, index=4.0, idec=6.0, a=.002, d=.4, s=.0, r=.2, seed=0): """2-op FM — rhodes / bells / glassy leads.""" n = int(dur*SR); t = np.arange(n)/SR mod = np.sin(2*np.pi*freq*ratio*t) * index * np.exp(-t*idec) return np.sin(2*np.pi*freq*t + mod) * adsr(n, a, d, s, r) def ks(freq, dur, damp=0.996, seed=0): """Karplus-Strong pluck — guitar / harp.""" n = int(dur*SR); L = max(2, int(SR/freq)) rng = np.random.RandomState(seed) buf = rng.uniform(-1, 1, L) out = np.zeros(n); j = 0 for i in range(n): out[i] = buf[j] buf[j] = damp * 0.5 * (buf[j] + buf[(j+1) % L]) j = (j+1) % L return out * adsr(n, .001, .05, .85, .25) def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Noise sources go through this so nothing in the kit is a raw full-band blast (AESTHETIC 13a).""" n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) g = np.ones_like(fq) if lo: g *= 1.0/np.sqrt(1.0 + (lo/fq)**order) if hi: g *= 1.0/np.sqrt(1.0 + (fq/hi)**order) return np.fft.irfft(X*g, n) def kick(dur=.30, f0=155, f1=48, punch=30, click=.5, seed=1): n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*punch) body = np.sin(2*np.pi*np.cumsum(f)/SR) * np.exp(-t*10.5) ck = np.random.RandomState(seed).randn(n) * np.exp(-t*300) * click return np.tanh((body + ck) * 1.7) * .95 def snare(dur=.22, tone=196, bright=1.0, seed=2): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=280, hi=6200) body = np.sin(2*np.pi*tone*t) + .6*np.sin(2*np.pi*tone*1.58*t) return nz*np.exp(-t*19)*.85*bright + body*np.exp(-t*26)*.50 def hat(dur=.055, openh=False, seed=7): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=5200, hi=9800) return nz * np.exp(-t*(14 if openh else 85)) * .40 def ride(dur=.6, seed=9): n = int(dur*SR); t = np.arange(n)/SR bell = sum(np.sin(2*np.pi*f*t) for f in (522, 831, 1180, 1567, 2103)) nz = bandshape(np.random.RandomState(seed).randn(n), lo=3800, hi=9000) return bell*np.exp(-t*10)*.10 + nz*np.exp(-t*5)*.16 def rim(dur=.09, seed=3): n = int(dur*SR); t = np.arange(n)/SR return (np.sin(2*np.pi*1750*t) + .5*np.sin(2*np.pi*2600*t)) * np.exp(-t*90) * .5 def shaker(dur=.09, seed=5): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=3600, hi=8600) return nz * (np.exp(-t*40) * np.clip(t*260, 0, 1)) * .40 def crash(dur=1.6, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=8200) return nz * (np.exp(-t*2.6) + .3*np.exp(-t*.6)) * .55 def riser(dur=2.0, seed=17): n = int(dur*SR); t = np.arange(n)/SR env = (t/dur) ** 1.7 sweep = np.sin(2*np.pi*np.cumsum(140 + 3000*(t/dur)**2)/SR) # noise through a band that *rises with the sweep* — pitched motion, # not a static full-band hiss (AESTHETIC 13a) rng = np.random.RandomState(seed) nz = np.zeros(n); blk = 2048 for i in range(0, n, blk): u = (i/max(1, n)) ** 1.4 fc = 300 + 5200*u seg = rng.randn(min(blk, n-i) + 256) nz[i:i+min(blk, n-i)] = bandshape(seg, lo=fc*.72, hi=fc*1.5)[:min(blk, n-i)] return (nz*env*.55 + sweep*env*.22) * .8 def vinyl(n, seed=23): """Surface noise: filtered hiss + sparse crackle.""" rng = np.random.RandomState(seed) hiss = bandshape(rng.randn(n), lo=140, hi=5200) * .022 cr = np.zeros(n) idx = rng.choice(n, size=max(1, n//2400), replace=False) cr[idx] = rng.uniform(-1, 1, len(idx)) * .10 cr = np.convolve(cr, np.exp(-np.arange(60)/9), "same") return hiss + cr def reverb(x, rt=1.6, mix=.3, seed=29, pre=0.02): """FFT convolution with a synthetic exponentially-decaying noise IR.""" 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=7): 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 lowpass(x, fc): a = np.exp(-2*np.pi*fc/SR); z = 0.0; y = np.empty_like(x) for i in range(len(x)): z = (1-a)*x[i] + a*z; y[i] = z return y class Song: """A multitrack canvas placed on an absolute bar/beat grid.""" def __init__(self, dur): self.n = int(dur*SR) self.tr = {} self.kick_t = [] def t(self, bar, step=0, swing=0.0): """absolute seconds of 16th-step `step` inside `bar`.""" sw = swing * (BEAT/4) if (step % 2) else 0.0 return bar*BAR + step*(BEAT/4) + sw def put(self, track, sig, at, g=1.0, pan=0.0): 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) st = np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1) * g b[i:j] += st 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.35): """Section dynamics: a smooth per-sample gain built from {section_name: level}. Arrangement alone tends to come out flat — this is the macro arc the ear actually follows.""" 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=.30, pump_rel=.16, 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(320)/320, "same") mix *= env[:, None] # DC / sub-30Hz rumble trim (one-pole HP per channel, vectorised # via cumulative difference of a one-pole LP) a = math.exp(-2*math.pi*30.0/SR) for c in range(2): lp = np.empty(self.n); z = 0.0 col = mix[:, c] for i in range(0, self.n, 4096): blk = col[i:i+4096] for j in range(len(blk)): z = (1-a)*blk[j] + a*z; lp[i+j] = z mix[:, c] = col - lp mix = np.tanh(mix*1.25)/np.tanh(1.25) return mix / (np.max(np.abs(mix))+1e-9) * .94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1)*32767).astype(" macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, voice, 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): """Resample to exactly n samples. Shifts formants a little; pitch is the carrier's job, so this is free.""" if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def carrier(f_per_sample, nh=30, detune=(0.0, -0.55, 0.62), vib=(0.0, 0.0)): """Band-limited additive carrier with continuous phase across note changes.""" n = len(f_per_sample) t = np.arange(n)/SR out = np.zeros(n) for d in detune: f = f_per_sample*(1 + d*0.005) if vib[0]: f = f*(1 + vib[0]*np.sin(2*np.pi*vib[1]*t)) 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=26, lo=110, hi=6500, gmax=12.0, rel=0.55, sib=0.06, tilt=4200.0): """Transfer mod's band envelope onto car. Gains are clamped and the band set is bounded — an unclamped vocoder turns carrier aliasing into hiss.""" 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 # floor the window sum: at the ramp-in/out edges it -> 0 and the divide # detonates into a single enormous spike 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.55*y + 0.85*lp + sib*hp return y/(np.max(np.abs(y))+1e-9) def sing(text, notes, dur, voice="Moira", rate=170, cache=None, nh=30, detune=(0.0, -0.55, 0.62), vib=(0.012, 5.2), gliss=0.012, **vk): """A sung line. `notes` = [(freq, weight), …] carved across `dur` seconds.""" 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: # portamento: smooth the note edges k = max(3, int(gliss*SR)); f = np.convolve(f, np.ones(k)/k, "same") f[:k] = f[k]; f[-k:] = f[-k-1] car = carrier(f, nh=nh, detune=detune, vib=vib) return vocode(mod, car, **vk) def speak(text, dur=None, voice="Alex", rate=170, cache=None, pitch=1.0): """Plain spoken line (no vocoder) — for verses that shouldn't sing.""" key = cache/("say_"+_h(text, voice, rate)+".wav") x = say_wav(text, voice, rate, key) if pitch != 1.0: x = fit(x, int(len(x)/pitch)) if dur: x = fit(x, int(dur*SR)) if len(x) > int(dur*SR) else \ np.pad(x, (0, int(dur*SR)-len(x))) return x/(np.max(np.abs(x))+1e-9) SW = 0.0 E_ = nf("E1") def build_song(): s = Song(DUR) R = np.random.RandomState(145) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "still" for bar in range(N_BARS): sec = sec_of(bar) drive = {"sending": 1, "reply": 2, "exchange": 3, "fast": 4, "silence": 0, "still": 1}[sec] if drive == 0: for k2, iv in enumerate((0, 3, 7)): s.put("pad", voice(E_*4*2**(iv/12), BAR*1.4, kind="saw", nh=18, c0=900, c1=500, ck=.6, detune=(-1.3, 0, 1.4), a=1.0, d=.9, s=.75, r=1.2, seed=bar*3+k2), s.t(bar, 0), g=.11, pan=-.5+.5*k2) continue # four to the floor, always for st in (0, 4, 8, 12): at = s.t(bar, st) s.put("drums", kick(dur=.28, f0=150, f1=46, punch=38, click=.35), at, g=.96) s.kick_t.append(at) for st in (2, 6, 10, 14): s.put("drums", hat(openh=True), s.t(bar, st), g=.24, pan=.30) if drive >= 2: for st in range(0, 16, 2): s.put("drums", hat(dur=.035), s.t(bar, st+1), g=.15, pan=-.26) if drive >= 3: for st in (4, 12): s.put("drums", snare(dur=.14, tone=260, bright=1.1), s.t(bar, st), g=.30, pan=-.05) # rolling 16th offbeat bass — the genre's spine for st in range(16): if st % 4 == 0: continue s.put("bass", voice(E_*2, BEAT*.20, kind="saw", nh=18, c0=700, c1=260, ck=16, res=.6, a=.002, d=.05, s=.25, r=.04, seed=bar*5+st), s.t(bar, st), g=.26) s.put("sub", voice(E_, BAR*.9, kind="sine", nh=2, c0=180, c1=90, ck=2, a=.01, d=.3, s=.9, r=.2, seed=bar), s.t(bar, 0), g=.28) # acid line if drive >= 2: ACID = [0, 0, 12, 0, 7, 3, 10, 7, 0, 5, 12, 3, 7, 0, 10, 5] for st in range(16): if (st + bar) % 3 == 0 and drive < 4: continue co = 500 + 3400*(0.5+0.5*math.sin(bar*0.42 + st*0.2)) s.put("acid", voice(E_*4*2**(ACID[(st+bar*3) % 16]/12.0), BEAT*.22, kind="saw", nh=20, c0=co, c1=co*0.35, ck=20, res=1.0, a=.002, d=.05, s=.3, r=.04, seed=bar*7+st), s.t(bar, st), g=.10, pan=-.3+.6*((st % 3)/2)) # gated pad if drive >= 1: for st in (0, 3, 6, 9, 12, 14): for k2, iv in enumerate((0, 3, 7, 10)): s.put("pad", voice(E_*4*2**(iv/12), BEAT*.30, kind="saw", nh=18, c0=2400+900*drive, c1=1100, ck=9, res=.35, detune=(-1.1, 0, 1.2), a=.006, d=.09, s=.35, r=.08, seed=bar*11+k2+st), s.t(bar, st), g=.055, pan=-.45+.30*k2) # THE SIGNAL: long long short, at the top of every 4 bars if bar % 4 == 0: for j, (off, ln) in enumerate(((0, 1.4), (2.0, 1.4), (4.0, 0.5))): s.put("sig", beacon(BEAT*ln, 330 if sec != "reply" else 330), s.t(bar, 0) + off*BEAT, g=.20) # the reply, an answering beacon a fifth up, offset by two beats if sec in ("reply", "exchange", "fast") and bar % 4 == 2: for j, (off, ln) in enumerate(((0, 1.2), (1.8, 1.2), (3.6, 0.45))): s.put("sig", beacon(BEAT*ln, 495), s.t(bar, 0) + off*BEAT, g=.17) for b in (12, 20, 32): s.put("fx", crash(dur=2.0), b*BAR, g=.26, pan=.1) s.put("fx", riser(BAR*4), 16*BAR, g=.28) s.bus("acid", lambda x: delay(x, BEAT*.75, .34, .24)) s.bus("pad", lambda x: reverb(x, rt=3.0, mix=.44, seed=577)) s.bus("sig", lambda x: reverb(delay(x, BEAT*1.5, .42, .34), rt=4.2, mix=.56, seed=587)) s.bus("fx", lambda x: reverb(x, rt=2.8, mix=.40, seed=593)) mix = s.mixdown(dict(drums=1.0, bass=1.0, sub=1.0, acid=1.0, pad=1.0, sig=1.0, fx=1.0), pump_depth=.34, pump_rel=.13, levels=dict(sending=.70, reply=.82, exchange=.94, fast=1.0, silence=.34, still=.66)) wav = AUD/"final.wav"; s.write(wav, mix); return wav, mix def beacon(dur, f0): n = int(dur*SR); t = np.arange(n)/SR x = (np.sin(2*np.pi*f0*t) + .35*np.sin(2*np.pi*f0*2*t) + .18*np.sin(2*np.pi*f0*3*t)) env = np.clip(t/.03, 0, 1)*np.clip((dur-t)/.10, 0, 1) return x*env*0.32 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 < 170].sum() E["mid"][f] = sp[(fr >= 170) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].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 # ════════════════════════════════════════════════════════════════════════════ # ANIMATION HELPERS — motion is the point, so it gets its own primitives # ════════════════════════════════════════════════════════════════════════════ def ease_io(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def ease_in(u): return u**3 def bounce(u, n=3): return abs(math.sin(u*math.pi*n))*(1-u) def lerp(a, b, u): return a + (b-a)*u def walk(t, speed=1.0): """Returns (leg_swing, body_bob, arm_swing) for a walk cycle at time t.""" p = t*speed*math.tau return math.sin(p), abs(math.sin(p))*-1.0, math.sin(p+math.pi) def spring(u, freq=3.0, damp=5.0): """Overshoot-and-settle, for things that arrive.""" if u <= 0: return 0.0 return 1 - math.exp(-damp*u)*math.cos(freq*math.tau*u) def arc(p0, p1, u, h=0.3): """Ballistic arc between two points.""" x = lerp(p0[0], p1[0], u) y = lerp(p0[1], p1[1], u) - math.sin(u*math.pi)*h*abs(p1[0]-p0[0]) return x, y # ════════════════════════════════════════════════════════════════════════════ # OP ART — flat, hard, optical # ════════════════════════════════════════════════════════════════════════════ NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} BLACK = (12, 12, 14); WHITE = (240, 240, 236) # The engines get a shot-LOCAL frame index, so anything derived from it drifts # out of phase at every cut. Each engine stores its shot's absolute start and # does all its timing on the real grid: quantised to 16ths, changing on bars. def grid(i0, k): """Absolute (beat, 16th index, bar, bar-phase) for a shot-local frame.""" t = (i0 + k)/FPS beat = t/BEAT return beat, int(beat*4), int(beat//4), (beat % 4)/4.0 def q16(beat): return math.floor(beat*4)/4.0 # quantise to 16ths def q8(beat): return math.floor(beat*2)/2.0 def stepv(n, phase): return int(phase) % n ACC = [(228, 40, 52), (24, 96, 220), (250, 206, 24)] # The op-art buffer. Round one painted 400x258 (14:9) and let post() blow it # up ~2.80x with LANCZOS — that blow-up ratio IS the look, so it is held fixed # here while both the aspect and the resolution change: # * 448x252 is exactly 16:9, so the vertical framing of every engine is # unchanged and the widening shows more field rather than a wider field. # * x S makes it 672x378 for a 1920x1080 delivery: 1920/672 = 2.857, the # same ratio as 1280/448, so the blow-up is as soft as it ever was. SWL, SHL = P(448), P(252) def Q(v): return v*SHL/258.0 # a round-one buffer-pixel -> this buffer def Qi(v): return int(round(Q(v))) _Y, _X = np.mgrid[0:SHL, 0:SWL].astype(np.float32) _NX = (_X - SWL/2)/(SHL/2); _NY = (_Y - SHL/2)/(SHL/2) _R = np.sqrt(_NX**2 + _NY**2); _TH = np.arctan2(_NY, _NX) def hard(v, a=BLACK, b=WHITE): m = (v > 0.5) out = np.zeros((SHL, SWL, 3), np.float32) out[m] = b; out[~m] = a return out class Interference: """Two (or three) sources. The story is whether they are in phase.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.n = int(rng.integers(2, 4)) self.src = [(float(rng.uniform(-1.1, 1.1)), float(rng.uniform(-.6, .6))) for _ in range(self.n)] self.k = float(rng.uniform(16, 40)) self.acc = int(rng.integers(0, 3)) self.inv = bool(rng.random() < .5) def frame(self, k, u, e): beat, s16, bar, bph = grid(self.i0, k) ph = beat*math.pi # continuous slide — no 16th quantise f = np.zeros((SHL, SWL), np.float32) for i, (sx, sy) in enumerate(self.src): d = np.sqrt((_NX-sx)**2 + (_NY-sy)**2) f += np.sin(d*self.k - ph + i*2.1) v = 0.5 + 0.5*np.tanh(f*1.4) inv = self.inv ^ (s16 % 8 >= 4) # polarity flips on the 8th a = hard(v if not inv else 1-v) edge = np.abs(v-0.5) < 0.035 a[edge] = ACC[(self.acc + bar) % 3] # accent changes on the bar return a class Moire: """Two grids that disagree.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.a1 = float(rng.uniform(0, math.pi)) self.a2 = self.a1 + float(rng.uniform(0.03, 0.34)) self.f1 = float(rng.uniform(30, 90)); self.f2 = self.f1*float(rng.uniform(0.94, 1.09)) self.acc = int(rng.integers(0, 3)) self.rings = bool(rng.random() < .35) def frame(self, k, u, e): beat, s16, bar, bph = grid(self.i0, k) sp = beat*0.55 # continuous slide — no 16th quantise if self.rings: g1 = np.sin(_R*self.f1 - sp) g2 = np.sin(np.sqrt((_NX-0.3)**2+(_NY)**2)*self.f2 + sp) else: g1 = np.sin((_NX*math.cos(self.a1)+_NY*math.sin(self.a1))*self.f1 + sp) g2 = np.sin((_NX*math.cos(self.a2)+_NY*math.sin(self.a2))*self.f2 - sp) v = 0.5+0.5*np.sign(g1)*np.sign(g2) a = hard(v) band = (np.abs(g1*g2) < 0.06) a[band] = ACC[(self.acc + bar) % 3] if s16 % 4 == 0: # hard invert on every downbeat 16th a = 255.0 - a return a class Radial: """A sweep. It is a lighthouse if you want it to be.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.n = int(rng.integers(6, 40)) self.dirn = 1 if rng.random() < .5 else -1 self.acc = int(rng.integers(0, 3)) self.beam = bool(rng.random() < .6) def frame(self, k, u, e): beat, s16, bar, bph = grid(self.i0, k) # continuous rotation — one notch per 16th of *smooth* travel, so the # wheel slides instead of ticking (the tick read as dropped frames) th = _TH + self.dirn*(beat*4.0)*(math.tau/self.n/4.0) v = 0.5+0.5*np.sign(np.sin(th*self.n)) a = hard(v) if self.beam: ang = (bph*math.tau) % (2*math.pi) # one sweep per bar, exactly d = (th - ang + math.pi) % (2*math.pi) - math.pi beam = np.exp(-(d/0.20)**2) a[beam > 0.5] = ACC[(self.acc + bar) % 3] # the hub pumps on the quarter, not on a smoothed envelope pump = max(0.0, 1.0 - (beat % 1.0)*2.2) rr = 0.14 + 0.09*pump a[_R < rr] = ACC[(self.acc + bar) % 3] a[(_R >= rr) & (_R < rr+0.03)] = WHITE return a class Bars: """Long long short, as a picture.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.acc = int(rng.integers(0, 3)) self.vert = bool(rng.random() < .5) self.n = int(rng.integers(3, 9)) def frame(self, k, u, e): a = np.zeros((SHL, SWL, 3), np.float32); a[:] = BLACK beat, s16, bar, bph = grid(self.i0, k) # long long short, in whole beats, so it lands on the grid pat = [1.5, 0.5, 1.5, 0.5, 0.5, 1.5] tot = sum(pat); pos = beat % tot acc = 0; on = False for i, p in enumerate(pat): if acc <= pos < acc+p: on = (i % 2 == 0); break acc += p # the gaps in the signal are part of it, but a frame that is entirely # black reads as a dropped frame — hold a dim version through the rest col = ACC[self.acc] if (int(beat) % 2) else WHITE if not on: col = tuple(int(c*0.30) for c in col) # continuous per-frame motion: the whole grid drifts sideways at a # steady rate, and the bar width breathes with the frame's energy. # The original bars were a static field toggling on beat boundaries — # 12+ identical frames in a row, the worst of the "dropped frame" feel. drift = (beat*0.22) % 1.0 m = 0.15 - 0.06*e["rms"] # margin shrinks as it gets loud span = SWL if self.vert else SHL for i in range(-1, self.n+1): f0 = (i + drift + m)/self.n; f1 = (i + drift + 1.0 - m)/self.n p0 = max(0, int(span*f0)); p1 = min(span, int(span*f1)) if p1 <= p0: continue if self.vert: a[:, p0:p1] = col else: a[p0:p1, :] = col return a class Beacon: """The tower itself, as flat shapes.""" def __init__(self, shot, rng): self.i0 = shot.i0 self.acc = int(rng.integers(0, 3)) # the answering light only exists while the answer exists — never in # "sending" (it hasn't come yet) or "silence"/"still" (it stopped) self.answer = (shot.section in ("reply", "exchange", "fast") and bool(rng.random() < .7)) def frame(self, k, u, e): im = Image.new("RGB", (SWL, SHL), BLACK); d = ImageDraw.Draw(im) # Fractions of the buffer place the tower; Q() carries round one's # absolute shape constants into this buffer at the same relative size. cx = SWL*0.30; base = SHL*0.96; top = SHL*0.22 wB, wT, lamp = Q(30), Q(13), Q(13) d.polygon([(cx-wB, base), (cx-wT, top), (cx+wT, top), (cx+wB, base)], fill=WHITE) for j in range(6): y0 = top + (base-top)*j/6.0; y1 = top + (base-top)*(j+0.5)/6.0 wl = wT + (wB-wT)*(j/6.0); wr = wT + (wB-wT)*((j+0.5)/6.0) if j % 2 == 0: d.polygon([(cx-wl, y0), (cx+wl, y0), (cx+wr, y1), (cx-wr, y1)], fill=ACC[self.acc]) d.rectangle([cx-Q(22), top-Q(26), cx+Q(22), top], fill=ACC[self.acc]) beat, s16, bar, bph = grid(self.i0, k) gl = 0.30 + 0.70*max(0.0, 1.0 - (beat % 1.0)*1.8) # flash on the beat r = Q(16+26*gl)*(1+0.35*e["kick"]) d.ellipse([cx-r, top-lamp-r, cx+r, top-lamp+r], fill=WHITE) ang = (bph*math.tau) % (2*math.pi) # one revolution per bar for sgn in (0, math.pi): aa = ang+sgn d.polygon([(cx, top-lamp), (cx+math.cos(aa-0.10)*SWL*1.6, top-lamp+math.sin(aa-0.10)*SWL*1.6), (cx+math.cos(aa+0.10)*SWL*1.6, top-lamp+math.sin(aa+0.10)*SWL*1.6)], fill=(60, 60, 58)) if self.answer: ax = SWL*0.86; ay = SHL*0.42 g2 = 0.3+0.7*max(0.0, 1.0 - ((beat+2.0) % 4.0)*0.9) rr = Q(5+11*g2) d.ellipse([ax-rr, ay-rr, ax+rr, ay+rr], fill=ACC[(self.acc+1) % 3]) d.rectangle([0, int(SHL*0.965), SWL, SHL], fill=ACC[self.acc]) return np.asarray(im, np.float32) ENGINES = {"interference": Interference, "moire": Moire, "radial": Radial, "bars": Bars, "beacon": Beacon} PLAN = { "sending": (["beacon", "radial", "bars"], [8, 4, 8]), "reply": (["interference", "beacon", "moire", "bars"], [4, 8, 4, 2]), "exchange": (["interference", "moire", "radial", "bars"], [2, 4, 2, 8]), "fast": (["moire", "interference", "bars", "radial"], [1, 2, 1, 4]), "silence": (["beacon", "radial"], [16, 12]), "still": (["beacon", "bars", "radial"], [8, 4, 12]), } CARDS = {"sending": "LIGHTHOUSE", "reply": None, "exchange": None, "fast": None, "silence": None, "still": None} SYSTEM_NAMES = ["...", "--", "-.-", ".-.", "..-", "---"] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "text", "card") def __init__(self, idx, i0, i1, engine, section, text=None, card=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.section = engine, section self.seed = 90210 + idx*7919 self.text, self.card = text, card def build_shots(): """Deterministic, but not a cycle: each section draws from its pool with no immediate repeats, and shot lengths come from a menu so the cut rhythm breathes instead of ticking.""" R = np.random.RandomState(5150) shots = []; idx = 0; last = None for nm, b0, b1 in SECTIONS: engs, 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*1.5: t2 = b1*BAR # no orphan sliver i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: pool = [x for x in engs if x != last] or list(engs) eng = pool[R.randint(len(pool))] last = eng txt = [SYSTEM_NAMES[(idx+q) % len(SYSTEM_NAMES)] for q in range(2)] shots.append(Shot(idx, i0, i1, eng, nm, txt, CARDS[nm] if j == 0 else None)) idx += 1; j += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 shots[-1].engine = "beacon" # close on the tower, still sending 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 = (size, name) if key not in _FC: p = _find_font(name) # Scaled ONCE, here. Call sites always pass authoring-frame sizes. _FC[key] = _load_font(p, max(1, P(size))) return _FC[key] def tracked(d, xy, s, f, fill, track): """Hard flat caps with letter-spacing — the piece has no other type idiom.""" x, y = xy for ch in s: d.text((x, y), ch, font=f, fill=fill) x += d.textlength(ch, font=f) + track return x _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.40*r**2.2, 0, 1)[..., None] return _VIG["v"] def post(arr, i, e, shot): a = arr.astype(np.float32) if isinstance(arr, np.ndarray) else np.asarray(arr, np.float32) im = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)).resize((W, H), Image.LANCZOS) a = np.asarray(im, np.float32) sh = P(1 + 7*e["kick"]) # chroma offset scales with S if sh > 1: a[..., 0] = np.roll(a[..., 0], sh, axis=1) a[..., 2] = np.roll(a[..., 2], -sh, axis=1) a *= vignette()*0.82 + 0.18 rng = np.random.RandomState(8300 + i) if S == 1.0: a += rng.normal(0, 2.2, a.shape) else: # Grain is a look, not a resolution: authored at 1280x720 and blown up # nearest-neighbour so a speck covers the same fraction of the frame. gn = rng.normal(0, 2.2, (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) if shot.card: # THE TITLE MOMENT. Flat, hard, tracked — the op-art idiom, not a # caption: name over an accent rule over the show's name. age = i - shot.i0 if age < FPS*3.0: al = min(1.0, age/6.0)*min(1.0, (FPS*3.0-age)/12.0) x0, y0 = P(44), P(46) fT, fS = font(34), font(15) d.text((x0, y0), shot.card, font=fT, fill=tuple(int(c*al) for c in WHITE)) wT = d.textlength(shot.card, font=fT) ry = y0 + P(46) d.rectangle([x0, ry, x0 + wT, ry + PW(3)], fill=tuple(int(c*al) for c in ACC[0])) tracked(d, (x0, ry + P(13)), SUBT, fS, tuple(int(c*al) for c in WHITE), PF(5.0)) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(0, 0, 0)); d.rectangle([0, H-bh, W, H], fill=(0, 0, 0)) return out def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) eng = ENGINES[shot.engine](shot, rng) made = 0 for k in range(shot.n): i = shot.i0 + k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} p = FRAMES / f"f{i:05d}.png" u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) # ALWAYS step the engine if p.exists() and not force: continue post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {shot.section:9s} {made}/{shot.n}" def contact_sheet(shots): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = P(320), P(180) # 16:9 thumbs sheet = Image.new("RGB", (cols*tw, rows*(th+P(24))), (10, 10, 14)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) eng = ENGINES[sh.engine](sh, rng) mid = sh.n//2 arr = None for k in range(mid+1): i = sh.i0+k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(k, k/max(1, sh.n-1), e) im = post(arr, sh.i0+mid, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+P(24)) sheet.paste(im, (cx, cy)) sd.text((cx+P(5), cy+th+P(4)), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:.1f}s", font=font(13), 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())) 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); 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}.mp4" subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"generator=renders/{SETDIR}/{NAME}/render.py", "-metadata", f"title={SUBT} — {TITLE}", str(out)], check=True, capture_output=True) try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"forked-from: renders/player_computer/{NAME}/render.py (round one)\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, op-art buffer {SWL}x{SHL}\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n,_,_ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel, stateful per shot)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()