#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Occident (01/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/occident # # Palo Alto, 1878: twelve plates of a horse go into a drum with slits to settle a bet about hooves. # # 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/occident.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/occident.mp4 # cover: https://genekogan.com/player_computer/media/occident.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 occident.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_2 (round 2 of night_watch 11) — "OCCIDENT" Barbershop quartet -> ragtime piano, 96bpm, C major. 26 bars. pitch(4) load(6) spin(5) judder(4) proof(6) out(3) + tag Palo Alto, 1878. Leland Stanford owns a trotter named Occident and an argument: when a horse gallops, is there a moment with all four hooves off the ground? Eadweard Muybridge sets a row of cameras beside the track, strings a trip wire to each shutter, and lets the horse answer. The plates say yes — during the *gathered* phase, when the legs fold up under the belly, not during the showy extended reach every painter had drawn for a century. This film loads those plates into a zoetrope and spins them. THE CONCEIT / THE NEW SUBSTRATE: there is no video of a horse here. There is a paper STRIP — twelve sequential lithographs of one gallop cycle, drawn by an articulated skeleton with real two-bone inverse kinematics — and there is a DRUM, and the film is what you see when you look through the slits. `drum_layer()` is a real zoetrope. The strip is wrapped onto the inside of a cylinder. For every screen column it solves the ray-cylinder intersection, takes the FAR wall angle, adds the drum's rotation, and gathers that column of the strip; the vertical resample carries a weak-perspective foreshortening so the strip curves away from the lens and the figures at the aperture edges swell. The NEAR wall angle goes to a slit test, and where no slit is open the column is solid walnut. Nothing about the animation is animated: the apparent motion is emergent, exactly as in the real device — each slit pass advances the drum by one slit AND the strip by one cell, so the pose in the aperture increments by N_cells/N_slits per pass. Cut twelve slits for twelve drawings and the horse gallops. Cut thirteen and he walks backwards. That is a fact about geometry, and this film uses it as a plot point. `ring_layer()` is the same instrument seen from above (a polar unwrap of the strip onto an annulus) and `disc_img()` is its cousin, the phenakistoscope — a disc of radial frames where the slit sweeps past a FIXED eye, so the whole picture blinks black instead of being striped. The drum's angular velocity is one function, `theta_at()`. The picture reads it. The click track — a wooden tick per slit crossing, which is the piece's hi-hat — is generated by finding its zero crossings. Audio and picture are the same number. Music: four close-harmony voices (bass/baritone/lead/tenor) built by singing one macOS `say` modulator through four carriers at four different formant warps, so the syllables lock like a real quartet and the timbres don't. They count the horse in; a stride piano with an oom-pah left hand and a 3+3+2 right hand takes the record over as the gallop resolves. Hoofbeats are a percussion instrument playing the actual gait. Composition: engine : audio-first x shot-parallel (tier 4-P) — the drum sim is a pure function of (t, camera), so shots are independent and the contact sheet renders a midpoint directly content: audio-groove (stride piano, gallop clops, slit ratchet) x tts-voices (Daniel as the whole quartet, four formants) x effects-post (plate shift -> tint -> vignette -> grain -> letterbox) Run from repo root: python3 renders/player_computer_2/zoetrope_horse/render.py --sheet python3 renders/player_computer_2/zoetrope_horse/render.py python3 renders/player_computer_2/zoetrope_horse/render.py --shots 7,8 --force python3 renders/player_computer_2/zoetrope_horse/render.py --mux-only """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "occident" TITLE = "OCCIDENT" SETDIR = "player_computer_final" SETNUM = "11" # Final cut: native 1920x1080. # # Every engine in this film draws in literal screen pixels — a gentleman's top # hat is 96 px across, the drum's rim is 7 px of brass — mixed with fractions # of W and H. Rewriting several hundred literals would be an invitation to # error, so instead W and H stay the AUTHORING frame (1280x720) and the # bitmaps are allocated at OW x OH; every ImageDraw call in an engine goes # through `SDraw`, which multiplies coordinates and stroke widths by S on the # way to Pillow. Font sizes are scaled inside `font()`. The three simulation # layers (drum_layer / ring_layer / disc_img) work in real output pixels and # are handed S-scaled geometry at their call sites; the paper strip they # sample is rasterised 1.5x larger too, so the aperture gains real detail # rather than an upsample. W, H, FPS = 1280, 720, 30 S = 1.5 # output px per authored px OW, OH = int(W*S), int(H*S) # 1920 x 1080 def si(v): return int(round(v*S)) def sf(v): return v*S BPM = 100.0 BEAT = 60.0 / BPM BAR = 4 * BEAT STEP = BEAT / 4 SR = 44100 TAU = math.tau OUT = Path(__file__).resolve().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 = [ ("pitch", 0, 4), ("load", 4, 10), ("spin", 10, 15), ("judder", 15, 19), ("proof", 19, 25), ("out", 25, 28), ] N_BARS = SECTIONS[-1][2] TAIL = 1.6 DUR = N_BARS * BAR + TAIL N_FRAMES = int(DUR * FPS) N_POSES = 12 # drawings on the strip CELL_W, CELL_H = 220, 264 # one drawing MUSIC_DESC = f"barbershop quartet -> ragtime stride piano, {BPM:.0f}bpm, C major, {N_BARS} bars" ENGINE_DESC = ("zoetrope simulation: 12-pose IK gallop strip wrapped on a cylinder, " "ray-cylinder far-wall unwrap, slit shutter, phenakistoscope disc") # ════════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ 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 nm(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return 12 * (int(name[i:]) + 1) + _PC[name[:i]] def nf(name): return mtof(nm(name)) 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 bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping — every noise source goes through it 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) # ── the parlour upright ───────────────────────────────────────────────────── _PIANO = {} def piano(midi, dur, vel=1.0, bright=1.0, seed=0): """Three detuned strings, inharmonic partials (B stretch), felt hammer thump, per-partial decay that scales with frequency. Honky-tonk enough for a rag; cached because a rag is thousands of notes of ~30 pitches.""" key = (round(midi), round(dur, 3), round(vel, 2), round(bright, 2)) if key in _PIANO: return _PIANO[key] f0 = mtof(midi) n = int(dur*SR) if n < 8: _PIANO[key] = np.zeros(max(1, n)); return _PIANO[key] t = np.arange(n)/SR B = 0.00042 * (1.0 + max(0.0, (60 - midi))/40.0) out = np.zeros(n) rng = np.random.RandomState(4400 + int(midi)) for det, g_s in ((0.0, 1.0), (-0.0016, .72), (0.0021, .68)): f = f0*(1+det) for k in range(1, 26): fk = f*k*math.sqrt(1 + B*k*k) if fk > SR*0.45: break amp = g_s / (k ** (1.35 / max(0.35, bright))) dec = 2.4 + 0.0075*fk + 1.5*k**0.6 ph = rng.uniform(0, TAU) out += amp*np.sin(TAU*fk*t + ph)*np.exp(-t*dec) thump = bandshape(rng.randn(n), lo=90, hi=2400)*np.exp(-t*140)*0.35 y = (out/ (np.max(np.abs(out))+1e-9) + thump) y *= adsr(n, .002, .02, .9, min(.08, dur*.35)) * vel y = np.tanh(y*1.15)*0.9 _PIANO[key] = y return y def bassdrum(dur=.34, seed=1): n = int(dur*SR); t = np.arange(n)/SR f = 46 + 68*np.exp(-t*26) body = np.sin(TAU*np.cumsum(f)/SR)*np.exp(-t*8.5) felt = bandshape(np.random.RandomState(seed).randn(n), lo=60, hi=900)*np.exp(-t*90)*.4 return np.tanh((body+felt)*1.5)*.8 def brushsnare(dur=.24, 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=420, hi=5200) return nz*np.exp(-t*15)*.55*bright def woodblock(pitch=1180, dur=.07, seed=3): n = int(dur*SR); t = np.arange(n)/SR body = (np.sin(TAU*pitch*t) + .55*np.sin(TAU*pitch*2.41*t) + .3*np.sin(TAU*pitch*3.9*t)) nz = bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=6000)*np.exp(-t*220) return (body*np.exp(-t*70) + nz*.5)*.55 def slit_tick(dur=.045, pitch=2200, seed=5): """The zoetrope's own hi-hat: one dry wooden tick per slit crossing.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=7200) body = np.sin(TAU*pitch*t)*np.exp(-t*260) return (nz*np.exp(-t*190) + body*.5)*.55 def clop(dur=.12, pitch=250, hard=1.0, seed=7): """A hoof on packed dirt: a woody knock over a short earth thump.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) knock = bandshape(rng.randn(n), lo=350*hard, hi=2600*hard)*np.exp(-t*120) earth = np.sin(TAU*(pitch*np.exp(-t*40)+70)*t)*np.exp(-t*36) return np.tanh((knock*.9 + earth*.8)*1.3)*.55 def shutter(dur=.10, seed=11): """A plate camera going off: a metal slap plus a spring twang.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) slap = bandshape(rng.randn(n), lo=1100, hi=8800)*np.exp(-t*160) spring = np.sin(TAU*3100*t)*np.exp(-t*90)*.35 return (slap + spring)*.7 def ratchet(dur=.5, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=800, hi=5200) grid = (np.sin(TAU*34*t) > 0).astype(float) return nz*grid*np.exp(-t*3.4)*.35 def reverb(x, rt=1.3, mix=.24, seed=29, pre=0.014): 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=.34, mix=.18, taps=5): 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 # ════════════════════════════════════════════════════════════════════════════ # THE QUARTET — one modulator, four formants # # A barbershop quartet is four people saying the same syllable at the same # instant on four different pitches. Giving each part its own `say` render # desynchronises the consonants and the ring dies, so all four parts share ONE # modulator (perfect lock) and differ by (a) carrier pitch, (b) a band-index # warp inside the vocoder that slides the formants, (c) vibrato and a few ms # of human lateness. Pitch is entirely the carrier's, so the modulator can be # stretched to the bar without anyone caring what that did to Daniel. # ════════════════════════════════════════════════════════════════════════════ def _h(*parts): """Stable cache key. Python's str hash is salted per process — hash() here would re-synthesise every line on every run.""" return hashlib.md5("|".join(str(p) for p in parts).encode()).hexdigest()[:16] def read_wav(p): with wave.open(str(p)) as w: ch = w.getnchannels() x = np.frombuffer(w.readframes(w.getnframes()), " 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): if len(x) < 2 or n < 2: return np.zeros(max(1, n)) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def carrier(f_per_sample, nh=34, detune=(0.0, -0.5, 0.55), vib=(0.0, 0.0), seed=0): 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.004) if vib[0]: f = f*(1 + vib[0]*np.sin(TAU*vib[1]*t + seed*0.7)) ph = TAU*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=28, lo=110, hi=6200, gmax=11.0, rel=0.5, sib=0.05, tilt=4000.0, warp=0): """Transfer mod's band envelope onto car. `warp` slides the band mapping — carrier band b takes modulator band b-warp — which moves the formants without touching pitch. That is the whole four-singers trick.""" 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) em = np.zeros(bands); ec = np.zeros(bands) for b, ii in enumerate(idx): if not len(ii): continue em[b] = np.sqrt((am[ii]**2).mean()); ec[b] = np.sqrt((ac[ii]**2).mean()) g = np.zeros(len(fr)) for b, ii in enumerate(idx): if not len(ii): continue sb = min(bands-1, max(0, b - warp)) gb = np.clip(em[sb]/(ec[b]+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 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(-TAU*tilt/2/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.5*y + 0.9*lp + sib*hp return y/(np.max(np.abs(y))+1e-9) def sing_part(mod, midis, dur, warp, vib, nh, detune, seed, gliss=0.014): n = int(dur*SR) f = np.zeros(n); at = 0 wts = [1.0]*len(midis); wts[-1] = 1.7 tot = sum(wts) for i, m in enumerate(midis): ln = int(n*wts[i]/tot) if i < len(midis)-1 else n-at f[at:at+ln] = mtof(m); at += ln if gliss: 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, seed=seed) return vocode(mod, car, warp=warp) # barbershop voicing: bass on the root, tenor above the lead, bari filling in def quartet_notes(root_name, ivs, lead): pcs = sorted({(nm(root_name) + iv) % 12 for iv in ivs}) rpc = nm(root_name) % 12 def find(rng_): for m in rng_: if m % 12 in pcs: return m return None tenor = find(range(lead+3, lead+10)) or lead+7 bari = find(range(lead-2, lead-11, -1)) or lead-5 bass = None for m in range(bari-3, bari-18, -1): if m % 12 == rpc: bass = m; break if bass is None: bass = bari-12 return bass, bari, lead, tenor PARTS = ( # name, warp, vib, nh, detune, pan, gain, late ("bass", -3, (0.010, 4.6), 30, (0.0, -0.6, 0.7), -0.10, 0.95, 0.000), ("bari", -1, (0.013, 5.1), 32, (0.0, -0.45, 0.5), -0.38, 0.80, 0.008), ("lead", 0, (0.016, 5.5), 34, (0.0, -0.5, 0.55), 0.06, 1.00, -0.006), ("tenor", 3, (0.020, 6.1), 36, (0.0, -0.4, 0.45), 0.42, 0.72, 0.012), ) # ════════════════════════════════════════════════════════════════════════════ # THE DRUM'S CLOCK — one function; the picture reads it and the click track # is its zero crossings. poses/second, piecewise linear in t. # ════════════════════════════════════════════════════════════════════════════ # bar poses/second RATE_BARS = [ ( 0.00, 0.0), ( 3.30, 0.0), ( 3.60, 7.0), ( 3.95, 1.0), # a hand nudge ( 4.50, 2.0), ( 9.00, 12.8), # load: spin up to lock (14.80, 12.8), # spin: locked (15.30, 3.2), (18.60, 3.0), # judder: the 13-slit drag (19.30, 17.0), (25.60, 17.0), # proof: full tilt (26.60, 10.0), (27.70, 1.5), (N_BARS + TAIL/BAR, 0.0), ] RATE_KEYS = [(b*BAR, v) for (b, v) in RATE_BARS] SLIT_SWAP = (15.0*BAR, 19.0*BAR) # the 13-slit drum is on the spindle def n_slits_at(t): return 13 if SLIT_SWAP[0] <= t < SLIT_SWAP[1] else 12 _TH = None def _theta_table(): global _TH if _TH is None: ts = np.arange(0.0, DUR + 1.0/240, 1.0/240) kt = np.array([k[0] for k in RATE_KEYS]); kv = np.array([k[1] for k in RATE_KEYS]) rate = np.interp(ts, kt, kv) # poses / second om = rate * (TAU / N_POSES) # rad / second th = np.concatenate([[0.0], np.cumsum((om[1:]+om[:-1])*0.5*(1/240.0))]) _TH = (ts, th, rate) return _TH def theta_at(t): ts, th, _ = _theta_table() return float(np.interp(t, ts, th)) def rate_at(t): ts, _, rate = _theta_table() return float(np.interp(t, ts, rate)) def slit_crossings(): """Every time a slit sweeps the viewing axis — the piece's hi-hat.""" ts, th, _ = _theta_table() outs = [] S = n_slits_at(float(ts[0])); span = TAU/S last = int(th[0]//span) for i in range(1, len(ts)): S2 = n_slits_at(float(ts[i])) if S2 != S: # a drum swap re-bases the count S = S2; span = TAU/S; last = int(th[i]//span); continue cur = int(th[i]//span) while cur > last: last += 1 outs.append((float(ts[i]), last)) return outs # ════════════════════════════════════════════════════════════════════════════ # THE GAIT — one clock for the drawings, the hoofbeats and the hoof counter # # Transverse gallop. Footfall onsets and contact durations as fractions of the # stride. The gap after the last forefoot leaves and before the first hind # lands is the GATHERED suspension — legs folded under the belly — and it is # the only part of the cycle with nothing on the ground. That is the wager. # ════════════════════════════════════════════════════════════════════════════ # (onset, release) leg GAIT = [(0.00, 0.28), # 0 right hind (far) (0.09, 0.37), # 1 left hind (near) (0.30, 0.58), # 2 right fore (far) (0.40, 0.68)] # 3 left fore (near) SUSPENSION = (0.68, 1.00) def down(ph, i): a, b = GAIT[i]; p = ph % 1.0 return a <= p < b def n_down(ph): return sum(1 for i in range(4) if down(ph, i)) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ PROG = [ ("C3", (0, 4, 7)), ("A2", (0, 3, 7, 10)), ("D3", (0, 4, 7, 10)), ("G2", (0, 4, 7, 10)), # pitch 0-3 ("C3", (0, 4, 7)), ("C3", (0, 4, 7, 10)), ("F3", (0, 4, 7)), ("F3", (0, 3, 6, 9)), ("C3", (0, 4, 7)), ("G2", (0, 4, 7, 10)), # load 4-9 ("C3", (0, 4, 7)), ("C3", (0, 4, 7)), ("G2", (0, 4, 7, 10)), ("G2", (0, 4, 7, 10)), ("C3", (0, 4, 7)), # spin 10-14 ("E3", (0, 4, 7, 10)), ("A2", (0, 4, 7, 10)), ("D3", (0, 4, 7, 10)), ("G2", (0, 4, 7, 10)), # judder 15-18 ("C3", (0, 4, 7)), ("C3", (0, 4, 7, 10)), ("F3", (0, 4, 7)), ("F3", (0, 3, 6, 9)), ("C3", (0, 4, 7)), ("G2", (0, 4, 7, 10)), # proof 19-24 ("C3", (0, 4, 7)), ("F3", (0, 4, 7)), ("C3", (0, 4, 7, 9)), # out 25-27 ] assert len(PROG) == N_BARS # (bar, beat, beats, text, lead midi contour) LINES = [ (0, 0, 3.6, "PALO ALTO", [67, 67, 64, 60]), (1, 0, 3.6, "EIGHTEEN SEVENTY EIGHT", [64, 64, 67, 65, 64, 60]), (2, 0, 3.0, "DOES THE HORSE", [62, 64, 65]), (3, 0, 3.8, "EVER FLY", [69, 67, 62]), (4, 0, 3.6, "STANFORD SAYS HE DOES", [60, 62, 64, 65, 64]), (5, 0, 3.6, "NOBODY CAN SEE IT", [64, 64, 65, 67, 64]), (6, 0, 3.4, "TWELVE CAMERAS IN A ROW", [65, 65, 64, 62, 64, 65]), (7, 0, 3.4, "AND A WIRE ACROSS THE TRACK", [66, 66, 65, 62, 66, 65]), (8, 0, 1.8, "LOAD THE STRIP", [60, 62, 64]), (8, 2, 1.8, "SPIN THE DRUM", [65, 67, 72]), (9, 0, 3.6, "ONE TWO THREE FOUR", [67, 67, 71, 74]), (12, 0, 3.2, "GO, OCCIDENT, GO", [74, 71, 67, 65, 62]), (15, 0, 3.4, "SOMEBODY CUT THIRTEEN SLITS", [64, 64, 62, 59, 62, 64, 64]), (17, 0, 3.8, "HE IS WALKING BACKWARDS", [62, 60, 59, 57, 55, 50]), (19, 0, 2.6, "COUNT THE HOOVES", [64, 64, 62, 60]), (21, 0, 3.6, "FOUR, THREE, TWO, ONE", [72, 71, 69, 67]), (22, 2, 1.8, "NONE", [76, 76]), (23, 0, 3.6, "ALL FOUR OFF THE GROUND", [72, 72, 71, 69, 67, 72]), (25, 2, 1.6, "THERE HE GOES", [69, 67, 64]), (26, 2, 4.2, "GONE", [72]), ] # the twelve shutters: fixed musical instants, and the picture is built to # put the horse on wire k at snap k SNAP_T0 = 6*BAR SNAP_DT = BEAT/2 CAM_SNAPS = [SNAP_T0 + k*SNAP_DT for k in range(N_POSES)] # stride piano right hand: (16th step, semitones over the chord root) RH_PATS = [ [(0, 12), (3, 16), (6, 19), (8, 24), (11, 19), (14, 16)], [(0, 24), (2, 23), (3, 19), (6, 16), (8, 12), (10, 16), (11, 19), (14, 24)], [(0, 19), (3, 24), (6, 28), (8, 24), (11, 19), (12, 16), (14, 12)], [(0, 16), (1, 19), (3, 24), (4, 23), (6, 19), (8, 16), (9, 12), (11, 16), (12, 24), (14, 19)], [(0, 12), (2, 16), (3, 19), (5, 24), (6, 19), (8, 16), (11, 24), (13, 19), (14, 16)], ] class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0, swing=0.0): 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 if i < 0: sig = sig[-i:]; i = 0; j = min(self.n, len(sig)) if j <= 0: return th = (pan*.5+.5)*(math.pi/2) st = np.stack([sig[:j-i]*math.cos(th), sig[:j-i]*math.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): env = np.ones(self.n) for name, 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(name, 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, levels=None, pump_depth=.12, pump_rel=.14): 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 0 <= 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] for c in range(2): # trim DC / sub-30Hz rumble mix[:, c] = bandshape(mix[:, c], lo=30.0) mix = np.tanh(mix*1.62)/np.tanh(1.62) 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(" 0: pat = RH_PATS[(bar*3 + 1) % len(RH_PATS)] for st, iv in pat: vel = .95 if st in (0, 3, 6, 11) else .62 s.put("rh", piano(rm+iv, BEAT*(.7 if st % 2 == 0 else .45), vel=vel, bright=1.1), s.t(bar, st, SW), g=rh_g, pan=.24+.04*(iv % 3)) # a grace/turn on the back half of alternate bars if full and bar % 2 == 1: for j, iv in enumerate((23, 24, 26, 24)): s.put("rh", piano(rm+iv, BEAT*.3, vel=.55, bright=1.3), s.t(bar, 12+j, SW), g=rh_g*.7, pan=.36) # DRUMS — a ragtime kit: bass drum on 1 & 3, brushes on 2 & 4 dg = .30 if entering else (.22 if drag else .46) for b in (0, 2): s.put("drums", bassdrum(), s.t(bar, b*4, SW), g=dg*.85) for b in (1, 3): s.put("drums", brushsnare(bright=1.0 if full else .7), s.t(bar, b*4, SW), g=dg*.42, pan=.12) if full: for st in (6, 7, 14, 15): s.put("drums", woodblock(1180 + 90*(st % 3), seed=bar*7+st), s.t(bar, st, SW), g=dg*.30, pan=-.34) # HOOFBEATS — the actual gait, one stride per beat hoof_on = (sec in ("load", "spin", "proof", "out")) and not drag if hoof_on: hg = .34 if sec != "load" else .22 for b in range(4): base = s.t(bar, b*4, 0.0) for i, (on, _off) in enumerate(GAIT): s.put("hoof", clop(pitch=210 + 46*i, hard=.85 + .1*i, seed=bar*29 + b*4 + i), base + on*BEAT, g=hg*(1.0 if i in (0, 3) else .72), pan=-.42 + .28*i) # ── the zoetrope's own hi-hat: one tick per slit crossing ─────────────── for (tt, k) in slit_crossings(): r = rate_at(tt) if r < 0.05: continue g = float(np.clip(0.16 + 0.34/(1.0 + r*0.16), 0.06, 0.34)) s.put("slits", slit_tick(pitch=1900 + 130*(k % 4), seed=1000+(k % 37)), tt, g=g*0.62, pan=-0.18 + 0.36*((k % 3)/2.0)) # ── events ────────────────────────────────────────────────────────────── for k, tt in enumerate(CAM_SNAPS): s.put("fx", shutter(seed=11+k), tt, g=.42, pan=-.55 + 1.1*(k/(N_POSES-1))) s.put("fx", ratchet(.6, seed=13), 8.85, g=.42, pan=-.1) # the hand spin s.put("fx", ratchet(.9, seed=17), 34.2, g=.32, pan=.1) # the drag s.put("fx", woodblock(760, .3, seed=23), SLIT_SWAP[0]-0.2, g=.5, pan=0) for j in range(5): # the run-out s.put("hoof", clop(pitch=190 + 30*j, hard=1.1, seed=333+j), 25*BAR + j*BEAT*0.30, g=.55, pan=-.5 + .25*j) s.put("fx", shutter(seed=99), 26*BAR + 2*BEAT, g=.45) s.bus("quartet", lambda x: reverb(x, rt=1.7, mix=.30, seed=901)) s.bus("rh", lambda x: reverb(delay(x, BEAT*.75, .28, .12), rt=1.2, mix=.20, seed=903)) s.bus("lh", lambda x: reverb(x, rt=.9, mix=.13, seed=905)) s.bus("hoof", lambda x: reverb(x, rt=1.4, mix=.24, seed=907)) s.bus("slits", lambda x: reverb(x, rt=.6, mix=.16, seed=909)) s.bus("fx", lambda x: reverb(x, rt=1.5, mix=.26, seed=911)) mix = s.mixdown(dict(quartet=1.0, lh=1.0, rh=1.0, drums=1.0, hoof=1.0, slits=1.0, fx=1.0, pipe=1.0), levels=dict(pitch=1.0, load=.95, spin=1.0, judder=.80, proof=1.0, out=.92)) wav = AUD/"final.wav" s.write(wav, mix) np.savez(AUD/"subs.npz", t0=np.array([a for a, b, c in SUBS]), t1=np.array([b for a, b, c in SUBS]), tx=np.array([c for a, b, c in SUBS], dtype=object)) 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 < 180].sum() E["mid"][f] = sp[(fr >= 180) & (fr < 2200)].sum() E["high"][f] = sp[fr >= 2200].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 _SUBS = {} def subs(): if not _SUBS: p = AUD/"subs.npz" if p.exists(): z = np.load(p, allow_pickle=True) _SUBS["t0"], _SUBS["t1"], _SUBS["tx"] = z["t0"], z["t1"], z["tx"] else: _SUBS["t0"] = _SUBS["t1"] = np.zeros(0) _SUBS["tx"] = np.zeros(0, dtype=object) return _SUBS # ════════════════════════════════════════════════════════════════════════════ # PALETTE — 1878 letterpress: rag paper, iron-gall ink, walnut, vermilion # ════════════════════════════════════════════════════════════════════════════ PAPER = (232, 220, 192) PAPER2 = (218, 203, 172) INK = ( 38, 32, 28) INK2 = ( 84, 72, 60) WALNUT = ( 82, 50, 34) WALNUT2= ( 58, 35, 24) GOLD = (196, 156, 74) VERM = (176, 58, 38) DARK = ( 22, 17, 15) CREAM = (244, 236, 214) def ease(u): return u*u*(3-2*u) def lerp(a, b, u): return a + (b-a)*u def mixc(a, b, u): return tuple(int(a[i] + (b[i]-a[i])*u) for i in range(3)) _FC = {} _SDRAW_COORD = {"line", "rectangle", "rounded_rectangle", "ellipse", "polygon", "arc", "chord", "pieslice", "point", "text", "multiline_text"} def _sxy(xy): if isinstance(xy, (int, float)): return xy*S if not len(xy): return xy if isinstance(xy[0], (tuple, list)): return [(v[0]*S, v[1]*S) for v in xy] return [v*S for v in xy] class SDraw: """An ImageDraw proxy for engine code authored in 1280x720 coordinates. Multiplies coordinates and stroke widths by S; fonts are already scaled by `font()`. At S == 1.0 it is a transparent pass-through.""" __slots__ = ("_d",) def __init__(self, im, mode=None): object.__setattr__(self, "_d", ImageDraw.Draw(im, mode) if mode else ImageDraw.Draw(im)) def __getattr__(self, name): f = getattr(object.__getattribute__(self, "_d"), name) if S == 1.0 or name not in _SDRAW_COORD: return f def wrapped(xy, *a, **kw): w = kw.get("width") if isinstance(w, (int, float)): kw["width"] = max(1, int(round(w*S))) return f(_sxy(xy), *a, **kw) return wrapped # ── 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) def font_raw(size, name="Georgia.ttf"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] def font(size, name="Georgia.ttf"): """Type is authored at 720p and scales with the frame.""" return font_raw(max(6, si(size)), name) def ctext(d, cx, y, s, f, fill, shadow=None): w = d.textlength(s, font=f) if shadow: d.text((cx-w/2+2, y+2), s, font=f, fill=shadow) d.text((cx-w/2, y), s, font=f, fill=fill) return w # ════════════════════════════════════════════════════════════════════════════ # THE HORSE — an articulated skeleton, drawn as a lithograph # # Nothing here is a stored drawing. Each pose is solved: the body pitches and # rises off its own gait clock, four two-bone IK chains reach for four foot # targets that plant, sweep back and fold, and the result is inked with an # outline and a hatched shadow rim. The poses on the strip ARE the animation; # there is nothing else moving in the whole film. # ════════════════════════════════════════════════════════════════════════════ def rot(p, c, a): ca, sa = math.cos(a), math.sin(a) dx, dy = p[0]-c[0], p[1]-c[1] return (c[0] + dx*ca - dy*sa, c[1] + dx*sa + dy*ca) def ik2(jx, jy, tx, ty, l1, l2, flip): dx, dy = tx-jx, ty-jy d = math.hypot(dx, dy) d = min(d, l1+l2-0.5); d = max(d, abs(l1-l2)+0.5) tx, ty = jx + dx/(math.hypot(dx, dy)+1e-9)*d, jy + dy/(math.hypot(dx, dy)+1e-9)*d ca = (l1*l1 + d*d - l2*l2)/(2*l1*d) a = math.acos(max(-1.0, min(1.0, ca))) base = math.atan2(ty-jy, tx-jx) ang = base + flip*a return (jx + l1*math.cos(ang), jy + l1*math.sin(ang)), (tx, ty) def taper(p0, p1, w0, w1): dx, dy = p1[0]-p0[0], p1[1]-p0[1] L = math.hypot(dx, dy) + 1e-9 nx, ny = -dy/L, dx/L return [(p0[0]+nx*w0, p0[1]+ny*w0), (p1[0]+nx*w1, p1[1]+ny*w1), (p1[0]-nx*w1, p1[1]-ny*w1), (p0[0]-nx*w0, p0[1]-ny*w0)] def foot_target(jx, jy, gy, ph, on, off, x0, reach, lift, fold): p = ph % 1.0 stance = off - on if on <= p < off: u = (p-on)/stance return (x0 + reach - 2*reach*u, gy) q = (p - off) % 1.0 swing = 1.0 - stance u = q/swing fx = x0 - reach + 2*reach*ease(u) fy = gy - lift*(math.sin(math.pi*u) ** 0.72) k = fold*math.sin(math.pi*u) return (jx + (fx-jx)*(1-k), jy + (fy-jy)*(1-k)) # model space -> cell space. The animal is drawn at life proportions and then # fitted to the 220x264 cell, nose to tail-tip, with the ground line held. MS, MDX, MDY = 0.82, 28.0, 42.0 GY = 232.0 def _unit(dx, dy): L = math.hypot(dx, dy) + 1e-9 return dx/L, dy/L def smooth(pts, closed=True, k=5): """Catmull-Rom through the control points. Straight-edged polygons read as a wooden toy; the same points through a spline read as a drawing.""" n = len(pts) if n < 3: return list(pts) out = [] last = n if closed else n-1 for i in range(last): p0 = pts[(i-1) % n] if closed else pts[max(0, i-1)] p1 = pts[i % n] p2 = pts[(i+1) % n] p3 = pts[(i+2) % n] if closed else pts[min(n-1, i+2)] for j in range(k): t = j/float(k); t2 = t*t; t3 = t2*t out.append(( 0.5*(2*p1[0] + (-p0[0]+p2[0])*t + (2*p0[0]-5*p1[0]+4*p2[0]-p3[0])*t2 + (-p0[0]+3*p1[0]-3*p2[0]+p3[0])*t3), 0.5*(2*p1[1] + (-p0[1]+p2[1])*t + (2*p0[1]-5*p1[1]+4*p2[1]-p3[1])*t2 + (-p0[1]+3*p1[1]-3*p2[1]+p3[1])*t3))) if not closed: out.append(pts[-1]) return out def _ribbon(pts, w0, w1): """A tapering closed strip through a polyline — the tail switch.""" n = len(pts) left, right = [], [] for j in range(n): a2 = pts[max(0, j-1)]; b2 = pts[min(n-1, j+1)] nx, ny = _unit(-(b2[1]-a2[1]), b2[0]-a2[0]) w = w0 + (w1-w0)*(j/(n-1.0)) left.append((pts[j][0]+nx*w, pts[j][1]+ny*w)) right.append((pts[j][0]-nx*w, pts[j][1]-ny*w)) return left + right[::-1] def horse_polys(ph, sc=1.0, ox=0.0, oy=0.0): """(fills, legs, hairs, eye) in cell coordinates. Nothing is stored — every pose is solved from the gait clock. Each fill is (mask_poly, stroke_poly, stroke_closed): the neck and head are stroked as OPEN curves so their base edges vanish into the body instead of drawing a seam across it.""" p = ph % 1.0 a, b = SUSPENSION rise = 0.0 if a <= p < b: # the gathered suspension lifts the animal rise = 19.0*math.sin(math.pi*(p-a)/(b-a)) ** 0.80 rise += 3.0*math.sin(TAU*p) pitch = -0.09*math.sin(TAU*(p-0.56)) # nose up through the gathered leap by = -rise C = (92.0, GY-108+by) def B(x, y): return rot((x, y+by), C, pitch) # the barrel: withers, back, loin, croup, dock, buttock, gaskin, belly, chest body = [B(133, 112), B(107, 115), B(80, 110), B(53, 105), B(32, 114), B(23, 141), B(38, 166), B(66, 176), B(104, 179), B(131, 171), B(152, 156), B(161, 132), B(151, 113)] SHO = B(139, 130) HIP = B(56, 126) DOCK = B(28, 118) # neck + head — the neck telescopes across the stride, which is most of # what makes a galloping horse read as galloping ne = 0.5 + 0.5*math.sin(TAU*(p-0.30)) nl = 58 + 22*ne na = -1.10 + 0.44*ne + pitch Bc = B(138, 120) ux, uy = math.cos(na), math.sin(na) qx, qy = -uy, ux P = (Bc[0]+ux*nl, Bc[1]+uy*nl) nA = (Bc[0]+qx*23, Bc[1]+qy*23) # base, crest side (inside the body) nB = (P[0]+qx*11 - ux*2, P[1]+qy*11 - uy*2) nC = (P[0]-qx*12 - ux*2, P[1]-qy*12 - uy*2) nD = (Bc[0]-qx*27, Bc[1]-qy*27) # base, throat side (inside the body) neck = [nA, nB, nC, nD] ha = na + 1.30 + 0.16*math.sin(TAU*(p-0.10)) hl = 47.0 hx_, hy_ = math.cos(ha), math.sin(ha) rx, ry = -hy_, hx_ M = (P[0]+hx_*hl, P[1]+hy_*hl) hA = (P[0]+rx*9, P[1]+ry*9) # poll, crest side hB = (M[0]+rx*4, M[1]+ry*4) # nose bridge hC = (M[0]-rx*6+hx_*2, M[1]-ry*6+hy_*2) # muzzle hD = (P[0]-rx*13+hx_*23, P[1]-ry*13+hy_*23) # jaw hE = (P[0]-rx*11, P[1]-ry*11) # throatlatch head = [hA, hB, hC, hD, hE] ear = [(P[0]+rx*8 -hx_*5, P[1]+ry*8 -hy_*5), (P[0]+rx*3 -hx_*17, P[1]+ry*3 -hy_*17), (P[0]-rx*3 -hx_*7, P[1]-ry*3 -hy_*7)] eye = (P[0]+rx*1+hx_*10, P[1]+ry*1+hy_*10) # tail ta = 3.28 + 0.38*math.sin(TAU*(p-0.18)) tl = 46 + 10*math.sin(TAU*(p+0.10)) tpts = [] for j in range(6): u2 = j/5.0 aa = ta + 0.52*u2*math.sin(TAU*(p-0.18)+1.1) tpts.append((DOCK[0]+tl*u2*math.cos(aa), DOCK[1]+tl*u2*math.sin(aa))) tail = _ribbon(tpts, 9, 2.5) sb = smooth(body, True) st = smooth(tail, True, 3) sn = smooth(neck, True, 4) sh_ = smooth(head, True, 2) fills = [(st, smooth(tail, True, 3), True), (sb, sb, True), (sn, smooth([nA, nB, nC, nD], False, 4), False), (sh_, smooth([hA, hB, hC, hD, hE], False, 2), False), (ear, ear, True)] legs = [] # leg joint far? order = [(0, HIP, 1), (2, SHO, 1), (1, HIP, 0), (3, SHO, 0)] for (i, joint, far) in order: on, off = GAIT[i] hind = i < 2 l1, l2 = (50, 56) if hind else (46, 52) x0 = (HIP[0]-6) if hind else (SHO[0]+10) reach = 48 if hind else 44 fl = 1.0 if hind else -1.0 fx, fy = foot_target(joint[0], joint[1], GY, p, on, off, x0, reach, 48, 0.32) knee, foot = ik2(joint[0], joint[1], fx, fy, l1, l2, fl) w = 1.0 if not far else 0.84 legs.append((smooth(taper(joint, knee, 13*w, 8*w), True, 2), smooth(taper(knee, foot, 7*w, 4*w), True, 2), [(foot[0]-5.5*w, foot[1]-4*w), (foot[0]+7*w, foot[1]-4*w), (foot[0]+5.5*w, foot[1]+5*w), (foot[0]-5*w, foot[1]+5*w)], far)) hairs = [] for j in range(9): # mane along the crest u = j/8.0 bx = lerp(nA[0], nB[0], u); byy = lerp(nA[1], nB[1], u) sw = 5 + 5*math.sin(TAU*(p + u*0.35)) hairs.append([(bx, byy), (bx - 4 - sw*0.5, byy - 3 - sw*0.30)]) for j in range(3): # strands inside the tail off2 = (j-1)*3.5 hairs.append(smooth([(x2+off2*0.3, y2+off2) for (x2, y2) in tpts[1:]], False, 3)) def xf(pt): return (ox + (pt[0]*MS + MDX)*sc, oy + (pt[1]*MS + MDY)*sc) fills = [([xf(q) for q in m], [xf(q) for q in st2], cl) for (m, st2, cl) in fills] legs = [([xf(q) for q in a2], [xf(q) for q in b2], [xf(q) for q in c2], far) for (a2, b2, c2, far) in legs] hairs = [[xf(q) for q in hh] for hh in hairs] return fills, legs, hairs, xf(eye) def horse_hoof_pts(ph, sc=1.0, ox=0.0, oy=0.0): _f, legs, _h, _e = horse_polys(ph, sc, ox, oy) return [((q[2][0][0]+q[2][1][0])/2, (q[2][0][1]+q[2][2][1])/2) for q in legs] def draw_horse(im, ph, sc=1.0, ox=0.0, oy=0.0, ink=INK, paper=PAPER, hatch=True, lw=3): fills, legs, hairs, eye = horse_polys(ph, sc, ox, oy) Wc, Hc = im.size mask = Image.new("L", (Wc, Hc), 0); md = ImageDraw.Draw(mask) shade = Image.new("L", (Wc, Hc), 0); sd = ImageDraw.Draw(shade) allp = [f[0] for f in fills] + [q for L in legs for q in (L[0], L[1], L[2])] for poly in allp: md.polygon(poly, fill=255); sd.polygon(poly, fill=255) off = max(2, int(6*sc)) for poly in allp: sd.polygon([(x-off, y-off*1.25) for (x, y) in poly], fill=0) tone = Image.new("RGB", (Wc, Hc), mixc(paper, ink, 0.09)) im.paste(tone, (0, 0), mask) if hatch: hl2 = Image.new("RGB", (Wc, Hc), mixc(paper, ink, 0.09)) hd = ImageDraw.Draw(hl2) stepp = max(4, int(6*sc)) for i in range(-Hc, Wc+Hc, stepp): hd.line([(i, 0), (i+Hc, Hc)], fill=mixc(ink, paper, 0.05), width=max(1, int(2*sc))) im.paste(hl2, (0, 0), shade) d = ImageDraw.Draw(im) W2 = max(1, int(lw*sc)) def stroke(poly, col, w, closed=True): d.line(list(poly)+([poly[0]] if closed else []), fill=col, width=w, joint="curve") for (a2, b2, c2, far) in legs: if not far: continue for poly in (a2, b2): stroke(poly, INK2, max(1, W2-1)) d.polygon(c2, fill=INK2) for (_m, st2, cl) in fills: stroke(st2, ink, W2, cl) for (a2, b2, c2, far) in legs: if far: continue for poly in (a2, b2): stroke(poly, ink, W2) d.polygon(c2, fill=ink) for hh in hairs: d.line(hh, fill=ink, width=max(1, int(2.0*sc)), joint="curve") d.ellipse([eye[0]-2.8*sc, eye[1]-2.8*sc, eye[0]+2.8*sc, eye[1]+2.8*sc], fill=ink) return im def horse_cutout(ph, sc=1.0, lw=3): """The pose as an RGB image + an alpha mask of everything that isn't paper.""" tmp = Image.new("RGB", (si(CELL_W), si(CELL_H)), PAPER) draw_horse(tmp, ph, S, 0, 0, lw=lw) a = np.asarray(tmp, np.int16); pa = np.array(PAPER, np.int16) m = Image.fromarray(((np.abs(a-pa).sum(2) > 16).astype(np.uint8)*255), "L") if sc != 1.0: tw, th = max(1, int(si(CELL_W)*sc)), max(1, int(si(CELL_H)*sc)) tmp = tmp.resize((tw, th), Image.LANCZOS) m = m.resize((tw, th), Image.LANCZOS) return tmp, m # ── the strip ──────────────────────────────────────────────────────────────── _STRIPS = {} def strip_np(kind="full", nblank=0): key = (kind, nblank) if key in _STRIPS: return _STRIPS[key] sw = CELL_W*N_POSES im = Image.new("RGB", (si(sw), si(CELL_H)), PAPER) d = SDraw(im) R = np.random.RandomState(1878) # paper speckle + a horizontal grain (the speck COUNT goes as the area, so # the paper has the same texture density at either resolution) for _ in range(int(2600*S*S)): x = R.rand()*sw; y = R.rand()*CELL_H d.point((x, y), fill=mixc(PAPER, INK, 0.10 + 0.25*R.rand())) for y in range(0, CELL_H, 5): d.line([(0, y), (sw, y)], fill=mixc(PAPER, PAPER2, 0.45*R.rand())) for i in range(N_POSES): x0 = i*CELL_W blank = (kind == "blank") or (i < nblank) # cell rule + perforations d.rectangle([x0+5, 8, x0+CELL_W-6, CELL_H-9], outline=mixc(INK, PAPER, .5), width=2) for py in (4, CELL_H-5): for k in range(4): px = x0 + 34 + k*48 d.ellipse([px-4, py-3, px+4, py+3], fill=mixc(PAPER, INK, .35)) d.line([(x0, 0), (x0, CELL_H)], fill=mixc(INK, PAPER, .35), width=2) # ground line d.line([(x0+16, 234), (x0+CELL_W-16, 234)], fill=mixc(INK, PAPER, .35), width=2) if not blank: ph = i/float(N_POSES) tmp, cellmask = horse_cutout(ph, 1.0, lw=5) im.paste(tmp, (si(x0), 0), cellmask) # hoof-contact ticks: the drawing states its own evidence nd = n_down(ph) for q in range(4): d.rectangle([x0+CELL_W-26, 30+q*9, x0+CELL_W-14, 36+q*9], fill=INK if q < nd else mixc(PAPER, INK, .18)) f = font(19, "Georgia Bold.ttf") d.text((x0+13, CELL_H-30), f"{i+1:02d}", font=f, fill=mixc(INK, PAPER, .25)) arr = np.asarray(im, np.float32) _STRIPS[key] = arr return arr # ════════════════════════════════════════════════════════════════════════════ # THE ZOETROPE — the substrate # ════════════════════════════════════════════════════════════════════════════ def _wall_shade(Wa, sx): """The near wall: japanned tin, nearly black, with a turned sheen. It has to read as a SHUTTER — if the bars are not far darker than the paper the strobe turns into porridge.""" lit = np.clip(np.cos(np.arcsin(np.clip(sx, -1, 1))*1.15), 0, 1.0) dark = np.array((16, 11, 9), np.float32) sheen = np.array(WALNUT, np.float32) col = dark[None, :] + sheen[None, :]*(lit[:, None] ** 3.2)*0.55 return col CAM_H_FRAC = 0.10 # lens height above the drum's mid-line, in band-heights K_FRAC = 4.4 # lens distance from the axis, in radii S_NEAR = K_FRAC/(K_FRAC-1.0) # 1.29 — perspective scale at the near wall S_FAR = K_FRAC/(K_FRAC+1.0) # 0.81 — ... and at the far wall def drum_geom(cx, cy, R, bh): """Where the tin ends up on screen, so the rims match the simulation.""" ch = CAM_H_FRAC*bh return dict( top_side=cy-(bh/2-ch), top_near=cy-(bh/2-ch)*S_NEAR, top_far=cy-(bh/2-ch)*S_FAR, bot_side=cy+(bh/2+ch), bot_near=cy+(bh/2+ch)*S_NEAR, bot_far=cy+(bh/2+ch)*S_FAR) def near_angle(sx, cosf, R, eyek): """Which slit does the ray to this far-wall point actually pass through? A wide lens sees the far wall through many slits at once and the picture comes out in narrow stripes. A real viewer's eye sits just outside the rim, so nearly every ray leaves through the SAME slit and the shutter works on the whole picture at once. `eyek` (eye distance in radii) is that dial, and this is the honest ray-cylinder solve for it: shoot from the eye to the far wall point and take the near root. """ if eyek >= 40.0: # orthographic limit return np.arcsin(sx) KE = eyek*R Fx = R*sx; Fz = -R*cosf dx = Fx; dz = Fz - KE a = dx*dx + dz*dz b = 2.0*KE*dz c = KE*KE - R*R disc = np.maximum(b*b - 4.0*a*c, 0.0) u = (-b - np.sqrt(disc))/(2.0*a + 1e-9) return np.arctan2(u*dx, KE + u*dz) def drum_layer(cx, cy, R, bh, theta, strip, n_slits=12, slit_frac=0.34, sub=1, dtheta=0.0, mirror=False, flip=False, glow=1.0, eyek=2.6): """A real zoetrope, solved per screen column. x -> sx = (x-cx)/R near-wall angle phi_n = asin(sx) (the slit shutter lives here) far-wall angle phi_f = pi - phi_n (the strip lives here) depth z = -R*sqrt(1-sx^2) -> weak-perspective vertical scale strip column u = ((phi_f + theta) mod 2pi)/2pi * strip_width Everything else — the strobe, the resolve, the backwards walk — falls out of those four lines, exactly as it falls out of a tin drum on a spindle. """ sh, sw = strip.shape[0], strip.shape[1] g = drum_geom(cx, cy, R, bh) # cx / R / bh arrive in REAL output pixels, so the clip is the output frame x0 = max(0, int(round(cx - R))); x1 = min(OW-1, int(round(cx + R))) ytop = max(0, int(round(g["top_near"]))-si(2)) ybot = min(OH-1, int(round(g["bot_near"]))+si(2)) if x1 <= x0 or ybot <= ytop: return np.zeros((1, 1, 3), np.float32), (0, 0, 1, 1), np.zeros((1, 1)) xs = np.arange(x0, x1+1, dtype=np.float32) sx = np.clip((xs - cx)/R, -1.0, 1.0) cosf = np.sqrt(np.clip(1.0 - sx*sx, 0.0, 1.0)) phi_f = math.pi - np.arcsin(sx) phi_n = near_angle(sx, cosf, R, eyek) scale = K_FRAC/(K_FRAC + cosf) # 1 at the rim, 0.81 mid cam_h = CAM_H_FRAC*bh hscale = bh/float(sh) yy = np.arange(ytop, ybot+1, dtype=np.float32) acc = np.zeros((len(yy), len(xs), 3), np.float32) accm = np.zeros((len(yy), len(xs)), np.float32) gate = np.zeros(len(xs), np.float32) wall = _wall_shade(len(xs), sx) span = TAU/n_slits for j in range(sub): # the sub-samples span ONE frame's exposure, not (sub-1) of them th = theta + ((j/(sub-1.0) - 0.5) if sub > 1 else 0.0)*dtheta # the far wall is seen directly, not in a mirror: screen x rises as # phi_f falls, so u must run the other way or the strip reads backwards ang = (phi_f if mirror else -phi_f) + th ui = ((ang % TAU)/TAU*sw).astype(np.int32) % sw r = (bh/2.0 - cam_h + (yy[:, None]-cy)/scale[None, :])/hscale if flip: r = (sh-1) - r ri = np.rint(r).astype(np.int32) valid = (ri >= 0) & (ri < sh) rc = np.clip(ri, 0, sh-1) px = strip[rc, np.broadcast_to(ui[None, :], rc.shape)] acc += px*valid[..., None] accm += valid # slit shutter on the near wall k = np.rint((phi_n - th)/span) dist = np.abs(phi_n - (th + k*span)) gate += np.clip((span*slit_frac*0.5 - dist)/(span*0.05 + 1e-6), 0, 1) gate = np.clip(gate/max(1, sub), 0, 1)[None, :] img = acc/np.maximum(accm, 1e-6)[..., None] interior = np.array((30, 21, 16), np.float32) img = np.where((accm > 0)[..., None], img, interior[None, None, :]) # lamp falloff: the middle of the far wall catches the window light lamp = (0.66 + 0.36*cosf)[None, :, None]*glow img = img*np.clip(lamp, 0, 1.45) out = img*gate[..., None] + wall[None, :, :]*(1.0 - gate[..., None]) return out, (x0, ytop, x1, ybot), gate def draw_tin(d, cx, cy, R, bh, base=True): """The furniture around the simulation: rims, pinstripes, spindle, stand.""" g = drum_geom(cx, cy, R, bh) d.ellipse([cx-R, g["top_near"], cx+R, g["top_far"]], outline=mixc(GOLD, WALNUT, .30), width=7) d.arc([cx-R, g["bot_far"], cx+R, g["bot_near"]], 0, 180, fill=mixc(GOLD, WALNUT, .45), width=7) for u in (0.03, 0.97): yy = g["top_side"] + (g["bot_side"]-g["top_side"])*u ry = (g["bot_near"]-g["bot_far"])*0.5 d.arc([cx-R, yy-ry, cx+R, yy+ry], 0, 180, fill=GOLD, width=3) if base: by = g["bot_near"] d.polygon([(cx-17, by-6), (cx+17, by-6), (cx+11, by+52), (cx-11, by+52)], fill=mixc(WALNUT2, DARK, .2)) d.ellipse([cx-128, by+42, cx+128, by+72], fill=mixc(WALNUT, DARK, .3), outline=mixc(GOLD, WALNUT, .5), width=3) def paste_layer(base, layer, box): x0, y0, x1, y1 = box hh, ww = layer.shape[0], layer.shape[1] bx0, by0 = max(0, x0), max(0, y0) bx1, by1 = min(OW, x0+ww), min(OH, y0+hh) if bx1 <= bx0 or by1 <= by0: return base base[by0:by1, bx0:bx1] = layer[by0-y0:by1-y0, bx0-x0:bx1-x0] return base _POLAR = {} def ring_layer(cx, cy, r_in, r_out, theta, strip, n_slits=12, slit_frac=0.34, squash=0.42): """The same drum from above: a polar unwrap of the strip onto an annulus, squashed into an ellipse because the lens is not directly overhead.""" sh, sw = strip.shape[0], strip.shape[1] key = (int(r_out), int(squash*100)) size = int(r_out*2)+4 if key not in _POLAR: yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) dx = xx - size/2.0; dy = (yy - size/2.0)/max(1e-6, squash) rr = np.sqrt(dx*dx + dy*dy) aa = np.arctan2(dy, dx) _POLAR[key] = (rr, aa) rr, aa = _POLAR[key] band = np.clip((rr - r_in)/max(1e-6, (r_out - r_in)), 0, 1) inside = (rr >= r_in) & (rr <= r_out) ui = (((aa + theta) % TAU)/TAU*sw).astype(np.int32) % sw ri = np.clip((band*(sh-1)).astype(np.int32), 0, sh-1) img = strip[ri, ui] span = TAU/n_slits k = np.rint((aa - theta)/span) dist = np.abs(aa - (theta + k*span)) gate = np.clip((span*slit_frac*0.5 - dist)/(span*0.05 + 1e-6), 0, 1) wall = np.array(WALNUT, np.float32)[None, None, :] out = img*gate[..., None] + wall*(1-gate[..., None]) # inside the ring: the floor of the drum + spindle shadow floorc = np.array(mixc(WALNUT2, DARK, 0.4), np.float32) out = np.where(inside[..., None], out, floorc[None, None, :]) # outside the drum: the table outside = rr > r_out tablec = np.array(mixc(WALNUT2, DARK, 0.25), np.float32) out = np.where(outside[..., None], tablec[None, None, :], out) return out, (int(cx-size/2), int(cy-size/2), int(cx+size/2), int(cy+size/2)) _DISC = {} def disc_img(n=N_POSES, size=560): if size in _DISC: return _DISC[size] im = Image.new("RGB", (si(size), si(size)), PAPER) d = SDraw(im) R = np.random.RandomState(1833) for _ in range(int(2400*S*S)): x = R.rand()*size; y = R.rand()*size d.point((x, y), fill=mixc(PAPER, INK, 0.08+0.22*R.rand())) d.ellipse([2, 2, size-3, size-3], outline=INK, width=4) d.ellipse([size*0.30, size*0.30, size*0.70, size*0.70], outline=mixc(INK, PAPER, .55), width=3) for i in range(n): a = i*TAU/n sc = size*0.40/CELL_W tmp, m = horse_cutout(i/float(n), sc, lw=4) cell = Image.new("RGBA", tmp.size, (0, 0, 0, 0)) cell.paste(tmp, (0, 0), m) cell = cell.rotate(-math.degrees(a)+90, expand=True, resample=Image.BICUBIC) rr = size*0.31 px = size/2 + rr*math.cos(a) - cell.size[0]/(2*S) py = size/2 + rr*math.sin(a) - cell.size[1]/(2*S) im.paste(cell, (int(sf(px)), int(sf(py))), cell) f = font(20, "Georgia Bold.ttf") d.text((size/2 + size*0.455*math.cos(a) - 8, size/2 + size*0.455*math.sin(a) - 10), f"{i+1}", font=f, fill=mixc(INK, PAPER, .3)) d.ellipse([size/2-14, size/2-14, size/2+14, size/2+14], fill=INK) _DISC[size] = im return im # ════════════════════════════════════════════════════════════════════════════ # SHOT ENGINES # ════════════════════════════════════════════════════════════════════════════ def bg_room(im, t, e, wash=0.0): d = SDraw(im) d.rectangle([0, 0, W, H], fill=mixc(DARK, WALNUT2, 0.28)) # a window of light behind, lightly breathing on the mids gw = int(W*0.46); gh = int(H*0.52) lit = 0.55 + 0.35*e["mid"] for k in range(9): u = k/8.0 c = mixc(mixc(DARK, PAPER2, 0.42*lit), DARK, u) d.ellipse([W*0.52-gw*(0.5+u*0.7), H*0.10-gh*u*0.45, W*0.52+gw*(0.5+u*0.7), H*0.62+gh*u*0.5], fill=c) d.rectangle([0, int(H*0.78), W, H], fill=mixc(WALNUT2, DARK, 0.45)) d.line([(0, int(H*0.78)), (W, int(H*0.78))], fill=mixc(WALNUT, GOLD, .25), width=3) return d class Wager: """Palo Alto, 1878. Two silhouettes and twenty-five thousand dollars.""" def __init__(self, shot, rng): self.s = shot def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), PAPER) d = SDraw(im) R = np.random.RandomState(701) for _ in range(3000): d.point((int(R.rand()*W), int(R.rand()*H)), fill=mixc(PAPER, INK, 0.06+0.22*R.rand())) # horizon + the little track hy = int(H*0.62) d.rectangle([0, hy, W, H], fill=mixc(PAPER, INK, 0.10)) d.line([(0, hy), (W, hy)], fill=mixc(INK, PAPER, .35), width=3) for k in range(28): xx = (k*46 - (t*40) % 46) d.line([(xx, hy+14), (xx+16, hy+14)], fill=mixc(INK, PAPER, .6), width=2) # a tiny horse crossing the horizon, running the real gait hp = (t*1.4) % 1.0 hx = W*0.06 + ((t*0.16) % 1.0)*W*0.95 tmp2, m2 = horse_cutout(hp, 0.75, lw=4) sil = Image.new("RGB", tmp2.size, mixc(INK, PAPER, 0.18)) im.paste(sil, (int(sf(hx)), int(sf(hy-CELL_H*0.75+18))), m2) # two gentlemen in silhouette beat = t/BEAT for (bx, hat, beard, sgn) in ((W*0.17, "top", False, 1), (W*0.83, "wide", True, -1)): sway = math.sin(beat*math.pi + (0 if sgn > 0 else math.pi))*5 gy = int(H*0.90) X = bx + sway # frock coat: shoulders, a waisted body, tails that flare d.polygon([(X-52, gy-206), (X-34, gy-222), (X+34, gy-222), (X+52, gy-206), (X+46, gy-118), (X+62, gy-14), (X+30, gy), (X+16, gy-104), (X-16, gy-104), (X-30, gy), (X-62, gy-14), (X-46, gy-118)], fill=INK) # trousers for lx in (X-19, X+19): d.polygon([(lx-14, gy-108), (lx+14, gy-108), (lx+11, gy), (lx-11, gy)], fill=INK) # collar + neck + head hx2 = X + sgn*5 d.polygon([(hx2-16, gy-224), (hx2+16, gy-224), (hx2+11, gy-244), (hx2-11, gy-244)], fill=INK) d.ellipse([hx2-27, gy-296, hx2+27, gy-234], fill=INK) # nose, so the profile reads d.polygon([(hx2+sgn*24, gy-278), (hx2+sgn*44, gy-266), (hx2+sgn*24, gy-258)], fill=INK) if hat == "top": d.polygon([(hx2-48, gy-296), (hx2+48, gy-296), (hx2+40, gy-308), (hx2-40, gy-308)], fill=INK) d.polygon([(hx2-29, gy-306), (hx2+29, gy-306), (hx2+33, gy-380), (hx2-33, gy-380)], fill=INK) else: d.polygon([(hx2-58, gy-292), (hx2+58, gy-292), (hx2+44, gy-306), (hx2-44, gy-306)], fill=INK) d.ellipse([hx2-34, gy-344, hx2+34, gy-292], fill=INK) if beard: jaw = 10 + 14*e["mid"] d.polygon([(hx2-30, gy-268), (hx2+8, gy-262), (hx2+18, gy-206-jaw), (hx2-6, gy-186-jaw), (hx2-34, gy-214)], fill=INK) # the arm reaching to the middle of the frame ax, ay = X + sgn*46, gy-190 eb = (ax + sgn*58, ay + 34 + 8*math.sin(beat*math.pi)) hd = (ax + sgn*118, ay + 16 + 14*math.sin(beat*math.pi)) d.line([(ax, ay), eb, hd], fill=INK, width=19, joint="curve") d.ellipse([hd[0]-13, hd[1]-13, hd[0]+13, hd[1]+13], fill=INK) # the coin, flipping on the beat ph = (beat*0.5) % 1.0 cxx = W/2 + math.sin(beat*0.7)*40 cyy = H*0.52 - math.sin(ph*math.pi)*130 rr = 24; ry = max(3, abs(math.cos(ph*math.pi*4))*rr) d.ellipse([cxx-rr, cyy-ry, cxx+rr, cyy+ry], fill=GOLD, outline=INK, width=3) f = font(23, "Georgia Italic.ttf") ctext(d, W/2, H*0.09, "$25,000 · PALO ALTO · 1878", f, mixc(INK, PAPER, .12)) return np.asarray(im, np.float32) class Cameras: """Twelve plate cameras and a wire across the track. Locked to the snaps.""" def __init__(self, shot, rng): self.s = shot def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), mixc(DARK, WALNUT2, .3)) d = SDraw(im) # the white sheet backdrop with numbered posts sy0, sy1 = int(H*0.10), int(H*0.70) d.rectangle([0, sy0, W, sy1], fill=CREAM) R = np.random.RandomState(88) for _ in range(1800): d.point((int(R.rand()*W), int(sy0 + R.rand()*(sy1-sy0))), fill=mixc(CREAM, INK, 0.05+0.18*R.rand())) fn = font(20, "Georgia Bold.ttf") for k in range(N_POSES+1): px = int(W*0.03 + k*(W*0.94/N_POSES)) d.line([(px, sy0), (px, sy1)], fill=mixc(INK, CREAM, .55), width=2) if k < N_POSES: d.text((px+8, sy0+8), f"{k+1:02d}", font=fn, fill=mixc(INK, CREAM, .3)) d.rectangle([0, sy1, W, H], fill=mixc(WALNUT2, DARK, .5)) d.line([(0, sy1), (W, sy1)], fill=mixc(WALNUT, GOLD, .3), width=4) # progress of the run across the wires t0, t1 = CAM_SNAPS[0]-BEAT*1.4, CAM_SNAPS[-1]+BEAT*1.2 run = (t - t0)/max(1e-6, (t1-t0)) hx = W*(-0.10) + np.clip(run, -0.4, 1.4)*W*1.22 gait = (t/BEAT) % 1.0 # trip wires: standing, then snapped once the horse is past for k in range(N_POSES): px = int(W*0.03 + (k+0.5)*(W*0.94/N_POSES)) fired = t >= CAM_SNAPS[k] col = mixc(INK, CREAM, .5) if not fired else mixc(VERM, CREAM, .25) if not fired: d.line([(px, sy1-6), (px, sy0+30)], fill=col, width=2) else: age = t - CAM_SNAPS[k] k2 = min(1.0, age*3.0) d.line([(px, sy1-6), (px-18*k2, sy1-90)], fill=col, width=2) d.line([(px+16*k2, sy0+120), (px, sy0+30)], fill=col, width=2) # the camera on its tripod byy = int(H*0.925) bod = mixc(WALNUT, PAPER2, .18) d.polygon([(px-26, byy-78), (px-26, byy-146), (px+30, byy-152), (px+30, byy-72)], fill=bod) d.polygon([(px-26, byy-146), (px+30, byy-152), (px+30, byy-142), (px-26, byy-136)], fill=mixc(bod, CREAM, .30)) d.rectangle([px+27, byy-136, px+48, byy-98], fill=mixc(GOLD, DARK, .2)) d.ellipse([px+40, byy-130, px+58, byy-104], fill=mixc(CREAM, GOLD, .5), outline=INK, width=2) for sgn in (-1, 0, 1): d.line([(px+2, byy-78), (px+2+sgn*30, byy)], fill=mixc(WALNUT2, PAPER2, .12), width=5) f4 = font(16, "Menlo.ttc") d.text((px-22, byy-118), f"{k+1:02d}", font=f4, fill=mixc(CREAM, WALNUT, .3)) if fired and (t - CAM_SNAPS[k]) < 0.14: d.ellipse([px-54, byy-186, px+72, byy-72], fill=CREAM) # the horse itself big, bm = horse_cutout(gait, 1.55, lw=4) tw, th2 = big.size im.paste(big, (int(hx-tw/2), int(sy1-th2+34)), bm) # dust Rd = np.random.RandomState(200 + int(t*30)) for q in range(14): dx = hx - tw*0.35 - Rd.rand()*150 dy = sy1 - 10 - Rd.rand()*46 rr = 4 + Rd.rand()*16 d.ellipse([dx-rr, dy-rr*.6, dx+rr, dy+rr*.6], fill=mixc(CREAM, WALNUT, .25)) f = font(21, "Menlo.ttc") lab = "TRIP-WIRE BATTERY · TWELVE PLATES" d.text((W*0.965-d.textlength(lab, font=f), H*0.615), lab, font=f, fill=mixc(INK, CREAM, .45)) return np.asarray(im, np.float32) class FlatStrip: """The strip laid out flat on the bench, scrolling one cell per 8th.""" def __init__(self, shot, rng): self.s = shot self.speed = 1.0 def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), mixc(WALNUT2, DARK, .35)) d = SDraw(im) d.rectangle([0, int(H*0.14), W, int(H*0.86)], fill=mixc(WALNUT, DARK, .1)) strip = strip_np("full") sh, sw = strip.shape[0], strip.shape[1] zoom = 1.42 bandh = int(sh*zoom) # output px (the strip is 1.5x) y0 = int(H*0.30) # authored px off = (t*(si(CELL_W)*zoom)*2.0/BEAT) % (sw*zoom) big = Image.fromarray(np.clip(strip, 0, 255).astype(np.uint8)).resize( (int(sw*zoom), bandh), Image.LANCZOS) canvas = Image.new("RGB", (OW, bandh), PAPER) x = -int(off) while x < OW: canvas.paste(big, (x, 0)); x += big.size[0] im.paste(canvas, (0, si(y0))) bandh_a = bandh/S # the band, back in authored px d = SDraw(im) d.line([(0, y0-3), (W, y0-3)], fill=mixc(INK, WALNUT, .4), width=4) d.line([(0, y0+bandh_a+3), (W, y0+bandh_a+3)], fill=mixc(INK, WALNUT, .4), width=4) f = font(30, "Georgia Bold.ttf") d.text((W*0.04, H*0.16), "OCCIDENT — TWELVE PHASES OF THE GALLOP", font=f, fill=mixc(CREAM, WALNUT, .2)) f2 = font(20, "Menlo.ttc") d.text((W*0.04, H*0.885), "ONE STRIDE · READ LEFT TO RIGHT", font=f2, fill=mixc(CREAM, WALNUT, .45)) # a rule with tick marks under the strip ry = int(H*0.865) d.line([(0, ry), (W, ry)], fill=mixc(CREAM, WALNUT, .55), width=2) for k in range(0, W, 28): hh = 12 if k % 112 == 0 else 6 d.line([(k, ry), (k, ry-hh)], fill=mixc(CREAM, WALNUT, .5), width=2) return np.asarray(im, np.float32) class Loading: """The strip fed into the drum, seen from above the rim.""" def __init__(self, shot, rng): self.s = shot def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), mixc(DARK, WALNUT2, .3)) base = np.asarray(im, np.float32).copy() strip = strip_np("full") theta = theta_at(t) lay, box = ring_layer(sf(W*0.5), sf(H*0.52), sf(150), sf(330), theta, strip, n_slits=n_slits_at(t), slit_frac=0.30, squash=0.44) base = paste_layer(base, lay, box) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) # the rim, the spindle d.ellipse([W*0.5-336, H*0.52-336*0.44, W*0.5+336, H*0.52+336*0.44], outline=mixc(GOLD, WALNUT, .35), width=6) d.ellipse([W*0.5-24, H*0.52-11, W*0.5+24, H*0.52+11], fill=mixc(GOLD, DARK, .3)) # a hand at the rim, feeding paper in hx = W*0.5 + 300*math.cos(-0.9 + 0.25*math.sin(t*1.5)) hy = H*0.52 + 300*0.44*math.sin(-0.9 + 0.25*math.sin(t*1.5)) d.polygon([(hx-48, hy-16), (hx+60, hy-40), (hx+70, hy+12), (hx-40, hy+30)], fill=mixc(PAPER2, INK, .18), outline=INK, width=3) for q in range(4): d.line([(hx+50-q*14, hy-30+q*12), (hx+96-q*10, hy-16+q*14)], fill=INK, width=6) f = font(21, "Menlo.ttc") d.text((W*0.04, H*0.06), f"LOADING · {N_POSES} CELLS", font=f, fill=mixc(CREAM, WALNUT, .4)) return np.asarray(im, np.float32) # A short shutter. A whole 1/30 s of a drum at 85rpm smears the slit into a # 50% grey and the strobe dies; the camera in this film has a 100-degree # shutter, which is what the real Muybridge rig was fighting for too. SHUTTER = 0.20 BAND = 0.95 # strip height as a fraction of the drum radius # per-shot camera presets, so six drum shots are six pictures DRUM_CAMS = { "wide": dict(R=0.375, cy=0.40, pan=0.00, zoom=0.10), "tight": dict(R=0.545, cy=0.42, pan=-0.08, zoom=0.10), "low": dict(R=0.430, cy=0.27, pan=0.04, zoom=0.06), "off": dict(R=0.430, cy=0.40, pan=0.14, zoom=-0.04), "push": dict(R=0.340, cy=0.38, pan=0.00, zoom=0.42), } class Drum: """The instrument, on its spindle, on a table. The main view.""" CAM = "wide" def __init__(self, shot, rng): self.s = shot self.cam = dict(DRUM_CAMS[getattr(shot, "cam", None) or self.CAM]) self.blank = 0 self.mirror = False self.flip = False self.slits = None self.slit_frac = 0.55 def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), DARK) bg_room(im, t, e) base = np.asarray(im, np.float32).copy() strip = strip_np("part", self.blank) if self.blank else strip_np("full") c = self.cam R = W*c["R"]*(1.0 + c["zoom"]*ease(u))*(1 + 0.012*math.sin(t*1.7)) cx = W*(0.50 + c["pan"]*(0.4+0.6*ease(u))) + 5*math.sin(t*0.9) cy = H*c["cy"] + 4*math.cos(t*0.7) bh = R*0.95 rate = rate_at(t) sub = 1 if rate < 6 else 3 dth = (rate*(TAU/N_POSES))/FPS*SHUTTER lay, box, gate = drum_layer(sf(cx), sf(cy), sf(R), sf(bh), theta_at(t), strip, n_slits=self.slits or n_slits_at(t), sub=sub, dtheta=dth, slit_frac=self.slit_frac, mirror=self.mirror, flip=self.flip) base = paste_layer(base, lay, box) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) draw_tin(d, cx, cy, R, bh) f = font(19, "Menlo.ttc") ns = self.slits or n_slits_at(t) # (final cut) the live "nn.n RPM" readout is gone — that number was # the renderer reading its own clock. The engraved specification, which # is the film's whole argument, stays. d.text((W*0.035, H*0.075), f"{ns} SLITS · {N_POSES} DRAWINGS", font=f, fill=mixc(CREAM, WALNUT, .45)) return np.asarray(im, np.float32) class SlitCU: """Right up on the shutter: black bars sweeping across one big figure.""" def __init__(self, shot, rng): self.s = shot self.side = float(rng.choice([-0.14, 0.0, 0.12])) def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), (14, 10, 9)) base = np.asarray(im, np.float32).copy() strip = strip_np("full") R = W*(0.80 + 0.10*ease(u)) cx = W*(0.5 + self.side) + math.sin(t*1.1)*26 cy = H*0.30 bh = H*0.72 rate = rate_at(t) sub = 1 if rate < 6 else 3 lay, box, gate = drum_layer(sf(cx), sf(cy), sf(R), sf(bh), theta_at(t), strip, n_slits=n_slits_at(t), sub=sub, dtheta=(rate*(TAU/N_POSES))/FPS*SHUTTER, slit_frac=0.60, glow=1.22, eyek=2.05) base = paste_layer(base, lay, box) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) d.rectangle([0, 0, W, int(H*0.06)], fill=(14, 10, 9)) d.rectangle([0, int(H*0.94), W, H], fill=(14, 10, 9)) f = font(19, "Menlo.ttc") d.text((W*0.035, H*0.075), "THROUGH THE SLIT", font=f, fill=mixc(CREAM, WALNUT, .45)) return np.asarray(im, np.float32) class TopDown: """Looking down into the drum. Same instrument, polar.""" def __init__(self, shot, rng): self.s = shot self.grow = float(rng.choice([0.0, 0.12])) def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), mixc(DARK, WALNUT2, .22)) base = np.asarray(im, np.float32).copy() strip = strip_np("full") r_out = 330*(1+self.grow*ease(u)) lay, box = ring_layer(sf(W*0.5), sf(H*0.5), sf(r_out*0.44), sf(r_out), theta_at(t), strip, n_slits=n_slits_at(t), slit_frac=0.32, squash=0.58) base = paste_layer(base, lay, box) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) d.ellipse([W*0.5-r_out-6, H*0.5-(r_out+6)*0.58, W*0.5+r_out+6, H*0.5+(r_out+6)*0.58], outline=mixc(GOLD, WALNUT, .3), width=7) d.ellipse([W*0.5-18, H*0.5-11, W*0.5+18, H*0.5+11], fill=mixc(GOLD, DARK, .25)) f = font(19, "Menlo.ttc") d.text((W*0.035, H*0.07), "PLAN VIEW", font=f, fill=mixc(CREAM, WALNUT, .45)) return np.asarray(im, np.float32) class Disc: """The phenakistoscope: the eye is fixed, so the whole picture blinks.""" def __init__(self, shot, rng): self.s = shot def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), mixc(DARK, WALNUT2, .18)) d = SDraw(im) for k in range(7): uu = k/6.0 d.ellipse([W*0.5-460+uu*220, H*0.5-330+uu*160, W*0.5+460-uu*220, H*0.5+330-uu*160], fill=mixc(mixc(DARK, WALNUT2, .5), DARK, uu)) theta = theta_at(t) S = n_slits_at(t) span = TAU/S view = -math.pi/2 # the eye, at twelve o'clock k = round((view - theta)/span) dist = abs(view - (theta + k*span)) gate = float(np.clip((span*0.58*0.5 - dist)/(span*0.07), 0.0, 1.0)) gate = 0.26 + 0.88*gate disc = disc_img() rotd = disc.rotate(-math.degrees(theta), resample=Image.BILINEAR, fillcolor=mixc(DARK, WALNUT2, .3)) sc = int(min(OW, OH)*0.86) rotd = rotd.resize((sc, sc), Image.LANCZOS) arr = np.asarray(rotd, np.float32)*gate # cut the slits into the visible disc yy, xx = np.mgrid[0:sc, 0:sc].astype(np.float32) aa = np.arctan2(yy-sc/2, xx-sc/2) rr = np.sqrt((xx-sc/2)**2 + (yy-sc/2)**2) kk = np.rint((aa - theta)/span) dd = np.abs(aa - (theta + kk*span)) cut = (dd < span*0.17) & (rr > sc*0.43) & (rr < sc*0.497) arr = np.where(cut[..., None], np.array(DARK, np.float32)[None, None, :]*0.4, arr) outside = rr > sc*0.497 arr = np.where(outside[..., None], np.array(mixc(DARK, WALNUT2, .4), np.float32)[None, None, :], arr) base = np.asarray(im, np.float32).copy() base = paste_layer(base, arr, (int(OW/2-sc/2), int(OH/2-sc/2), int(OW/2+sc/2), int(OH/2+sc/2))) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) f = font(19, "Menlo.ttc") d.text((W*0.035, H*0.075), "PHENAKISTOSCOPE · RADIAL FRAMES", font=f, fill=mixc(CREAM, WALNUT, .45)) return np.asarray(im, np.float32) class Upside: """Somebody loaded the strip upside down. He gallops on the ceiling.""" def __init__(self, shot, rng): self.d = Drum(shot, rng) self.d.mirror = True; self.d.flip = True def frame(self, i, u, e): arr = self.d.frame(i, u, e) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) d = SDraw(im) f = font(32, "Georgia Italic.ttf") ctext(d, W/2, H*0.115, "(loaded upside down)", f, mixc(CREAM, WALNUT, .18), shadow=DARK) return np.asarray(im, np.float32) class Judder: """Thirteen slits, twelve drawings, and a drum turning too slowly.""" def __init__(self, shot, rng): self.d = Drum(shot, rng) self.d.slits = 13 def frame(self, i, u, e): t = i/FPS arr = self.d.frame(i, u, e) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) d = SDraw(im) f = font(25, "Menlo.ttc") d.text((W*0.035, H*0.115), "13 SLITS / 12 CELLS = -1/13 CELL PER PASS", font=f, fill=mixc(VERM, CREAM, .30)) # an arrow, pointing the wrong way, drifting ax = W*0.72 + math.sin(t*1.3)*12 ay = H*0.845 d.line([(ax+120, ay), (ax-90, ay)], fill=mixc(VERM, CREAM, .2), width=7) d.polygon([(ax-108, ay), (ax-78, ay-16), (ax-78, ay+16)], fill=mixc(VERM, CREAM, .2)) f2 = font(24, "Georgia Bold.ttf") d.text((ax-96, ay-46), "BACKWARDS", font=f2, fill=mixc(VERM, CREAM, .2)) return np.asarray(im, np.float32) class ProofCard: """The evidence, enlarged. A counter that reads the rig, not a caption.""" def __init__(self, shot, rng): self.s = shot def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), PAPER) d = SDraw(im) R = np.random.RandomState(455) for _ in range(2600): d.point((int(R.rand()*W), int(R.rand()*H)), fill=mixc(PAPER, INK, 0.05+0.20*R.rand())) for x in range(0, W, 40): d.line([(x, 0), (x, H)], fill=mixc(PAPER, INK, .07), width=1) for y in range(0, H, 40): d.line([(0, y), (W, y)], fill=mixc(PAPER, INK, .07), width=1) # one pose per beat, marching through the cycle idx = int(t/ (BEAT*0.5)) % N_POSES ph = idx/float(N_POSES) sc = 2.10 big, bm = horse_cutout(ph, sc, lw=4) tw, th2 = big.size px, py = int(W*0.50-tw/2), int(H*0.40-th2/2) im.paste(big, (px, py), bm) d = SDraw(im) # ground line + circled hooves gyy = py + int(232*sc) d.line([(W*0.10, gyy), (W*0.90, gyy)], fill=mixc(INK, PAPER, .35), width=3) nd = n_down(ph) for (hx, hy) in horse_hoof_pts(ph, sc, px, py): onground = hy > gyy - 10*sc col = INK if onground else VERM rr = 22 d.ellipse([hx-rr, hy-rr, hx+rr, hy+rr], outline=col, width=4) if not onground: d.line([(hx-rr*0.7, hy+rr*0.7), (hx+rr*0.7, hy-rr*0.7)], fill=col, width=3) # the filmstrip along the bottom strip = strip_np("full") sm = Image.fromarray(np.clip(strip, 0, 255).astype(np.uint8)).resize( (int(W*0.94), int(CELL_H*(W*0.94/strip.shape[1]))), Image.LANCZOS) im.paste(sm, (int(W*0.03), int(H*0.845))) d = SDraw(im) cw2 = W*0.94/N_POSES d.rectangle([W*0.03+idx*cw2, H*0.845, W*0.03+(idx+1)*cw2, H*0.845+sm.size[1]], outline=VERM, width=4) f = font(34, "Georgia Bold.ttf") d.text((W*0.045, H*0.070), f"PLATE {idx+1:02d}", font=f, fill=INK) f3 = font(21, "Menlo.ttc") d.text((W*0.615, H*0.075), "HOOVES ON THE GROUND", font=f3, fill=mixc(INK, PAPER, .28)) f2 = font(132, "Georgia Bold.ttf") col = VERM if nd == 0 else INK lab = str(nd) lw2 = d.textlength(lab, font=f2) d.rectangle([W*0.845-lw2/2-16, H*0.115, W*0.845+lw2/2+16, H*0.115+140], outline=col, width=5) d.text((W*0.845-lw2/2, H*0.118), lab, font=f2, fill=col) return np.asarray(im, np.float32) class RunOut: """He leaves the strip. The cells go blank behind him, one per beat.""" def __init__(self, shot, rng): self.s = shot self.t0 = shot.i0/FPS def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), DARK) bg_room(im, t, e) base = np.asarray(im, np.float32).copy() gone = int(np.clip((t - self.t0)/(BEAT*0.55), 0, N_POSES)) strip = strip_np("part", gone) R = W*0.375*(1 + 0.10*ease(u)) cx, cy = W*0.44, H*0.38 bh = R*0.95 rate = rate_at(t) lay, box, gate = drum_layer(sf(cx), sf(cy), sf(R), sf(bh), theta_at(t), strip, n_slits=n_slits_at(t), sub=3, dtheta=(rate*(TAU/N_POSES))/FPS*SHUTTER) base = paste_layer(base, lay, box) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) draw_tin(d, cx, cy, R, bh) # He comes off the paper. Still a drawing — a torn cell of the strip # with a horse on it — but no longer wrapped around anything. run = float(np.clip((t - self.t0 - BEAT*1.2)/(BEAT*5.6), 0, 1.4)) if run > 0: gait = ((t - self.t0)*2.4) % 1.0 sc = 1.15 + 1.9*run hx = cx - R*0.35 + run*W*0.98 hy = cy + bh*0.42 + 170*run - 100*math.sin(min(1, run)*math.pi) big, bm = horse_cutout(gait, sc, lw=5) tw, th2 = big.size # output px px, py = int(sf(hx)-tw/2), int(sf(hy)-th2*0.86) # the torn scrap of paper he is drawn on card = Image.new("RGB", (tw, th2), PAPER) cd = ImageDraw.Draw(card) Rt = np.random.RandomState(4711) tear = [(0, 0)] for q in range(1, 13): tear.append((tw*q/12.0, sf(6+Rt.rand()*16))) tear += [(tw, th2), (0, th2)] cm = Image.new("L", (tw, th2), 0) ImageDraw.Draw(cm).polygon(tear, fill=232) im.paste(card, (px, py), cm) im.paste(big, (px, py), bm) d = ImageDraw.Draw(im) # this block is in OUTPUT pixels # shadow on the table + dust kicked up behind gy2 = py + int(sf(232)*sc) d.ellipse([px+tw*0.10, gy2-sf(10), px+tw*0.92, gy2+sf(18)], fill=mixc(WALNUT2, DARK, .45)) Rd = np.random.RandomState(900+i) for q in range(20): dx = sf(hx) - tw*0.45 - Rd.rand()*sf(300)*run dy = gy2 - Rd.rand()*sf(90) rr = sf(6 + Rd.rand()*26*(0.3+run)) d.ellipse([dx-rr, dy-rr*.6, dx+rr, dy+rr*.6], fill=mixc(PAPER2, WALNUT2, .55)) return np.asarray(im, np.float32) class Empty: """The slits keep turning. Nothing is drawn on the paper any more.""" def __init__(self, shot, rng): self.d = Drum(shot, rng) def frame(self, i, u, e): t = i/FPS im = Image.new("RGB", (OW, OH), DARK) bg_room(im, t, e) base = np.asarray(im, np.float32).copy() strip = strip_np("blank") R = W*0.375*(1 + 0.05*ease(u)) cx, cy = W*0.5, H*0.40 bh = R*0.95 rate = rate_at(t) lay, box, gate = drum_layer(sf(cx), sf(cy), sf(R), sf(bh), theta_at(t), strip, n_slits=n_slits_at(t), sub=1 if rate < 6 else 3, dtheta=(rate*(TAU/N_POSES))/FPS*SHUTTER) base = paste_layer(base, lay, box) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = SDraw(im) draw_tin(d, cx, cy, R, bh) if t > (N_BARS*BAR - BEAT*1.5): age = t - (N_BARS*BAR - BEAT*1.5) al = float(np.clip(age/0.9, 0, 1)) f = font(26, "Georgia Italic.ttf") ctext(d, W/2, H*0.885, "all four, 1878", f, mixc(mixc(DARK, CREAM, al), WALNUT, .1)) return np.asarray(im, np.float32) ENGINES = { "wager": Wager, "cameras": Cameras, "flatstrip": FlatStrip, "loading": Loading, "drum": Drum, "slitcu": SlitCU, "topdown": TopDown, "disc": Disc, "upside": Upside, "judder": Judder, "proofcard": ProofCard, "runout": RunOut, "empty": Empty, } # ════════════════════════════════════════════════════════════════════════════ # THE CUT # ════════════════════════════════════════════════════════════════════════════ # (engine, beats) — laid on the bar grid by hand so the shutters, the slit # swap and the run-out land where the record puts them. CUTS = [ # pitch — a cappella # (engine, start beat, length in beats, drum-camera preset) ("wager", 0.0, 6.0, None), ("flatstrip", 6.0, 4.0, None), ("wager", 10.0, 6.0, None), # load — the cameras fire on their own snaps, then the strip goes in ("flatstrip",16.0, 4.0, None), ("loading", 20.0, 4.0, None), ("cameras", 24.0, 8.0, None), ("loading", 32.0, 4.0, None), ("drum", 36.0, 4.0, "wide"), # spin — the record takes over ("drum", 40.0, 5.0, "push"), ("slitcu", 45.0, 3.0, None), ("topdown", 48.0, 4.0, None), ("drum", 52.0, 3.0, "low"), ("upside", 55.0, 5.0, "off"), # judder — thirteen slits ("disc", 60.0, 4.0, None), ("judder", 64.0, 6.0, "tight"), ("slitcu", 70.0, 3.0, None), ("judder", 73.0, 3.0, "wide"), # proof ("drum", 76.0, 4.0, "off"), ("proofcard",80.0, 6.0, None), ("slitcu", 86.0, 3.0, None), ("drum", 89.0, 3.0, "tight"), ("proofcard",92.0, 4.0, None), ("topdown", 96.0, 4.0, None), # out ("runout", 100.0, 8.0, None), ("empty", 108.0, 4.0, None), ] SECTION_CARDS = { 0.0: ("OCCIDENT", 3.0), 24.0: ("TWELVE CAMERAS", 2.2), 64.0: ("THIRTEEN SLITS", 2.2), 76.0: ("ALL FOUR", 2.4), } class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "card", "cam") def __init__(self, idx, i0, i1, engine, section, card=None, cam=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.section = engine, section self.seed = 187806 + idx*7919 self.card = card self.cam = cam def build_shots(): shots = [] for idx, (eng, b0, blen, cam) in enumerate(CUTS): t0 = b0*BEAT; t1 = (b0+blen)*BEAT i0, i1 = int(round(t0*FPS)), int(round(t1*FPS)) sec = sec_of_bar(int(b0//4)) card = SECTION_CARDS.get(b0) shots.append(Shot(idx, i0, i1, eng, sec, card, cam)) if shots: shots[-1].i1 = N_FRAMES shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ # POST — plate shift -> tint -> vignette -> grain -> text -> letterbox # ════════════════════════════════════════════════════════════════════════════ _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:OH, 0:OW] nx = (xx-OW/2)/(OW/2); ny = (yy-OH/2)/(OH/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.46*r**2.1, 0, 1)[..., None] return _VIG["v"] _PAPERTEX = {} def papertex(): if "p" not in _PAPERTEX: rng = np.random.RandomState(1878) small = rng.rand(H//8, W//8).astype(np.float32) im = Image.fromarray((small*255).astype(np.uint8)).resize((OW, OH), Image.BICUBIC) f = np.asarray(im, np.float32)/255.0 _PAPERTEX["p"] = (f - f.mean()) return _PAPERTEX["p"] def post(arr, i, e, shot): a = np.asarray(arr, np.float32) if a.shape[0] != OH or a.shape[1] != OW: a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((OW, OH), Image.LANCZOS), np.float32) t = i/FPS rng = np.random.RandomState(31337 + i) # (imagery only) plate misregistration — three-colour separation drifting sx = int(round(sf(1.1*math.sin(t*2.1) + 0.7*e["high"]))) if sx != 0: b = a.copy() b[:, :, 0] = np.roll(a[:, :, 0], sx, axis=1) b[:, :, 2] = np.roll(a[:, :, 2], -sx, axis=1) a = a*0.74 + b*0.26 # tint: albumen sepia, cooled in the shadows lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([16, 4, -14], np.float32) \ + lum*np.array([10, 3, -12], np.float32) a += papertex()[..., None]*16.0 # a slow plate flicker + occasional emulsion scratch a *= (0.965 + 0.045*math.sin(t*11.3) + 0.02*math.sin(t*3.1)) if rng.rand() < 0.10: cx = int(rng.rand()*OW); hw = si(1) a[:, max(0, cx-hw):cx+hw] = np.clip(a[:, max(0, cx-hw):cx+hw]*1.5 + 40, 0, 255) a *= vignette() # grain rolled at 720p and blown up NEAREST: the grain SIZE scales too if S == 1.0: a += rng.normal(0, 3.1, a.shape) else: g = rng.normal(0, 3.1, (H, W, 3)).astype(np.float32) a += np.stack([np.asarray(Image.fromarray(g[..., c], "F") .resize((OW, OH), Image.NEAREST), np.float32) for c in range(3)], -1) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = SDraw(out) # section card if shot.card: txt, hold = shot.card age = (i - shot.i0)/FPS if age < hold: al = min(1.0, age/0.22)*min(1.0, (hold-age)/0.36) f = font(74, "Georgia Bold.ttf") ctext(d, W/2, H*0.38, txt, f, mixc(DARK, CREAM, al), shadow=DARK) if txt == TITLE: # the show, set as the printer's imprint under the title f9 = font(20, "Georgia Bold.ttf") ctext(d, W/2, H*0.38-44, "P L A Y E R C O M P U T E R", f9, mixc(DARK, mixc(CREAM, WALNUT, .30), al), shadow=DARK) # sung line, crisp, never shifted SB = subs() if len(SB["t0"]): k = np.where((SB["t0"] <= t) & (t < SB["t1"]))[0] if len(k): txt = str(SB["tx"][k[-1]]) f = font(35, "Georgia Bold.ttf") wdt = d.textlength(txt, font=f) bx0, bx1 = W/2-wdt/2-24, W/2+wdt/2+24 byy = H*0.885 d.rectangle([bx0, byy-9, bx1, byy+50], fill=mixc(DARK, WALNUT2, .18)) d.text((W/2-wdt/2+2, byy+3), txt, font=f, fill=DARK) d.text((W/2-wdt/2, byy), txt, font=f, fill=CREAM) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=DARK) d.rectangle([0, H-bh, W, H], fill=DARK) return out def render_shot(job): shot, force = job E = env(); subs() 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 p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} u = k/max(1, shot.n-1) arr = eng.frame(i, u, e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:10s} {shot.section:7s} {made}/{shot.n}" def contact_sheet(shots): cols = 5 rows = (len(shots)+cols-1)//cols tw, th = 320, 180 sheet = Image.new("RGB", (cols*tw, rows*(th+26)), (12, 10, 9)) sd = ImageDraw.Draw(sheet) E = env(); subs() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) eng = ENGINES[sh.engine](sh, rng) mid = sh.n//2 i = sh.i0+mid e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(i, mid/max(1, sh.n-1), e) im = post(arr, i, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+26) sheet.paste(im, (cx, cy)) sd.text((cx+6, cy+th+5), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:5.1f}s " f"({sh.n/FPS:.1f}s) · {rate_at(i/FPS):.1f}p/s", font=font_raw(13, "Menlo.ttc"), fill=(198, 190, 175)) p = OUT/"contact_sheet.png" sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots, {DUR:.1f}s)") 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(12, os.cpu_count() or 4)) 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 " f"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) have = sum(1 for i in range(N_FRAMES) if (FRAMES/f"f{i:05d}.png").exists()) if have < N_FRAMES: print(f"[3/3] mux skipped — {have}/{N_FRAMES} frames on disk; " f"re-run without --shots, then --mux-only.") return print("[3/3] mux…") stamp = f"renders/{SETDIR}/{NAME}/render.py" 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", "19", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", "-metadata", f"comment=generator: {stamp}", "-metadata", f"description={MUSIC_DESC} | {ENGINE_DESC}", "-metadata", "artist=poop / player_computer_2", 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: {stamp}\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engines: {ENGINE_DESC}\n" f"shots: {len(build_shots())} (shot-parallel, stateless per frame)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()