#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Dance Class (31/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/dance_class # # A shoggoth takes a beginners' dance class. # # 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/dance_class.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/dance_class.mp4 # cover: https://genekogan.com/player_computer/media/dance_class.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 dance_class.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 — "DANCE CLASS" (tightened cut of side_quests 03) Disco-funk / house, 112bpm, Bb minor. Instrumental except for a counted-in "five, six, seven, eight". A shoggoth takes a beginners' dance class. Everyone else picks up the routine in about four bars. The shoggoth does not. It has too many limbs and all of them are enthusiastic. It goes off on its own in the mirror for a while. Then, somewhere in the second run-through, it lands one move — exactly one, exactly on the beat — and the whole class stops to look at it. No dialogue, no narration. The joke has to survive on timing alone, which is also what the piece is about. Look: a bright wood-floor studio with a mirrored wall, drawn on a stage the camera crops — wides of the line, close-ups on the instructor's face, an insert on eight feet that are not together, and the mirror, which never lies. Composition: engine : audio-first x shot-parallel x stage-and-camera content: audio-groove (house kit, clav, wah guitar, horn stabs) x shoggoth (body, tentacles, the mask states) x mars-characters (blocky bodies) x effects-post Round 2 (player_computer_2): the second half becomes a disco. Twenty bars in, the music modulates out of Bb minor into Db major and turns into four-on-the- floor disco — warm detuned pads, rubbery octave bass, open hats on the offbeat, claps on two and four, octave string stabs. Daylight drains out of the clerestory windows, a silver mirror ball comes down on its cable, and the room fills with sweeping specular dots and rotating colour washes. The class dances bigger. The shoggoth is still on the wrong beat — until it isn't. Canvas is 1280x720 (16:9) for this set, recomposed rather than padded: the 1680x940 stage is itself ~16:9, so the wider delivery frame simply reveals more of each painted set. Nothing is stretched and the cinematic bars are gone. There is also a native 1920x1080 delivery (`--1080p`): the same drawing re-rasterised on a 2520x1410 stage with every pixel constant multiplied by S = 1.5, not an upscale of the 720p master. Same audio, separate frames dir, separate mp4. Run from repo root: python3 renders/player_computer_final/dance_class/render.py --sheet python3 renders/player_computer_final/dance_class/render.py --jobs 4 python3 renders/player_computer_final/dance_class/render.py --720p """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "dance_class" TITLE = "DANCE CLASS" SETNUM = "03" SETDIR = "player_computer_final" W, H, FPS = 1920, 1080, 30 # ── delivery scale ─────────────────────────────────────────────────────────── # Everything in this file is authored in *stage units* — the 1680x940 painted # stage and the 1280x720 delivery frame. `S` is the single global that turns # those units into real pixels: S = H/720. At the default S = 1.0 every code # path below is the original one, byte for byte. `--1080p` sets S = 1.5 and # W/H = 1920/1080, and every rasterised constant — coordinates, stroke widths, # blur radii, font sizes, chroma offset, film grain, the stage itself — scales # with it. Nothing is upscaled after the fact; the drawing is re-rasterised. # FINAL CUT: 1080p is the default delivery — S = 1.5. `--720p` restores the # round-2 master byte for byte. S = H/720.0 def P(v): """stage/delivery unit -> real pixel (int)""" return int(round(v*S)) def B(r): """blur radius in stage units -> real pixels (float; identity at S=1)""" 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 that multiplies geometry by S at rasterisation time. Only the first positional argument (the xy geometry) and the `width` keyword are touched. arc/chord/pieslice take *angles* as positional args 2 and 3 — those must pass through untouched, which is why this scales the first positional only rather than everything numeric. """ __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))) 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 = 126.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" # The tightened arrangement. Original bar lengths in the comments — the cut is # proportional, not uniform: the two sections that carry the joke (solo, where # it is alone with the mirror, and themove, where it lands) give up the least. # The first twenty bars are the round-1 cut, untouched. Bar 20 is the hinge: # everything after it is the disco, and it gets exactly half the piece. SECTIONS = [ ("corridor", 0, 3), # one look at the door ("arrive", 3, 7), ("warmup", 7, 10), # the class gets it, the shoggoth doesn't ("routine1", 10, 16), # the wrong-beat comedy ("solo", 16, 20), # mirror, sad mask, strings # ── the disco half ── ("nightfall",20, 24), # modulation; the windows go from gold to blue ("balldrop", 24, 28), # the mirror ball comes down ("disco", 28, 34), # full four-on-the-floor, the room is a dance floor ("themove", 34, 38), # it snaps to the beat under the ball ("street", 38, 40), # walk home over the outro tail ] SEC_START = {n: a for n, a, b in SECTIONS} FREEZE_BAR = 36.0 # inside themove, on a shot boundary and a horn stab FILL_BARS = (15, 19, 23, 27, 33, 37) # Picture timings for the disco transition, in bars. NIGHT_B0, NIGHT_B1 = 20.0, 23.7 # daylight -> night through the windows BALL_B0, BALL_B1 = 24.0, 25.9 # the ball descends DOTS_B0, DOTS_B1 = 25.3, 27.4 # specular dots + colour washes come up BALL_X, BALL_Y, BALL_R = 830, 268, 66 MIR_TOP, MIR_BOT = 210, 470 # the mirror, now under a clerestory band WIN_TOP, WIN_BOT = 22, 190 N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 3.0 N_FRAMES = int(DUR * FPS) MUSIC_DESC = (f"disco-funk / house in Bb minor turning full disco in Db major, " f"{BPM:.0f}bpm, {N_BARS} bars, instrumental") ENGINE_DESC = "three sets — corridor / studio / street — on a 1680x940 stage, day into disco" 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.06 PROG = [(nf("Bb1"), [nf("Db4"), nf("F4"), nf("Ab4")]), (nf("Gb1"), [nf("Db4"), nf("Gb4"), nf("Bb4")]), (nf("Eb1"), [nf("Gb4"), nf("Bb4"), nf("Eb5")]), (nf("F1"), [nf("C4"), nf("F4"), nf("Ab4")])] # The disco half lifts into the relative major — same pitch collection, the # whole thing suddenly reads warm. I - vi - IV - V in Db. PROG2 = [(nf("Db2"), [nf("Ab3"), nf("Db4"), nf("F4")]), (nf("Bb1"), [nf("Db4"), nf("F4"), nf("Bb4")]), (nf("Gb1"), [nf("Db4"), nf("Gb4"), nf("Bb4")]), (nf("Ab1"), [nf("Eb4"), nf("Ab4"), nf("C5")])] DISCO_SECS = ("nightfall", "balldrop", "disco", "themove", "street") def build_song(): s = Song(DUR) R = np.random.RandomState(112) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "bow" for bar in range(N_BARS): sec = sec_of(bar) disco = sec in DISCO_SECS root, notes = (PROG2 if disco else PROG)[bar % 4] full = sec == "routine1" thin = sec in ("corridor", "arrive", "solo") # how hard the disco is pushing: it fades up across nightfall, sits at # full through balldrop/disco/themove and eases off for the walk home dg = 1.0 if sec == "nightfall": dg = 0.58 + 0.42*((bar - SEC_START["nightfall"])/3.0) elif sec == "balldrop": dg = 0.94 elif sec == "street": dg = 0.70 if disco: # four on the floor, open hats on the & of every beat, claps on 2/4 for st in (0, 4, 8, 12): at = s.t(bar, st, SW) s.put("drums", kick(dur=.33, f0=152, f1=47, punch=33), at, g=.97*dg) s.kick_t.append(at) for st in (2, 6, 10, 14): s.put("drums", hat(dur=.19, openh=True), s.t(bar, st, SW), g=.33*dg, pan=.28) for st in range(1, 16, 2): s.put("drums", hat(), s.t(bar, st, SW), g=.11*dg, pan=-.24) for st in (4, 12): s.put("drums", clapn(), s.t(bar, st, SW), g=.46*dg, pan=-.05) for st in range(0, 16, 2): s.put("perc", shaker(), s.t(bar, st+1, SW), g=.15*dg, pan=.44) if bar in FILL_BARS: for j, st in enumerate((12, 13, 14, 15)): s.put("drums", tomh(202-30*j), s.t(bar, st, SW), g=.40, pan=-.42+.28*j) elif not (sec == "corridor" and bar < 2): for st in (0, 4, 8, 12): at = s.t(bar, st, SW) s.put("drums", kick(dur=.30, f0=140, f1=48, punch=30), at, g=.62 if thin else .94) s.kick_t.append(at) for st in (2, 6, 10, 14): s.put("drums", hat(openh=True), s.t(bar, st, SW), g=.24, pan=.30) if not thin: for st in range(0, 16, 2): s.put("drums", hat(), s.t(bar, st+1, SW), g=.15, pan=-.25) for st in (4, 12): s.put("drums", clapn(), s.t(bar, st, SW), g=.40, pan=-.06) if bar in FILL_BARS: for j, st in enumerate((12, 13, 14, 15)): s.put("drums", tomh(190-28*j), s.t(bar, st, SW), g=.36, pan=-.4+.26*j) if disco: # rubbery octave-jumping eighths — the whole point of the genre for st in range(0, 16, 2): o = 2.0 if (st//2) % 2 else 1.0 s.put("bass", voice(root*2*o, BEAT*.29, kind="saw", nh=18, c0=1650, c1=360, ck=12, res=.80, a=.003, d=.065, s=.22, r=.05, seed=bar*3+st), s.t(bar, st, SW), g=.35*dg) s.put("sub", voice(root, BAR*.92, kind="sine", nh=2, c0=200, c1=95, ck=2, a=.01, d=.3, s=.9, r=.2, seed=bar), s.t(bar, 0), g=.32*dg) # warm detuned analog pad — the thing that makes it feel like a room for k2, f2 in enumerate(notes): s.put("pads", voice(f2/2, BAR*1.3, kind="saw", nh=20, c0=2300, c1=880, ck=1.05, detune=(-1.4, -.35, .45, 1.5), vib=(.008, 4.2), a=.34, d=.6, s=.80, r=.95, seed=bar*19+k2), s.t(bar, 0), g=.135*dg, pan=-.46+.31*k2) elif sec not in ("corridor", "arrive"): for st in range(0, 16, 2): o = 2.0 if (st//2) % 2 else 1.0 s.put("bass", voice(root*2*o, BEAT*.34, kind="saw", nh=16, c0=1300, c1=430, ck=9, res=.55, a=.003, d=.08, s=.32, r=.06, seed=bar*3+st), s.t(bar, st, SW), g=.28 if not thin else .18) s.put("sub", voice(root, BAR*.9, kind="sine", nh=2, c0=190, c1=95, ck=2, a=.01, d=.3, s=.9, r=.2, seed=bar), s.t(bar, 0), g=.30 if not thin else .20) # octave string stabs, the disco signature if sec in ("disco", "themove"): for j, st in enumerate((0, 3, 6, 8, 11, 14)): f2 = notes[j % 3] * (2.0 if j % 2 else 1.0) s.put("strs", voice(f2, BEAT*.30, kind="saw", nh=22, c0=4400, c1=1500, ck=7.5, res=.35, detune=(-1.15, 1.2), a=.012, d=.10, s=.32, r=.10, seed=bar*23+st), s.t(bar, st, SW), g=.105, pan=.22 if j % 2 else -.22) # clav — the class's own metronome if sec not in ("corridor", "arrive", "solo"): for st in (3, 6, 7, 11, 14, 15): s.put("clav", voice(notes[st % 3], BEAT*.20, kind="square", nh=14, c0=3800, c1=1600, ck=17, res=.5, a=.002, d=.05, s=.2, r=.05, seed=bar*7+st), s.t(bar, st, SW), g=.14, pan=-.32) # wah guitar if full or sec in ("disco", "themove"): for st in (2, 6, 10, 14): wah = 700 + 2200*(0.5+0.5*math.sin(bar*1.3 + st*0.8)) s.put("gtr", voice(notes[0]*2, BEAT*.26, kind="saw", nh=18, c0=wah, c1=wah*0.5, ck=13, res=.85, a=.003, d=.06, s=.25, r=.05, seed=bar*11+st), s.t(bar, st, SW), g=.12, pan=.34) # horn stabs for the move + the bow if sec in ("themove", "street") and (bar - SEC_START["themove"]) % 2 == 0: for k2, f2 in enumerate(notes): s.put("horns", voice(f2*2, BEAT*.7, kind="saw", nh=20, c0=3200, c1=1300, ck=5, res=.45, detune=(-.9, 1.0), a=.02, d=.15, s=.55, r=.2, seed=bar*13+k2), s.t(bar, 0, SW), g=.14, pan=-.25+.25*k2) # strings pad in the solo — the sad mirror bit if sec == "solo": for k2, f2 in enumerate(notes): s.put("strings", voice(f2/2, BAR*1.2, kind="saw", nh=22, c0=1700, c1=800, ck=.8, detune=(-1.6, -.4, .6, 1.7), vib=(.014, 4.6), a=.8, d=.8, s=.75, r=1.0, seed=bar*17+k2), s.t(bar, 0), g=.11, pan=-.5+.35*k2) # count-in, spoken, once # count-in lands in the last bar before routine1, exactly as before s.put("vox_sp", speak("five, six, seven, eight", voice="Moira", rate=210, cache=AUD), (SEC_START["routine1"] - 1)*BAR + BEAT*2.0, g=.42) s.put("fx", crash(dur=1.6), SEC_START["routine1"]*BAR, g=.26, pan=.1) # the modulation — riser out of the mirror solo, crash onto the new key s.put("fx", riser(BAR*2.2), (SEC_START["nightfall"] - 2.2)*BAR, g=.21) s.put("fx", crash(dur=2.6), SEC_START["nightfall"]*BAR, g=.30, pan=.05) # the ball comes down on a string glissando s.put("fx", gliss(BAR*1.7), (SEC_START["balldrop"] - 1.6)*BAR, g=.20, pan=-.12) s.put("fx", crash(dur=2.2), SEC_START["balldrop"]*BAR, g=.24, pan=.12) s.put("fx", riser(BAR*2.0), (SEC_START["disco"] - 2.0)*BAR, g=.18) s.put("fx", crash(dur=2.8), SEC_START["disco"]*BAR, g=.30, pan=.1) s.put("fx", crash(dur=2.0), SEC_START["themove"]*BAR, g=.28, pan=.1) s.put("fx", applause(3.4), SEC_START["street"]*BAR, g=.34) s.bus("pads", lambda x: reverb(x, rt=2.8, mix=.34, seed=467)) s.bus("strs", lambda x: delay(x, BEAT*.75, .24, .16)) s.bus("strs", lambda x: reverb(x, rt=1.9, mix=.26, seed=479)) s.bus("clav", lambda x: delay(x, BEAT*.75, .28, .18)) s.bus("gtr", lambda x: delay(x, BEAT*.5, .30, .20)) s.bus("horns", lambda x: reverb(x, rt=1.8, mix=.28, seed=443)) s.bus("strings", lambda x: reverb(x, rt=3.4, mix=.50, seed=449)) s.bus("vox_sp", lambda x: reverb(x, rt=1.4, mix=.24, seed=457)) s.bus("fx", lambda x: reverb(x, rt=2.2, mix=.30, seed=461)) mix = s.mixdown(dict(drums=1.0, bass=1.0, sub=1.0, clav=1.0, gtr=1.0, horns=1.0, strings=1.0, vox_sp=1.0, fx=1.0, pads=1.0, strs=1.0, perc=1.0), pump_depth=.26, pump_rel=.14, levels=dict(corridor=.34, arrive=.52, warmup=.74, routine1=.94, solo=.46, nightfall=.86, balldrop=.94, disco=1.0, themove=1.0, street=.70)) wav = AUD / "final.wav" s.write(wav, mix) return wav, mix def clapn(dur=.32, seed=151): n = int(dur*SR); out = np.zeros(n); rng = np.random.RandomState(seed) for off in (0.0, .009, .018, .028): i = int(off*SR); m = n-i if m <= 0: continue t = np.arange(m)/SR out[i:] += bandshape(rng.randn(m), lo=1000, hi=5000)*np.exp(-t*44)*0.6 return out def gliss(dur=1.9, seed=61): """Ascending harp/string run — the sweep the mirror ball comes down on.""" n = int(dur*SR); out = np.zeros(n + SR//2) steps = [0, 2, 4, 5, 7, 9, 11] base = nf("Db3") for i in range(21): f = base * 2 ** ((steps[i % 7] + 12*(i//7)) / 12.0) at = int(((i/21.0) ** 0.86) * dur * SR) seg = ks(f, 0.55, damp=0.9968, seed=seed+i) * (0.30 + 0.70*i/21.0) j = min(len(out), at+len(seg)) out[at:j] += seg[:j-at] return out[:n] * 0.55 def tomh(f0): n = int(.28*SR); t = np.arange(n)/SR f = f0*0.5 + (f0-f0*0.5)*np.exp(-t*15) return np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*8)*0.8 def applause(dur=4.0, seed=157): n = int(dur*SR); rng = np.random.RandomState(seed) out = np.zeros(n) for _ in range(420): i = rng.randint(0, n-2000) m = 1400 t = np.arange(m)/SR out[i:i+m] += bandshape(rng.randn(m), lo=900, hi=5200)*np.exp(-t*38)*rng.uniform(.2, .8) env = np.clip(np.arange(n)/(0.25*SR), 0, 1)*np.exp(-np.arange(n)/SR*0.55) return out*env*0.30 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) if S != 1.0: # the crop is computed in stage units box = tuple(P(v) for v in box) return stage_img.crop(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) # ════════════════════════════════════════════════════════════════════════════ # 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 # ════════════════════════════════════════════════════════════════════════════ # THE STUDIO # ════════════════════════════════════════════════════════════════════════════ SH_S = 940 # The old close-ups cropped to 0.32 of the stage, which put a shoggoth's # mouth across the whole frame. Everything moves out one size. SHOT_W = {"wide": 1.00, "full": 0.88, "mid": 0.70, "ots": 0.58, "cu": 0.48, "ins": 0.34, "macro": 0.22} NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} SCENE_OF = {"corridor": "corridor", "arrive": "studio", "warmup": "studio", "routine1": "studio", "solo": "studio", "nightfall": "studio", "balldrop": "studio", "disco": "studio", "themove": "studio", "street": "street"} PAL = { "floor": (198, 156, 104), "floor2": (176, 134, 86), "wall": (238, 232, 220), "mirror": (206, 218, 224), "mirror2":(178, 196, 206), "barre": (150, 120, 84), "shog": (128, 124, 120), "shog2": (98, 94, 92), "tooth": (238, 232, 214), "gum": (72, 32, 34), "eye": (248, 248, 244), "mask": (250, 208, 60), "dark": (28, 26, 30), } CLASS = [ dict(skin=(232, 200, 168), shirt=(232, 96, 120), pants=(48, 50, 62), hair=(40, 32, 26), h=0.96, skirt=True, longhair=True), dict(skin=(112, 78, 52), shirt=(96, 196, 178), pants=(230, 226, 214), hair=(28, 24, 20), h=1.04, skirt=False, longhair=False), dict(skin=(226, 192, 160), shirt=(120, 130, 236), pants=(56, 58, 70), hair=(198, 176, 96), h=0.88, skirt=True, longhair=True), dict(skin=(200, 160, 124), shirt=(246, 206, 92), pants=(40, 42, 52), hair=(52, 40, 30), h=1.02, skirt=False, longhair=False), dict(skin=(244, 214, 186), shirt=(214, 124, 208), pants=(214, 124, 208), hair=(122, 66, 40), h=0.92, skirt=True, longhair=True), ] INSTR = dict(skin=(238, 208, 176), shirt=(34, 34, 40), pants=(34, 34, 40), hair=(206, 60, 90), h=1.00, skirt=True, longhair=True) FLOOR_Y = 720 LINE_X = [470, 690, 910, 1130, 1350] # where the class stands SHOG_X = 1450 def dancer(d, c, cx, cy, t, *, beat=0.0, style=0, face="neutral", pep=1.0): """Blocky body doing the routine. `beat` is 0..1 through the 4-count.""" h = c["h"]; skin, shirt, pants, hair = c["skin"], c["shirt"], c["pants"], c["hair"] hr = int(21*h); nk = int(6*h); tw = int(32*h); th = int(48*h) aw = int(9*h); ah = int(43*h); lh = int(48*h) step = math.sin(beat*math.tau) hip = step*int(9*h*pep) bob = abs(math.sin(beat*math.tau*2))*int(7*h*pep) cx = int(cx + hip) hy = int(cy - lh - th - nk - hr - bob) tt = hy + hr + nk; tb = tt + th ls = step*int(16*h*(0.72+0.28*pep)) d.line([(cx-4, tb), (cx-4-ls, tb+lh)], fill=pants, width=int(12*h)) d.line([(cx+4, tb), (cx+4+ls, tb+lh)], fill=pants, width=int(12*h)) for sgn in (-1, 1): fx = cx + sgn*4 + sgn*abs(ls) d.rectangle([fx-int(9*h), tb+lh-4, fx+int(9*h), tb+lh+5], fill=(26, 26, 30)) d.rectangle([cx-tw//2, tt, cx+tw//2, tb], fill=shirt) if c.get("skirt"): flare = int(9*h) + int(abs(step)*6*h) d.polygon([(cx-tw//2, tb-int(6*h)), (cx+tw//2, tb-int(6*h)), (cx+tw//2+flare, tb+int(16*h)), (cx-tw//2-flare, tb+int(16*h))], fill=shirt) # arms: the routine is arms-up on 1, out on 3 up = min(1.0, max(0.0, math.sin(beat*math.tau))*pep) out_ = max(0.0, -math.sin(beat*math.tau)) for sgn in (-1, 1): sx = cx + sgn*(tw//2); sy = tt+7 ang = math.pi/2 - up*1.5 - out_*0.55 ex = sx + sgn*abs(math.cos(math.pi/2-ang))*ah*0.85 ey = sy + math.sin(ang)*ah d.line([(sx, sy), (ex, ey)], fill=shirt, width=aw) d.ellipse([ex-aw*.6, ey-aw*.6, ex+aw*.6, ey+aw*.6], fill=skin) # Hair, drawn in three parts so it frames the face instead of covering it. # The first version was one slab from the crown to below the chin, which # buried the neck and the top of the torso. if c.get("longhair"): sw2 = step*int(3*h) # back mass: BEHIND the head, falling to about the shoulder line d.ellipse([cx-hr-int(4*h)+sw2, hy-hr-int(2*h), cx+hr+int(4*h)+sw2, hy+int(hr*1.25)], fill=hair) d.ellipse([cx-hr, hy-hr, cx+hr, hy+hr], fill=skin) # face if c.get("longhair"): sw2 = step*int(3*h) # two side locks down to the jaw, leaving the neck and chin clear for sgn in (-1, 1): lx = cx + sgn*int(hr*0.80) d.ellipse([lx-int(hr*0.34)+sw2, hy-int(hr*0.85), lx+int(hr*0.34)+sw2, hy+int(hr*0.95)], fill=hair) # the cap, always d.chord([cx-hr, hy-hr, cx+hr, hy+int(hr*0.55)], 180, 360, fill=hair) ey2 = hy+int(hr*.05) for ex2 in (-int(hr*.36), int(hr*.36)): d.ellipse([cx+ex2-3, ey2-4, cx+ex2+3, ey2+4], fill=(255, 255, 255)) d.ellipse([cx+ex2-2, ey2-2, cx+ex2+2, ey2+3], fill=(26, 22, 20)) mo = int(hr*.40) if face == "happy": d.arc([cx-mo, ey2+int(hr*.16), cx+mo, ey2+int(hr*.60)], 20, 160, fill=(120, 60, 50), width=3) elif face == "shock": d.ellipse([cx-6, ey2+int(hr*.30), cx+6, ey2+int(hr*.30)+10], fill=(90, 45, 40)) else: d.line([cx-mo*.6, ey2+int(hr*.40), cx+mo*.6, ey2+int(hr*.40)], fill=(120, 70, 60), width=2) def shoggoth(d, cx, cy, t, *, beat=0.0, sc=1.0, mood="neutral", limbs=7, seed=3): """Grey lobed blob, too many eyes, too many enthusiastic limbs.""" rng = np.random.RandomState(seed) R = 118*sc wob = math.sin(t*2.1)*5*sc + abs(math.sin(beat*math.tau*2))*10*sc cy = cy - R*0.9 - wob # tentacles first, behind the body for i in range(limbs): a = (i/limbs)*math.tau + math.sin(t*0.7+i)*0.35 ln = R*(1.15 + 0.55*abs(math.sin(t*1.9 + i*1.3 + beat*math.tau))) x0, y0 = cx + math.cos(a)*R*0.7, cy + math.sin(a)*R*0.55 mx = x0 + math.cos(a+0.5)*ln*0.6 my = y0 + math.sin(a+0.5)*ln*0.6 ex = x0 + math.cos(a + math.sin(t*2.6+i)*0.9)*ln ey = y0 + math.sin(a + math.sin(t*2.6+i)*0.9)*ln d.line([(x0, y0), (mx, my), (ex, ey)], fill=PAL["shog2"], width=max(2, int(11*sc)), joint="curve") d.ellipse([ex-4*sc, ey-4*sc, ex+4*sc, ey+4*sc], fill=PAL["shog2"]) # lobed body for (ox, oy, rr) in ((-0.42, -0.10, 0.66), (0.40, -0.16, 0.62), (0.0, 0.24, 0.72), (-0.16, -0.42, 0.52), (0.24, 0.36, 0.50)): d.ellipse([cx+ox*R-rr*R, cy+oy*R-rr*R, cx+ox*R+rr*R, cy+oy*R+rr*R], fill=PAL["shog"]) # eyes, many, various EY = [(-0.42, -0.30, 0.19), (-0.10, -0.44, 0.12), (0.30, -0.34, 0.22), (0.50, -0.02, 0.11), (-0.62, 0.06, 0.10), (-0.24, 0.30, 0.16), (0.14, 0.44, 0.09), (0.44, 0.30, 0.13)] for (ox, oy, rr) in EY: ex, ey = cx+ox*R, cy+oy*R r = rr*R d.ellipse([ex-r, ey-r, ex+r, ey+r], fill=PAL["eye"]) look = 0.30*r px = ex + math.cos(t*1.1+ox*7)*look py = ey + math.sin(t*1.4+oy*7)*look if mood == "shut": d.line([ex-r, ey, ex+r, ey], fill=PAL["shog2"], width=max(2, int(3*sc))) else: d.ellipse([px-r*.5, py-r*.5, px+r*.5, py+r*.5], fill=(22, 20, 24)) # mouth mw, mh = R*0.66, R*0.20 my2 = cy + R*0.10 d.ellipse([cx-mw, my2-mh, cx+mw, my2+mh], fill=PAL["gum"]) nteeth = 11 for i in range(nteeth): tx = cx - mw + (i+0.5)*(2*mw/nteeth) d.polygon([(tx-mw/nteeth*0.8, my2-mh*0.85), (tx+mw/nteeth*0.8, my2-mh*0.85), (tx, my2+mh*0.5)], fill=PAL["tooth"]) if mood in ("mask", "mask_sad"): mr = R*0.30 mx2, my3 = cx-R*0.34, cy-R*0.30 d.ellipse([mx2-mr, my3-mr, mx2+mr, my3+mr], fill=PAL["mask"]) for ex2 in (-0.36, 0.36): d.ellipse([mx2+ex2*mr-mr*.13, my3-mr*.28, mx2+ex2*mr+mr*.13, my3-mr*.02], fill=(40, 34, 12)) if mood == "mask": d.arc([mx2-mr*.5, my3-mr*.1, mx2+mr*.5, my3+mr*.55], 20, 160, fill=(40, 34, 12), width=max(2, int(3*sc))) else: d.arc([mx2-mr*.5, my3+mr*.15, mx2+mr*.5, my3+mr*.8], 20, 160, fill=(40, 34, 12), width=max(2, int(3*sc))) def clamp01(u): return 0.0 if u < 0 else (1.0 if u > 1 else float(u)) def draw_windows(d, night, t): """Clerestory band above the mirror. Afternoon gold -> dusk -> night.""" hgt = WIN_BOT - WIN_TOP if night < 0.5: u = night*2.0 a = ((150, 196, 238), (214, 232, 246)) # flat afternoon b = ((236, 152, 96), (252, 214, 150)) # dusk else: u = (night-0.5)*2.0 a = ((236, 152, 96), (252, 214, 150)) b = ((16, 18, 48), (54, 44, 86)) top = tuple(lerp(a[0][i], b[0][i], u) for i in range(3)) bot = tuple(lerp(a[1][i], b[1][i], u) for i in range(3)) for wi in range(4): x0 = 128 + wi*396; x1 = x0 + 316 for row in range(0, hgt, 6): v = row/float(hgt) col = tuple(int(lerp(top[i], bot[i], v)) for i in range(3)) d.rectangle([x0, WIN_TOP+row, x1, WIN_TOP+row+6], fill=col) # the sun sinks, the moon comes up if night < 0.82: sy = WIN_TOP + 34 + night*104 sc = tuple(int(lerp(c, dd, min(1.0, night*1.3))) for c, dd in ((252, 250), (238, 168), (186, 108))) d.ellipse([x0+120, sy-26, x0+172, sy+26], fill=sc) if night > 0.62: mu = (night-0.62)/0.38 my = WIN_TOP + 86 - mu*46 d.ellipse([x0+206, my-15, x0+236, my+15], fill=(int(160+70*mu), int(162+70*mu), int(150+68*mu))) # rooftops d.polygon([(x0, WIN_BOT), (x0, WIN_BOT-20), (x0+58, WIN_BOT-20), (x0+58, WIN_BOT-38), (x0+126, WIN_BOT-38), (x0+126, WIN_BOT-14), (x0+196, WIN_BOT-14), (x0+196, WIN_BOT-44), (x0+254, WIN_BOT-44), (x0+254, WIN_BOT-18), (x1, WIN_BOT-18), (x1, WIN_BOT)], fill=tuple(int(lerp(c, 12, night)) for c in (104, 96, 104))) if night > 0.55: g = (night-0.55)/0.45 for k, (lx, ly) in enumerate(((22, 26), (74, 8), (140, 30), (208, 4), (262, 28))): if (k*7 + wi*3) % 4 == 0: continue d.rectangle([x0+lx, WIN_BOT-44+ly, x0+lx+11, WIN_BOT-44+ly+11], fill=(int(228*g), int(188*g), int(104*g))) # frame + mullions fr = tuple(int(lerp(c, c*0.30, night)) for c in (208, 198, 182)) d.rectangle([x0-11, WIN_TOP-11, x1+11, WIN_BOT+13], outline=fr, width=11) d.line([(x0+158, WIN_TOP), (x0+158, WIN_BOT)], fill=fr, width=9) d.line([(x0, WIN_TOP+hgt//2), (x1, WIN_TOP+hgt//2)], fill=fr, width=7) d.rectangle([x0-22, WIN_BOT+13, x1+22, WIN_BOT+27], fill=tuple(int(lerp(c, c*0.34, night)) for c in (186, 174, 156))) d.rectangle([0, WIN_BOT+30, SW_S, WIN_BOT+38], fill=tuple(int(lerp(c, c*0.34, night)) for c in (198, 190, 176))) def disco_ball(d, cx, cy, r, spin): """A sphere of little mirrors. Facets are shaded by their normal and a few of them catch the light dead-on and go white.""" d.ellipse([cx-r, cy-r, cx+r, cy+r], fill=(74, 78, 92)) nlat, nlon = 12, 22 for i in range(nlat): ph = -math.pi/2 + (i+0.5)*math.pi/nlat yy = math.sin(ph); rr = math.cos(ph) fs = r*0.052 + r*0.030*rr for j in range(nlon): th = j*math.tau/nlon + spin X = math.cos(th)*rr; Z = math.sin(th)*rr if Z <= 0.06: continue px = cx + X*r*0.94; py = cy - yy*r*0.94 nl = max(0.0, X*(-0.52) + yy*0.52 + Z*0.68) sh = 0.30 + 0.70*nl spec = nl ** 9 v = int(min(255, 128*sh + 150*spec)) col = (v, min(255, v+4), min(255, v+12)) hw = fs*(0.55 + 0.45*Z) d.rectangle([px-hw, py-hw, px+hw, py+hw], fill=col) d.arc([cx-r, cy-r, cx+r, cy+r], 0, 360, fill=(46, 48, 58), width=3) def ball_dots(od, t, spin, amount, ball_y): """The specular dots the ball throws around the room. Each one is a facet: it sweeps horizontally at a rate set by its latitude and wraps.""" TINT = ((236, 240, 248), (208, 226, 246), (246, 232, 208), (224, 214, 246)) for k in range(74): ph = ((k*0.61803398) % 1.0) yy = 40 + ph*(SH_S-90) + math.sin(t*0.55 + k*1.31)*26 sp = 0.55 + 0.75*(1.0 - abs(ph-0.5)*2.0) xx = (((k*0.38196601) + spin*sp/math.tau) % 1.0)*(SW_S+340) - 170 rr = 5.0 + 7.0*(k % 4)/3.0 b = (0.42 + 0.58*abs(math.sin(t*2.7 + k*1.7))) * amount col = tuple(int(c*b) for c in TINT[k % 4]) od.ellipse([xx-rr, yy-rr*0.86, xx+rr, yy+rr*0.86], fill=col) if k % 5 == 0: # a few throw little cross flares od.line([(xx-rr*2.3, yy), (xx+rr*2.3, yy)], fill=tuple(int(c*0.55) for c in col), width=2) class Studio: def __init__(self, shot, rng): self.rng = rng def anchors(self): a = {"_": (SW_S/2, SH_S*0.54), "instr": (250, 540), "shog": (SHOG_X, 520), "mirror": (SW_S/2, 330), "feet": (880, 790), "line": (880, 560), "win": (1400, 432), "ball": (BALL_X, BALL_Y + 40)} for i, x in enumerate(LINE_X): a[f"d{i}"] = (x, 520) return a def draw(self, t, bar, e, sec): scene = SCENE_OF.get(sec, "studio") if scene == "corridor": return self.draw_corridor(t, bar, e, sec) if scene == "street": return self.draw_street(t, bar, e, sec) return self.draw_studio(t, bar, e, sec) def draw_corridor(self, t, bar, e, sec): """Outside the door, five minutes early.""" im, d = new_stage((206, 198, 186)) beat = (t/BEAT) % 4 / 4.0 d.rectangle([0, 0, SW_S, 300], fill=(226, 220, 208)) d.rectangle([0, 300, SW_S, 560], fill=(176, 168, 156)) # dado d.rectangle([0, 552, SW_S, 566], fill=(120, 112, 100)) d.rectangle([0, 566, SW_S, SH_S], fill=(146, 138, 128)) for x in range(-120, SW_S+200, 160): # lino d.line([x, 566, x-90, SH_S], fill=(134, 126, 116), width=3) for lx in range(240, SW_S, 420): # strip lights fl = 0.82 + 0.18*math.sin(t*7.7 + lx) d.rectangle([lx-130, 34, lx+130, 58], fill=tuple(min(255, int(v*fl)) for v in (250, 250, 236))) # the studio door with a wired-glass window d.rectangle([980, 250, 1310, 800], fill=(118, 96, 72), outline=(70, 56, 42), width=6) d.rectangle([1030, 300, 1260, 520], fill=(196, 214, 206)) for gx in range(1030, 1260, 22): d.line([gx, 300, gx, 520], fill=(170, 190, 184)) for gy in range(300, 520, 22): d.line([1030, gy, 1260, gy], fill=(170, 190, 184)) d.ellipse([1268, 540, 1296, 568], fill=(206, 176, 88)) # noticeboard d.rectangle([220, 250, 640, 520], fill=(232, 226, 210), outline=(120, 96, 60), width=8) f = font(26) d.text((250, 282), "BEGINNERS", font=f, fill=(40, 36, 40)) d.text((250, 322), "TUESDAYS 7PM", font=f, fill=(40, 36, 40)) d.text((250, 372), "ALL WELCOME", font=f, fill=(178, 46, 66)) d.text((250, 430), "no experience", font=font(22), fill=(90, 86, 90)) d.text((250, 460), "necessary", font=font(22), fill=(90, 86, 90)) # bench + coat hooks d.rectangle([120, 640, 560, 664], fill=(150, 120, 84)) for bx in (150, 520): d.rectangle([bx, 664, bx+18, 790], fill=(120, 96, 66)) for hx in range(700, 940, 60): d.line([hx, 300, hx, 330], fill=(90, 80, 70), width=5) # the shoggoth, waiting, mask on, small u2 = np.clip(bar/2.5, 0, 1) sx = lerp(-260, 700, ease_out(u2)) shoggoth(d, sx, FLOOR_Y+40, t, beat=0.0, sc=0.72, mood="mask", limbs=6) return im def draw_street(self, t, bar, e, sec): """Outside afterwards. The lit window is the one they were just in.""" im, d = new_stage((48, 44, 76)) beat = (t/BEAT) % 4 / 4.0 g = np.linspace(0, 1, P(SH_S))[:, None] arr = np.zeros((P(SH_S), P(SW_S), 3), np.float32) for j, c in enumerate((236, 150, 120)): arr[..., j] = c*(1-g)**2.6 + (40+j*6) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)); d = mkdraw(im) for bx, bh, bw in ((-40, 420, 300), (300, 560, 260), (600, 340, 220), (860, 620, 300), (1220, 460, 280), (1520, 520, 260)): d.rectangle([bx, SH_S-160-bh, bx+bw, SH_S-160], fill=(34, 30, 54)) for wy in range(SH_S-160-bh+30, SH_S-180, 46): for wx in range(bx+22, bx+bw-22, 44): if ((wx*7+wy*3) % 5) < 2: d.rectangle([wx, wy, wx+20, wy+26], fill=(248, 206, 126)) # the studio window, first floor, still lit d.rectangle([880, 240, 1180, 430], fill=(252, 226, 160), outline=(30, 26, 44), width=8) for i, x in enumerate(range(920, 1150, 56)): bobh = abs(math.sin(beat*math.tau + i))*10 d.ellipse([x, 330-bobh, x+34, 372-bobh], fill=(70, 56, 40)) d.rectangle([0, SH_S-160, SW_S, SH_S], fill=(40, 36, 58)) d.rectangle([0, SH_S-160, SW_S, SH_S-146], fill=(58, 52, 78)) for lx in (240, 1180): # streetlights d.rectangle([lx-6, 300, lx+6, SH_S-160], fill=(30, 26, 42)) d.ellipse([lx-40, 276, lx+40, 322], fill=(252, 232, 176)) for q in range(6, 0, -1): rr = q*13 d.ellipse([lx-rr*1.4, 300-rr, lx+rr*1.4, 300+rr*1.3], fill=(int(58+11*(7-q)), int(52+9*(7-q)), int(80+7*(7-q)))) # walking home, in time # The outro is 2 bars + the tail, so we join the walk home already in # progress — with the original ramp the first street shot was an empty # street for its whole length. u2 = np.clip((bar - SEC_START["street"] + 1.6)/3.8, 0, 1) for i, c in enumerate(CLASS[:3]): x = lerp(-200-i*150, SW_S*0.55+i*150, ease_io(u2)) dancer(d, c, int(x), SH_S-170, t, beat=beat, face="happy") shoggoth(d, lerp(-420, SW_S*0.30, ease_io(u2)), SH_S-150, t, beat=beat, sc=0.80, mood="mask", limbs=7) return im def draw_studio(self, t, bar, e, sec): im, d = new_stage(PAL["wall"]) beat = (t/BEAT) % 4 / 4.0 night = clamp01((bar - NIGHT_B0)/(NIGHT_B1 - NIGHT_B0)) bdrop = clamp01((bar - BALL_B0)/(BALL_B1 - BALL_B0)) dots = clamp01((bar - DOTS_B0)/(DOTS_B1 - DOTS_B0)) pep = 1.0 + 0.80*dots # ── mirror wall — a room behind the room ── d.rectangle([0, MIR_TOP, SW_S, MIR_BOT], fill=PAL["mirror"]) d.rectangle([0, MIR_TOP, SW_S, MIR_TOP+30], fill=PAL["mirror2"]) d.rectangle([0, 348, SW_S, MIR_BOT], fill=(196, 176, 154)) d.rectangle([0, 338, SW_S, 348], fill=(150, 128, 100)) refl = Image.new("RGB", (P(SW_S), P(300)), PAL["mirror"]) rd = mkdraw(refl) beat_r = (t/BEAT) % 4 / 4.0 for i, x in enumerate(LINE_X): # the class, from behind b = 0.0 if sec == "arrive" else beat_r dancer(rd, CLASS[i], x, 292, t, beat=b, pep=pep) dancer(rd, INSTR, 250, 292, t, beat=0.0, pep=pep) if sec == "solo": shoggoth(rd, SW_S*0.5, 292, t, beat=beat_r*0.37, sc=0.78, mood="mask_sad", limbs=9) refl = refl.resize((P(SW_S), P(160)), Image.BILINEAR) ra = np.asarray(refl, np.float32) mm = np.asarray(Image.new("RGB", (P(SW_S), P(160)), PAL["mirror"]), np.float32) ra = ra*0.42 + mm*0.58 # glass haze im.paste(Image.fromarray(np.clip(ra, 0, 255).astype(np.uint8)), (0, P(236))) d = mkdraw(im) for q in range(9): # streak highlights x = 120 + q*190 d.polygon([(x, MIR_TOP), (x+16, MIR_TOP), (x-30, MIR_BOT), (x-44, MIR_BOT)], fill=(212, 222, 232)) d.rectangle([0, MIR_TOP-8, SW_S, MIR_TOP], fill=(168, 184, 194)) d.rectangle([0, MIR_BOT-6, SW_S, MIR_BOT+4], fill=(168, 184, 194)) # barre d.rectangle([0, 500, SW_S, 516], fill=PAL["barre"]) for x in range(140, SW_S, 380): d.rectangle([x-7, 516, x+7, 560], fill=PAL["barre"]) # floor d.rectangle([0, 560, SW_S, SH_S], fill=PAL["floor"]) for i, x in enumerate(range(-200, SW_S+400, 118)): if i % 2: d.polygon([(x, 560), (x+118, 560), (x+230, SH_S), (x+100, SH_S)], fill=PAL["floor2"]) d.line([0, 560, SW_S, 560], fill=(150, 116, 74), width=5) # ── the class ── fac = "neutral" if sec == "themove": fac = "shock" elif sec in ("balldrop", "disco"): fac = "happy" for i, x in enumerate(LINE_X): c = CLASS[i] b = 0.0 if sec in ("arrive",) else beat if sec == "themove" and bar >= FREEZE_BAR: b = 0.0 # they stop to look dancer(d, c, x, FLOOR_Y, t, beat=b, face=fac, pep=pep) # instructor, front-left, facing the class ib = beat if sec not in ("arrive", "solo") else 0.0 dancer(d, INSTR, 250, FLOOR_Y, t, beat=ib, pep=pep, face="happy" if sec in ("themove", "balldrop", "disco") else "neutral") # ── the shoggoth ── if sec == "arrive": u = np.clip((bar-SEC_START["arrive"])/3.0, 0, 1) sx = lerp(SW_S+260, SHOG_X, ease_out(u)) shoggoth(d, sx, FLOOR_Y, t, beat=0.0, sc=1.0, mood="mask", limbs=7) elif sec == "solo": # alone with the mirror, mask sad, out of time on purpose shoggoth(d, SW_S*0.5, FLOOR_Y, t, beat=beat*0.37, sc=1.15, mood="mask_sad", limbs=9) elif sec == "themove": if bar < FREEZE_BAR: shoggoth(d, SHOG_X, FLOOR_Y, t, beat=beat*1.37, sc=1.0, mood="mask", limbs=8) else: shoggoth(d, SHOG_X, FLOOR_Y, t, beat=beat, sc=1.05, mood="mask", limbs=8) elif sec == "street": pass else: # wrong. confidently wrong. shoggoth(d, SHOG_X, FLOOR_Y, t, beat=beat*1.63 + 0.31, sc=1.0, mood="mask", limbs=8) # ── nightfall: one global grade, so every drawn colour goes with it ── if night > 0.002: arr = np.asarray(im, np.float32) arr = arr*(1.0 - 0.74*night) + np.array([26, 22, 54], np.float32)*(0.40*night) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) d = mkdraw(im) draw_windows(d, night, t) # ── the mirror ball ── by = None spin = t*1.15 if bdrop > 0.002: by = lerp(-170.0, float(BALL_Y), ease_out(bdrop)) + 5.0*math.sin(t*1.7)*bdrop d.line([(BALL_X, -10), (BALL_X, by-BALL_R+6)], fill=(52, 50, 62), width=5) d.rectangle([BALL_X-14, by-BALL_R-14, BALL_X+14, by-BALL_R+4], fill=(62, 60, 72)) disco_ball(d, BALL_X, by, BALL_R, spin) # ── additive light: washes, ceiling cans, specular dots ── if dots > 0.002: q = 4 soft = Image.new("RGB", (P(SW_S)//q, P(SH_S)//q), (0, 0, 0)) sd2 = mkdraw(soft) for k, col in enumerate(((196, 26, 150), (18, 108, 198), (200, 112, 16), (110, 32, 198))): a = t*0.62 + k*math.tau/4 wx = SW_S/2 + math.cos(a)*520; wy = 806 + math.sin(a)*54 rr = 330 g = dots*0.62 sd2.ellipse([(wx-rr)/q, (wy-rr*0.19)/q, (wx+rr)/q, (wy+rr*0.19)/q], fill=tuple(int(c*g) for c in col)) for k in range(4): # ceiling cans + their cones fx = 250 + k*395 col = ((196, 58, 62), (52, 152, 196), (196, 156, 48), (140, 62, 196))[k] b2 = 0.42 + 0.58*abs(math.sin(t*2.1 + k*1.6)) g = dots*b2 sd2.polygon([(fx/q, 4), ((fx-120)/q, 660/q), ((fx+120)/q, 660/q)], fill=tuple(int(c*g*0.13) for c in col)) sd2.ellipse([(fx-40)/q, 0, (fx+40)/q, 56/q], fill=tuple(int(c*g*0.85) for c in col)) if by is not None: # halo around the ball hr = BALL_R*2.6 sd2.ellipse([(BALL_X-hr)/q, (by-hr)/q, (BALL_X+hr)/q, (by+hr)/q], fill=(int(34*dots), int(36*dots), int(46*dots))) soft = soft.filter(ImageFilter.GaussianBlur(B(6))).resize( (P(SW_S), P(SH_S)), Image.BILINEAR) sharp = Image.new("RGB", (P(SW_S), P(SH_S)), (0, 0, 0)) hd = mkdraw(sharp) ball_dots(hd, t, spin, dots, by if by is not None else BALL_Y) sharp = sharp.filter(ImageFilter.GaussianBlur(B(2))) arr = (np.asarray(im, np.float32) + np.asarray(soft, np.float32) + np.asarray(sharp, np.float32)) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) 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 SHOTS = [ (0, 3, "mid_shog>wide"), # corridor (3, 5, "wide"), (5, 7, "full_shog"), # arrive (7, 9, "wide"), (9, 10, "mid_d1"), # warmup (10, 12, "ots_shog"), (12, 14, "mid_shog"), (14, 16, "full_line>wide"), (16, 18, "mid_shog"), (18, 20, "full_shog>wide"), # solo (20, 22, "mid_win"), (22, 24, "wide"), # nightfall (24, 25.5, "mid_ball"), (25.5, 27, "wide"), (27, 28, "macro_ball"), # balldrop (28, 30, "wide"), (30, 31.5, "mid_d2"), (31.5, 32.5, "ins_feet"), (32.5, 34, "full_line>wide"), # disco (34, 36, "mid_shog"), (36, 37, "wide"), (37, 38, "full_line>wide"), (38, 39, "full_shog"), (39, 40, "wide"), # street ] 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), "bow") 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 stage/delivery units; the loaded face scales with 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(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"] def post_frame(img, i, e, shot): a = np.asarray(img, np.float32) sm = img.resize((W//4, H//4), Image.BILINEAR).filter( ImageFilter.GaussianBlur(B(5))).resize((W, H), Image.BILINEAR) a = np.clip(a + np.asarray(sm, np.float32)*(0.12+0.18*e["high"]), 0, 255) lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([4, 0, 8], np.float32) + lum*np.array([12, 8, -4], np.float32) sh = int(1 + 3*e["kick"]) if S != 1.0: sh = max(1, int(round(sh*S))) if sh > 1: a[..., 0] = np.roll(a[..., 0], sh, axis=1) a[..., 2] = np.roll(a[..., 2], -sh, axis=1) a *= vignette() rng = np.random.RandomState(4400 + i) if S == 1.0: a += rng.normal(0, 2.2, a.shape) else: # Grain is a *look*, not a resolution: authored at 1280x720 and blown # up nearest-neighbour so a speck covers the same fraction of the frame # at 1080p. Per-pixel noise at 1920x1080 would read a third finer. gn = rng.normal(0, 2.2, (720, 1280, 3))*8.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a += (np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = mkdraw(out) # ── title flash ───────────────────────────────────────────────────────── # In the corridor, before the door opens: the piece's own name and the show # subtitle, set in the same Menlo/noticeboard ink as the class poster on the # wall behind it (the red is the poster's ALL WELCOME red). The round-2 code # gated this on shot.section == "arrive" AND i < 3s, which can never both be # true (arrive starts at bar 3 ≈ 5.7s), so the title never actually drew. T0, T1 = int(FPS*0.35), int(FPS*3.0) if T0 <= i < T1: al = min(1.0, (i-T0)/9.0)*min(1.0, (T1-i)/14.0) lay = Image.new("RGBA", out.size, (0, 0, 0, 0)); ld = mkdraw(lay) a8 = int(round(255*al)) ld.text((60, 516), "DANCE CLASS", font=font(40), fill=(40, 36, 40, a8)) ld.text((62, 572), "PLAYER COMPUTER", font=font(22), fill=(178, 46, 66, a8)) out = Image.alpha_composite(out.convert("RGBA"), lay).convert("RGB") return out def render_shot(job): shot, force = job E = env(); rng = np.random.default_rng(shot.seed) st = Studio(shot, rng); anch = st.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 = st.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.04, 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:9s} {made}/{shot.n}" def contact_sheet(shots): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = P(300), P(193) lab = P(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): st = Studio(sh, np.random.default_rng(sh.seed)); anch = st.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 = st.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+P(5), cy+th+P(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(): global S, W, H, FRAMES 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("--720p", dest="sd", action="store_true", help="fall back to the round-2 1280x720 master (S=1.0)") ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count())) a = ap.parse_args() if a.sd: W, H = 1280, 720 S = 1.0 FRAMES = OUT/"frames_720p"; FRAMES.mkdir(exist_ok=True) print(f"[0/3] native {W}x{H} (S={S}) frames -> {FRAMES.name}") 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\n" f"git: {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 {P(SW_S)}x{P(SH_S)} stage\n" + f"music: {MUSIC_DESC}\nlook: {ENGINE_DESC}\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()