#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — The Understudy (13/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/the_understudy # # In a theatre where everyone is a circle, tonight's understudy is a triangle. # # 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/the_understudy.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/the_understudy.mp4 # cover: https://genekogan.com/player_computer/media/the_understudy.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 the_understudy.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 — "THE UNDERSTUDY" (final-curation cut) Fork of renders/side_quests/understudy/ (118.4s, 76 bars), recomposed down to 40 bars / ~63.8s of eventful runtime for the ./spiral final curation. Not a speed-up and not a truncation: every section was re-proportioned and the ska arrangement, the bubble script and the shot list were rebuilt on the shorter grid. The arc — call board / panic / on / wobble / commit / ovation — and the one trick (horns held back until `committing`, still at 60% of the runtime) survive intact. Ska / rocksteady, 158bpm, F major. 40 bars, instrumental. A theatre where everyone is a circle. Tonight the lead circle has not turned up, and the only cover is a triangle, who has been standing in the wings for eleven months learning a part written for somebody with no corners. The stage manager has forty seconds to decide. The triangle goes on. It is wrong for the part in every possible way, and for a while everyone can see that, including the triangle. Then somewhere in the second act it stops trying to be round and starts being extremely triangular, and the horns come in, and the house goes up. Round 2 (player_computer_2) re-plots the ending. The ovation is for the spin: a triangle turned fast enough sweeps out its own circumscribed circle, and the house is applauding a circle. Nothing can spin forever. It winds down, it resolves back into a triangle in front of everybody, and the faces turn one at a time. The card says what the piece has been about the whole time. Delivered at 1280x720 (16:9) for this set — recomposed on the same 1680x940 stage, which is itself ~16:9, so the wider frame simply reveals more of each painted set; nothing is stretched and the cinematic bars are gone. Look: flat vector cartoon — heavy black outlines, six flat colours, no gradients, squash-and-stretch. Bright as a poster. Composition: engine : audio-first x shot-parallel x stage-and-camera content: audio-groove (ska kit, offbeat skank, walking bass, horn section) x effects-post Run from repo root: python3 renders/player_computer_final/understudy/render.py --sheet python3 renders/player_computer_final/understudy/render.py """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "the_understudy" TITLE = "THE UNDERSTUDY" SETNUM = "12" SETDIR = "player_computer_final" W, H, FPS = 1920, 1080, 30 # ── delivery scale ─────────────────────────────────────────────────────────── # FINAL CUT: native 1920x1080. Every constant in this file stays authored in # the original 1280x720 delivery / 1680x940 stage units; `S = H/720` is the one # global that turns those units into real pixels. Coordinates and stroke widths # go through `ScaledDraw`, fonts through `font()`, per-pixel effects are scaled # explicitly where they appear. Nothing is upscaled after the fact — the # drawing is re-rasterised at the larger size. WB, HB = 1280, 720 # the authoring frame S = H/720.0 def PX(v): return int(round(v*S)) # unit -> real pixel def B(r): return r if S == 1.0 else r*S def _scale_xy(v, s): if isinstance(v, (list, tuple)): return [_scale_xy(u, s) for u in v] return v*s class ScaledDraw: """ImageDraw proxy multiplying geometry (first positional) and `width` by S. arc/chord/pieslice take angles as positionals 2/3, which pass through.""" __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(1, int(round(w*s))) r = kw.get("radius") if r is not None: kw["radius"] = max(1, int(round(r*s))) return f(_scale_xy(xy, s), *a, **kw) return wrapped def mkdraw(im): d = ImageDraw.Draw(im) return d if S == 1.0 else ScaledDraw(d, S) BPM = 158.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" # FINAL-CURATION RECOMPOSITION (76 bars -> 40). # # Not a truncation — every section was re-proportioned against the whole, with # the two "he is failing out there" sections (onstage + wobble) taking the # deepest cut because they were the piece's only stretch that repeated itself. # The percentages the arc actually depends on are preserved: # # section old bars % new bars % what it is # callboard 0-8 10.5 0-4 10.0 backstage, the board, he arrives # panic 8-22 18.4 4-12 20.0 the lead is missing, "YOU'RE ON" # onstage 22-38 21.1 12-18 15.0 out there, wrong, chorus judging # wobble 38-48 13.2 18-24 15.0 trying to be round, failing # committing 48-64 21.1 24-32 20.0 HORNS. stops trying to be round # ovation 64-76 15.8 32-40 20.0 spins itself into a circle # # The one trick — no horns for the whole first half — still fires at 60% of the # runtime (old 48/76 = 63%). Ovation gained share so the spin-into-a-circle # still has room to resolve and then hold. SECTIONS = [ ("callboard", 0, 4), ("panic", 4, 12), ("onstage", 12, 18), ("wobble", 18, 24), ("committing",24, 32), ("ovation", 32, 38), # the house applauds a circle ("stopping", 38, 42), # nothing spins forever ("frown", 42, 45), # oh. he's a triangle. ("moral", 45, 48), # the card ] SEC_START = {nm: a for nm, a, b in SECTIONS} N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 3.0 N_FRAMES = int(DUR * FPS) MUSIC_DESC = f"ska / rocksteady, {BPM:.0f}bpm, F major, {N_BARS} bars, instrumental" ENGINE_DESC = "flat vector cartoon on a 1680x940 stage" 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.05 PROG = [(nf("F1"), [nf("A3"), nf("C4"), nf("F4")]), (nf("D1"), [nf("A3"), nf("D4"), nf("F4")]), (nf("Bb0"), [nf("Bb3"), nf("D4"), nf("F4")]), (nf("C1"), [nf("C4"), nf("E4"), nf("G4")])] # The house turning: borrowed minor, a whole tone under the tonic. Sour on # purpose — the same room, the same band, the light gone out of it. PROG_SAD = [(nf("Bb0"), [nf("Db4"), nf("F4"), nf("Bb4")]), (nf("Gb0"), [nf("Bb3"), nf("Db4"), nf("Gb4")])] def build_song(): s = Song(DUR) R = np.random.RandomState(158) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "moral" for bar in range(N_BARS): sec = sec_of(bar) root, notes = PROG[bar % 4] big = sec in ("committing", "ovation") thin = sec in ("callboard", "wobble") WALK = [0, 4, 7, 9] if sec == "stopping": # the band winds down with him: a notch quieter every bar and the # horns stepping downward instead of climbing kk = bar - SEC_START["stopping"] g0 = 1.0 - 0.19*kk at0 = s.t(bar, 0, SW) s.put("drums", kick(dur=.26, f0=132, f1=48, punch=28), at0, g=.82*g0) s.kick_t.append(at0) s.put("drums", snare(dur=.20, tone=210, bright=1.1), s.t(bar, 8, SW), g=.72*g0, pan=-.05) for st in (2, 6, 10, 14): s.put("drums", hat(openh=(st in (6, 14))), s.t(bar, st, SW), g=.22*g0, pan=.30) for j, st in enumerate((0, 4, 8, 12)): s.put("bass", voice(root*2*2**(WALK[(j+bar) % 4]/12.0), BEAT*.80, kind="tri", nh=12, c0=680, c1=210, ck=7, a=.006, d=.14, s=.42, r=.14, seed=bar*5+j), s.t(bar, st, SW), g=.44*g0) for st in (2, 6, 10, 14): for k2, f2 in enumerate(notes): s.put("skank", voice(f2, BEAT*.16, kind="square", nh=12, c0=2600, c1=1200, ck=22, res=.4, a=.002, d=.04, s=.15, r=.03, seed=bar*7+k2+st), s.t(bar, st, SW), g=.12*g0, pan=-.30+.30*k2) fh = notes[0]*2**((7 - 3*kk)/12.0) s.put("horns", voice(fh*2, BEAT*1.7, kind="saw", nh=20, c0=3000-480*kk, c1=1150, ck=5, res=.4, detune=(-.9, 1.0), a=.02, d=.22, s=.5, r=.34, seed=bar*13), at0, g=.15*g0, pan=-.2) for k2, f2 in enumerate(notes): s.put("org", voice(f2/2, BAR*1.1, kind="sine", nh=3, c0=1100, c1=600, ck=.5, vib=(.005, 5.6), a=.3, d=.4, s=.8, r=.6, seed=bar*19+k2), s.t(bar, 0), g=.05, pan=-.45+.45*k2) continue if sec == "frown": # the room deflates: no backbeat, a rim, a hat, a sour organ root, notes = PROG_SAD[bar % 2] s.put("drums", rim(), s.t(bar, 8, SW), g=.32, pan=-.10) for st in (2, 6, 10, 14): s.put("drums", hat(), s.t(bar, st, SW), g=.10, pan=.30) for k2, f2 in enumerate(notes): s.put("org", voice(f2/2, BAR*1.5, kind="sine", nh=4, c0=820, c1=420, ck=.4, vib=(.006, 4.4), a=.55, d=.6, s=.8, r=.95, seed=bar*29+k2), s.t(bar, 0), g=.11, pan=-.42+.42*k2) s.put("bass", voice(root*2, BAR*.85, kind="tri", nh=12, c0=560, c1=190, ck=5, a=.02, d=.3, s=.5, r=.34, seed=bar*31), s.t(bar, 0), g=.34) continue if sec == "moral": # a dry turnaround and one flat stab. He is fine. The room isn't. kk = bar - SEC_START["moral"] at0 = s.t(bar, 0, SW) s.put("drums", kick(dur=.26, f0=132, f1=48, punch=28), at0, g=.80) s.kick_t.append(at0) if kk < 2: s.put("drums", snare(dur=.20, tone=210, bright=1.1), s.t(bar, 8, SW), g=.74, pan=-.05) for st in (2, 6, 10, 14): s.put("drums", hat(openh=(st in (6, 14))), s.t(bar, st, SW), g=.24, pan=.30) for j, st in enumerate((0, 4, 8, 12)): s.put("bass", voice(root*2*2**(WALK[(j+bar) % 4]/12.0), BEAT*.80, kind="tri", nh=12, c0=680, c1=210, ck=7, a=.006, d=.14, s=.42, r=.14, seed=bar*5+j), s.t(bar, st, SW), g=.44) for st in (2, 6, 10, 14): for k2, f2 in enumerate(notes): s.put("skank", voice(f2, BEAT*.16, kind="square", nh=12, c0=2600, c1=1200, ck=22, res=.4, a=.002, d=.04, s=.15, r=.03, seed=bar*7+k2+st), s.t(bar, st, SW), g=.13, pan=-.30+.30*k2) s.put("gtr", ks(notes[1]*2, .16, damp=.9860, seed=bar*11+st), s.t(bar, st, SW), g=.13, pan=.34) for k2, f2 in enumerate(notes): s.put("org", voice(f2/2, BAR*1.1, kind="sine", nh=3, c0=1400, c1=620, ck=.5, vib=(.005, 5.6), a=.3, d=.4, s=.8, r=.6, seed=bar*19+k2), s.t(bar, 0), g=.055, pan=-.45+.45*k2) else: s.put("drums", snare(dur=.22, tone=214, bright=1.2), at0, g=.60, pan=-.05) for k2, f2 in enumerate(notes): s.put("horns", voice(f2*2, BEAT*.60, kind="saw", nh=22, c0=3600, c1=1400, ck=6, res=.5, detune=(-.9, 1.0), a=.008, d=.12, s=.40, r=.18, seed=bar*13+k2), at0, g=.19, pan=-.25+.25*k2) s.put("bass", voice(root*2, BEAT*.7, kind="tri", nh=12, c0=700, c1=210, ck=7, a=.006, d=.14, s=.42, r=.16, seed=bar*5), at0, g=.46) continue if sec == "callboard" and bar < 2: for k2, f2 in enumerate(notes): s.put("org", voice(f2/2, BAR*1.3, kind="sine", nh=3, c0=900, c1=500, ck=.5, a=.7, d=.7, s=.75, r=.9, seed=bar*3+k2), s.t(bar, 0), g=.11, pan=-.4+.4*k2) continue # ska kit: snare on 3, hats on offbeats, kick on 1 (+3 when it's big) s.put("drums", kick(dur=.26, f0=132, f1=48, punch=28), s.t(bar, 0, SW), g=.82) s.kick_t.append(s.t(bar, 0, SW)) if not thin: s.put("drums", kick(dur=.24, f0=132, f1=48, punch=28), s.t(bar, 8, SW), g=.60) s.put("drums", snare(dur=.20, tone=210, bright=1.1), s.t(bar, 8, SW), g=.78 if not thin else .48, pan=-.05) for st in (2, 6, 10, 14): s.put("drums", hat(openh=(st in (6, 14))), s.t(bar, st, SW), g=.26 if not thin else .16, pan=.30) if big and bar % 4 == 3: for j, st in enumerate((12, 13, 14, 15)): s.put("drums", snare(dur=.13, tone=230, bright=.9)*(.5+.18*j), s.t(bar, st, SW), g=.46, pan=-.3+.2*j) # walking bass for j, st in enumerate((0, 4, 8, 12)): s.put("bass", voice(root*2*2**(WALK[(j+bar) % 4]/12.0), BEAT*.80, kind="tri", nh=12, c0=680, c1=210, ck=7, a=.006, d=.14, s=.42, r=.14, seed=bar*5+j), s.t(bar, st, SW), g=.44) # THE SKANK — organ + guitar on every offbeat, short and dry for st in (2, 6, 10, 14): for k2, f2 in enumerate(notes): s.put("skank", voice(f2, BEAT*.16, kind="square", nh=12, c0=2600, c1=1200, ck=22, res=.4, a=.002, d=.04, s=.15, r=.03, seed=bar*7+k2+st), s.t(bar, st, SW), g=.13 if not thin else .08, pan=-.30+.30*k2) s.put("gtr", ks(notes[1]*2, .16, damp=.9860, seed=bar*11+st), s.t(bar, st, SW), g=.13 if not thin else .07, pan=.34) # horns — the whole story is when they arrive if big: HORN = [0, 4, 7, 12, 7, 4] for j, st in enumerate((0, 3, 6, 10, 13)): f2 = notes[0]*2**(HORN[(j+bar) % 6]/12.0) s.put("horns", voice(f2*2, BEAT*.42, kind="saw", nh=22, c0=3400, c1=1400, ck=6, res=.45, detune=(-.9, 1.0), a=.012, d=.10, s=.55, r=.14, seed=bar*13+j), s.t(bar, st, SW), g=.15, pan=-.28+.14*j) elif sec == "onstage" and bar % 2 == 0: s.put("horns", voice(notes[0]*2, BEAT*.9, kind="saw", nh=18, c0=2200, c1=1000, ck=4, res=.4, a=.03, d=.2, s=.5, r=.3, seed=bar*17), s.t(bar, 6, SW), g=.10, pan=-.2) # organ pad for k2, f2 in enumerate(notes): s.put("org", voice(f2/2, BAR*1.1, kind="sine", nh=3, c0=1100 + (900 if big else 0), c1=600, ck=.5, vib=(.005, 5.6), a=.3, d=.4, s=.8, r=.6, seed=bar*19+k2), s.t(bar, 0), g=.05, pan=-.45+.45*k2) for b in (12, 24, 32): # curtain-up / horns-in / ovation s.put("fx", crash(dur=1.6), b*BAR, g=.28, pan=.1) s.put("fx", riser(BAR*2.4), 21.6*BAR, g=.22) # 2.4 bars into committing s.put("fx", applause2(6.0), 32*BAR, g=.34) # the deflation: a slide off the end of the spin, then a room going quiet s.put("fx", fall(BAR*1.35), SEC_START["frown"]*BAR - BEAT*0.6, g=.28, pan=-.16) s.put("fx", murmur(3.4), SEC_START["frown"]*BAR + BEAT*0.8, g=.22) s.put("fx", crash(dur=1.2), SEC_START["moral"]*BAR, g=.18, pan=.1) s.put("fx", crash(dur=2.4), 47*BAR, g=.26, pan=.1) s.bus("skank", lambda x: delay(x, BEAT*.75, .30, .20)) s.bus("gtr", lambda x: delay(x, BEAT*.75, .34, .24)) s.bus("horns", lambda x: reverb(x, rt=1.8, mix=.26, seed=641)) s.bus("org", lambda x: reverb(x, rt=2.6, mix=.36, seed=643)) s.bus("fx", lambda x: reverb(x, rt=2.4, mix=.40, seed=647)) mix = s.mixdown(dict(drums=1.0, bass=1.0, skank=1.0, gtr=1.0, horns=1.0, org=1.0, fx=1.0), pump_depth=.16, pump_rel=.13, levels=dict(callboard=.40, panic=.82, onstage=.88, wobble=.54, committing=1.0, ovation=.94, stopping=.80, frown=.44, moral=.86)) wav = AUD/"final.wav"; s.write(wav, mix); return wav, mix def fall(dur=1.4, seed=77): """A trombone slide off the end of the applause.""" n = int(dur*SR); t = np.arange(n)/SR u = (t/dur) ** 1.15 f = nf("F4") * (nf("Bb1")/nf("F4")) ** u ph = 2*np.pi*np.cumsum(f)/SR out = sum(np.sin(ph*k)/k for k in (1, 2, 3, 4, 5, 6, 7)) return out * adsr(n, .02, .22, .68, .40) * 0.42 def murmur(dur=3.4, seed=199): """A house that has stopped clapping and started muttering.""" n = int(dur*SR); rng = np.random.RandomState(seed) x = bandshape(rng.randn(n), lo=180, hi=1100) t = np.arange(n)/SR wob = 0.55 + 0.45*np.sin(2*np.pi*1.7*t) * np.sin(2*np.pi*0.43*t) env = np.clip(t/0.5, 0, 1) * np.exp(-t*0.62) return x * wob * env * 0.9 def applause2(dur=6.0, seed=193): n = int(dur*SR); rng = np.random.RandomState(seed); out = np.zeros(n) for _ in range(700): i = rng.randint(0, n-2000); m = 1400 t = np.arange(m)/SR out[i:i+m] += bandshape(rng.randn(m), lo=900, hi=5400)*np.exp(-t*36)*rng.uniform(.2, .9) env = np.clip(np.arange(n)/(0.2*SR), 0, 1)*np.exp(-np.arange(n)/SR*0.32) return out*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 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.""" box = cam_box(anchors, shot, f01, jseed, push, drift) return stage_img.crop(tuple(PX(v) for v in box)).resize((W, H), Image.LANCZOS) def new_stage(bg): im = Image.new("RGB", (PX(SW_S), PX(SH_S)), bg) return im, mkdraw(im) # ════════════════════════════════════════════════════════════════════════════ # 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 # ════════════════════════════════════════════════════════════════════════════ # FLAT VECTOR # ════════════════════════════════════════════════════════════════════════════ SH_S = 940 NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} INK = (24, 20, 28) P = {"red": (232, 72, 70), "yellow": (248, 198, 62), "teal": (72, 190, 178), "blue": (66, 106, 210), "cream": (246, 238, 216), "plum": (128, 66, 132), "pink": (240, 138, 170), "green": (108, 190, 96)} FLOOR = 720 LW = 7 # the outline weight — never varies def shape(d, kind, cx, cy, r, col, *, t=0.0, squash=0.0, face="neutral", rot=0.0, blink=False): """A flat character with a heavy outline. kind: circle/tri/square/diamond.""" sx = 1.0 + squash*0.35; sy = 1.0 - squash*0.30 rx, ry = r*sx, r*sy if kind == "circle": d.ellipse([cx-rx, cy-ry, cx+rx, cy+ry], fill=col, outline=INK, width=LW) elif kind == "tri": pts = [(cx + math.cos(rot - math.pi/2 + q*math.tau/3)*rx*1.16, cy + math.sin(rot - math.pi/2 + q*math.tau/3)*ry*1.16) for q in range(3)] d.polygon(pts, fill=col, outline=INK) for q in range(3): d.line([pts[q], pts[(q+1) % 3]], fill=INK, width=LW, joint="curve") elif kind == "square": d.rectangle([cx-rx*.9, cy-ry*.9, cx+rx*.9, cy+ry*.9], fill=col, outline=INK, width=LW) else: pts = [(cx, cy-ry*1.15), (cx+rx*1.05, cy), (cx, cy+ry*1.15), (cx-rx*1.05, cy)] d.polygon(pts, fill=col, outline=INK) for q in range(4): d.line([pts[q], pts[(q+1) % 4]], fill=INK, width=LW, joint="curve") # face ey = cy - ry*0.14 ex = rx*0.34 for sgn in (-1, 1): if blink: d.line([cx+sgn*ex-r*0.13, ey, cx+sgn*ex+r*0.13, ey], fill=INK, width=5) else: d.ellipse([cx+sgn*ex-r*0.15, ey-r*0.17, cx+sgn*ex+r*0.15, ey+r*0.17], fill=(255, 255, 255), outline=INK, width=3) px = cx+sgn*ex + math.sin(t*1.3)*r*0.04 d.ellipse([px-r*0.07, ey-r*0.07, px+r*0.07, ey+r*0.07], fill=INK) my = cy + ry*0.36 mw = rx*0.36 if face == "happy": d.arc([cx-mw, my-r*0.22, cx+mw, my+r*0.30], 20, 160, fill=INK, width=6) elif face == "sing": d.ellipse([cx-mw*0.6, my-r*0.10, cx+mw*0.6, my+r*0.24], fill=(120, 44, 52), outline=INK, width=4) elif face == "worry": d.arc([cx-mw, my-r*0.10, cx+mw, my+r*0.30], 200, 340, fill=INK, width=6) else: d.line([cx-mw*0.7, my+r*0.06, cx+mw*0.7, my+r*0.06], fill=INK, width=6) CHORUS_X = [430, 640, 850, 1060, 1270] SPIN_B0, SPIN_PEAK, SPIN_STOP = 32.0, 37.0, 41.2 def _revs(b): """Revolutions accumulated by bar b, integrating a piecewise-linear rate (1.2 -> 15 rev/s over the ovation, 15 -> 0 as he winds down). Integrated rather than rate*t — multiplying a changing rate by absolute time lurches.""" if b <= SPIN_B0: return 0.0 A = SPIN_PEAK - SPIN_B0 if b <= SPIN_PEAK: u = b - SPIN_B0 return BAR*(1.2*u + 13.8*u*u/(2*A)) r0 = BAR*(1.2*A + 13.8*A/2) T = SPIN_STOP - SPIN_PEAK v = min(b, SPIN_STOP) - SPIN_PEAK return r0 + BAR*15.0*(v - v*v/(2*T)) _R_STOP = None def spin_state(t): """(rot, uo) — uo is how round he currently looks. It goes to zero when the spin does, which is the whole point of the ending.""" global _R_STOP if _R_STOP is None: _R_STOP = _revs(SPIN_STOP) b = t/BAR rot = math.tau*(_revs(min(b, SPIN_STOP)) - _R_STOP) up = min(1.0, max(0.0, (b - SPIN_B0)/5.5)) dn = 1.0 - min(1.0, max(0.0, (b - 38.3)/2.7)) return rot, min(up, dn) def spin_blur(im, cx, cy, r, rot, uo): """The triangle as N ghosts across the angle it sweeps in one frame. A triangle has 3-fold symmetry, so a 120-degree sweep already fills the disc; ramp the sweep to that and it resolves into a circle.""" ghosts = max(1, int(1 + 26*uo)) sweep = (math.tau/3.0)*ease_in(uo) ov = Image.new("RGBA", im.size, (0, 0, 0, 0)) od = mkdraw(ov) for j in range(ghosts): a = rot - sweep*(j/max(1, ghosts-1)) al = int(235*(1.0 - 0.55*j/max(1, ghosts))) pts = [(cx + math.cos(a - math.pi/2 + q*math.tau/3)*r*1.16, cy + math.sin(a - math.pi/2 + q*math.tau/3)*r*1.16) for q in range(3)] od.polygon(pts, fill=P["yellow"] + (al,)) if uo < 0.55: od.line(pts + [pts[0]], fill=INK + (al,), width=LW, joint="curve") if uo > 0.62: # once it is a disc, give it a disc's edge k2 = (uo-0.62)/0.38 od.ellipse([cx-r*1.16, cy-r*1.16, cx+r*1.16, cy+r*1.16], outline=INK + (int(235*k2),), width=LW) # the face rides on top of the blur, so it is still recognisably HIM once # he has spun himself into a circle — that is the whole joke if uo > 0.42: k3 = min(1.0, (uo-0.42)/0.28); al = int(255*k3) ex = r*0.34; ey = cy - r*0.12 for sgn in (-1, 1): od.ellipse([cx+sgn*ex-r*0.15, ey-r*0.17, cx+sgn*ex+r*0.15, ey+r*0.17], fill=(255, 255, 255, al), outline=INK + (al,), width=3) od.ellipse([cx+sgn*ex-r*0.07, ey-r*0.07, cx+sgn*ex+r*0.07, ey+r*0.07], fill=INK + (al,)) mw = r*0.36; my = cy + r*0.34 od.arc([cx-mw, my-r*0.22, cx+mw, my+r*0.30], 20, 160, fill=INK + (al,), width=6) return Image.alpha_composite(im.convert("RGBA"), ov).convert("RGB") # (bar, who, kind, text) — who is "tri" or a chorus index # # The script rebuilt on the 40-bar grid. Eighteen lines became thirteen: the # cuts are the ones that restated a beat already made — "oh no" (the dread is # already in "...me?"), "be rounder" and "hmm" (the wobble says it in one line, # not three), and the second "BRAVO". Every line that turns the story is still # here, and each now lands inside its own section instead of drifting. BUB_LIFE = 2.5 # bars a bubble stays up (was 3.0) BUBBLES = [ (5.0, "sm", "talk", "WHERE IS SHE"), # noqa (8.0, "tri", "think", "...me?"), (11.0, "sm", "talk", "YOU'RE ON"), # rides the cut to the stage (14.5, "c1", "think", "a triangle?"), (17.5, "tri", "think", "be round"), (21.0, "c3", "think", "corners"), (23.5, "tri", "think", "...so"), # hesitation resolving into horns (26.0, "tri", "talk", "SO I WON'T"), (29.0, "c1", "think", "oh"), (31.5, "c3", "talk", "OH"), # lands on the ovation crash (34.0, "tri", "talk", "HA"), (36.3, "c4", "talk", "BRAVO"), # the twist: the spin stops and the room does the arithmetic (39.2, "c1", "think", "wait"), (40.7, "c3", "talk", "HE'S A TRIANGLE"), (42.4, "c4", "think", "ugh"), (43.6, "tri", "talk", "YEAH"), ] # The card. Delivery-space, flat-vector register, same ink and outline weight # as everything else — the piece has exactly one other card (the title). MORAL_B0, MORAL_B1 = 46.0, 48.0 def bubble(d, x, y, text, kind="talk", grow=1.0): """A cartoon bubble, drawn in DELIVERY coordinates so the camera can never crop it. `grow` 0..1 pops it in on the beat.""" if grow <= 0.02: return f = font(30) tw = f.getlength(text)/S; th_ = 40 bw = tw*0.5 + 26; bh = th_*0.5 + 14 bw *= grow; bh *= grow box = [x-bw, y-bh, x+bw, y+bh] if kind == "talk": d.rounded_rectangle(box, radius=int(16*grow), fill=(250, 248, 240), outline=INK, width=LW) d.polygon([(x-16*grow, y+bh-3), (x-2*grow, y+bh+30*grow), (x+16*grow, y+bh-3)], fill=(250, 248, 240), outline=INK) d.line([(x-16*grow, y+bh-2), (x-2*grow, y+bh+30*grow)], fill=INK, width=LW) d.line([(x-2*grow, y+bh+30*grow), (x+16*grow, y+bh-2)], fill=INK, width=LW) else: d.ellipse(box, fill=(250, 248, 240), outline=INK, width=LW) for q, r in ((26, 11), (48, 7)): d.ellipse([x-r-6, y+bh+q*grow-r, x-r-6+r*2, y+bh+q*grow+r], fill=(250, 248, 240), outline=INK, width=4) if grow > 0.55: # was .85 — that left the box on screen, empty, for a d.text((x-tw/2, y-14), text, font=f, fill=INK) # visible beat class Theatre: def __init__(self, shot, rng): self.rng = rng self.sec = shot.section def anchors(self): a = {"_": (SW_S/2, SH_S*0.50), "tri": (860, 560), "sm": (300, 560), "board": (300, 380), "house": (SW_S/2, 800), "wings": (1500, 560), # the chalked X in `panic` was drawn but never framed — the # tightened cut needs the empty-spot beat to carry a whole shot "spot": (1290, 560)} for i, x in enumerate(CHORUS_X): a[f"c{i}"] = (x, 560) return a def draw(self, t, bar, e, sec): self._spin = None backstage = sec in ("callboard", "panic") bg = (58, 44, 66) if backstage else (176, 42, 58) im, d = new_stage(bg) beat = (t/BEAT) % 4/4.0 # bar phase 0..1 qb = (t/BEAT) % 1.0 # quarter-note phase 0..1 qi = int(t/BEAT) # absolute quarter index # a sharp attack that decays over the beat reads as "on the beat"; # a sine reads as "vaguely rhythmic", which is what it did before pulse = max(0.0, 1.0 - qb*1.55)**1.4 if backstage: # brick, a call board, a door for y in range(120, SH_S, 46): off = 0 if (y//46) % 2 == 0 else 46 for x in range(-46, SW_S, 92): d.rectangle([x+off, y, x+off+84, y+38], fill=(74, 56, 82), outline=(50, 38, 58), width=3) d.rectangle([180, 250, 470, 470], fill=P["cream"], outline=INK, width=LW) f = font(26) d.text((206, 268), "TONIGHT", font=f, fill=INK) d.text((206, 316), "LEAD ...... ?", font=f, fill=(190, 40, 40)) d.text((206, 356), "COVER ..... TRI", font=f, fill=INK) d.text((206, 400), "CURTAIN 19:30", font=f, fill=INK) d.rectangle([1180, 240, 1470, 780], fill=(48, 36, 54), outline=INK, width=LW) d.ellipse([1420, 500, 1450, 530], fill=P["yellow"], outline=INK, width=4) d.rectangle([0, FLOOR+60, SW_S, SH_S], fill=(44, 34, 50)) else: # the stage: boards, curtains, footlights, a painted backdrop d.rectangle([0, 120, SW_S, FLOOR+60], fill=(212, 176, 120)) d.ellipse([SW_S*0.5-520, 150, SW_S*0.5+520, 640], fill=(232, 206, 150), outline=INK, width=LW) d.rectangle([0, FLOOR+60, SW_S, SH_S], fill=(120, 78, 54)) for x in range(0, SW_S, 96): d.line([x, FLOOR+60, x-40, SH_S], fill=(96, 62, 42), width=4) for side in (0, 1): x0 = -40 if side == 0 else SW_S-300 d.rectangle([x0, 0, x0+340, SH_S], fill=(150, 30, 46), outline=INK, width=LW) for q in range(7): xx = x0+30+q*46 d.line([xx, 0, xx+16, SH_S], fill=(122, 22, 38), width=9) d.rectangle([0, 0, SW_S, 120], fill=(150, 30, 46), outline=INK, width=LW) dim = 1.0 - 0.45*float(np.clip((bar - 41.4)/2.0, 0, 1)) for lx in range(180, SW_S, 190): # footlights gl = (0.7+0.3*math.sin(t*3+lx))*dim d.ellipse([lx-26, FLOOR+52, lx+26, FLOOR+92], fill=tuple(min(255, int(v*gl)) for v in P["yellow"]), outline=INK, width=5) # ── the cast ── bop = pulse if sec == "callboard": shape(d, "circle", 300, 560, 92, P["teal"], t=t, face="neutral", blink=(math.sin(t*0.7) > 0.95)) u2 = np.clip(bar/4.0, 0, 1) shape(d, "tri", lerp(1700, 900, ease_out(u2)), 560, 96, P["yellow"], t=t, face="worry", rot=math.sin(t*1.2)*0.05) elif sec == "panic": sq = 0.25*bop shape(d, "circle", 300 + math.sin(t*7)*26, 560, 92, P["teal"], t=t, face="worry", squash=sq) shape(d, "tri", 900, 560 - bop*18, 96, P["yellow"], t=t, face="worry", rot=math.sin(t*2.4)*0.10, squash=sq*0.6) # empty spot where the lead should be, marked with an X d.line([1290-40, 560-40, 1290+40, 560+40], fill=INK, width=LW) d.line([1290-40, 560+40, 1290+40, 560-40], fill=INK, width=LW) else: # the round chorus # once the spin stops, the house turns — one circle at a time, # left to right, so you watch the mood cross the stage turn = float(np.clip((bar - 40.6)/1.9, 0, 1)) for i, x in enumerate(CHORUS_X): if i == 2: continue # the lead's spot # everyone bobs on the SAME beat — a chorus is in time off = 0.5 if (i % 2 and sec in ("committing", "ovation")) else 0.0 p2 = max(0.0, 1.0 - ((qb+off) % 1.0)*1.55)**1.4 amp = 34 if sec == "ovation" else 14 if sec in ("stopping", "frown", "moral"): amp = 14*(1.0 - turn) yy = 560 - p2*amp + turn*10 fc = "happy" if sec in ("committing", "ovation", "stopping") else \ ("worry" if sec == "wobble" else "sing") if sec in ("stopping", "frown", "moral") and turn > (i*0.16 + 0.10): fc = "worry" shape(d, "circle", x, yy, 78, [P["teal"], P["blue"], P["pink"], P["green"], P["plum"]][i], t=t+i*1.1, face=fc, squash=0.20*p2*(1.0-0.7*turn)) # the triangle, centre, in the part if sec == "onstage": shape(d, "tri", CHORUS_X[2], 560 - bop*8, 96, P["yellow"], t=t, face="worry", rot=math.sin(t*1.6)*0.07, squash=0.10*bop) elif sec == "wobble": shape(d, "tri", CHORUS_X[2] + math.sin(t*3)*14, 566, 92, P["yellow"], t=t, face="worry", rot=math.sin(t*2.2)*0.16, squash=0.05) elif sec == "committing": spin = (qi % 8)*(math.tau/8.0) # snaps an eighth-turn per beat shape(d, "tri", CHORUS_X[2], 560 - bop*44, 104, P["yellow"], t=t, face="sing", rot=spin, squash=0.26*bop) elif sec in ("ovation", "stopping"): # the spin accelerates until the swept triangle fills its own # circumscribed circle — and then it runs out, and it doesn't rot, uo = spin_state(t) self._spin = dict(cx=CHORUS_X[2], cy=520 - bop*58*(1.0-0.75*turn), r=112, rot=rot, uo=uo) else: # frown / moral: no spin left. A triangle, pointing up, still. shape(d, "tri", CHORUS_X[2], 560, 104, P["yellow"], t=t, face="happy" if sec == "moral" else "neutral", rot=0.0, blink=(math.sin(t*0.8) > 0.94)) if sec == "ovation": for q in range(26): # thrown flowers, on the beat ph = ((qi*0.25) + q*0.13) % 1.0 fx2 = 200 + q*56 fy2 = lerp(SH_S+60, 520, ph) + math.sin(ph*6+q)*30 d.ellipse([fx2-14, fy2-14, fx2+14, fy2+14], fill=P["pink"] if q % 2 else P["red"], outline=INK, width=4) elif sec in ("stopping", "frown", "moral"): # the flowers are all on the boards now, and nobody is throwing for q in range(22): fx2 = 176 + q*68 + ((q*37) % 23) fy2 = FLOOR + 46 + ((q*53) % 34) d.ellipse([fx2-13, fy2-11, fx2+13, fy2+11], fill=P["pink"] if q % 2 else P["red"], outline=INK, width=4) if self._spin: im = spin_blur(im, **self._spin) # Bubbles are NOT drawn on the stage any more. Anything painted here # is at the mercy of the crop, and close-ups kept eating them; they # are composited in delivery space instead — see post_frame. return im 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 # 24 shots on the 40-bar grid (was 32 on 76) — the cut is slightly *faster* # than the source, 1.7 bars/shot vs 2.4, which is how the tightened version # stays eventful instead of merely shorter. SHOTS = [ # callboard 0-4 — mid_board, not mid_sm: centring on the stage manager # pushes the board's top line up under the letterbox bar (0, 2, "wide"), (2, 3, "ins_board"), (3, 4, "mid_board"), # panic 4-12 — ONE board insert in the piece; the second one read as a # duplicate tile on the sheet, so the empty-spot shot took its place (4, 6, "wide"), (6, 8, "cu_sm"), (8, 9.5, "cu_tri"), (9.5, 11, "mid_spot"), (11, 12, "mid_tri>cu_tri"), # onstage 12-18 — curtain up. Three shots, not four: a fourth kept coming # back as a re-framing of the same tan band on the sheet. (12, 14, "wide"), (14, 16, "cu_c1"), (16, 18, "mid_tri"), # wobble 18-24 (18, 20, "cu_tri"), (20, 22, "mid_c3"), (22, 24, "cu_tri>wide"), # committing 24-32 — horns (24, 26, "wide"), (26, 28, "cu_tri"), (28, 29.5, "mid_c1"), (29.5, 31, "mid_tri"), (31, 32, "wide"), # ovation 32-38 — the spin. The close-up waits until uo>0.62 so it lands # on the disc-with-a-face, not on a faceless blur. (32, 34, "wide"), (34, 35.5, "mid_tri"), (35.5, 37, "wide"), (37, 38, "cu_tri"), # stopping 38-42 — hold on him while the disc turns back into a triangle, # then cut wide the instant it has: the reveal is a cut, not a zoom (38, 39.5, "mid_tri"), (39.5, 41, "cu_tri"), (41, 42, "wide"), # frown 42-45 — the house (42, 43, "cu_c1"), (43, 44, "mid_c3"), (44, 45, "wide"), # moral 45-48 (45, 46, "cu_tri"), (46, 48, "wide"), ] def build_shots(): shots = [] for i, (b0, b1, sh) in enumerate(SHOTS): i0, i1 = int(b0*BAR*FPS), int(b1*BAR*FPS) sec = next((n for n, a, b in SECTIONS if a <= b0 < b), "ovation") shots.append(Shot(i, i0, i1, sh, sec)) 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"): """Size is in authoring units; the loaded face is scaled once by S.""" key = (size, name, S) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size if S == 1.0 else max(2, PX(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"] def active_bubble(t): """(text, kind, side, grow) for whatever is being said at time t.""" bar = t/BAR for (bb, who, kind, txt) in BUBBLES: age = bar - bb if not (0 <= age < BUB_LIFE): continue grow = min(1.0, age*7.0) * min(1.0, (BUB_LIFE-age)*3.0) side = 0 if who == "tri" else (-1 if who == "sm" else (-1 if int(who[1]) < 2 else 1)) return txt, kind, side, grow return None def post_frame(img, i, e, shot): a = np.asarray(img, np.float32) lum = a.mean(2, keepdims=True)/255.0 a = a + lum*np.array([8, 4, -4], np.float32) sh = int(round((1 + 3*e["kick"])*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.86 + 0.14 rng = np.random.RandomState(1100 + i) if S == 1.0: a += rng.normal(0, 2.0, 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.0, (HB, WB, 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) ab = active_bubble(i/FPS) if ab: txt, kind, side, grow = ab f = font(30); tw = f.getlength(txt)/S half = tw*0.5 + 26 bx = WB*0.5 + side*WB*0.24 bx = max(half + 26, min(WB - half - 26, bx)) # always fully in frame bubble(d, bx, HB*0.20, txt, kind, grow) # ── title flash: the piece's name and the show, on the dark call-board # wall, in the same cream ink as the TONIGHT card behind it ─────────── if shot.section == "callboard" and i < FPS*3.2: al = min(1.0, i/8.0)*min(1.0, (FPS*3.2-i)/14.0) lay = Image.new("RGBA", out.size, (0, 0, 0, 0)); ld = mkdraw(lay) a8 = int(round(255*al)) ld.text((44, 44), "THE UNDERSTUDY", font=font(32), fill=(246, 238, 216, a8)) ld.text((46, 88), "PLAYER COMPUTER", font=font(19), fill=(228, 176, 96, a8)) out = Image.alpha_composite(out.convert("RGBA"), lay).convert("RGB") d = mkdraw(out) b = i/FPS/BAR if MORAL_B0 <= b: u = min(1.0, (b - MORAL_B0)/0.55) k = int(200*u) sc = Image.new("RGBA", (W, H), (0, 0, 0, 0)); scd = mkdraw(sc) scd.rectangle([0, 0, WB, HB], fill=(24, 20, 28, k)) scd.rounded_rectangle([90, HB*0.22, WB-90, HB*0.78], radius=26, fill=(246, 238, 216, int(255*u)), outline=INK + (int(255*u),), width=LW) out = Image.alpha_composite(out.convert("RGBA"), sc).convert("RGB") d = mkdraw(out) if u > 0.55: f1 = font(52); f2 = font(30) for j, ln in enumerate(("YOU CAN'T BE", "WHAT YOU AIN'T")): lw2 = f1.getlength(ln)/S d.text((WB/2-lw2/2, HB*0.315+j*64), ln, font=f1, fill=INK) d.line([(WB/2-170, HB*0.565), (WB/2+170, HB*0.565)], fill=INK, width=4) ln = "so be what you are" lw2 = f2.getlength(ln)/S d.text((WB/2-lw2/2, HB*0.615), ln, font=f2, fill=(150, 30, 46)) return out def render_shot(job): shot, force = job E = env(); th = Theatre(shot, np.random.default_rng(shot.seed)) anch = th.anchors(); made = 0 for k in range(shot.n): i = shot.i0 + k p = FRAMES / f"f{i:05d}.png" if p.exists() and not force: continue t = i/FPS; e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} stage = th.draw(t, t/BAR, e, shot.section) fr = shoot(stage, anch, shot.engine, k/max(1, shot.n-1), jseed=shot.idx*13 % 97, push=0.045, drift=0.8) post_frame(fr, i, e, shot).save(p, compress_level=1); made += 1 return f"shot {shot.idx:02d} {shot.engine:20s} {shot.section:11s} {made}/{shot.n}" def contact_sheet(shots): cols = 6; rows = (len(shots)+cols-1)//cols tw, th_ = PX(300), PX(193) lab = PX(24) sheet = Image.new("RGB", (cols*tw, rows*(th_+lab)), (10, 10, 14)) sd = ImageDraw.Draw(sheet); E = env() for n, sh in enumerate(shots): th2 = Theatre(sh, np.random.default_rng(sh.seed)); anch = th2.anchors() mid = sh.n//2; i = sh.i0+mid; t = i/FPS e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} stage = th2.draw(t, t/BAR, e, sh.section) fr = shoot(stage, anch, sh.engine, mid/max(1, sh.n-1), jseed=sh.idx*13 % 97) im = post_frame(fr, i, e, sh).resize((tw, th_), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th_+lab) sheet.paste(im, (cx, cy)) sd.text((cx+PX(5), cy+th_+PX(4)), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:.1f}s", font=font(12), fill=(190, 195, 205)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true"); ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true"); ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--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={SETDIR} — {TITLE}", str(out)], check=True, capture_output=True) try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = "unknown" (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\ngit: {sha}\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 of the {PX(SW_S)}x{PX(SH_S)} stage\n" f"music: {MUSIC_DESC}\nlook: {ENGINE_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"source: renders/side_quests/{NAME}/render.py (76 bars, 118.4s)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()