#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Fifth Floor (18/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/fifth_floor # # Two movers, one couch, five flights, no elevator. # # 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/fifth_floor.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/fifth_floor.mp4 # cover: https://genekogan.com/player_computer/media/fifth_floor.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 fifth_floor.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 01 — "FIFTH FLOOR" (tightened cut) Boom-bap, 90bpm, G minor, swung. 24 bars. Rapped, subtitled. Intro(2) V1(4) Hook(4) V2(4) Bridge(2) V3(4) Outro(4) Final-curation recut of renders/spiral_jam/fifth_floor (44 bars, 1:59) down to ~66 s: hook2 cut entirely, intro and bridge halved, each verse trimmed from 8 lines to its 4 funniest. The arc is intact — truck, stairs, the argument at the fulcrum, the doorframe by half a sticker, the cat moves in. Two movers, one couch, five flights, no elevator. That is the entire plot and it is enough: the couch goes up, the argument happens at the fulcrum on landing three, the doorframe wins by half a sticker, the cat decides to move in. A moving day told at the speed it actually happens. Look: marker on cardboard. Tan corrugate ground, thick boiling marker lines (the jitter re-rolls at 8fps like hand-drawn animation), packing tape, box labels, hook cards lettered in Impact. Camera is a moving crop of a 1680x1080 stage. Returns to: night_shift (the rap + subtitle engine), night_bus (the stage-and-camera character work) — re-mediated into a drawn cartoon. Composition: engine : audio-first x shot-parallel x stage-camera content: audio-groove (boom-bap kit, rhodes, vocoder hooks) x mars-characters (blocky cast redrawn in marker) x tts-voices FINAL CUT (player_computer_final): * Native 1920x1080. The 1680x1080 marker-on-cardboard stage is authored geometry; S = 1.5 rasterises it at 2520x1620 through a ScaledDraw proxy, so every marker stroke, flute line and line-boil jitter keeps its weight against the frame and the camera crop lands on real pixels instead of an upscale. Fonts are scaled once in font(); grain is drawn on the 1280x720 grid and NEAREST-blown-up so the paper tooth stays the same size. * There was no renderer-debug strip on this piece and none was added; the box labels, the "5", "TOO SMALL", "3 1/2" and the subtitles are all lettering inside the drawing. * Title flash: the FIFTH FLOOR tape card gains "PLAYER COMPUTER" hand- lettered under it on the same strip of packing tape. """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "fifth_floor" TITLE = "FIFTH FLOOR" SETDIR = "player_computer_final" SETNUM = "01" # ── delivery scale ─────────────────────────────────────────────────────────── # AW/AH are the authored delivery frame; W/H are real pixels; S is the only # number the look scales by. SW_S/SH_S (the painted stage) stay in authored # stage units and rasterise through the same S. AW, AH = 1280, 720 W, H, FPS = 1920, 1080, 30 S = H / AH def P(v): return int(round(v*S)) def B(r): return r*S def _sxy(v, s): if isinstance(v, (list, tuple)): return [_sxy(u, s) for u in v] return v*s class ScaledDraw: """ImageDraw proxy: authored units in, pixels out. Only the first positional arg (xy) and `width` are touched — arc/chord/pieslice take angles positionally and those must pass through untouched.""" __slots__ = ("_d", "_s") _GEOM = frozenset(("line", "rectangle", "rounded_rectangle", "ellipse", "polygon", "arc", "chord", "pieslice", "point", "text")) def __init__(self, d, s): self._d, self._s = d, s def __getattr__(self, name): f = getattr(self._d, name) if name not in self._GEOM: return f s = self._s def wrapped(xy, *a, **kw): w = kw.get("width") if w is not None: kw["width"] = max(2, int(round(w*s))) return f(_sxy(xy, s), *a, **kw) return wrapped def mkdraw(im): d = ImageDraw.Draw(im) return d if S == 1.0 else ScaledDraw(d, S) BPM = 90.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 = [ ("intro", 0, 2), ("verse1", 2, 6), ("hook1", 6, 10), ("verse2", 10, 14), ("bridge", 14, 16), ("verse3", 16, 20), ("outro", 20, 24), ] N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 2.0 N_FRAMES = int(DUR * FPS) MUSIC_DESC = f"boom-bap, {BPM:.0f}bpm, G minor, {N_BARS} bars, rapped + vocoder hooks" ENGINE_DESC = "truck / stairs / pivot / door / landing / room / hookcard (marker on cardboard)" 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) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ SW = 0.16 GM = {n: nf(n) for n in ("G2","A2","Bb2","C3","D3","Eb3","F3","G3","A3","Bb3", "C4","D4","Eb4","F4","G4")} # Trimmed from the 8-line spiral_jam verses: couplets kept whole so the # rhymes land. Cut: elevator sign (the hook already says it), FOUR MORE, # the map couplet, the neighbor-with-a-piano couplet, tip-it-like-a-theorem, # window-wall couplet, order-something-greasy. VERSE1 = [ "Nine a.m., the truck backs in, I crack my knuckles slow,", "One box says FRAGILE, forty-nine say WHO KNOWS.", "Grip it low and lift with legs, the way the video said,", "The video was lifting foam, I'm lifting lead instead.", ] VERSE2 = [ "He says clockwise, I say counter, we agree,", "To disagree at fulcrum point on landing number three.", "The cat is in the carrier, the carrier's on the truck,", "The cat has seen the staircase and she wishes us good luck.", ] VERSE3 = [ "Final flight, the couch is light, or maybe I've gone numb,", "The doorframe is a centimeter thinner than my thumb.", "It clears the frame by half a sticker: HANDLE ME WITH PLEASE.", "The cat walks out the carrier, decides to move on in.", ] HOOK = [ ("Fifth floor, no elevator", [("D4",1),("D4",.7),("C4",1),("Bb3",1),("C4",1.6)]), ("Heavy now or heavy later", [("Bb3",1),("Bb3",.7),("A3",1),("G3",1),("A3",1.6)]), ("Turn it, tip it, pivot, pray",[("D4",1),("Eb4",1),("D4",1),("C4",1.6)]), ("We live up here now anyway", [("Bb3",1),("C4",1),("D4",1),("G3",2.0)]), ] BRIDGE_SP = [ "Landing three and a half. We rest.", "The couch rests too. It has seen things today.", ] ADLIB = ["pivot!", "four more!", "fragile!", "hup!"] def tape_rip(dur=.5, seed=41): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) out = np.zeros(n); blk = 1024 for i in range(0, n, blk): u = i/max(1, n) fc = 2600 - 2100*u # the rip falls in pitch seg = rng.randn(min(blk, n-i)+256) out[i:i+min(blk, n-i)] = bandshape(seg, lo=fc*.6, hi=fc*1.8)[:min(blk, n-i)] return out*np.exp(-t*2.2)*(np.clip(t*90, 0, 1))*.8 def thump(dur=.3, f0=95, seed=43): n = int(dur*SR); t = np.arange(n)/SR f = 42 + (f0-42)*np.exp(-t*30) return np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*13)*.9 def creak(dur=.6, seed=47): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) fc = 640 + 260*np.sin(2*np.pi*2.4*t) nz = bandshape(rng.randn(n), lo=420, hi=1200) return nz*(0.5+0.5*np.sin(2*np.pi*11*t))*np.exp(-t*3.2)*.6 def doorbell(seed=53): a = fm(nf("E5"), 1.0, ratio=2.0, index=2.5, idec=4.0, d=.7, r=.3, seed=seed) b = fm(nf("C5"), 1.2, ratio=2.0, index=2.5, idec=4.0, d=.9, r=.3, seed=seed+1) out = np.zeros(int(1.7*SR)); out[:len(a)] += a out[int(.35*SR):int(.35*SR)+len(b)] += b*.9 return out*.6 def build_song(): s = Song(DUR) R = np.random.RandomState(9001) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "outro" # chords, one per bar: Gm7 Cm9 EbM7 F7 — all inside G natural minor PROG = [("G2", (0, 3, 7, 10)), ("C3", (0, 3, 7, 10, 14)), ("Eb3", (0, 4, 7, 11)), ("F3", (0, 4, 7, 10))] for bar in range(N_BARS): sec = sec_of(bar) hooky = sec.startswith("hook") bridge = sec == "bridge" quiet = sec in ("intro", "outro") rootn, ivs = PROG[bar % 4] rootf = nf(rootn) # ---- drums: swung boom-bap ---------------------------------------- if not bridge or bar % 2 == 0: for st in ((0, 10) if not quiet else (0,)): at = s.t(bar, st, SW) s.put("drums", kick(dur=.30, f0=140, f1=52, punch=26, click=.35), at, g=.88 if not quiet else .6) s.kick_t.append(at) if not bridge: for st in (4, 12): s.put("drums", snare(dur=.26, tone=188, bright=1.1), s.t(bar, st, SW), g=.74 if not quiet else .4, pan=-.05) else: for st in (4, 12): s.put("drums", rim(), s.t(bar, st, SW), g=.4, pan=.2) for st in range(0, 16, 2): if quiet and st % 4: continue s.put("drums", hat(dur=.05, openh=(st == 14 and bar % 2 == 1)), s.t(bar, st, SW), g=.16+.05*R.rand(), pan=-.25+.5*R.rand()) for st in (3, 7, 11, 15): s.put("drums", shaker(), s.t(bar, st, SW), g=.13, pan=.3) # ---- bass --------------------------------------------------------- for st, ln in ((0, 1.6), (10, 0.9)): s.put("bass", voice(rootf, BEAT*ln, kind="saw", nh=10, c0=300, c1=120, ck=4.0, a=.006, d=.25, s=.8, r=.12, seed=bar*3+st), s.t(bar, st, SW), g=.44) s.put("bass", voice(rootf/2, BEAT*ln, kind="sine", nh=2, c0=160, c1=90, ck=2.0, a=.008, d=.3, s=.85, r=.14, seed=bar*5+st), s.t(bar, st, SW), g=.30) if bar % 4 == 3: # walkup into the next root s.put("bass", voice(nf("F3")/2, BEAT*.45, kind="saw", nh=8, c0=280, c1=120, ck=5, a=.005, d=.15, s=.7, r=.1, seed=bar), s.t(bar, 14, SW), g=.26) # ---- rhodes chords (dusty) ---------------------------------------- if not bridge: for k2, iv in enumerate(ivs): s.put("keys", fm(rootf*2*2**(iv/12.0), BEAT*1.9, ratio=1.0, index=3.0, idec=3.2, a=.01, d=.9, s=.25, r=.5, seed=bar*7+k2), s.t(bar, 2 if bar % 2 == 0 else 6, SW), g=.115 if not quiet else .085, pan=-.4+.22*k2) # ---- bridge: sparse piano line, pentatonic ------------------------ if bridge: PENT = ["G3", "Bb3", "C4", "D4", "F4"] for st in (0, 5, 8, 13): nt = PENT[R.randint(len(PENT))] s.put("keys", fm(nf(nt)*2, BEAT*1.7, ratio=1.0, index=2.2, idec=3.5, a=.008, d=1.0, s=.2, r=.6, seed=bar*31+st), s.t(bar, st, SW), g=.15, pan=.1) # ---- horns on the hooks ------------------------------------------- if hooky and bar % 2 == 0: for k2, iv in enumerate((0, 3, 7)): s.put("horn", voice(nf("G3")*2**(iv/12.0), BEAT*.6, kind="saw", nh=18, c0=2100, c1=800, ck=6, res=.4, a=.02, d=.2, s=.5, r=.15, seed=bar*11+k2), s.t(bar, 12, SW), g=.12, pan=-.2+.2*k2) # ---- SFX as instruments ----------------------------------------------- for bar in (0, 1): # intro: the tape gun is the lead s.put("fx", tape_rip(.5, seed=61+bar), s.t(bar, 2, SW), g=.5, pan=-.2) s.put("fx", thump(seed=67+bar), s.t(bar, 8, SW), g=.5, pan=.15) for bar in (4, 12, 18): s.put("fx", creak(seed=71+bar), s.t(bar, 6, SW), g=.4, pan=.25) s.put("fx", doorbell(), 20*BAR + BEAT*0.1, g=.5, pan=0) s.put("fx", thump(.5, 80, seed=83), 23*BAR + BEAT*2, g=.6) # couch lands s.put("fx", vinyl(int(DUR*SR)), 0.0, g=.5) # ---- vocals ------------------------------------------------------------ SUBS = [] SECD0 = {n: a for n, a, b in SECTIONS} def rap(sec_name, lines, voices, rate=196, gain=.54): b0 = SECD0[sec_name] for i, line in enumerate(lines): v = voices[i % len(voices)] at = (b0 + i)*BAR + BEAT*0.40 sig = speak(line, voice=v, rate=rate, cache=AUD) if len(sig) > 2.60*SR: # keep the line inside the bar sig = fit(sig, int(2.60*SR)) s.put("vox_sp", sig, at, g=gain, pan=-.05 if v == "Alex" else .05) SUBS.append((at, at + len(sig)/SR + 0.25, line)) if i % 2 == 1 and sec_name != "verse3": al = ADLIB[(b0//8 + i//2) % len(ADLIB)] asig = speak(al, voice="Fred", rate=215, cache=AUD) s.put("vox_sp", asig, at + BEAT*2.6, g=.20, pan=.35) rap("verse1", VERSE1, ["Alex"]) rap("verse2", VERSE2, ["Moira"], rate=190) rap("verse3", VERSE3, ["Alex", "Moira"], rate=198) for hb in (6,): # one hook only in this cut for i, (text, mel) in enumerate(HOOK): at = (hb + i)*BAR + BEAT*0.2 dur = BAR*0.94 mel_f = [(nf(nm), w) for nm, w in mel] lead = sing(text, mel_f, dur, voice="Moira", rate=175, cache=AUD, detune=(0.0, -0.6, 0.7), vib=(.014, 5.2)) s.put("vox", lead, at, g=.55, pan=0.0) low = sing(text, [(f/2, w) for f, w in mel_f], dur, voice="Moira", rate=175, cache=AUD, detune=(0.0, -1.1), vib=(.008, 4.2)) s.put("vox", low, at, g=.16, pan=0.0) SUBS.append((at, at + dur, text.upper())) b0 = SECD0["bridge"] for i, line in enumerate(BRIDGE_SP): at = (b0 + i)*BAR + BEAT*0.2 # 2-bar bridge: one line per bar sig = speak(line, voice="Alex", rate=172, cache=AUD) s.put("vox_sp", sig, at, g=.48, pan=-.05) SUBS.append((at, at + len(sig)/SR + 0.3, line)) # ---- one-shots + busses ------------------------------------------------ for b in (6, 16): s.put("fx", crash(dur=1.1), b*BAR, g=.20, pan=.1) s.bus("keys", lambda x: reverb(delay(x, BEAT*.75, .28, .16), rt=1.6, mix=.28, seed=101)) s.bus("horn", lambda x: reverb(x, rt=2.0, mix=.32, seed=103)) s.bus("vox", lambda x: reverb(delay(x, BEAT*.75, .24, .15), rt=1.6, mix=.26, seed=107)) s.bus("vox_sp", lambda x: reverb(x, rt=0.9, mix=.14, seed=109)) s.bus("fx", lambda x: reverb(x, rt=1.1, mix=.18, seed=113)) mix = s.mixdown(dict(drums=1.0, bass=1.0, keys=1.0, horn=1.0, vox=1.0, vox_sp=1.0, fx=1.0), pump_depth=.18, pump_rel=.14, levels=dict(intro=.48, verse1=.80, hook1=1.0, verse2=.84, bridge=.50, verse3=.90, outro=.55)) np.savez(AUD/"subs.npz", t0=np.array([a for a, b, c in SUBS]), t1=np.array([b for a, b, c in SUBS]), tx=np.array([c for a, b, c in SUBS], dtype=object)) 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 < 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 def ramp(stops, n=256): stops = np.array(stops, np.float32) xs = np.linspace(0, 1, len(stops)); g = np.linspace(0, 1, n) return np.stack([np.interp(g, xs, stops[:, c]) for c in range(3)], 1) def apply_ramp(v01, lut): i = np.clip(v01*(len(lut)-1), 0, len(lut)-1).astype(np.int32) return lut[i] def value_noise(h, w, scale, seed): rng = np.random.RandomState(seed) gh, gw = int(h/scale)+2, int(w/scale)+2 g = rng.rand(gh, gw) ys = np.linspace(0, gh-1-1e-3, h); xs = np.linspace(0, gw-1-1e-3, w) y0 = ys.astype(int); x0 = xs.astype(int) fy = (ys-y0)[:, None]; fx = (fx0 := (xs-x0))[None, :] sy = fy*fy*(3-2*fy); sx = fx*fx*(3-2*fx) g00 = g[np.ix_(y0, x0)]; g01 = g[np.ix_(y0, x0+1)] g10 = g[np.ix_(y0+1, x0)]; g11 = g[np.ix_(y0+1, x0+1)] return (g00*(1-sx)+g01*sx)*(1-sy) + (g10*(1-sx)+g11*sx)*sy def fbm(h, w, scale, seed, oct=4): out = np.zeros((h, w)); amp = 1.0; nrm = 0.0 for o in range(oct): out += amp*value_noise(h, w, max(2, scale/(2**o)), seed+o) nrm += amp; amp *= .5 return out/nrm # ════════════════════════════════════════════════════════════════════════════ # STAGE + CAMERA — the frame is a 14:9 crop of a much larger stage # # Round 1 of this set was shot entirely in locked-off full-frame, which is a # large part of why ten pieces read as one piece. Here every scene is painted # once onto a 1680x1080 stage and the delivered frame is a moving crop of it, # so the same drawing yields a wide, a mid, a close-up and a dolly. # # A shot string is either a single shot ("cu_driver") or a move between two # ("wide>cu_driver"), interpolated with an ease across the shot's duration. # ════════════════════════════════════════════════════════════════════════════ SW_S, SH_S = 1680, 1080 ASPECT = W / H SHOT_W = {"wide": 1.00, "full": 0.82, "mid": 0.60, "ots": 0.48, "cu": 0.32, "ins": 0.22, "macro": 0.14} def _box(anchors, shot): kind, _, target = shot.partition("_") fw = SHOT_W.get(kind, 1.0) cx, cy = anchors.get(target or "_", anchors.get("_", (SW_S/2, SH_S/2))) if kind == "wide": cx, cy = SW_S/2, SH_S/2 elif kind == "full": cx = (cx + SW_S/2)/2; cy = (cy + SH_S/2)/2 elif kind == "ots": cx = cx*.62 + SW_S/2*.38 + (150 if cx < SW_S/2 else -150) cy = cy*.58 + SH_S/2*.42 bw = SW_S*fw; bh = bw/ASPECT if bh > SH_S: bh = SH_S; bw = bh*ASPECT return cx, cy, bw, bh def _ease(u): return u*u*(3-2*u) def cam_box(anchors, shot, f01, jseed=0, push=0.04, drift=1.0): """Return the (x0,y0,x1,y1) crop of the stage for this frame.""" if ">" in shot: a, b = shot.split(">", 1) ca = _box(anchors, a.strip()); cb = _box(anchors, b.strip()) e = _ease(min(max(f01, 0.0), 1.0)) cx, cy, bw, bh = (ca[i] + (cb[i]-ca[i])*e for i in range(4)) else: cx, cy, bw, bh = _box(anchors, shot) k = 1.0 - push*f01 # slow push-in bw *= k; bh *= k # deterministic handheld drift — slow enough to breathe, not shake fw = bw/SW_S jx = math.sin(f01*1.525 + jseed)*7*(1-fw*.6)*drift jy = math.cos(f01*1.175 + jseed*1.7)*5*(1-fw*.6)*drift x0 = cx - bw/2 + jx; y0 = cy - bh/2 + jy x0 = max(0, min(SW_S-bw, x0)); y0 = max(0, min(SH_S-bh, y0)) return (int(x0), int(y0), int(x0+bw), int(y0+bh)) def shoot(stage_img, anchors, shot, f01, jseed=0, push=0.04, drift=1.0): """Crop the stage to the shot and scale to delivery size. cam_box works in stage units; the raster is S times bigger, so the box is scaled with it — the framing is identical, the crop just carries more pixels.""" box = cam_box(anchors, shot, f01, jseed, push, drift) return stage_img.crop(tuple(P(v) for v in box)).resize((W, H), Image.LANCZOS) def new_stage(bg): im = Image.new("RGB", (P(SW_S), P(SH_S)), bg) return im, mkdraw(im) # ════════════════════════════════════════════════════════════════════════════ # MARKER ON CARDBOARD # ════════════════════════════════════════════════════════════════════════════ NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} INK = (52, 38, 30) INK2 = (84, 62, 46) TAN = (198, 168, 126) TAN2 = (182, 150, 108) TAPE = (222, 206, 162) LABEL = (238, 232, 216) RED = (176, 62, 48) BLUE = (70, 92, 146) GREEN = (86, 116, 74) COUCHC = (146, 84, 58) _CARD = {} def cardboard(): """The stage ground: corrugate with flute lines and mottle. Built once.""" if "img" not in _CARD: v = fbm(SH_S//4, SW_S//4, 60, seed=77, oct=3) arr = np.zeros((SH_S//4, SW_S//4, 3), np.float32) for c in range(3): arr[..., c] = TAN[c]*(0.92 + 0.14*v) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)).resize( (P(SW_S), P(SH_S)), Image.BILINEAR) d = mkdraw(im) for x in range(0, SW_S, 26): # flute lines d.line([x, 0, x, SH_S], fill=tuple(int(v*0.96) for v in TAN), width=2) _CARD["img"] = im return _CARD["img"].copy() def _jit(seed, t, n, amp=2.4): """Line boil: jitter re-rolled at 8fps, deterministic.""" R = np.random.RandomState((seed*7919 + int(t*8)) % (2**31 - 1)) return R.uniform(-amp, amp, (n, 2)) def mline(d, pts, col=INK, w=7, jseed=0, t=0.0, amp=2.4): pts = list(pts) J = _jit(jseed, t, len(pts), amp) pj = [(x+J[i, 0], y+J[i, 1]) for i, (x, y) in enumerate(pts)] d.line(pj, fill=col, width=w, joint="curve") def mrect(d, box, col=INK, w=7, jseed=0, t=0.0, fill=None): x0, y0, x1, y1 = box if fill: d.rectangle(box, fill=fill) mline(d, [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], col, w, jseed, t) def mellipse(d, box, col=INK, w=7, jseed=0, t=0.0, fill=None, n=14): x0, y0, x1, y1 = box cx, cy = (x0+x1)/2, (y0+y1)/2; rx, ry = (x1-x0)/2, (y1-y0)/2 pts = [(cx+rx*math.cos(a), cy+ry*math.sin(a)) for a in np.linspace(0, math.tau, n)] if fill: d.polygon(pts, fill=fill) mline(d, pts, col, w, jseed, t, amp=1.8) def rot2(pts, cx, cy, ang): c, s2 = math.cos(ang), math.sin(ang) return [(cx + (x-cx)*c - (y-cy)*s2, cy + (x-cx)*s2 + (y-cy)*c) for x, y in pts] def cardbox(d, x, y, s, label, jseed, t, tilt=0.0): pts = rot2([(x, y), (x+s, y), (x+s, y+s*.8), (x, y+s*.8)], x+s/2, y+s*.4, tilt) d.polygon(pts, fill=TAN2) mline(d, pts + [pts[0]], INK, 6, jseed, t) mid = [((pts[0][0]+pts[3][0])/2, (pts[0][1]+pts[3][1])/2), ((pts[1][0]+pts[2][0])/2, (pts[1][1]+pts[2][1])/2)] d.line([mid[0], mid[1]], fill=TAPE, width=int(s*.13)) if label: f = font(int(s*.2), "Helvetica.ttc") lw = d.textlength(label, font=f) lx, ly = x+s*.5-lw/2, y+s*.44 d.rectangle([lx-6, ly-4, lx+lw+6, ly+s*.22], fill=LABEL) d.text((lx, ly), label, font=f, fill=INK) def mover(d, x, y, sc, t, col, walk_u=0.0, carry=None, lean=0.0, strain=0.0, jseed=0, flip=1): """A chunky marker figure. `carry`: None | 'box' | 'couch_front' | 'couch_back' | 'sit' | 'soda'. y = feet line.""" lw = max(4, int(9*sc)) hip = (x, y - 46*sc) sh = (x + flip*lean*30*sc, y - 92*sc) if carry == "sit": hip = (x, y - 30*sc); sh = (x, y - 74*sc) mline(d, [(x, hip[1]), (x + flip*34*sc, hip[1]), (x + flip*34*sc, y)], col, lw, jseed+1, t) else: sw_, bob, aw_ = walk(walk_u, 1.0) sh = (sh[0], sh[1] + bob*4*sc) mline(d, [hip, (x + flip*sw_*22*sc, y)], col, lw, jseed+1, t) mline(d, [hip, (x - flip*sw_*22*sc, y - abs(sw_)*6*sc)], col, lw, jseed+2, t) mline(d, [hip, sh], col, int(lw*1.25), jseed+3, t) hx, hy = sh[0] + flip*lean*14*sc, sh[1] - 26*sc mellipse(d, [hx-16*sc, hy-16*sc, hx+16*sc, hy+16*sc], col, max(4, int(7*sc)), jseed+4, t, fill=TAN) ex = hx + flip*5*sc d.ellipse([ex-2.5*sc, hy-4*sc, ex+2.5*sc, hy+1*sc], fill=INK) d.ellipse([ex+7*sc-2.5*sc, hy-4*sc, ex+7*sc+2.5*sc, hy+1*sc], fill=INK) if strain > 0.3: mellipse(d, [hx+2*sc, hy+6*sc, hx+11*sc, hy+13*sc], INK, 3, jseed+5, t) d.ellipse([hx-24*sc, hy-20*sc, hx-19*sc, hy-15*sc], fill=BLUE) # sweat else: mline(d, [(hx+1*sc, hy+9*sc), (hx+10*sc, hy+9*sc)], INK, 3, jseed+5, t) if carry == "box": bx = x + flip*26*sc cardbox(d, bx-20*sc, sh[1]-14*sc, 46*sc, "", jseed+6, t) mline(d, [sh, (bx-14*sc, sh[1]+2*sc)], col, int(lw*.8), jseed+7, t) mline(d, [sh, (bx+20*sc, sh[1]+6*sc)], col, int(lw*.8), jseed+8, t) elif carry in ("couch_front", "couch_back"): ha = (x + flip*30*sc, sh[1] - (18*sc if carry == "couch_front" else -6*sc)) mline(d, [sh, ha], col, int(lw*.8), jseed+7, t) mline(d, [sh, (ha[0], ha[1]+12*sc)], col, int(lw*.8), jseed+8, t) elif carry == "soda": ha = (x + flip*26*sc, sh[1] - 6*sc) mline(d, [sh, ha], col, int(lw*.8), jseed+7, t) mrect(d, [ha[0]-6*sc, ha[1]-16*sc, ha[0]+6*sc, ha[1]], RED, 3, jseed+9, t) else: mline(d, [sh, (sh[0]+flip*20*sc, sh[1]+30*sc)], col, int(lw*.8), jseed+7, t) mline(d, [sh, (sh[0]-flip*16*sc, sh[1]+32*sc)], col, int(lw*.8), jseed+8, t) def couch(d, cx, cy, sc, ang, jseed, t, jolt=0.0): """The couch, rotated. cy = its centre.""" a = ang + jolt*0.06*math.sin(t*40) L, Ht = 130*sc, 52*sc body = rot2([(cx-L, cy-Ht*.2), (cx+L, cy-Ht*.2), (cx+L, cy+Ht), (cx-L, cy+Ht)], cx, cy, a) back = rot2([(cx-L, cy-Ht), (cx+L, cy-Ht), (cx+L, cy-Ht*.2), (cx-L, cy-Ht*.2)], cx, cy, a) d.polygon(back, fill=tuple(int(v*.82) for v in COUCHC)) d.polygon(body, fill=COUCHC) mline(d, back + [back[0]], INK, int(6*sc+2), jseed, t) mline(d, body + [body[0]], INK, int(6*sc+2), jseed+1, t) for u3 in (-0.33, 0.33): # cushion seams p = rot2([(cx+L*u3, cy-Ht*.2), (cx+L*u3, cy+Ht)], cx, cy, a) mline(d, p, INK, int(4*sc+1), jseed+2, t) for sgn in (-1, 1): # arms arm = rot2([(cx+sgn*L, cy-Ht*.55), (cx+sgn*(L+16*sc), cy-Ht*.55), (cx+sgn*(L+16*sc), cy+Ht)], cx, cy, a) mline(d, arm, INK, int(6*sc+2), jseed+3+sgn, t) def cat(d, x, y, sc, t, jseed): mellipse(d, [x-22*sc, y-14*sc, x+22*sc, y], INK, 4, jseed, t, fill=(90, 78, 70)) mellipse(d, [x+14*sc, y-26*sc, x+34*sc, y-8*sc], INK, 4, jseed+1, t, fill=(90, 78, 70)) for ex in (x+20*sc, x+28*sc): d.ellipse([ex-1.5*sc, y-20*sc, ex+1.5*sc, y-17*sc], fill=(226, 210, 120)) mline(d, [(x+18*sc, y-26*sc), (x+21*sc, y-32*sc), (x+24*sc, y-26*sc)], INK, 3, jseed+2, t) mline(d, [(x+26*sc, y-26*sc), (x+29*sc, y-32*sc), (x+32*sc, y-26*sc)], INK, 3, jseed+3, t) tw = math.sin(t*3+jseed)*8*sc mline(d, [(x-22*sc, y-10*sc), (x-34*sc, y-22*sc+tw)], INK, 4, jseed+4, t) MOV_A = (54, 74, 120) # mover A wears blue MOV_B = (140, 60, 70) # mover B wears red class Truck: """Curbside. Boxes leave the truck one at a time.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0; self.sec = shot.section self.cam = str(rng.choice(["wide", "wide>mid_door", "mid_truck>mid_door"])) self.labels = ["MISC", "MISC", "FRAGILE", "BOOKS?", "CABLES", "MISC", "PANS", "THE GOOD ONE"] def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) gy = SH_S*0.78 mline(d, [(0, gy), (SW_S, gy)], INK, 9, 1, t) # curb # building face, right side, with door mrect(d, [SW_S*0.66, SH_S*0.10, SW_S*0.98, gy], INK, 8, 2, t, fill=TAN2) for fy in range(5): wy = SH_S*0.14 + fy*SH_S*0.115 for wx in range(2): mrect(d, [SW_S*(0.71+wx*0.13), wy, SW_S*(0.79+wx*0.13), wy+SH_S*0.07], BLUE, 5, 3+fy*2+wx, t, fill=(214, 222, 232)) mrect(d, [SW_S*0.80, gy-SH_S*0.16, SW_S*0.88, gy], INK, 8, 20, t, fill=(120, 92, 66)) f2 = font(30, "Helvetica.ttc") d.text((SW_S*0.805, gy-SH_S*0.15), "5", font=f2, fill=LABEL) # the truck, left mrect(d, [SW_S*0.04, gy-SH_S*0.30, SW_S*0.40, gy], INK, 9, 5, t, fill=(168, 150, 120)) mrect(d, [SW_S*0.01, gy-SH_S*0.17, SW_S*0.08, gy], INK, 7, 6, t, fill=(150, 132, 104)) for wx in (SW_S*0.10, SW_S*0.33): mellipse(d, [wx-34, gy-16, wx+34, gy+52], INK, 7, 7, t, fill=(60, 54, 48)) mline(d, [(SW_S*0.40, gy-SH_S*0.02), (SW_S*0.50, gy)], INK, 7, 8, t) # ramp d.text((SW_S*0.10, gy-SH_S*0.27), "MOVERS", font=font(34, "Impact.ttf"), fill=LABEL) # box stack on the curb nb = 3 + int(u*4) for b in range(nb): bx = SW_S*0.46 + (b % 3)*150 + (b//3)*40 by = gy - 110 - (b//3)*115 cardbox(d, bx, by, 130, self.labels[b % len(self.labels)], 30+b, t, tilt=0.03*((b*7) % 3 - 1)) # mover A carries a box truck->door; B works the ramp wu = (t*0.9) % 2.0 ax = lerp(SW_S*0.46, SW_S*0.80, (wu if wu < 1 else 2-wu)) mover(d, ax, gy, 1.6, t, MOV_A, walk_u=t*2.1, carry="box", lean=0.2, jseed=50, flip=1 if wu < 1 else -1) mover(d, SW_S*0.44, gy, 1.55, t, MOV_B, walk_u=t*1.7, carry="box", lean=-0.1, jseed=60, flip=-1) anchors = {"_": (SW_S*0.5, SH_S*0.55), "door": (SW_S*0.84, gy-SH_S*0.1), "truck": (SW_S*0.22, gy-SH_S*0.15)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Stairs: """The cutaway stairwell. The couch goes up one flight per shot.""" FLOOR = {"verse1": 0, "hook1": 1, "verse2": 2, "bridge": 2, "verse3": 3, "outro": 3, "intro": 0} def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0; self.sec = shot.section self.fl = self.FLOOR.get(shot.section, 1) + int(rng.integers(0, 2)) self.dirn = 1 if (self.fl % 2 == 0) else -1 self.cam = str(rng.choice(["full_couch", "mid_couch", "wide>full_couch", "full_couch>mid_couch"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) # one big flight filling the stage, drawn as chunky filled treads ns = 9 x0, y0 = SW_S*0.06, SH_S*0.94 # bottom of the flight x1, y1 = SW_S*0.94, SH_S*0.34 # top run = (x1-x0)/ns; rise = (y0-y1)/ns wall = tuple(int(v*0.90) for v in TAN) d.rectangle([0, 0, SW_S, SH_S], fill=wall) # wall detail: baseboard along the slope + graffiti + a small window mrect(d, [SW_S*0.62, SH_S*0.10, SW_S*0.78, SH_S*0.26], BLUE, 6, 99, t, fill=(210, 220, 230)) d.text((SW_S*0.14, SH_S*0.30), "FOUR MORE", font=font(34, "Helvetica.ttc"), fill=INK2) f2 = font(44, "Impact.ttf") d.text((SW_S*0.05, SH_S*0.12), str(self.fl+2), font=f2, fill=INK2) for si in range(ns+1): # treads, front faces filled sx = x0 + si*run; sy = y0 - si*rise d.polygon([(sx, sy), (sx+run, sy), (sx+run, sy-rise), (sx, sy)], fill=TAN2) d.polygon([(sx, sy), (sx+run, sy), (sx+run, sy+SH_S), (sx, sy+SH_S)], fill=TAN2) mline(d, [(sx, sy), (sx+run, sy), (sx+run, sy-rise)], INK, 9, 100+si, t) mline(d, [(x0, y0), (x1, y1)], INK2, 5, 130, t) # stringer # railing for si in range(0, ns+1, 2): sx = x0 + si*run; sy = y0 - si*rise mline(d, [(sx+run*0.5, sy), (sx+run*0.5, sy-170)], INK, 6, 140+si, t) mline(d, [(x0+run*0.5, y0-170), (x1-run*0.5, y1+rise-170)], INK, 8, 150, t) # the couch going up the slope cu = 0.12 + 0.72*ease_io(u) cx = lerp(x0+run*1.5, x1-run*1.5, cu) cy = lerp(y0-rise*1.5, y1+rise*1.5, cu) - 92 ang = -math.atan2(rise, run) bob = math.sin(t*BPM/60*math.tau)*4 # bob on the beat couch(d, cx, cy+bob, 1.1, ang, 200, t, jolt=e["kick"]) # movers: below-behind and above-ahead, feet on the slope def slope_y(px): return y0 - (px-x0)/(x1-x0)*(y0-y1) ax = cx - 210 mover(d, ax, slope_y(ax)+6, 1.35, t, MOV_A, walk_u=t*2.4, carry="couch_back", lean=0.35, strain=e["rms"], jseed=210, flip=1) bx = cx + 210 mover(d, bx, slope_y(bx)+6, 1.35, t, MOV_B, walk_u=t*2.4+1.6, carry="couch_front", lean=0.30, strain=e["rms"]*0.7, jseed=220, flip=1) anchors = {"_": (SW_S/2, SH_S/2), "couch": (cx, cy)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Pivot: """The argument at the fulcrum. The couch goes vertical.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_couch", "mid_couch>full_couch"])) self.ccw = bool(rng.random() < 0.5) def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) gy = SH_S*0.86 # the landing: floor line, corner, baseboard, a crooked picture frame mline(d, [(0, gy), (SW_S, gy)], INK, 9, 300, t) mline(d, [(SW_S*0.5, 0), (SW_S*0.5, gy)], INK2, 5, 301, t) mline(d, [(0, gy-26), (SW_S, gy-26)], INK2, 4, 302, t) mrect(d, [SW_S*0.12, SH_S*0.16, SW_S*0.24, SH_S*0.32], INK, 6, 303, t, fill=(206, 196, 168)) mline(d, [(SW_S*0.66, SH_S*0.20), (SW_S*0.90, SH_S*0.20)], INK2, 4, 304, t) cx, cy = SW_S*0.5, gy-190 base = -0.55 if self.ccw else 0.55 ang = base + (1.1 if self.ccw else -1.1)*ease_io(u) \ + 0.05*math.sin(t*2.2) couch(d, cx, cy, 1.30, ang, 310, t, jolt=e["kick"]) # one big rotation arrow arcing over the couch sw = 1 if self.ccw else -1 a0 = -math.pi*0.85 if self.ccw else -math.pi*0.15 pts = [(cx + math.cos(a0 + sw*q*0.13)*300, cy - 40 + math.sin(a0 + sw*q*0.13)*230) for q in range(9)] mline(d, pts, RED, 9, 320, t) tip = pts[-1]; prv = pts[-2] vx, vy = tip[0]-prv[0], tip[1]-prv[1] nl = math.hypot(vx, vy)+1e-6; vx, vy = vx/nl, vy/nl mline(d, [(tip[0]-vx*40-vy*22, tip[1]-vy*40+vx*22), tip, (tip[0]-vx*40+vy*22, tip[1]-vy*40-vx*22)], RED, 9, 321, t) # the movers hold the two ends mover(d, cx-235, gy, 1.45, t, MOV_A, walk_u=0.3, carry="couch_back", lean=0.4, strain=1.0, jseed=330, flip=1) mover(d, cx+235, gy, 1.45, t, MOV_B, walk_u=0.5, carry="couch_front", lean=0.4, strain=0.8, jseed=340, flip=-1) anchors = {"_": (cx, cy+60), "couch": (cx, cy+40)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Door: """The doorframe wins by half a sticker.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["wide", "wide>full_door"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) gy = SH_S*0.88 mline(d, [(0, gy), (SW_S, gy)], INK, 9, 400, t) mline(d, [(0, gy-26), (SW_S, gy-26)], INK2, 4, 399, t) # baseboard # open doorway on the right third: frame + swung-open door leaf dx0, dx1 = SW_S*0.62, SW_S*0.76 d.rectangle([dx0, SH_S*0.22, dx1, gy], fill=(224, 214, 192)) # opening mrect(d, [dx0-18, SH_S*0.20, dx1+18, SH_S*0.22], INK, 8, 402, t, fill=TAN2) mline(d, [(dx0, SH_S*0.22), (dx0, gy)], INK, 10, 403, t) mline(d, [(dx1, SH_S*0.22), (dx1, gy)], INK, 10, 404, t) leaf = rot2([(dx1, SH_S*0.24), (dx1+SW_S*0.10, SH_S*0.24), (dx1+SW_S*0.10, gy), (dx1, gy)], dx1, gy, -0.06) d.polygon(leaf, fill=(120, 92, 66)) mline(d, leaf + [leaf[0]], INK, 7, 405, t) d.text((dx0+16, SH_S*0.13), "5B", font=font(40, "Impact.ttf"), fill=INK) # measuring tape across the opening mline(d, [(dx0, SH_S*0.30), (dx1, SH_S*0.30)], BLUE, 6, 440, t) d.text((dx0-190, SH_S*0.26), "TOO SMALL", font=font(28, "Helvetica.ttc"), fill=BLUE) # the couch comes in from the left, tips up diagonal, squeezes through cu = ease_io(min(1.0, u*1.1)) cx = lerp(SW_S*0.14, (dx0+dx1)/2, cu) tilt = -0.05 - 0.85*np.clip((cx-SW_S*0.36)/(SW_S*0.2), 0, 1) sq = 1.0 - 0.10*np.clip((cx-dx0+180)/240, 0, 1) cy = gy - 170 - 60*abs(tilt) couch(d, cx, cy, 1.2*sq, tilt, 410, t, jolt=e["kick"]*0.5) mover(d, cx-250, gy, 1.45, t, MOV_A, walk_u=t*2.2, carry="couch_back", lean=0.4, strain=1.0, jseed=420, flip=1) bxx = min(cx+250, dx0-40) mover(d, bxx, gy, 1.45, t, MOV_B, walk_u=t*2.2+1.5, carry="couch_front", lean=0.3, strain=0.6, jseed=430, flip=1) anchors = {"_": (SW_S*0.45, SH_S*0.55), "door": (SW_S*0.62, SH_S*0.5)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Landing: """Bridge. Sitting on the stairs with sodas. The couch leans and rests.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_pair", "wide>full_pair"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) fh = SH_S*0.26 y = SH_S*0.86 pts = [] for si in range(9): sx = SW_S*0.14 + si*SW_S*0.08 sy = y - si*fh/8 pts += [(sx, sy), (sx+34, sy)] mline(d, pts[:-1], INK, 8, 500, t) couch(d, SW_S*0.70, SH_S*0.52, 1.15, -1.35, 510, t) # couch on end br = math.sin(t*0.9)*3 # breathing mover(d, SW_S*0.30, y-fh*0.25+br, 1.5, t, MOV_A, carry="sit", jseed=520, flip=1) mover(d, SW_S*0.42, y-fh*0.45+br, 1.5, t, MOV_B, carry="soda", walk_u=0.2, jseed=530, flip=-1) # the cat carrier, waiting mrect(d, [SW_S*0.14, y-110, SW_S*0.24, y-30], INK, 6, 540, t, fill=TAN2) for gx in range(4): mline(d, [(SW_S*(0.15+gx*0.02), y-104), (SW_S*(0.15+gx*0.02), y-36)], INK, 3, 541+gx, t) cat_eyes = (226, 210, 120) for ex in (SW_S*0.175, SW_S*0.195): d.ellipse([ex-4, y-80, ex+4, y-72], fill=cat_eyes) d.text((SW_S*0.55, SH_S*0.12), "3 1/2", font=font(40, "Impact.ttf"), fill=INK2) anchors = {"_": (SW_S*0.45, SH_S*0.6), "pair": (SW_S*0.36, y-fh*0.4)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Room: """It lands against the window wall, the city in the glass.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0; self.u_land = 0.35 self.cam = str(rng.choice(["wide>full_couch", "full_couch"])) self.sky = [(rng.random(), rng.uniform(0.25, 0.85)) for _ in range(9)] def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) gy = SH_S*0.85 mline(d, [(0, gy), (SW_S, gy)], INK, 9, 600, t) # window with marker skyline wx0, wx1 = SW_S*0.30, SW_S*0.74 mrect(d, [wx0, SH_S*0.12, wx1, SH_S*0.60], INK, 10, 601, t, fill=(196, 214, 226)) for i, (bx, bh) in enumerate(self.sky): x = lerp(wx0+20, wx1-90, bx) mrect(d, [x, SH_S*0.60 - bh*SH_S*0.36, x+70, SH_S*0.60], BLUE, 5, 610+i, t) mline(d, [(wx0, SH_S*0.36), (wx1, SH_S*0.36)], INK, 6, 620, t) mline(d, [((wx0+wx1)/2, SH_S*0.12), ((wx0+wx1)/2, SH_S*0.60)], INK, 6, 621, t) # the couch arrives on a spring su = spring(min(1.0, u/self.u_land), freq=2.2, damp=4.0) if u > 0 else 0 cy = lerp(SH_S*0.30, gy-120, min(1.0, su)) couch(d, SW_S*0.52, cy, 1.30, 0.0, 630, t) if u > self.u_land: uu = (u - self.u_land)/(1-self.u_land) bob = math.sin(t*BPM/60*math.tau)*3 # heads nod on the beat mover(d, SW_S*0.42, cy+40+bob, 1.3, t, MOV_A, carry="sit", jseed=640, flip=1) mover(d, SW_S*0.60, cy+40-bob, 1.3, t, MOV_B, carry="sit", jseed=650, flip=-1) # the cat walks in from the left, tail up cx2 = lerp(SW_S*0.05, SW_S*0.36, ease_io(min(1.0, uu*1.5))) cat(d, cx2, gy-8, 1.4, t, 660) for b, lab in ((SW_S*0.06, "MISC"), (SW_S*0.84, "PANS"), (SW_S*0.13, "")): cardbox(d, b, gy-120, 120, lab, 670+int(b), t, tilt=0.02) # pizza box on a MISC box mrect(d, [SW_S*0.075, gy-150, SW_S*0.155, gy-122], RED, 5, 680, t, fill=(210, 180, 140)) anchors = {"_": (SW_S*0.5, SH_S*0.55), "couch": (SW_S*0.52, gy-160)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Hookcard: """The hook, lettered big, bouncing on the beat.""" LINES = ["FIFTH FLOOR", "NO ELEVATOR", "HEAVY NOW", "OR HEAVY LATER"] def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.ang = float(rng.uniform(-0.05, 0.05)) def frame(self, k, u, e): t = (self.i0+k)/FPS im = cardboard(); d = mkdraw(im) beat_u = (t/BEAT) % 1.0 drop = (1-beat_u)**3 * 26 * (0.4+0.6*e["kick"]) for i, ln in enumerate(self.LINES): f = font(120, "Impact.ttf") lw = d.textlength(ln, font=f) x = SW_S/2 - lw/2 y = SH_S*0.10 + i*SH_S*0.21 + (drop if i == int(t/BEAT) % 4 else 0) col = [INK, RED, BLUE, GREEN][i % 4] # tape corners d.rectangle([x-30, y+30, x+18, y+62], fill=TAPE) d.rectangle([x+lw-18, y+30, x+lw+30, y+62], fill=TAPE) d.text((x+4, y+4), ln, font=f, fill=tuple(int(v*0.5) for v in TAN2)) d.text((x, y), ln, font=f, fill=col) anchors = {"_": (SW_S/2, SH_S/2)} return np.asarray(shoot(im, anchors, "wide", ease_io(u), jseed=self.i0, push=0.06), np.float32) ENGINES = {"truck": Truck, "stairs": Stairs, "pivot": Pivot, "door": Door, "landing": Landing, "room": Room, "hookcard": Hookcard} PLAN = { "intro": (["truck"], [8, 8]), "verse1": (["truck", "stairs"], [8, 8, 16]), "hook1": (["hookcard", "pivot"], [8, 8]), "verse2": (["stairs", "pivot"], [8, 8, 16]), "bridge": (["landing"], [8, 16]), "verse3": (["stairs", "door"], [8, 8, 16]), "outro": (["room"], [16, 32]), } CARDS = {"intro": "FIFTH FLOOR", "verse1": None, "hook1": None, "verse2": None, "bridge": None, "verse3": None, "outro": None} SYSTEM_NAMES = ["FLIGHT 1", "FLIGHT 2", "FLIGHT 3", "FLIGHT 4", "FLIGHT 5", "5B"] # The cut-plan seed. At 24 bars the old 5150 drew only 9 shots and ran verse3 # door-then-stairs (the doorframe gag before the final flight) with two # near-identical stairs framings back to back. 5157 draws 10, puts the hook # card first, and restores stairs -> door so the arc reads. SHOT_SEED = 5157 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(SHOT_SEED) 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 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) _FC[key] = _load_font(p, max(1, P(size))) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.40*r**2.2, 0, 1)[..., None] return _VIG["v"] _SUBS = {} def subs(): if not _SUBS: p = AUD/"subs.npz" if p.exists(): z = np.load(p, allow_pickle=True) _SUBS["t0"], _SUBS["t1"], _SUBS["tx"] = z["t0"], z["t1"], z["tx"] else: _SUBS["t0"] = _SUBS["t1"] = np.zeros(0) _SUBS["tx"] = np.zeros(0, dtype=object) return _SUBS def post(arr, i, e, shot): a = arr.astype(np.float32) if isinstance(arr, np.ndarray) else \ np.asarray(arr, np.float32) if a.shape[0] != H or a.shape[1] != W: a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((W, H), Image.LANCZOS), np.float32) # warm paper grade + vignette + grain lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([6, 1, -8], np.float32) a *= vignette() rng = np.random.RandomState(4100 + i) if S == 1.0: a += rng.normal(0, 2.2, a.shape) else: # paper tooth is a look, not a resolution: drawn on the 1280x720 grid # and blown up nearest-neighbour so a speck keeps its size on screen gn = rng.normal(0, 2.2, (AH, AW, 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 = mkdraw(out) if shot.card: age = i - shot.i0 if age < FPS*2.6: al = min(1.0, age/6.0)*min(1.0, (FPS*2.6-age)/10.0) f = font(52, "Impact.ttf") lw = d.textlength(shot.card, font=f)/S is_title = (shot.card == TITLE) y1 = 152 if is_title else 122 d.rectangle([AW/2-lw/2-18, 48, AW/2+lw/2+18, y1], fill=tuple(int(v*al) for v in TAPE)) d.text((AW/2-lw/2, 56), shot.card, font=f, fill=tuple(int(v*al) for v in INK)) if is_title: # the show, biro'd on the same strip of tape under the title f2 = font(22, "Helvetica.ttc") sw = d.textlength("PLAYER COMPUTER", font=f2)/S d.text((AW/2-sw/2, 118), "PLAYER COMPUTER", font=f2, fill=tuple(int(v*al) for v in INK2)) bh = int(AH*0.025) # subtitles in delivery space, on a cardboard label SB = subs(); t = i/FPS # not `S` — that is the delivery scale idx = np.where((SB["t0"] <= t) & (t < SB["t1"]))[0] if len(idx): line = str(SB["tx"][idx[-1]]) f = font(26, "Helvetica.ttc") lw = d.textlength(line, font=f)/S y0 = AH - bh - 54 d.rectangle([AW/2-lw/2-14, y0-6, AW/2+lw/2+14, y0+34], fill=LABEL) d.rectangle([AW/2-lw/2-14, y0-6, AW/2+lw/2+14, y0+34], outline=INK, width=2) d.text((AW/2-lw/2, y0), line, font=f, fill=INK) 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 = 300, 169 sheet = Image.new("RGB", (cols*tw, rows*(th+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+24) sheet.paste(im, (cx, cy)) sd.text((cx+5, cy+th+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…") sd = globals().get("SETDIR", "second_nature") 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/{sd}/{NAME}/render.py", "-metadata", f"title={sd} {SETNUM} — {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/{sd}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n,_,_ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel, stateful per shot)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()