#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — The Wedding Window (02/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/the_wedding_window # # A klezmer wedding seen through one window, goat included. # # 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_wedding_window.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/the_wedding_window.mp4 # cover: https://genekogan.com/player_computer/media/the_wedding_window.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_wedding_window.py (writes frames/, audio/, and the final mp4 # next to the script; writes ~8 GB of frames, # takes 5-15 min on a modern machine) # ═════════════════════════════════════════════════════════════════════════════ """ player_computer_final — "THE WEDDING WINDOW" (final delivery cut) Klezmer, 138bpm, D freygish (D Eb F# G A Bb C). 36 bars, instrumental. Procession(4) Freylekhs1(8) BottleDance(4) Freylekhs2(8) Goat(6) Finale(6) Recut of spiral_jam/the_wedding_window (60 bars, ~107s) down to ~65s: every narrative beat survives — the procession under the chuppah, the band, the hora with the couple up on chairs, the bottle dance, the goat that leaves with the tablecloth, and the plate smash on the finale downbeat — but each section is composed shorter. Not sped up, not truncated: the arrangement is re-timed on a new 36-bar grid and every music event (bleats, cork pop, risers, smash) re-placed on it. GENE'S NOTE applied: the old background of abstract breathing blue quads is gone. The wedding now happens SOMEWHERE — a shtetl village square at night, still in leaded stained glass: a cobalt night sky in leaded bands, gold star panes and a cream moon pane, wooden houses with candlelit amber windows, the synagogue carrying the rose window on its facade, and a string of wedding lanterns sagging across the square. FINAL CUT (player_computer_final). The film is unchanged; the delivery is: * 1920x1080 native. S = H/720 = 1.5. The stage is painted 1.5x larger and the camera crop is taken from it at 1.5x, so lead came, glass panes, the bloom radius and the type are all re-rasterised rather than upscaled. Grain is generated at 1280x720 and NEAREST-blown-up so a speck keeps its size on screen. * No debug-metadata overlays. There were none burnt in — the PANE I..VI strings the shot builder carries were never drawn — and none are added. * Title moment: the "THE WEDDING WINDOW" card now carries the show subtitle PLAYER COMPUTER beneath it, cut as a memorial-glass inscription in the window's own gold and rose over a hard shadow. Composition: engine : audio-first x shot-parallel x stage-camera content: audio-groove (klezmer kit, freygish clarinet, tuba) x mars-characters (cast as glass) x effects-post Run from repo root: python3 renders/player_computer_final/the_wedding_window/render.py --sheet python3 renders/player_computer_final/the_wedding_window/render.py --jobs 3 """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "the_wedding_window" TITLE = "THE WEDDING WINDOW" SUBT = "PLAYER COMPUTER" SETDIR = "player_computer_final" SETNUM = "08" W, H, FPS = 1920, 1080, 30 # ── delivery scale ────────────────────────────────────────────────────────── # Every coordinate in this file is authored in the units it always was: the # 1920x1080 painted stage and the 1280x720 delivery frame. S = H/720 = 1.5 is # the single global that turns those units into real pixels. The stage is # re-rasterised 1.5x larger and the camera crop is taken from it at 1.5x, so # the film is frame-for-frame the same movie at 1080p — not an upscale. S = H / 720.0 def P(v): return int(round(v*S)) def PF(v): return v*S def B(r): return r*S # blur radius 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 positionals 2 and 3, which must pass through untouched. """ __slots__ = ("_d", "_s") _GEOM = frozenset(("line", "rectangle", "rounded_rectangle", "ellipse", "polygon", "arc", "chord", "pieslice", "point", "text")) def __init__(self, d, s): self._d, self._s = d, s def __getattr__(self, name): f = getattr(self._d, name) if name not in self._GEOM: return f s = self._s def wrapped(xy, *a, **kw): w = kw.get("width") if w is not None: kw["width"] = max(2, int(round(w*s))) # lead never thins out 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 = 138.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 OUT = Path(__file__).parent FRAMES = OUT / "frames"; FRAMES.mkdir(exist_ok=True) AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = Path(__file__).resolve().parent # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" SECTIONS = [ ("procession", 0, 4), ("frey1", 4, 12), ("bottle", 12, 16), ("frey2", 16, 24), ("goat", 24, 30), ("finale", 30, 36), ] FIN_BAR = SECTIONS[-1][1] # finale downbeat = plate smash N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 2.5 N_FRAMES = int(DUR * FPS) MUSIC_DESC = f"klezmer, {BPM:.0f}bpm, D freygish, {N_BARS} bars, instrumental" ENGINE_DESC = "procession / band / hora / bottle / goat / finale (stained glass)" 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.05 FREY = [0, 1, 4, 5, 7, 8, 10] # D Eb F# G A Bb C (freygish degrees) D3 = nf("D3") def fdeg(i): """Scale degree (can exceed 7) -> semitone offset.""" return FREY[i % 7] + 12*(i//7) def glasschime(freq=2093, seed=111): return fm(freq, 1.2, ratio=3.98, index=3.0, idec=5.0, d=.8, r=.4, seed=seed)*.5 def corkpop(seed=113): n = int(.12*SR); t = np.arange(n)/SR f = 400 + 900*np.exp(-t*90) return np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*50)*.7 def platesmash(seed=117): n = int(.9*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=1800, hi=9500)*np.exp(-t*9) shards = np.zeros(n) for i, fq in enumerate((3170, 4230, 5480, 6660)): shards += np.sin(2*np.pi*fq*t)*np.exp(-t*(14+i*4))*.25 return (nz*.7 + shards)*.7 def bleat(seed=119): n = int(.55*SR); t = np.arange(n)/SR f = 500*(1 + .12*np.sin(2*np.pi*17*t)) - 120*(t/.55) x = np.sin(2*np.pi*np.cumsum(f)/SR) x += .6*np.sin(2*2*np.pi*np.cumsum(f)/SR) + .3*np.sin(3*2*np.pi*np.cumsum(f)/SR) env = np.sin(np.pi*np.clip(t/.55, 0, 1))**.5 return np.tanh(x*1.5)*env*.5 def clar(deg, dur, g_ornament=True, seed=0): """Clarinet note with a krekhts-style grace note above.""" fq = D3*2*2**(fdeg(deg)/12.0) main = voice(fq, dur, kind="square", nh=13, c0=3400, c1=1300, ck=3.0, res=.35, vib=(.035, 6.2), a=.03, d=.12, s=.8, r=.10, seed=seed) if not g_ornament: return main gq = D3*2*2**(fdeg(deg+1)/12.0) gr = voice(gq, .07, kind="square", nh=11, c0=3400, c1=1500, ck=6, a=.005, d=.03, s=.6, r=.03, seed=seed+1) out = np.zeros(int(dur*SR) + len(gr)) out[:len(gr)] += gr*.8 out[len(gr)//2:len(gr)//2+len(main)] += main[:len(out)-len(gr)//2] return out def build_song(): s = Song(DUR) R = np.random.RandomState(13800) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "finale" # D (I, major in freygish) -> Gm (iv) -> Cm (bVII) -> D PROG = [("D2", (0, 4, 7)), ("G1", (0, 3, 7)), ("C2", (0, 3, 7)), ("D2", (0, 4, 7))] # freygish licks (scale-degree indices; 0=D 1=Eb 2=F# 3=G 4=A 5=Bb 6=C) LICKS = [ [2, 3, 4, 5, 4, 3, 2, 0], [4, 5, 6, 7, 6, 5, 4, 2], [0, 2, 4, 2, 5, 4, 2, 1], [7, 6, 5, 4, 3, 2, 1, 0], ] for bar in range(N_BARS): sec = sec_of(bar) slow = sec == "procession" tense = sec == "bottle" chaos = sec == "goat" fin = sec == "finale" rootn, ivs = PROG[(bar//2) % 4] rootf = nf(rootn) dens = 1.6 if fin else (1.0 if not slow else 0.6) # ---- oom-pah ------------------------------------------------------- for st in (0, 8): at = s.t(bar, st, SW) s.put("tuba", voice(rootf, BEAT*.8, kind="saw", nh=8, c0=300, c1=120, ck=5, a=.01, d=.2, s=.7, r=.12, seed=bar*3+st), at, g=.40*min(1, dens)) s.put("drums", kick(dur=.24, f0=120, f1=50, punch=18, click=.2), at, g=.6*min(1, dens)) if st == 0: s.kick_t.append(at) if not tense: for st in (4, 12): for k2, iv in enumerate(ivs): s.put("acc", voice(rootf*4*2**(iv/12.0), BEAT*.3, kind="saw", nh=14, c0=2100, c1=900, ck=8, detune=(-.8, .9), a=.008, d=.09, s=.4, r=.06, seed=bar*7+st+k2), s.t(bar, st, SW), g=.08*dens, pan=-.25+.18*k2) s.put("drums", snare(dur=.14, tone=220, bright=.9), s.t(bar, st, SW), g=.36*min(1, dens), pan=-.05) else: s.put("drums", rim(), s.t(bar, 8, SW), g=.3) # snare rolls into sections + finale rolls if bar % 4 == 3 and not slow and not tense: for j, st in enumerate((12, 13, 14, 15)): s.put("drums", snare(dur=.08, tone=250, bright=1.0)*(.4+.15*j), s.t(bar, st, SW), g=.44, pan=-.2+.13*j) # ---- clarinet lead ------------------------------------------------- lick = LICKS[(bar//2) % 4] if slow: if bar % 2 == 0: for j, dg in enumerate(lick[:4]): s.put("clar", clar(dg, BEAT*.9, seed=bar*11+j), s.t(bar, j*4, SW), g=.16) elif tense: # long held notes, sparse — the bottle wobbles dg = [4, 5, 4, 1][bar % 4] s.put("clar", clar(dg, BEAT*3.4, seed=bar*11), s.t(bar, 0, SW), g=.15) else: for j, dg in enumerate(lick): dd = dg + (7 if fin and bar % 4 >= 2 else 0) s.put("clar", clar(dd, BEAT*.42, seed=bar*11+j), s.t(bar, j*2, SW), g=.17) if chaos and bar % 2 == 1: # goat register panic s.put("clar", clar(9, BEAT*.9, seed=bar*13), s.t(bar, 6, SW), g=.15) # ---- fiddle shadowing a third-ish above (freygish 2 degrees) ------- if (fin or sec == "frey2") and bar % 2 == 1: for j, dg in enumerate(lick[::2]): s.put("fid", voice(D3*4*2**(fdeg(dg+2)/12.0), BEAT*.8, kind="saw", nh=16, c0=2600, c1=1100, ck=2.4, vib=(.025, 6.6), a=.05, d=.2, s=.65, r=.15, seed=bar*17+j), s.t(bar, j*4+2, SW), g=.10, pan=.25) # ---- trombone smear at phrase ends -------------------------------- if bar % 4 == 3 and not slow: n2 = int(BEAT*1.2*SR); tt = np.arange(n2)/SR f0 = rootf*2; f1 = rootf*2*2**(fdeg(2)/12.0) fsw = f0 + (f1-f0)*np.clip(tt/(BEAT*1.0), 0, 1) sm = np.sin(2*np.pi*np.cumsum(fsw)/SR) sm += .5*np.sin(2*2*np.pi*np.cumsum(fsw)/SR) sm *= adsr(n2, .04, .2, .75, .3) s.put("bone", sm*.5, s.t(bar, 12, SW), g=.22, pan=-.2) # ---- hey! shouts --------------------------------------------------- if (sec in ("frey2", "finale")) and bar % 4 == 0: hey = speak("hey!", voice="Fred", rate=200, cache=AUD) s.put("vox", fit(hey, int(.30*SR)), s.t(bar, 12, SW), g=.4, pan=-.1+.2*R.rand()) # ---- events (re-placed on the 36-bar grid) ------------------------------ s.put("fx", glasschime(seed=131), 0.5, g=.4, pan=.2) s.put("fx", glasschime(1568, seed=133), 11*BAR, g=.35, pan=-.2) s.put("fx", corkpop(), 16*BAR + BEAT, g=.5, pan=.2) s.put("fx", bleat(), 24*BAR + BEAT*0.5, g=.55, pan=-.15) s.put("fx", bleat(seed=121), 27*BAR + BEAT*2, g=.45, pan=.2) s.put("fx", platesmash(), FIN_BAR*BAR, g=.65, pan=0) s.put("fx", glasschime(2637, seed=137), FIN_BAR*BAR + .1, g=.4, pan=.1) for b in (12, FIN_BAR): s.put("fx", riser(BAR*1.5), (b-1.5)*BAR, g=.2) s.bus("clar", lambda x: reverb(x, rt=2.0, mix=.3, seed=801)) s.bus("fid", lambda x: reverb(x, rt=2.2, mix=.34, seed=803)) s.bus("acc", lambda x: reverb(x, rt=1.6, mix=.24, seed=807)) s.bus("bone", lambda x: reverb(x, rt=1.8, mix=.3, seed=809)) s.bus("vox", lambda x: reverb(x, rt=1.6, mix=.3, seed=811)) s.bus("fx", lambda x: reverb(x, rt=2.4, mix=.32, seed=813)) mix = s.mixdown(dict(drums=1.0, tuba=1.0, acc=1.0, clar=1.0, fid=1.0, bone=1.0, vox=1.0, fx=1.0), pump_depth=.14, pump_rel=.12, levels=dict(procession=.6, frey1=.9, bottle=.5, frey2=.95, goat=.9, finale=1.0)) wav = AUD / "final.wav" s.write(wav, mix) return wav, mix def analyze(mix): x = mix.mean(1) hop = SR / FPS; win = int(hop*1.7) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high")} for f in range(N_FRAMES): i = int(f*hop); seg = x[i:i+win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg**2).mean()) sp = np.abs(np.fft.rfft(seg*np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1/SR) E["low"][f] = sp[fr < 170].sum() E["mid"][f] = sp[(fr >= 170) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].sum() for k in E: p = np.percentile(E[k], 96) + 1e-9 E[k] = np.clip(E[k]/p, 0, 1.25) lo = E["low"] flux = np.maximum(0, lo - np.concatenate([[0], lo[:-1]])) E["kick"] = np.clip(np.convolve(flux, [.25,.5,.25], "same") / (np.percentile(flux, 97)+1e-9), 0, 1) np.savez(AUD/"env.npz", **E) return E _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV # ════════════════════════════════════════════════════════════════════════════ # ANIMATION HELPERS — motion is the point, so it gets its own primitives # ════════════════════════════════════════════════════════════════════════════ def ease_io(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def ease_in(u): return u**3 def bounce(u, n=3): return abs(math.sin(u*math.pi*n))*(1-u) def lerp(a, b, u): return a + (b-a)*u def walk(t, speed=1.0): """Returns (leg_swing, body_bob, arm_swing) for a walk cycle at time t.""" p = t*speed*math.tau return math.sin(p), abs(math.sin(p))*-1.0, math.sin(p+math.pi) def spring(u, freq=3.0, damp=5.0): """Overshoot-and-settle, for things that arrive.""" if u <= 0: return 0.0 return 1 - math.exp(-damp*u)*math.cos(freq*math.tau*u) def arc(p0, p1, u, h=0.3): """Ballistic arc between two points.""" x = lerp(p0[0], p1[0], u) y = lerp(p0[1], p1[1], u) - math.sin(u*math.pi)*h*abs(p1[0]-p0[0]) return x, y def ramp(stops, n=256): stops = np.array(stops, np.float32) xs = np.linspace(0, 1, len(stops)); g = np.linspace(0, 1, n) return np.stack([np.interp(g, xs, stops[:, c]) for c in range(3)], 1) def apply_ramp(v01, lut): i = np.clip(v01*(len(lut)-1), 0, len(lut)-1).astype(np.int32) return lut[i] def value_noise(h, w, scale, seed): rng = np.random.RandomState(seed) gh, gw = int(h/scale)+2, int(w/scale)+2 g = rng.rand(gh, gw) ys = np.linspace(0, gh-1-1e-3, h); xs = np.linspace(0, gw-1-1e-3, w) y0 = ys.astype(int); x0 = xs.astype(int) fy = (ys-y0)[:, None]; fx = (fx0 := (xs-x0))[None, :] sy = fy*fy*(3-2*fy); sx = fx*fx*(3-2*fx) g00 = g[np.ix_(y0, x0)]; g01 = g[np.ix_(y0, x0+1)] g10 = g[np.ix_(y0+1, x0)]; g11 = g[np.ix_(y0+1, x0+1)] return (g00*(1-sx)+g01*sx)*(1-sy) + (g10*(1-sx)+g11*sx)*sy def fbm(h, w, scale, seed, oct=4): out = np.zeros((h, w)); amp = 1.0; nrm = 0.0 for o in range(oct): out += amp*value_noise(h, w, max(2, scale/(2**o)), seed+o) nrm += amp; amp *= .5 return out/nrm # ════════════════════════════════════════════════════════════════════════════ # STAGE + CAMERA — the frame is a 16: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 = 1920, 1080 # round 2: 16:9 stage (was 1680x1080 for 14:9) ASPECT = W / H SHOT_W = {"wide": 1.00, "full": 0.82, "mid": 0.60, "ots": 0.48, "cu": 0.32, "ins": 0.22, "macro": 0.14} def _box(anchors, shot): kind, _, target = shot.partition("_") fw = SHOT_W.get(kind, 1.0) cx, cy = anchors.get(target or "_", anchors.get("_", (SW_S/2, SH_S/2))) if kind == "wide": cx, cy = SW_S/2, SH_S/2 elif kind == "full": cx = (cx + SW_S/2)/2; cy = (cy + SH_S/2)/2 elif kind == "ots": cx = cx*.62 + SW_S/2*.38 + (150 if cx < SW_S/2 else -150) cy = cy*.58 + SH_S/2*.42 bw = SW_S*fw; bh = bw/ASPECT if bh > SH_S: bh = SH_S; bw = bh*ASPECT return cx, cy, bw, bh def _ease(u): return u*u*(3-2*u) def cam_box(anchors, shot, f01, jseed=0, push=0.04, drift=1.0): """Return the (x0,y0,x1,y1) crop of the stage for this frame.""" if ">" in shot: a, b = shot.split(">", 1) ca = _box(anchors, a.strip()); cb = _box(anchors, b.strip()) e = _ease(min(max(f01, 0.0), 1.0)) cx, cy, bw, bh = (ca[i] + (cb[i]-ca[i])*e for i in range(4)) else: cx, cy, bw, bh = _box(anchors, shot) k = 1.0 - push*f01 # slow push-in bw *= k; bh *= k # deterministic handheld drift — slow enough to breathe, not shake fw = bw/SW_S jx = math.sin(f01*1.525 + jseed)*7*(1-fw*.6)*drift jy = math.cos(f01*1.175 + jseed*1.7)*5*(1-fw*.6)*drift x0 = cx - bw/2 + jx; y0 = cy - bh/2 + jy x0 = max(0, min(SW_S-bw, x0)); y0 = max(0, min(SH_S-bh, y0)) return (int(x0), int(y0), int(x0+bw), int(y0+bh)) def shoot(stage_img, anchors, shot, f01, jseed=0, push=0.04, drift=1.0): """Crop the stage to the shot and scale to delivery size. cam_box works in authoring stage units; the raster is S times larger, so the box is scaled on the way into crop(). Same framing, more pixels. """ box = cam_box(anchors, shot, f01, jseed, push, drift) if S != 1.0: box = tuple(int(round(v*S)) 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) # ════════════════════════════════════════════════════════════════════════════ # STAINED GLASS # ════════════════════════════════════════════════════════════════════════════ NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} LEAD = (24, 20, 18) STONE = (52, 48, 46) GLASS = { "ruby": (196, 40, 52), "amber": (238, 168, 42), "gold": (246, 214, 96), "emer": (44, 138, 80), "cobalt": (38, 76, 168), "sky": (120, 180, 220), "violet": (128, 70, 158), "rose": (226, 130, 156), "cream": (240, 230, 200), "brown": (140, 92, 52), "white": (248, 244, 232), } def rot2(pts, cx, cy, ang): c, s2 = math.cos(ang), math.sin(ang) return [(cx + (x-cx)*c - (y-cy)*s2, cy + (x-cx)*s2 + (y-cy)*c) for x, y in pts] def pane(d, pts, col, glow=1.0, w=7): if isinstance(col, str): col = GLASS[col] c = tuple(min(255, int(v*glow)) for v in col) d.polygon(pts, fill=c) d.line(list(pts) + [pts[0]], fill=LEAD, width=w, joint="curve") def pane_ell(d, box, col, glow=1.0, w=7, n=12): x0, y0, x1, y1 = box cx, cy = (x0+x1)/2, (y0+y1)/2; rx, ry = (x1-x0)/2, (y1-y0)/2 pts = [(cx+rx*math.cos(a), cy+ry*math.sin(a)) for a in np.linspace(0, math.tau, n, endpoint=False)] pane(d, pts, col, glow, w) def breathe(t, ph=0.0, e=None): g = 0.82 + 0.12*math.sin(t*0.9 + ph) if e: g += 0.25*e["kick"] return g def _house(dd, t, hx, base, hw, hh, roofcol, ph, glow, nwin=2, flash=0.0): """A wooden shtetl house in leaded glass: body pane, steep roof pane, candlelit amber windows, a door. hx = centre-x, base = ground line.""" # body pane(dd, [(hx-hw/2, base), (hx+hw/2, base), (hx+hw/2, base-hh), (hx-hw/2, base-hh)], "brown", 0.85*glow, w=6) # roof: steep triangle with a little overhang pane(dd, [(hx-hw/2-14, base-hh), (hx+hw/2+14, base-hh), (hx, base-hh-hw*0.52)], roofcol, 0.9*glow, w=6) # candlelit windows — the warm hearts of the scene; they flicker for wq in range(nwin): wx = hx - hw*0.28 + wq*hw*0.56/max(1, nwin-1) if nwin > 1 else hx - hw*0.22 wy = base - hh*0.62 cg = 1.05 + 0.28*math.sin(t*4.1 + ph + wq*2.3) + flash pane(dd, [(wx-16, wy-22), (wx+16, wy-22), (wx+16, wy+22), (wx-16, wy+22)], "amber", cg, w=5) dd.line([wx, wy-22, wx, wy+22], fill=LEAD, width=3) dd.line([wx-16, wy, wx+16, wy], fill=LEAD, width=3) # door pane(dd, [(hx+hw*0.24, base), (hx+hw*0.44, base), (hx+hw*0.44, base-hh*0.48), (hx+hw*0.24, base-hh*0.48)], "cream", 0.75*glow, w=4) def window_stage(t, e, flash=0.0): """GENE'S NOTE: no more abstract quads. The scene is a shtetl village square at night, in the same leaded-glass language — cobalt night-sky bands, gold star panes, a moon, wooden houses with candlelit windows, the synagogue wearing the rose window, and a lantern string over the square where the wedding happens.""" im, d = new_stage(STONE) dd = d R = np.random.RandomState(77) X0, X1 = SW_S*0.03, SW_S*0.97 Y0, Y1 = SH_S*0.03, SH_S*0.975 HOR = SH_S*0.68 # ground line for the buildings # night sky: horizontal leaded bands of deep cobalt (violet near horizon) ny_, nx_ = 5, 9 for iy in range(ny_): for ix in range(nx_): x0 = X0 + ix*(X1-X0)/nx_ + R.uniform(-12, 12) y0 = Y0 + iy*(HOR-Y0)/ny_ + R.uniform(-8, 8) x1 = x0 + (X1-X0)/nx_ + R.uniform(-8, 14) y1 = y0 + (HOR-Y0)/ny_ + R.uniform(-6, 10) ph = ix*0.7 + iy*1.3 col = "violet" if (iy >= ny_-1 and R.rand() < 0.55) else "cobalt" g = 0.62*breathe(t, ph) + flash # darker than day glass: night pane(dd, [(x0, y0), (x1, y0), (x1+R.uniform(-6, 6), y1), (x0, y1)], col, g, w=6) # gold star panes (small diamonds, slow twinkle) for i in range(16): sx = R.uniform(X0+40, X1-40); sy = R.uniform(Y0+30, HOR-SH_S*0.22) r = R.uniform(7, 13) tw = 0.85 + 0.45*math.sin(t*1.6 + i*2.13) pane(dd, [(sx, sy-r), (sx+r*0.7, sy), (sx, sy+r), (sx-r*0.7, sy)], "gold", tw + flash, w=3) # the moon: one cream pane, top right mx, my, mr = SW_S*0.84, SH_S*0.14, SH_S*0.055 pane_ell(dd, [mx-mr, my-mr, mx+mr, my+mr], "cream", 1.2 + 0.06*math.sin(t*0.7) + flash, w=5, n=12) # ground: packed-earth panes of the village square (warm, low glow) gy_, gx_ = 3, 8 for iy in range(gy_): for ix in range(gx_): x0 = X0 + ix*(X1-X0)/gx_ + R.uniform(-14, 14) y0 = HOR + iy*(Y1-HOR)/gy_ + R.uniform(-6, 6) x1 = x0 + (X1-X0)/gx_ + R.uniform(-8, 14) y1 = y0 + (Y1-HOR)/gy_ + R.uniform(-5, 8) col = "brown" if R.rand() < 0.7 else "amber" g = 0.55*breathe(t, ix*1.1+iy*0.6) + flash*0.7 pane(dd, [(x0, y0), (x1, y0), (x1+R.uniform(-6, 6), y1), (x0, y1)], col, g, w=6) # the synagogue, left of centre-back: tall body, arched windows, # and the rose window moved onto its facade sx0, sb = SW_S*0.20, HOR+8 swd, sht = SW_S*0.17, SH_S*0.30 pane(dd, [(sx0-swd/2, sb), (sx0+swd/2, sb), (sx0+swd/2, sb-sht), (sx0-swd/2, sb-sht)], "brown", 0.8, w=7) pane(dd, [(sx0-swd/2-16, sb-sht), (sx0+swd/2+16, sb-sht), (sx0, sb-sht-swd*0.42)], "violet", 0.95+flash, w=6) for sgn in (-1, 1): # two arched amber windows wx = sx0 + sgn*swd*0.26; wy = sb - sht*0.30 cg = 1.1 + 0.24*math.sin(t*3.7 + sgn) + flash pane(dd, [(wx-18, wy), (wx+18, wy), (wx+18, wy-40), (wx, wy-56), (wx-18, wy-40)], "amber", cg, w=5) cx, cy, r0 = sx0, sb - sht*0.72, swd*0.20 # the rose window, kept for q in range(8): a0 = q*math.tau/8 + t*0.05 pts = [(cx, cy)] for aa in np.linspace(a0, a0+math.tau/8, 4): pts.append((cx+math.cos(aa)*r0, cy+math.sin(aa)*r0)) pane(dd, pts, ["ruby", "amber", "violet", "emer"][q % 4], breathe(t, q)+flash, w=5) pane_ell(dd, [cx-r0*0.35, cy-r0*0.35, cx+r0*0.35, cy+r0*0.35], "gold", 1.0+flash, w=5) # wooden houses along the square _house(dd, t, SW_S*0.055, HOR+6, SW_S*0.11, SH_S*0.15, "ruby", 0.4, 0.95, 1, flash) _house(dd, t, SW_S*0.46, HOR+6, SW_S*0.13, SH_S*0.17, "emer", 1.7, 1.0, 2, flash) _house(dd, t, SW_S*0.66, HOR+6, SW_S*0.15, SH_S*0.20, "ruby", 2.9, 1.0, 2, flash) _house(dd, t, SW_S*0.88, HOR+6, SW_S*0.14, SH_S*0.16, "violet", 4.2, 0.95, 2, flash) # the lantern string: sagging across the square between two poles px0, px1 = SW_S*0.07, SW_S*0.93 ptop = SH_S*0.30 for px in (px0, px1): pane(dd, [(px-7, HOR+4), (px+7, HOR+4), (px+5, ptop), (px-5, ptop)], "brown", 0.85, w=4) npts = 24 pts = [] for q in range(npts+1): u = q/npts sag = math.sin(u*math.pi)*SH_S*0.075 pts.append((px0 + (px1-px0)*u, ptop + sag + math.sin(t*0.8)*4)) dd.line(pts, fill=LEAD, width=4) for q in range(1, 8): # the lanterns themselves u = q/8 lx = px0 + (px1-px0)*u ly = ptop + math.sin(u*math.pi)*SH_S*0.075 + math.sin(t*0.8)*4 lg = 1.15 + 0.3*math.sin(t*5.0 + q*1.9) + flash + 0.3*e["kick"] pane(dd, [(lx-11, ly+6), (lx+11, ly+6), (lx+8, ly+34), (lx-8, ly+34)], ["amber", "gold"][q % 2], lg, w=4) return im, dd def glass_figure(d, x, y, sc, t, robe="cobalt", head="cream", hat=None, arms="down", bounce=0.0, flip=1): """A chunky stained-glass person. y = feet.""" b = bounce # robe: trapezoid of two panes pane(d, [(x-34*sc, y), (x+34*sc, y), (x+20*sc, y-70*sc-b), (x-20*sc, y-70*sc-b)], robe, 1.0, w=6) pane(d, [(x-20*sc, y-70*sc-b), (x+20*sc, y-70*sc-b), (x+14*sc, y-104*sc-b), (x-14*sc, y-104*sc-b)], robe, 1.15, w=6) # head pane_ell(d, [x-14*sc, y-134*sc-b, x+14*sc, y-106*sc-b], head, 1.1, w=5, n=10) if hat == "veil": pane(d, [(x-16*sc, y-132*sc-b), (x+16*sc, y-132*sc-b), (x+24*sc, y-96*sc-b), (x+16*sc, y-96*sc-b)], "white", 1.2, w=4) elif hat == "cap": pane(d, [(x-14*sc, y-134*sc-b), (x+14*sc, y-134*sc-b), (x, y-146*sc-b)], "brown", 1.0, w=4) elif hat == "flower": pane_ell(d, [x-6*sc, y-148*sc-b, x+10*sc, y-134*sc-b], "rose", 1.2, w=4, n=8) # arms if arms == "up": for sgn in (-1, 1): pane(d, [(x+sgn*18*sc, y-98*sc-b), (x+sgn*44*sc, y-128*sc-b), (x+sgn*36*sc, y-136*sc-b), (x+sgn*12*sc, y-104*sc-b)], robe, 1.2, w=5) elif arms == "link": for sgn in (-1, 1): pane(d, [(x+sgn*18*sc, y-96*sc-b), (x+sgn*52*sc, y-88*sc-b), (x+sgn*52*sc, y-78*sc-b), (x+sgn*16*sc, y-86*sc-b)], robe, 1.2, w=5) def glass_goat(d, x, y, sc, t, run=False): ws = t*BPM/60*math.tau kick_leg = math.sin(ws*2)*14*sc if run else 0 # body pane(d, [(x-50*sc, y-30*sc), (x+40*sc, y-36*sc), (x+46*sc, y-6*sc), (x-44*sc, y)], "white", 1.15, w=6) # legs for i, lx in enumerate((-36, -14, 12, 34)): off = kick_leg*(1 if i % 2 else -1) pane(d, [(x+lx*sc-5*sc, y-8*sc), (x+lx*sc+5*sc, y-8*sc), (x+lx*sc+3*sc+off*0.3, y+26*sc), (x+lx*sc-7*sc+off*0.3, y+26*sc)], "cream", 1.0, w=4) # head + horns + beard hx, hy = x+52*sc, y-44*sc pane(d, [(hx-14*sc, hy-10*sc), (hx+18*sc, hy-4*sc), (hx+14*sc, hy+14*sc), (hx-12*sc, hy+12*sc)], "white", 1.2, w=5) for sgn, hh in ((-1, 26), (1, 20)): pane(d, [(hx-4*sc+sgn*4*sc, hy-8*sc), (hx+sgn*2*sc, hy-hh*sc), (hx+sgn*8*sc, hy-8*sc)], "amber", 1.1, w=4) pane(d, [(hx+6*sc, hy+12*sc), (hx+14*sc, hy+12*sc), (hx+10*sc, hy+24*sc)], "cream", 1.0, w=3) d.ellipse([hx+2*sc, hy-2*sc, hx+8*sc, hy+4*sc], fill=LEAD) # the stolen tablecloth in its mouth if run: wavef = math.sin(t*7)*10*sc pane(d, [(hx+14*sc, hy+8*sc), (hx+70*sc, hy-6*sc+wavef), (hx+96*sc, hy+26*sc-wavef), (hx+30*sc, hy+22*sc)], "white", 1.25, w=4) pane(d, [(hx+30*sc, hy+10*sc), (hx+60*sc, hy+4*sc+wavef*0.5), (hx+58*sc, hy+18*sc)], "ruby", 1.2, w=3) class Procession: def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_couple", "full_couple>mid_couple", "wide>full_couple"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im, d = window_stage(t, e) gy = SH_S*0.86 # chuppah: four poles + canopy for px in (SW_S*0.30, SW_S*0.70): pane(d, [(px-8, gy), (px+8, gy), (px+8, SH_S*0.34), (px-8, SH_S*0.34)], "brown", 1.0, w=5) pane(d, [(SW_S*0.26, SH_S*0.34), (SW_S*0.74, SH_S*0.34), (SW_S*0.70, SH_S*0.28), (SW_S*0.30, SH_S*0.28)], "violet", 1.1) # the couple walks in from the left, quantized steps beat = t/BEAT step = int(beat); frac = ease_io(min(1, (beat-step)*1.6)) cx = SW_S*0.16 + min((step+frac)*SW_S*0.02, SW_S*0.34) bob = abs(math.sin(beat*math.pi))*8 glass_figure(d, cx, gy, 1.35, t, robe="white", hat="veil", arms="link", bounce=bob) glass_figure(d, cx+SW_S*0.075, gy, 1.4, t, robe="cobalt", hat="cap", arms="link", bounce=-bob) # guests right, swaying for i, gx in enumerate((0.78, 0.87)): sw2 = math.sin(t*1.4 + i)*6 glass_figure(d, SW_S*gx + sw2, gy, 1.2, t, robe=["emer", "ruby"][i % 2], hat="flower", arms="down") anchors = {"_": (SW_S/2, SH_S*0.55), "couple": (cx+SW_S*0.04, gy-SH_S*0.12)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Band: def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_clar", "mid_clar", "wide>full_clar"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im, d = window_stage(t, e) gy = SH_S*0.86 beat = t/BEAT # clarinetist: instrument tilts with the melody cx = SW_S*0.28 tilt = 0.5 + 0.35*math.sin(t*2.6) + 0.3*e["mid"] glass_figure(d, cx, gy, 1.4, t, robe="violet", hat="cap", bounce=abs(math.sin(beat*math.pi))*6) cl = rot2([(cx, gy-160), (cx+150, gy-160)], cx, gy-160, -tilt) pane(d, [(cl[0][0], cl[0][1]-8), (cl[1][0], cl[1][1]-14), (cl[1][0]+10, cl[1][1]+10), (cl[0][0], cl[0][1]+8)], "brown", 1.1, w=5) # tuba player: bell flashes on the oom tx = SW_S*0.52 oomph = (1-((t/(BEAT*2)) % 1.0))**3 glass_figure(d, tx, gy, 1.4, t, robe="emer", hat="cap", bounce=oomph*10) pane_ell(d, [tx+30, gy-260-oomph*14, tx+150, gy-140-oomph*14], "gold", 1.0+oomph*0.5, w=7, n=14) pane(d, [(tx+40, gy-150), (tx+90, gy-160), (tx+80, gy-90), (tx+36, gy-96)], "amber", 1.1, w=5) # fiddler: bow saws on 8ths fx = SW_S*0.76 bow = math.sin(t/(BEAT/2)*math.pi)*30 glass_figure(d, fx, gy, 1.35, t, robe="ruby", hat="flower", bounce=abs(math.sin(beat*math.pi+1))*5) pane(d, [(fx-40, gy-190), (fx+30, gy-215), (fx+34, gy-200), (fx-36, gy-176)], "brown", 1.1, w=4) d.line([fx-20+bow, gy-240, fx+26+bow, gy-170], fill=LEAD, width=6) anchors = {"_": (SW_S/2, SH_S*0.55), "clar": (cx+70, gy-SH_S*0.16)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Hora: """The couple up on chairs, dancers circling.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_chairs", "mid_chairs", "wide>full_chairs"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im, d = window_stage(t, e) gy = SH_S*0.86 beat = t/BEAT # circling dancers (positions rotate around an ellipse) for i in range(6): a = t*0.9 + i*math.tau/6 dx = SW_S*0.5 + math.cos(a)*SW_S*0.34 dy = gy - 10 + math.sin(a)*SH_S*0.05 sc = 1.0 + 0.25*math.sin(a) # nearer = bigger glass_figure(d, dx, dy, sc, t, robe=["emer", "ruby", "amber", "violet", "sky", "rose"][i], hat="flower" if i % 2 else "cap", arms="up" if int(beat) % 2 == i % 2 else "link", bounce=abs(math.sin(beat*math.pi + i))*10) # the chairs, centre, bouncing hard on the beat for j, (chx, robe, hat) in enumerate(((SW_S*0.42, "white", "veil"), (SW_S*0.58, "cobalt", "cap"))): b2 = abs(math.sin(beat*math.pi + j*0.5))*26 # chair pane(d, [(chx-40, gy-140-b2), (chx+40, gy-140-b2), (chx+34, gy-120-b2), (chx-34, gy-120-b2)], "brown", 1.0, w=5) for lx in (chx-32, chx+30): pane(d, [(lx-5, gy-120-b2), (lx+5, gy-120-b2), (lx+5, gy-70-b2), (lx-5, gy-70-b2)], "brown", 1.0, w=4) glass_figure(d, chx, gy-140-b2, 1.25, t, robe=robe, hat=hat, arms="up") anchors = {"_": (SW_S/2, SH_S*0.52), "chairs": (SW_S*0.5, gy-SH_S*0.22)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Bottle: """The bottle dancer. Eight bars of pure tension.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_dancer", "full_dancer>mid_dancer"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im, d = window_stage(t, e) gy = SH_S*0.86 cx = SW_S*0.5 # kneeling dancer, arms out wob = math.sin(t*3.1)*0.10 + e["mid"]*0.12 glass_figure(d, cx, gy, 1.6, t, robe="amber", hat="cap", arms="up") # the bottle on the head, tilting bx, by = cx, gy - 240 bpts = rot2([(bx-14, by), (bx+14, by), (bx+10, by-40), (bx+16, by-44), (bx+12, by-74), (bx-12, by-74), (bx-16, by-44), (bx-10, by-40)], bx, by, wob) pane(d, bpts, "emer", 1.25, w=5) # watchers left + right, leaning with the wobble for sgn, gx in ((-1, 0.16), (1, 0.84)): glass_figure(d, SW_S*gx + sgn*wob*60, gy, 1.15, t, robe="ruby" if sgn < 0 else "sky", hat="flower", arms="down", bounce=0) anchors = {"_": (cx, SH_S*0.5), "dancer": (cx, gy-SH_S*0.18)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Goat: """The goat has the tablecloth and regrets nothing.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.cam = str(rng.choice(["full_goat", "full_goat>mid_goat", "wide>full_goat"])) def frame(self, k, u, e): t = (self.i0+k)/FPS im, d = window_stage(t, e) gy = SH_S*0.86 # the table, cloth halfway gone tx = SW_S*0.68 pane(d, [(tx-140, gy-90), (tx+140, gy-90), (tx+120, gy-80), (tx-120, gy-80)], "brown", 1.0, w=5) for lx in (tx-120, tx+110): pane(d, [(lx-6, gy-80), (lx+6, gy-80), (lx+6, gy), (lx-6, gy)], "brown", 1.0, w=4) # cake sliding slide = ease_in(min(1, u*1.3))*80 pane(d, [(tx-30-slide, gy-120), (tx+30-slide, gy-120), (tx+22-slide, gy-90), (tx-22-slide, gy-90)], "rose", 1.2, w=5) # plates arcing off for q in range(3): au = np.clip(u*1.5 - q*0.2, 0, 1) if au <= 0 or au >= 1: continue px2, py2 = arc((tx+q*30, gy-95), (SW_S*0.9, SH_S*0.98), au, h=0.35) pane_ell(d, [px2-24, py2-10, px2+24, py2+10], "white", 1.2, w=4, n=8) # the goat, running left with the cloth gx = SW_S*0.62 - ease_io(u)*SW_S*0.42 glass_goat(d, gx, gy-10, 1.5, t, run=True) # a guest giving chase glass_figure(d, min(gx+SW_S*0.28, SW_S*0.86), gy, 1.3, t, robe="ruby", hat="flower", arms="up", bounce=abs(math.sin(t/BEAT*math.pi))*12) anchors = {"_": (SW_S/2, SH_S*0.55), "goat": (gx+60, gy-SH_S*0.08)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) class Finale: """Everyone. Plate smash on the one; the window flashes.""" def __init__(self, shot, rng): self.rng = rng; self.i0 = shot.i0 self.t0 = FIN_BAR*BAR self.cam = str(rng.choice(["wide", "wide>full_mid"])) def frame(self, k, u, e): t = (self.i0+k)/FPS smash_u = np.clip((t - self.t0)/0.5, 0, 1) flash = 0.5*(1-smash_u) + 0.3*e["kick"] im, d = window_stage(t, e, flash=flash if t >= self.t0 else 0.3*e["kick"]) gy = SH_S*0.86 beat = t/BEAT # the whole cast in a line, bouncing alternately cast = [("white", "veil"), ("cobalt", "cap"), ("emer", "cap"), ("ruby", "flower"), ("violet", "cap"), ("amber", "flower"), ("sky", "cap")] for i, (robe, hat) in enumerate(cast): gx = SW_S*(0.10 + i*0.132) glass_figure(d, gx, gy, 1.25, t, robe=robe, hat=hat, arms="up" if (int(beat)+i) % 2 else "link", bounce=abs(math.sin(beat*math.pi + i*0.5))*18) # the goat, small, back right, wearing the cloth like a cape glass_goat(d, SW_S*0.90, gy-6, 0.9, t, run=False) # smashed plate shards at centre front if t >= self.t0: for q in range(7): a = q/7.0*math.pi - math.pi r = 40 + smash_u*180 + q*8 sx = SW_S*0.5 + math.cos(a)*r sy = gy + 20 + math.sin(a)*r*0.3 - smash_u*(1-smash_u)*160 pane(d, [(sx-14, sy), (sx+10, sy-8), (sx+16, sy+10), (sx-4, sy+14)], "white", 1.3, w=3) anchors = {"_": (SW_S/2, SH_S*0.55), "mid": (SW_S*0.5, gy-SH_S*0.14)} return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0), np.float32) ENGINES = {"procession": Procession, "band": Band, "hora": Hora, "bottle": Bottle, "goat": Goat, "finale": Finale} PLAN = { "procession": (["procession", "band"], [8, 8]), "frey1": (["band", "hora", "procession"], [8, 8, 16]), "bottle": (["bottle", "band"], [8, 16]), "frey2": (["hora", "band", "bottle"], [8, 8, 16]), "goat": (["goat", "hora"], [8, 16]), "finale": (["finale", "hora", "band"], [8, 8, 16]), } CARDS = {"procession": "THE WEDDING WINDOW", "frey1": None, "bottle": None, "frey2": None, "goat": None, "finale": None} SYSTEM_NAMES = ["PANE I", "PANE II", "PANE III", "PANE IV", "PANE V", "PANE VI"] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "text", "card") def __init__(self, idx, i0, i1, engine, section, text=None, card=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.section = engine, section self.seed = 90210 + idx*7919 self.text, self.card = text, card def build_shots(): """Deterministic, but not a cycle: each section draws from its pool with no immediate repeats, and shot lengths come from a menu so the cut rhythm breathes instead of ticking.""" R = np.random.RandomState(5150) shots = []; idx = 0; last = None for nm, b0, b1 in SECTIONS: engs, menu = PLAN[nm] t = b0*BAR; j = 0 while t < b1*BAR - 1e-6: step = menu[R.randint(len(menu))]*BEAT t2 = min(t+step, b1*BAR) if (b1*BAR - t2) < BEAT*1.5: t2 = b1*BAR # no orphan sliver i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: pool = [x for x in engs if x != last] or list(engs) eng = pool[R.randint(len(pool))] last = eng txt = [SYSTEM_NAMES[(idx+q) % len(SYSTEM_NAMES)] for q in range(2)] shots.append(Shot(idx, i0, i1, eng, nm, txt, CARDS[nm] if j == 0 else None)) idx += 1; j += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) _FC = {} def font(size, name="Menlo.ttc"): key = (size, name) if key not in _FC: p = _find_font(name) # Scaled ONCE, here. Call sites always pass authoring-frame sizes. _FC[key] = _load_font(p, max(1, P(size))) return _FC[key] def tracked(d, xy, s, f, fill, track): x, y = xy for ch in s: d.text((x, y), ch, font=f, fill=fill) x += d.textlength(ch, font=f) + track return x def tracked_w(d, s, f, track): return sum(d.textlength(ch, font=f) + track for ch in s) - track _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.40*r**2.2, 0, 1)[..., None] return _VIG["v"] def post(arr, i, e, shot): a = arr.astype(np.float32) if isinstance(arr, np.ndarray) else \ np.asarray(arr, np.float32) if a.shape[0] != H or a.shape[1] != W: a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((W, H), Image.LANCZOS), np.float32) # backlight bloom — glass glows im = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) sm = im.resize((W//4, H//4), Image.BILINEAR).filter( ImageFilter.GaussianBlur(B(7))).resize((W, H), Image.BILINEAR) a = np.clip(a + np.asarray(sm, np.float32)*(0.22+0.18*e["rms"]), 0, 255) a *= vignette() rng = np.random.RandomState(11000 + 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 stays the same size on screen. gn = rng.normal(0, 2.2, (720, 1280, 3))*8.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a += (np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) if shot.card: # THE TITLE MOMENT. Set as a memorial-glass inscription: Georgia in # the window's own gold, cut into the dark with a hard drop shadow, # with the show's name tracked out underneath it. age = i - shot.i0 if age < FPS*3.4: al = min(1.0, age/6.0)*min(1.0, (FPS*3.4-age)/12.0) f = font(44, "Georgia Bold.ttf") fs = font(17, "Georgia Bold.ttf") y0 = H*0.80 lw = d.textlength(shot.card, font=f) d.text((W/2-lw/2+P(3), y0+P(3)), shot.card, font=f, fill=tuple(int(c*al) for c in (20, 14, 10))) d.text((W/2-lw/2, y0), shot.card, font=f, fill=tuple(int(c*al) for c in (244, 226, 160))) tr = PF(7.0); sy = y0 + P(58) sw2 = tracked_w(d, SUBT, fs, tr) tracked(d, (W/2-sw2/2+P(2), sy+P(2)), SUBT, fs, tuple(int(c*al) for c in (20, 14, 10)), tr) tracked(d, (W/2-sw2/2, sy), SUBT, fs, tuple(int(c*al) for c in (196, 130, 152)), tr) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(12, 10, 9)); d.rectangle([0, H-bh, W, H], fill=(12, 10, 9)) return out def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) eng = ENGINES[shot.engine](shot, rng) made = 0 for k in range(shot.n): i = shot.i0 + k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} p = FRAMES / f"f{i:05d}.png" u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) # ALWAYS step the engine if p.exists() and not force: continue post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {shot.section:9s} {made}/{shot.n}" def contact_sheet(shots): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = P(320), P(180) # 16:9 thumbs sheet = Image.new("RGB", (cols*tw, rows*(th+P(24))), (10, 10, 14)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) eng = ENGINES[sh.engine](sh, rng) mid = sh.n//2 arr = None for k in range(mid+1): i = sh.i0+k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(k, k/max(1, sh.n-1), e) im = post(arr, sh.i0+mid, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+P(24)) sheet.paste(im, (cx, cy)) sd.text((cx+P(5), cy+th+P(4)), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:.1f}s", font=font(13), fill=(190, 195, 205)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count())) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() or a.force: print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") wav, mix = build_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames on {a.jobs} workers") import multiprocessing as mp with mp.get_context("fork").Pool(a.jobs) as pool: for r in pool.imap_unordered(render_shot, jobs): print(" ", r) print("[3/3] mux…") sd = globals().get("SETDIR", "second_nature") out = OUT/f"{NAME}.mp4" subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"generator=renders/{sd}/{NAME}/render.py", "-metadata", f"title={SUBT} — {TITLE}", str(out)], check=True, capture_output=True) try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{sd}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"scale: S={S} — native re-rasterisation of the {P(SW_S)}x{P(SH_S)} stage\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n,_,_ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel, stateful per shot)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()