#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Vesalius Dance (03/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/vesalius_dance # # The figures of a 1543 anatomy atlas step off the page for a second line. # # 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/vesalius_dance.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/vesalius_dance.mp4 # cover: https://genekogan.com/player_computer/media/vesalius_dance.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 vesalius_dance.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 — "VESALIVS DANCE" (round 2 of night_watch_2 08: same engraver, new score) New Orleans second line, 118bpm, blues Bb. Thirty-one bars built as a parade assembling: parts enter one at a time and never leave. A marching bass drum alone, leaning on the BIG FOUR; then the street beat — snare with press rolls, tambourine, cowbell; then the sousaphone; then the banjo chank; then the handclaps; then the horns, trumpet on top with the trombone a parallel fourth under; then a call-and-response shout where the caller is an anatomy lecturer and the band answers with the name of a muscle; then the shout chorus. One final unison hit and the page is a page again. The picture is a 16th-century anatomical atlas in the manner of Vesalius's *De humani corporis fabrica* (Basel, 1543). Flayed men — écorchés — stand in a Renaissance landscape of hills and broken ruins. When the horns come in the figures step out of their plates and dance, and their muscles articulate correctly while they do it: the biceps shortens and bulges when the elbow flexes, the gastrocnemius balls up on the toe-off, the sartorius stays a strap across the thigh no matter what the leg does. This is the joke — anatomically educational dancing. At the final horn hit they drop back into their exact engraved poses and the page is an ordinary anatomy plate again, except that one figure's hand is now waving. THE NEW SUBSTRATE — `BURIN`, a copperplate engraver. Nothing in this film is a filled shape. There is no flat tone anywhere: every value in the frame is made of lines. * `stroke()` lays a single burin line as a filled envelope polygon whose width varies point to point and tapers to a point at both ends — a real graver's swelled line, not a constant-width segment. * `hatch_belly()` shades a generalized cylinder with CROSS-CONTOUR hatching. It walks the muscle's axis, and at each station lays an arc across the form whose 3-D surface normal is reconstructed from its position across the belly (nx = θ, nz = √(1-θ²)). Lambert against the plate light gives the tone; the tone sets the line's WIDTH at every point along the arc and the SPACING to the next arc. Where the form turns to the light the ink falls below the biting threshold and the line simply stops — which is how an engraver leaves a highlight. In the darks a second, longitudinal family is laid over the first: cross-hatching. * `hatch_terrain()` / `hatch_sky()` shade the landscape the same way, with lines that follow the ridge contours and break for cloud. * the plate is printed on laid paper (laid lines, chain lines, fibre, foxing), through a worn copper plate (a low-frequency bite field, scratches, a plate mark embossed into the sheet). THE FIGURE is a real articulated écorché, not a sprite: a forward-kinematic skeleton of 22 joints, and 40-odd muscle bellies declared as (origin anchor, insertion anchor, control offset, width profile). Each belly is resolved from the posed skeleton every frame and re-hatched from scratch, so the hatching is redrawn correctly as the figure moves. Bulge is volume-preserving: halfwidth *= (rest_length / current_length) ** 0.55. Composition: engine : audio-first x shot-parallel (tier 4-P). Engines are stateless functions of (page time, shot progress) — the plate is re-engraved every frame — so the sync trap does not apply and partial re-renders are exact. content: audio-groove (second-line street kit, sousaphone, vectorised Karplus-Strong banjo, additive brass with an amplitude-opening filter) x tts-voices (Daniel as the lecturer, a three-voice chorus answering) x effects-post (tint -> vignette -> grain -> letterbox) No RGB / channel shift is used anywhere in this piece: the letterpress caption and the figure keys are part of the printed plate and must stay crisp. Run from repo root: python3 renders/player_computer_final/vesalius_dance/render.py --sheet python3 renders/player_computer_final/vesalius_dance/render.py python3 renders/player_computer_final/vesalius_dance/render.py --shots 12,13 --force python3 renders/player_computer_final/vesalius_dance/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 = "vesalius_dance" TITLE = "VESALIVS DANCE" SETDIR = "player_computer_final" SETNUM = "08" W, H, FPS = 1920, 1080, 30 SS = 2 # supersample for the ink plate # ── delivery scale ─────────────────────────────────────────────────────────── # FINAL CUT: native 1920x1080. Everything in this file is authored in PAGE # units — the 1120x600 copper plate — and the only place page units become # pixels is Cam.px(), so the whole engraving scales by multiplying the camera # zoom by SCL = H/720 once, at Cam construction. Line widths, letterpress sizes # and hatch spacings are all derived from cam.z and therefore follow for free; # what has to be scaled by hand is the handful of DEVICE-space constants (the # finest line the plate holds, the two width ceilings, the page-unit spacing # floors) and the paper/post chain, whose texture is a look, not a resolution. WB, HB = 1280, 720 # the authoring frame SCL = H/720.0 def PXi(v): return max(1, int(round(v*SCL))) def PXf(v): return v*SCL BPM = 118.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 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" # parts enter one at a time and never leave SECTIONS = [ ("bassdrum", 0, 2), ("street", 2, 5), ("sousa", 5, 8), ("banjo", 8, 11), ("claps", 11, 14), ("horns", 14, 19), ("call", 19, 23), ("peak", 23, 29), ("land", 29, 31), ] N_BARS = SECTIONS[-1][2] TAIL = 1.70 DUR = N_BARS * BAR + TAIL N_FRAMES = int(DUR * FPS) HIT_T = 29 * BAR # the final unison horn hit MUSIC_DESC = (f"New Orleans second line, {BPM:.0f}bpm, blues Bb, {N_BARS} bars, " "big-four bass drum + press-roll street snare + sousaphone + " "banjo chank + harmonised horn head + call/response") ENGINE_DESC = ("burin copperplate engraver (cross-contour hatching) x " "articulated ecorche rig") # ════════════════════════════════════════════════════════════════════════════ # 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 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 bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Every noise source in the kit goes through this so nothing 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 afrobeat kit ------------------------------------------------------ def nrm(x, peak=1.0): """Normalise a one-shot to a known peak. Band-shaped noise loses most of its amplitude in the filter; without this the mix gains are meaningless and the lone-shekere intro ends up 40 dB under the band.""" m = np.max(np.abs(x)) return x if m < 1e-9 else x/m*peak def shekere(dur=.10, hard=1.0, seed=5): """Gourd rattle: two beadswells, a bright band, very fast decay.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=2600, hi=11000) env = np.exp(-t*46) * np.clip(t*900, 0, 1) env = env + 0.45*np.exp(-np.abs(t-0.012)*260) return nrm(nz*env) * .80 * hard def conga(f0=232, dur=.20, open_=True, slap=0.5, seed=6): n = int(dur*SR); t = np.arange(n)/SR f = f0*(1 + .55*np.exp(-t*70)) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*(9 if open_ else 34)) body += .32*np.sin(2*np.pi*np.cumsum(f*1.62)/SR)*np.exp(-t*22) nz = bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=6200) return nrm(np.tanh((body + nrm(nz)*np.exp(-t*130)*slap*.6)*1.3))*.85 def talkdrum(f0=150, f1=290, dur=.26, seed=8): """Squeezed hourglass drum: the head is bent mid-note.""" n = int(dur*SR); t = np.arange(n)/SR u = np.clip(t/dur, 0, 1) f = f0 + (f1-f0)*(u**0.55) + 60*np.exp(-t*60) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*11) body += .40*np.sin(2*np.pi*np.cumsum(f*2.4)/SR)*np.exp(-t*20) nz = bandshape(np.random.RandomState(seed).randn(n), lo=400, hi=3200) return nrm(np.tanh((body + nrm(nz)*np.exp(-t*160)*.5)*1.4))*.85 def kick(dur=.28, f0=132, f1=46, seed=1): n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*26) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*11.5) ck = np.random.RandomState(seed).randn(n)*np.exp(-t*340)*.30 return np.tanh((body+ck)*1.6)*.92 def snare(dur=.16, tone=214, bright=1.0, seed=2): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=320, hi=6800) body = np.sin(2*np.pi*tone*t) + .55*np.sin(2*np.pi*tone*1.61*t) return nrm(nz*np.exp(-t*24))*.80*bright + body*np.exp(-t*30)*.42 def rimclick(dur=.06, seed=3): n = int(dur*SR); t = np.arange(n)/SR return (np.sin(2*np.pi*1820*t)+.5*np.sin(2*np.pi*2760*t))*np.exp(-t*110)*.42 def hat(dur=.05, openh=False, seed=7): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=5600, hi=10500) return nrm(nz*np.exp(-t*(11 if openh else 96))) * .55 def sticks(dur=.05, seed=11): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1600, hi=7000) return nrm(nz*np.exp(-t*150))*.55 def crash(dur=1.5, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1500, hi=9000) return nrm(nz*(np.exp(-t*2.4)+.28*np.exp(-t*.55)))*.70 # ---- pitched voices -------------------------------------------------------- def bassnote(freq, dur, seed=0, g=1.0): """Round electric bass: a filtered saw with a fingered pluck.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR co = 240 + 900*np.exp(-t*22) out = np.zeros(n) for k in range(1, 20): fk = freq*k if fk > SR*0.45: break gk = (1.0/k)/np.sqrt(1.0 + (fk/co)**4) out += gk*np.sin(2*np.pi*fk*t + (k*0.7)) click = bandshape(np.random.RandomState(seed).randn(n), lo=700, hi=3200) out += click*np.exp(-t*200)*.10 return out*adsr(n, .006, .11, .74, .07)*g def ks(freq, dur, damp=.9955, seed=0, bright=1.0, g=1.0): """Vectorised Karplus-Strong. The delay line is advanced a whole period at a time, which is the block form of the same recurrence — plucky, cheap, deterministic.""" n = int(dur*SR) if n <= 0: return np.zeros(0) L = max(3, int(SR/max(freq, 30.0))) rng = np.random.RandomState(seed) buf = rng.uniform(-1, 1, L) buf = bandshape(buf, lo=110*bright, hi=5200*bright) buf /= np.max(np.abs(buf))+1e-9 out = np.empty(n); i = 0 while i < n: m = min(L, n-i) out[i:i+m] = buf[:m] buf = damp*0.5*(buf + np.roll(buf, -1)) i += m return out*adsr(n, .001, .04, .80, .05)*g def brass(freq, dur, kind="sax", seed=0, g=1.0, vib=(.010, 5.4), scoop=.030): """Additive brass whose filter OPENS with the amplitude envelope — the single thing that makes a synthesised horn read as blown rather than plucked. Plus a breath band and an attack scoop.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR env = adsr(n, .022, .10, .86, .085) co = 620 + (2900 if kind == "sax" else 4200)*env**1.35 f = freq*(1 - scoop*np.exp(-t*36)) if vib[0]: f = f*(1 + vib[0]*np.sin(2*np.pi*vib[1]*t)*np.clip((t-.06)*7, 0, 1)) ph = 2*np.pi*np.cumsum(f)/SR out = np.zeros(n) for k in range(1, 26): fk = freq*k if fk > SR*0.45: break base = (1.0/k**.82) if kind == "sax" and k % 2 == 0: base *= .58 # reedy odd bias gk = base/np.sqrt(1.0 + (fk/co)**5) # a formant bump: the bell's resonance fc = 1250.0 if kind == "sax" else 1500.0 gk = gk*(1.0 + .75*np.exp(-((fk-fc)/620.0)**2)) out += gk*np.sin(ph*k + k*0.31) out /= np.max(np.abs(out))+1e-9 breath = bandshape(np.random.RandomState(seed).randn(n), lo=1800, hi=6000) return (out + breath*.055)*env*g def organ(freq, dur, seed=0, g=1.0): n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR lez = 1 + .006*np.sin(2*np.pi*6.6*t) out = sum(a*np.sin(2*np.pi*freq*h*t*lez) for h, a in ((1, 1.0), (2, .6), (3, .34), (4, .28), (6, .16), (8, .12))) return out*adsr(n, .012, .10, .82, .09)*.30*g def reverb(x, rt=1.5, mix=.28, seed=29, pre=.018): 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=.36, mix=.22, taps=6): d = int(time*SR); out = x.copy() for i in range(1, taps+1): gg = mix*(fb**i); s = d*i if s >= len(x): break out[s:] += x[:len(x)-s]*gg return out class Song: """A multitrack canvas 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): 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: 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=.30): 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=.20, pump_rel=.13, 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 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): # FFT DC / sub-30 trim mix[:, c] = bandshape(mix[:, c], lo=32.0, order=3) mix = np.tanh(mix*1.22)/np.tanh(1.22) 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 shout(text, voice="Daniel", rate=200, g=1.0): x = say_wav(text, voice, rate, AUD/("say_"+_h(text, voice, rate)+".wav")) x = bandshape(x, lo=180, hi=5200) x = np.tanh(x*2.4) # megaphone bite return x/(np.max(np.abs(x))+1e-9)*g def chorus_shout(text, g=1.0): """Three voices answering — the band, not a person.""" parts = [("Tessa", 205, 0.000), ("Ralph", 190, 0.026), ("Karen", 212, 0.045)] n = 0; outs = [] for v, r, off in parts: x = shout(text, v, r, 1.0) outs.append((x, off)) n = max(n, len(x)+int(off*SR)) mix = np.zeros(n) for x, off in outs: i = int(off*SR); mix[i:i+len(x)] += x*0.62 return mix/(np.max(np.abs(mix))+1e-9)*g # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════ # THE SECOND LINE # # The Afrobeat version was a Fela accretion: one part enters per section and # nothing ever leaves. That structure is dead right for a picture where flayed # men climb out of an atlas one at a time — so it stays. What changes is the # music inside it. A New Orleans second line is, literally, a dancing # procession; these figures are a dancing procession. The parade assembles in # the same order the plates fill up: # # bass drum alone → the street beat (snare, tambourine, cowbell) → # the sousaphone → the banjo chank → the handclaps → the horn head → # the call-and-response (the lecturer calls, the band answers with a muscle) # → the shout chorus → one last unison hit and the parade is a page again. # # The rhythmic signature is the BIG FOUR: the bass drum's heaviest note is not # on 1, it is on beat 4, and the whole strut leans forward into it. # ════════════════════════════════════════════════════════════════════════════ SW = 0.155 # the second-line shuffle on 16ths def n_(semi, octv=3): """A chromatic offset in semitones above Bb at the given octave. Written chromatically, not in scale degrees, because the whole idiom lives on the flat third and the flat seventh and a scale cannot say those.""" return mtof(22 + 12*octv + semi) # blues Bb: 0 Bb · 3 Db · 4 D · 5 Eb · 6 E · 7 F · 9 G · 10 Ab · 12 Bb # ── drums ─────────────────────────────────────────────────────────────────── # the big four: the heaviest bass-drum note of the bar is on beat 4 (step 12) BD = [(0, 1.00), (3, .58), (6, .84), (10, .56), (12, 1.00)] BD2 = [(0, 1.00), (2, .48), (6, .86), (9, .52), (12, 1.00), (14, .60)] SD = [(2, .28), (4, 1.00), (6, .32), (7, .40), (9, .28), (11, .56), (12, .82), (14, .38), (15, .46)] SD2 = [(2, .30), (4, 1.00), (5, .34), (7, .44), (10, .34), (11, .60), (12, .86), (13, .32), (14, .44), (15, .52)] COWB = [(0, 1.0), (6, .8), (10, .9)] # ── the sousaphone ────────────────────────────────────────────────────────── TUBA = [ (0, 0, 0, 4), (0, 4, 7, 2), (0, 6, 0, 2), (0, 10, 7, 2), (0, 12, 0, 2), (0, 14, 10, 2), (1, 0, 0, 4), (1, 4, 5, 2), (1, 6, 7, 2), (1, 10, 0, 2), (1, 12, -2, 2), (1, 14, -5, 2), ] # ── the head. Two bars, in Bb, sitting on the flat seventh and the blue third. HEAD = [ (0, 0, 12, 2), (0, 2, 10, 1), (0, 3, 9, 1), (0, 4, 7, 3), (0, 7, 9, 1), (0, 8, 10, 2), (0, 10, 12, 2), (0, 12, 7, 4), (1, 0, 15, 2), (1, 2, 14, 2), (1, 4, 12, 2), (1, 6, 10, 2), (1, 8, 9, 1), (1, 9, 7, 1), (1, 10, 4, 2), (1, 12, 0, 4), ] # the tag the band plays back at the caller TAG = [(0, 0, 7, 2), (0, 3, 10, 1), (0, 4, 12, 3), (0, 8, 10, 2), (0, 10, 7, 2), (0, 12, 0, 4)] BANJO = [0, 4, 7, 10] # Bb7 — the parade chord CALLS = [("MUSCULUS DELTOIDEUS", "DELTOIDEUS"), ("GASTROCNEMIUS", "GASTROCNEMIUS"), ("SARTORIUS", "SARTORIUS"), ("TABULA OCTAVA", "OCTAVA")] def bassdrum(dur=.52, seed=1, g=1.0): """A marching bass drum struck with a felt mallet: low, soft-edged, and long enough that it walks.""" n = int(dur*SR); t = np.arange(n)/SR f = 44 + 62*np.exp(-t*30) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*7.0) body += .30*np.sin(2*np.pi*np.cumsum(f*1.58)/SR)*np.exp(-t*15) mall = bandshape(np.random.RandomState(seed).randn(n), lo=140, hi=1500) return np.tanh((body + mall*np.exp(-t*130)*.30)*1.35)*.92*g def parade_snare(dur=.16, seed=2, g=1.0, bright=1.0, rim=0.0): """High-tuned street snare, wires wide open; `rim` adds the shot.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) wires = bandshape(rng.randn(n), lo=900, hi=9000)*np.exp(-t*30) head = (np.sin(2*np.pi*268*t) + .5*np.sin(2*np.pi*268*1.59*t))*np.exp(-t*44) shot = bandshape(rng.randn(n), lo=2200, hi=9500)*np.exp(-t*180)*rim return (nrm(wires)*.86*bright + head*.36 + shot*.6)*g def press_roll(dur=.34, seed=4, g=1.0): """The buzz that carries a second line across the bar line.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) buzz = bandshape(rng.randn(n), lo=1100, hi=9000) buzz *= (0.55 + 0.45*np.sin(2*np.pi*34.0*t)) return nrm(buzz)*np.clip(t*40, 0, 1)*np.exp(-np.clip(t-dur*.55, 0, 9)*9)*.5*g def cowbell(dur=.13, seed=6, g=1.0): n = int(dur*SR); t = np.arange(n)/SR x = sum(a*np.sin(2*np.pi*f*t) for f, a in ((587., 1.0), (845., .8), (1290., .5), (1720., .34), (2410., .2))) return np.tanh(x*1.4)*np.exp(-t*24)*.30*g def tambourine(dur=.22, shake=False, seed=9, g=1.0): """Jingles: many detuned metal discs, not a noise burst.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) x = np.zeros(n) for k in range(14): f = rng.uniform(4200, 9600) x += np.sin(2*np.pi*f*t + rng.uniform(0, 6))*np.exp(-t*rng.uniform(24, 70)) env = np.exp(-t*(9 if shake else 34))*np.clip(t*1200, 0, 1) return nrm(x*env)*.42*g def sousa(freq, dur, seed=0, g=1.0): """Sousaphone: a wide conical bore, so the even harmonics are strong, the attack has air in it, and the note falls slightly at the release.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR env = adsr(n, .030, .12, .80, .10) f = freq*(1 - .020*np.exp(-t*30))*(1 + .004*np.sin(2*np.pi*4.6*t)) ph = 2*np.pi*np.cumsum(f)/SR co = 220 + 900*env**1.2 out = np.zeros(n) for k in range(1, 15): fk = freq*k if fk > SR*0.45: break out += (1.0/k**0.72)/np.sqrt(1.0+(fk/co)**4)*np.sin(ph*k + k*0.4) out /= np.max(np.abs(out))+1e-9 air = bandshape(np.random.RandomState(seed).randn(n), lo=200, hi=1800) return (out + air*.06)*env*g def handclap(seed=12, g=1.0): n = int(.20*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) x = np.zeros(n) for d in (0.0, .009, .017, .024): i = int(d*SR) x[i:] += bandshape(rng.randn(n-i), lo=900, hi=5200)*np.exp(-t[:n-i]*90) return nrm(x)*.55*g def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return SECTIONS[-1][0] ORDER = [s[0] for s in SECTIONS] def live(sec, part): """Has this part joined the parade yet? (nobody ever drops out)""" return ORDER.index(sec) >= ORDER.index(part) def build_song(): s = Song(DUR) R = np.random.RandomState(11800) for bar in range(N_BARS): sec = sec_of(bar) after_hit = bar >= 29 b2 = bar % 2 # ---- the bass drum: present from bar 0, and it owns the big four ---- if not after_hit: for st, v in (BD if b2 == 0 else BD2): at = s.t(bar, st, SW) gg = v*(1.12 if sec == "bassdrum" else 1.0) s.put("kit", bassdrum(seed=bar*5+st), at, g=.62*gg) if v >= .84: s.kick_t.append(at) else: for st in (0, 12): # a heartbeat left in the street s.put("kit", bassdrum(dur=.60, seed=bar*5+st), s.t(bar, st, SW), g=.30) for st in (4, 12): s.put("perc", tambourine(seed=bar*3+st), s.t(bar, st, SW), g=.14, pan=.24) if after_hit: continue # ---- the street beat: snare, tambourine, cowbell -------------------- if live(sec, "street"): for st, v in (SD if b2 == 0 else SD2): s.put("kit", parade_snare(seed=bar*16+st, bright=.95+.2*R.rand(), rim=1.0 if v >= .8 else 0.0), s.t(bar, st, SW), g=.40*v, pan=-.08) if b2 == 1 or sec in ("peak", "call"): s.put("kit", press_roll(.34, seed=bar*7), s.t(bar, 13, SW), g=.22, pan=-.10) for st in range(0, 16, 2): s.put("perc", tambourine(dur=.20 if st % 4 == 2 else .11, shake=(st % 8 == 6), seed=bar*11+st), s.t(bar, st, SW), g=.15+.05*(st % 4 == 2), pan=.26) for st, v in COWB: s.put("perc", cowbell(seed=bar*3+st), s.t(bar, st, SW), g=.17*v, pan=-.30) # ---- the sousaphone ------------------------------------------------- if live(sec, "sousa"): for bb, st, semi, ln in TUBA: if bb != b2: continue s.put("bass", sousa(n_(semi, 1), ln*(BEAT/4)*.92, seed=bar*13+st), s.t(bar, st, SW), g=.52) # ---- the banjo chank ------------------------------------------------ if live(sec, "banjo"): for st in (2, 6, 10, 14): for q, semi in enumerate(BANJO): s.put("gtr2", ks(n_(semi, 4), (BEAT/4)*.62, damp=.968, seed=bar*19+st*4+q, bright=1.25), s.t(bar, st, SW) + q*.004, g=.13, pan=.42) # a rolling 8th figure on the upper strings, precessing in threes st = (bar*16) % 3 while st < 16: s.put("gtr1", ks(n_(BANJO[(st//3) % 4]+12, 4), (BEAT/4)*1.2, damp=.987, seed=bar*17+st, bright=1.4), s.t(bar, st, SW), g=.13, pan=-.42) st += 3 # ---- the handclaps -------------------------------------------------- if live(sec, "claps"): for st in ((4, 12) if sec != "peak" else (4, 11, 12, 15)): s.put("perc", handclap(seed=bar*23+st), s.t(bar, st, SW), g=.20, pan=-.20+.40*(st % 2)) # ---- the horns: trumpet on top, trombone a fourth under ------------- if live(sec, "horns"): riff = TAG if (sec == "call" and b2 == 1) else HEAD for bb, st, semi, ln in riff: if bb != b2: continue at = s.t(bar, st, SW) dl = ln*(BEAT/4)*.95 s.put("horn", brass(n_(semi, 4), dl, "tpt", seed=bar*37+st, vib=(.008, 5.9)), at, g=.28, pan=.24) s.put("horn", brass(n_(semi-5, 4), dl, "sax", seed=bar*41+st), at+.008, g=.22, pan=-.26) # trombone, parallel fourth if sec == "peak": # the shout chorus s.put("horn", brass(n_(semi, 5), dl, "tpt", seed=bar*43+st), at+.014, g=.13, pan=.06) if sec == "peak" and b2 == 1: # the trombone rip for q in range(5): s.put("horn", brass(n_(-5+q*2, 3), (BEAT/4)*.7, "sax", seed=bar*53+q, scoop=.09), s.t(bar, 12+q*0.6, 0.0), g=.16, pan=-.34) # ---- the organ, holding the vamp in the peak ------------------------ if sec == "peak" and b2 == 0: for q, semi in enumerate(BANJO): s.put("org", organ(n_(semi, 3), BAR*1.7, seed=bar*47+q), s.t(bar, 0), g=.15, pan=-.35+.24*q) # ---- call & response ------------------------------------------------- if sec == "call": ci = (bar - 19) % len(CALLS) call, resp = CALLS[ci] s.put("vox", shout(call, "Daniel", 205, 1.0), s.t(bar, 0, SW), g=.34, pan=-.10) s.put("vox", chorus_shout(resp), s.t(bar, 8, SW), g=.30, pan=.10) # ---- the final unison hit --------------------------------------------- for k, semi in enumerate((0, 7, 12)): s.put("horn", brass(n_(semi, 4), 1.30, "tpt", seed=900+k), HIT_T, g=.28, pan=.30-.30*k) s.put("horn", brass(n_(semi-12, 4), 1.20, "sax", seed=910+k), HIT_T+.006, g=.22, pan=-.30+.30*k) s.put("kit", bassdrum(dur=.80, seed=777), HIT_T, g=.95) s.put("kit", parade_snare(dur=.30, seed=778, rim=1.4), HIT_T, g=.60) s.put("kit", crash(dur=1.6), HIT_T, g=.30) s.put("bass", sousa(n_(0, 1), 1.3, seed=999), HIT_T, g=.58) s.put("perc", tambourine(dur=.9, shake=True, seed=780), HIT_T, g=.22, pan=.2) # the wave, after the parade has gone round the corner s.put("perc", tambourine(dur=.30, seed=4242), HIT_T + BAR*1.55, g=.20, pan=.1) s.put("perc", cowbell(seed=4243), HIT_T + BAR*1.55 + .10, g=.13, pan=-.1) # a roll into each new part, so the build is legible for nm in ("horns", "peak"): b0 = dict((n, a) for n, a, _ in SECTIONS)[nm] s.put("kit", crash(dur=1.2), b0*BAR, g=.20) s.put("kit", press_roll(.55, seed=1000+b0), b0*BAR-0.55, g=.30, pan=-.06) s.bus("gtr1", lambda x: delay(x, BEAT*.75, .28, .16, taps=4)) s.bus("gtr2", lambda x: reverb(x, rt=.9, mix=.16, seed=211)) s.bus("horn", lambda x: reverb(x, rt=1.6, mix=.22, seed=223)) s.bus("vox", lambda x: reverb(delay(x, BEAT*.5, .28, .18), rt=1.3, mix=.24, seed=227)) s.bus("org", lambda x: reverb(x, rt=2.0, mix=.30, seed=229)) s.bus("kit", lambda x: reverb(x, rt=1.1, mix=.15, seed=231)) s.bus("perc", lambda x: reverb(x, rt=0.9, mix=.13, seed=233)) mix = s.mixdown(dict(perc=1.0, kit=1.0, bass=1.05, gtr1=1.0, gtr2=1.0, horn=1.15, org=1.0, vox=1.0), pump_depth=.14, pump_rel=.10, levels=dict(bassdrum=1.02, street=1.00, sousa=.96, banjo=.95, claps=.97, horns=1.0, call=.99, peak=1.02, land=.96)) 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.8) 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 >= 300) & (fr < 2600)].sum() # the horn band E["high"][f] = sp[fr >= 3200].sum() # shekere / hats 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) md = E["mid"] hf = np.maximum(0, md - np.concatenate([[0], md[:-1]])) E["horn"] = np.clip(np.convolve(hf, [.2, .3, .3, .2], "same") / (np.percentile(hf, 96)+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 # ════════════════════════════════════════════════════════════════════════════ # BURIN — the copperplate engraver # # The whole picture is lines. `stroke` is the graver: a swelled line that # tapers to nothing at both ends. Everything else in this section is a way of # choosing where to put strokes so that a region reads as a tone AND as a # form. # ════════════════════════════════════════════════════════════════════════════ LIGHT = np.array([-0.58, -0.62, 0.53]) # plate light, upper-left LIGHT = LIGHT/np.linalg.norm(LIGHT) class Plate: """The inked copper. Draw into it; it hands back a coverage array.""" __slots__ = ("img", "d", "w", "h") def __init__(self): self.w, self.h = W*SS, H*SS self.img = Image.new("L", (self.w, self.h), 0) self.d = ImageDraw.Draw(self.img) def coverage(self): im = self.img.resize((W, H), Image.LANCZOS) return np.asarray(im, np.float32)/255.0 def stroke(P, xs, ys, ws): """One burin line. xs/ys/ws are numpy arrays in DEVICE pixels; ws is the width at each station. Ends taper to a point because ws already does.""" n = len(xs) if n < 2: return if xs.max() < -30 or xs.min() > P.w+30 or ys.max() < -30 or ys.min() > P.h+30: return dx = np.gradient(xs); dy = np.gradient(ys) L = np.hypot(dx, dy)+1e-9 nx, ny = -dy/L, dx/L hw = np.maximum(ws, 0.0)*0.5 lx, ly = xs+nx*hw, ys+ny*hw rx, ry = xs-nx*hw, ys-ny*hw poly = [(float(a), float(b)) for a, b in zip(lx, ly)] poly += [(float(a), float(b)) for a, b in zip(rx[::-1], ry[::-1])] P.d.polygon(poly, fill=255) def taper(n, head=.22, tail=.22, p=.62): """Graver taper: sharp in, swell, sharp out.""" u = np.linspace(0, 1, n) a = np.clip(u/max(head, 1e-3), 0, 1)**p b = np.clip((1-u)/max(tail, 1e-3), 0, 1)**p return a*b def runs(mask): """Contiguous True runs of a boolean array -> list of (i0, i1).""" out = []; i = 0; n = len(mask) while i < n: if mask[i]: j = i while j+1 < n and mask[j+1]: j += 1 if j > i: out.append((i, j+1)) i = j+1 else: i += 1 return out MINW = 0.92*SS*SCL # the finest line the plate will hold def hatch_belly(P, cam, ax, ay, hw, *, bow=.52, dens=1.0, gamma=1.05, wmax=2.1, wmin=.42, cut=.15, cross=.55, jit=.30, rng=None, twist=0.0, arcs=None): """CROSS-CONTOUR hatching of a generalized cylinder. ax, ay, hw are the muscle's axis and half-width in PAGE units. At each station along the axis the routine lays an arc across the form. The arc's 3-D surface normal is reconstructed from the position across the belly (theta): n = (N2*theta) + (Z * sqrt(1-theta^2)). Lambert against LIGHT gives tone; tone sets the width along the arc and the spacing to the next. Where tone falls below `cut` the line breaks — the highlight. """ rng = rng or np.random.RandomState(7) ax = np.asarray(ax, np.float64); ay = np.asarray(ay, np.float64) hw = np.asarray(hw, np.float64) seg = np.hypot(np.diff(ax), np.diff(ay)) sarc = np.concatenate([[0.0], np.cumsum(seg)]) Ltot = sarc[-1] if Ltot < 1e-6: return z = cam.z # spacing in page units, floored so a wide shot doesn't emit 10k strokes sp_page = max(2.9/dens, 2.6*SCL/z) M = 11 if z*float(hw.max()) > 9 else 7 th = np.linspace(-1, 1, M) nz = np.sqrt(np.clip(1-th*th, 0, 1)) s = sp_page*0.5 guard = 0 while s < Ltot and guard < 900: guard += 1 # resolve station px = np.interp(s, sarc, ax); py = np.interp(s, sarc, ay) w0 = np.interp(s, sarc, hw) eps = max(Ltot*1e-3, .35) tx = np.interp(min(s+eps, Ltot), sarc, ax) - np.interp(max(s-eps, 0), sarc, ax) ty = np.interp(min(s+eps, Ltot), sarc, ay) - np.interp(max(s-eps, 0), sarc, ay) tl = math.hypot(tx, ty)+1e-9 tx, ty = tx/tl, ty/tl nx, ny = -ty, tx # 3-D normal across the belly (with a little axial twist) tw = twist*(s/Ltot - .5) thr = th*math.cos(tw) + nz*math.sin(tw) nzr = np.sqrt(np.clip(1-thr*thr, 0, 1)) N3x = nx*thr; N3y = ny*thr; N3z = nzr lam = np.clip(N3x*LIGHT[0] + N3y*LIGHT[1] + N3z*LIGHT[2], 0, 1) ink = np.clip(1.0 - lam, 0, 1)**gamma # the arc itself, bowing toward the near end wob = 1.0 + jit*0.06*math.sin(s*0.7 + guard) qx = px + nx*w0*th*wob + tx*bow*w0*nz qy = py + ny*w0*th*wob + ty*bow*w0*nz X, Y = cam.px(qx, qy) Wd = np.minimum(np.maximum((wmin + (wmax-wmin)*ink)*z*SS*0.55, 0.0), 7.5*SS*SCL) keep = ink > cut for i0, i1 in runs(keep): if i1-i0 < 2: continue sx, sy = X[i0:i1], Y[i0:i1] sw = Wd[i0:i1]*taper(i1-i0, .18, .18, .5) sw = np.maximum(sw, MINW*0.75) stroke(P, sx, sy, sw) # cross-hatch the darks: a short longitudinal lick if cross > 0 and ink.max() > cross: dk = np.where(ink > cross)[0] if len(dk): c = int(dk[len(dk)//2]) if guard % 2 == 0: L2 = sp_page*1.55 s0 = max(0.0, s-L2*.5); s1 = min(Ltot, s+L2*.5) ss = np.linspace(s0, s1, 6) bxs = np.interp(ss, sarc, ax); bys = np.interp(ss, sarc, ay) bw = np.interp(ss, sarc, hw) off = th[c] cxs = bxs + (-1)*0.0 + nx*bw*off cys = bys + ny*bw*off CX, CY = cam.px(cxs, cys) cw = np.full(6, max(MINW, wmin*z*SS*.55))*taper(6, .3, .3, .55) stroke(P, CX, CY, np.maximum(cw, MINW*.7)) mi = float(ink.mean()) s += sp_page/(0.42 + 1.05*mi) def contour(P, cam, xs, ys, w0=1.4, w1=1.4, close=False, taper_ends=True): """A drawn outline with a swelled middle.""" xs = np.asarray(xs, np.float64); ys = np.asarray(ys, np.float64) if close: xs = np.concatenate([xs, xs[:1]]); ys = np.concatenate([ys, ys[:1]]) X, Y = cam.px(xs, ys) n = len(X) ws = np.minimum(np.linspace(w0, w1, n)*cam.z*SS*0.55, 6.5*SS*SCL) if taper_ends: ws = ws*taper(n, .12, .12, .45) stroke(P, X, Y, np.maximum(ws, MINW)) def hatch_terrain(P, cam, xs, ridge, depth, *, sp=5.0, tone=.55, wmax=1.5, wmin=.45, rng=None, wob=1.6, breaks=0.0, seed=0): """Hills / ground: lines that follow the ridge contour, spacing opening downward so the near ground goes lighter.""" rng = rng or np.random.RandomState(seed) xs = np.asarray(xs, np.float64); ridge = np.asarray(ridge, np.float64) z = cam.z sp = max(sp, 2.4*SCL/z) d = sp*.6; k = 0 while d < depth: k += 1 fall = d/max(depth, 1e-6) ys = ridge + d + wob*np.sin(xs*0.021 + k*1.7) + wob*.5*np.sin(xs*0.053 + k) X, Y = cam.px(xs, ys) t = tone*(1.0 - .72*fall) ws = np.full(len(X), max((wmin + (wmax-wmin)*t)*z*SS*0.55, MINW)) ws = ws*taper(len(X), .06, .06, .4) if breaks > 0: m = rng.rand(len(X)) > breaks for i0, i1 in runs(m): if i1-i0 > 3: stroke(P, X[i0:i1], Y[i0:i1], np.maximum(ws[i0:i1], MINW*.8)) else: stroke(P, X, Y, np.maximum(ws, MINW*.8)) d += sp*(1.0 + 1.5*fall) def hatch_sky(P, cam, x0, x1, y0, y1, *, sp=9.0, cloud=None, seed=3, tone=.35): """Long horizontal sky lines, breaking where the clouds are.""" rng = np.random.RandomState(seed) z = cam.z sp = max(sp, 3.0*SCL/z) xs = np.linspace(x0, x1, 150) y = y0; k = 0 while y < y1: k += 1 ys = np.full_like(xs, y) + 1.1*np.sin(xs*0.014 + k*.9) X, Y = cam.px(xs, ys) w = max(tone*z*SS*0.55, MINW*.8) keep = np.ones(len(xs), bool) if cloud is not None: for (cx, cy, cr) in cloud: keep &= ((xs-cx)**2/(cr*cr) + (y-cy)**2/((cr*.42)**2)) > 1.0 ws = np.full(len(X), w)*taper(len(X), .10, .10, .4) for i0, i1 in runs(keep): if i1-i0 > 4: stroke(P, X[i0:i1], Y[i0:i1], np.maximum(ws[i0:i1], MINW*.7)) y += sp class Cam: """page space -> device pixels (already including supersampling).""" __slots__ = ("cx", "cy", "z", "rot") def __init__(self, cx=560.0, cy=360.0, z=1.0, rot=0.0): # SCL folded in here once: page->pixel is the only conversion in the # file, so the whole engraving comes out natively at delivery size. self.cx, self.cy, self.z, self.rot = cx, cy, z*SCL, rot def px(self, x, y): x = np.asarray(x, np.float64); y = np.asarray(y, np.float64) dx = x-self.cx; dy = y-self.cy if self.rot: c, s = math.cos(self.rot), math.sin(self.rot) dx, dy = dx*c-dy*s, dx*s+dy*c return (dx*self.z + W*0.5)*SS, (dy*self.z + H*0.5)*SS def p1(self, x, y): a, b = self.px(np.array([x]), np.array([y])) return float(a[0]), float(b[0]) # ════════════════════════════════════════════════════════════════════════════ # THE ÉCORCHÉ — an articulated flayed man # # Body units: the standing figure is ~200 tall, origin at the pelvis, +y down. # ════════════════════════════════════════════════════════════════════════════ BONE = dict(femur=46.0, tibia=44.0, foot=15.0, humerus=34.0, ulna=30.0, hand=11.0, lumbar=26.0, thorax=30.0, neck=11.0, skull=15.0, clav=24.0, hipw=13.0) def _rot(px, py, ang): c, s = math.cos(ang), math.sin(ang) return px*c - py*s, px*s + py*c def skeleton(p): """Forward kinematics. `p` is a pose dict of angles (radians). Returns joint name -> (x, y) in body units.""" J = {} ox, oy = p.get("px", 0.0), p.get("py", 0.0) pt = p.get("pelvis_tilt", 0.0) J["pelvis"] = (ox, oy) hw = BONE["hipw"] for side, sgn in (("L", -1), ("R", 1)): dx, dy = _rot(sgn*hw, 0.0, pt) J["hip"+side] = (ox+dx, oy+dy) # spine lb = p.get("lumbar", 0.0) + pt lx, ly = _rot(0.0, -BONE["lumbar"], lb) J["lumbar"] = (ox+lx, oy+ly) tb = lb + p.get("thorax", 0.0) tx, ty = _rot(0.0, -BONE["thorax"], tb) J["thorax"] = (J["lumbar"][0]+tx, J["lumbar"][1]+ty) nb = tb + p.get("neck", 0.0) nx2, ny2 = _rot(0.0, -BONE["neck"], nb) J["neck"] = (J["thorax"][0]+nx2, J["thorax"][1]+ny2) hb = nb + p.get("head", 0.0) hx, hy = _rot(0.0, -BONE["skull"], hb) J["skull"] = (J["neck"][0]+hx, J["neck"][1]+hy) # shoulders hang off the thorax for side, sgn in (("L", -1), ("R", 1)): sx, sy = _rot(sgn*BONE["clav"], -4.0, tb) J["sh"+side] = (J["thorax"][0]+sx, J["thorax"][1]+sy) # limbs for side in ("L", "R"): a = p.get("hip"+side, 0.0) + pt kx, ky = _rot(0.0, BONE["femur"], a) J["knee"+side] = (J["hip"+side][0]+kx, J["hip"+side][1]+ky) b = a + p.get("knee"+side, 0.0) axx, ayy = _rot(0.0, BONE["tibia"], b) J["ankle"+side] = (J["knee"+side][0]+axx, J["knee"+side][1]+ayy) c = b + p.get("ankle"+side, 0.0) + math.pi/2 fx, fy = _rot(0.0, BONE["foot"], c) J["toe"+side] = (J["ankle"+side][0]+fx, J["ankle"+side][1]+fy) d = p.get("sh"+side, 0.0) + tb ex, ey = _rot(0.0, BONE["humerus"], d) J["elb"+side] = (J["sh"+side][0]+ex, J["sh"+side][1]+ey) e2 = d + p.get("elb"+side, 0.0) wx, wy = _rot(0.0, BONE["ulna"], e2) J["wri"+side] = (J["elb"+side][0]+wx, J["elb"+side][1]+wy) f2 = e2 + p.get("wri"+side, 0.0) hx2, hy2 = _rot(0.0, BONE["hand"], f2) J["hand"+side] = (J["wri"+side][0]+hx2, J["wri"+side][1]+hy2) return J def anc(J, a, b, t, perp=0.0, along=0.0): """A muscle attachment: t of the way from joint a to joint b, offset `perp` body units perpendicular and `along` units further along.""" ax, ay = J[a]; bx, by = J[b] dx, dy = bx-ax, by-ay L = math.hypot(dx, dy)+1e-9 ux, uy = dx/L, dy/L nx, ny = -uy, ux return (ax+dx*t+nx*perp+ux*along, ay+dy*t+ny*perp+uy*along) # A muscle is declared, not drawn: (origin anchor, insertion anchor, control # offset, width profile). The origin/insertion are resolved from the POSED # skeleton every frame, so the hatching is regenerated — not warped — as the # figure moves. `perp` is written for the RIGHT side and multiplied by the # side sign, so left and right are true mirrors rather than copies. def M(nm, o, ins, ctrl, hm, he, layer, bex, dens=1.0, key=None): return dict(nm=nm, o=o, i=ins, ctrl=ctrl, hm=hm, he=he, layer=layer, bex=bex, dens=dens, key=key) # ---- limb + trunk masses: the flesh the named muscles sit on -------------- MASSES = [ M("thigh_mass", ("hip%s", "knee%s", -.08, 0.0), ("hip%s", "knee%s", 1.0, 0.0), 0.0, 9.8, 3.2, -1, .30, .40), M("shank_mass", ("knee%s", "ankle%s", -.03, 0.0), ("knee%s", "ankle%s", 1.0, 0.0), 0.0, 7.0, 2.0, -1, .30, .40), M("arm_mass", ("sh%s", "elb%s", .00, 0.0), ("sh%s", "elb%s", 1.0, 0.0), 0.0, 7.0, 2.6, -1, .25, .40), M("fore_mass", ("elb%s", "wri%s", .00, 0.0), ("elb%s", "wri%s", 1.0, 0.0), 0.0, 5.6, 1.8, -1, .25, .40), ] # ---- legs and arms, mirrored ---------------------------------------------- MUSCLES = [ M("vastus_lat", ("hip%s", "knee%s", .04, -6.6), ("hip%s", "knee%s", .93, -3.2), -2.6, 7.0, 1.6, 0, .45), M("vastus_med", ("hip%s", "knee%s", .48, 5.6), ("hip%s", "knee%s", .96, 2.4), 2.4, 5.6, 1.4, 0, .45), M("biceps_fem", ("hip%s", "knee%s", .10, 7.0), ("hip%s", "knee%s", .90, 5.0), 3.2, 4.8, 1.2, 0, .55), M("soleus", ("knee%s", "ankle%s", .36, 4.2), ("knee%s", "ankle%s", .97, 1.0), 1.4, 3.2, 0.9, 0, .40), M("triceps", ("sh%s", "elb%s", .08, 3.8), ("sh%s", "elb%s", .94, 1.6), 2.4, 4.4, 1.1, 0, .60), M("flexors", ("elb%s", "wri%s", .05, 3.0), ("elb%s", "wri%s", .93, 0.8), 1.6, 3.2, 0.9, 0, .35), M("rectus_fem", ("hip%s", "knee%s", .02, 0.0), ("hip%s", "knee%s", .92, 0.0), 0.0, 6.4, 1.5, 1, .60), M("sartorius", ("hip%s", "knee%s", .02, -7.4), ("hip%s", "knee%s", .98, 4.8), 1.2, 2.0, 0.9, 1, .30, key="C"), M("gastroc", ("knee%s", "ankle%s", .00, 4.8), ("knee%s", "ankle%s", .60, 2.2), 3.0, 6.6, 1.4, 1, .70, key="B"), M("tib_ant", ("knee%s", "ankle%s", .08, -4.4), ("knee%s", "ankle%s", .95, -1.4), -1.2, 3.2, 0.9, 1, .35), M("deltoid", ("sh%s", "elb%s", -.18, 0.0), ("sh%s", "elb%s", .42, -1.0), 0.0, 6.6, 1.4, 1, .40, key="A"), M("biceps_br", ("sh%s", "elb%s", .18, -3.4), ("sh%s", "elb%s", .96, -1.2), -2.4, 4.6, 1.1, 1, .80), M("brachiorad", ("elb%s", "wri%s", .02, -3.2), ("elb%s", "wri%s", .90, -1.0), -1.8, 3.4, 0.9, 1, .35), ] # ---- trunk, mirrored ------------------------------------------------------- TORSO = [ M("lat_%s", ("lumbar", "sh%s", .05, -2.0), ("lumbar", "sh%s", .82, 3.4), -3.0, 5.2, 1.2, 0, .40), M("oblique_%s", ("hip%s", "thorax", .08, 0.5), ("hip%s", "thorax", .78, -1.0), 2.4, 4.6, 1.1, 0, .35), M("trap_%s", ("neck", "sh%s", .08, 3.0), ("neck", "sh%s", .92, 2.0), 2.6, 3.4, 1.0, 0, .30), M("pect_%s", ("thorax", "sh%s", .08, 4.2), ("thorax", "sh%s", .88, 1.0), 3.2, 6.4, 1.4, 1, .45), M("scm_%s", ("neck", "sh%s", .00, 0.0), ("neck", "sh%s", .50, 0.0), 1.0, 1.9, 0.7, 1, .30), ] # ---- unmirrored singles ---------------------------------------------------- SINGLES = [ M("trunk", ("pelvis", "thorax", -.04, 0.0), ("pelvis", "thorax", 1.06, 0.0), 0.0, 18.5, 9.0, -1, .18, .42), M("pelvis_blk", ("hipL", "hipR", -.18, 2.0), ("hipL", "hipR", 1.18, 2.0), 0.0, 10.5, 5.5, -1, .18, .80), M("glute", ("hipL", "hipR", -.05, 8.5), ("hipL", "hipR", 1.05, 8.5), 0.0, 7.5, 3.0, 0, .18, .70), M("neck_mass", ("thorax", "skull", .00, 0.0), ("thorax", "skull", .80, 0.0), 0.0, 5.6, 3.4, -1, .20, .55), ] # rectus abdominis — two columns of three, with the tendinous intersections for _r in range(3): _t0 = .16 + _r*.19 for _c, _p in ((0, -4.3), (1, 4.3)): SINGLES.append(M(f"abs{_r}{_c}", ("pelvis", "thorax", _t0, _p), ("pelvis", "thorax", _t0+.16, _p*0.92), _p*0.30, 4.0, 1.5, 1, .25, .95)) _REST_CACHE = {} def _sgn(side): return -1.0 if side == "L" else 1.0 def _resolve(J, spec, side): a, b, t, perp = spec return anc(J, a % side if "%s" in a else a, b % side if "%s" in b else b, t, perp*_sgn(side)) def _all_specs(): for m in MASSES: yield m, ("L", "R") for m in TORSO: yield m, ("L", "R") for m in MUSCLES: yield m, ("L", "R") for m in SINGLES: yield m, ("R",) def rest_lengths(): if "rest" in _REST_CACHE: return _REST_CACHE["rest"] J = skeleton(REST) out = {} for m, sides in _all_specs(): for side in sides: o = _resolve(J, m["o"], side); ins = _resolve(J, m["i"], side) out[(m["nm"], side)] = math.hypot(ins[0]-o[0], ins[1]-o[1]) _REST_CACHE["rest"] = out return out def belly(o, ins, ctrl, hwmid, hwend, n=13): """A quadratic-bezier fusiform belly between two attachments.""" ox, oy = o; ix, iy = ins dx, dy = ix-ox, iy-oy L = math.hypot(dx, dy)+1e-9 nx, ny = -dy/L, dx/L mx, my = (ox+ix)*.5 + nx*ctrl, (oy+iy)*.5 + ny*ctrl u = np.linspace(0, 1, n) xs = (1-u)**2*ox + 2*(1-u)*u*mx + u*u*ix ys = (1-u)**2*oy + 2*(1-u)*u*my + u*u*iy prof = np.sin(np.pi*np.clip(u, 0, 1))**0.55 hw = hwend + (hwmid-hwend)*prof return xs, ys, hw, L class Ecorche: """One flayed man. Poses come in as angle dicts; the drawing is regenerated from the skeleton every single frame.""" def __init__(self, x, y, sc, seed=0, flip=False): self.x, self.y, self.sc, self.flip = x, y, sc, flip self.seed = seed def to_page(self, bx, by): s = self.sc*(-1 if self.flip else 1) return self.x + bx*s, self.y + by*self.sc def pts(self, xs, ys): s = self.sc*(-1 if self.flip else 1) return self.x + np.asarray(xs)*s, self.y + np.asarray(ys)*self.sc def draw(self, P, cam, pose, *, dens=1.0, keys=None): J = skeleton(pose) RL = rest_lengths() sc = self.sc gw = max(0.32, min(1.0, cam.z*sc/1.9)) # detail falls off with size rngs = np.random.RandomState(self.seed) keymarks = [] def bellies(layer): for m, sides in _all_specs(): if m["layer"] != layer: continue for side in sides: nm = m["nm"] % side if "%s" in m["nm"] else m["nm"] o = _resolve(J, m["o"], side); ins = _resolve(J, m["i"], side) L = math.hypot(ins[0]-o[0], ins[1]-o[1]) r0 = RL.get((m["nm"], side), L) bul = float(np.clip((r0/max(L, 1e-6))**m["bex"], .74, 1.55)) n = 13 if gw > .60 else (9 if gw > .40 else 7) hm = m["hm"]*bul; he = m["he"]*max(.6, bul*.75) xs, ys, hw, _ = belly(o, ins, m["ctrl"]*_sgn(side)*(.6+.4*bul), hm, he, n=n) pxs, pys = self.pts(xs, ys) phw = hw*sc d = dens*gw*m["dens"] hatch_belly(P, cam, pxs, pys, phw, dens=d, bow=.50, wmax=1.95, wmin=.40, cut=.19, cross=.70 if m["layer"] == 1 else 2.0, rng=rngs, gamma=1.15, twist=.16*_sgn(side)) if m["layer"] == 1: dxs = np.gradient(pxs); dys = np.gradient(pys) LL = np.hypot(dxs, dys)+1e-9 nx, ny = -dys/LL, dxs/LL for sg, wg in ((1, 1.15), (-1, .62)): contour(P, cam, pxs+nx*phw*sg, pys+ny*phw*sg, .74*wg*sc, .42*wg*sc) elif m["layer"] == 0: dxs = np.gradient(pxs); dys = np.gradient(pys) LL = np.hypot(dxs, dys)+1e-9 nx, ny = -dys/LL, dxs/LL contour(P, cam, pxs+nx*phw, pys+ny*phw, .70*sc, .38*sc) else: # the mass gets one silhouette dxs = np.gradient(pxs); dys = np.gradient(pys) LL = np.hypot(dxs, dys)+1e-9 nx, ny = -dys/LL, dxs/LL for sg in (1, -1): contour(P, cam, pxs+nx*phw*sg, pys+ny*phw*sg, 1.05*sc, .55*sc) if m["key"] and keys and m["key"] in keys and side == "R": mid = ((o[0]+ins[0])*.5, (o[1]+ins[1])*.5) keymarks.append((self.to_page(*mid), m["key"], keys[m["key"]])) # ---------- bones that show --------------------------------------- def bonelink(a, b, w=1.5, t0=0.0, t1=1.0): ax, ay = J[a]; bx, by = J[b] xs = np.array([ax+(bx-ax)*t0, ax+(bx-ax)*t1]) ys = np.array([ay+(by-ay)*t0, ay+(by-ay)*t1]) pxs, pys = self.pts(xs, ys) contour(P, cam, pxs, pys, w*sc*.6, w*sc*.6, taper_ends=False) bellies(-1) # the flesh for side in ("L", "R"): bonelink("sh"+side, "thorax", 1.5, .05, .88) # clavicle bonelink("pelvis", "thorax", 1.4, .05, .95) # spine # ---------- ribs --------------------------------------------------- lx, ly = J["lumbar"]; tx, ty = J["thorax"] for r in range(5): u = .30 + r*.15 cx = lx+(tx-lx)*u; cy = ly+(ty-ly)*u wdt = 16.5 - abs(r-2)*1.3 ang = np.linspace(-1.35, 1.35, 11) rxs = cx + np.sin(ang)*wdt rys = cy + (1-np.cos(ang))*3.2 + 1.2 pxs, pys = self.pts(rxs, rys) contour(P, cam, pxs, pys, .95*sc*.7, .95*sc*.7) bellies(0) # deep muscles bellies(1) # superficial muscles # ---------- patellae, serratus slips -------------------------------- for side in ("L", "R"): kx, ky = J["knee"+side] aa = np.linspace(0, 2*np.pi, 14) pxs, pys = self.pts(kx+np.cos(aa)*4.6, ky+np.sin(aa)*4.0) contour(P, cam, pxs, pys, .9*sc, .9*sc, taper_ends=False) hx, hy = J["hip"+side]; sx, sy = J["sh"+side] for q in range(4): u = .34 + q*.10 ax0 = hx+(sx-hx)*u + _sgn(side)*9.5 ay0 = hy+(sy-hy)*u pxs, pys = self.pts([ax0, ax0+_sgn(side)*5.2], [ay0, ay0-3.2]) contour(P, cam, pxs, pys, .75*sc, .35*sc) # ---------- skull --------------------------------------------------- skx, sky = J["skull"] ang = np.linspace(0, 2*np.pi, 34) rx, ry = 10.4, 12.2 cxs = skx + np.cos(ang)*rx*(1+.10*np.sin(ang)) cys = sky + np.sin(ang)*ry - 1.0 pxs, pys = self.pts(cxs, cys) contour(P, cam, pxs, pys, 1.15*sc, 1.15*sc, taper_ends=False) ncr = int(np.clip(round(7*max(1.0, cam.z*sc/2.4)), 7, 26)) for hgi in range(ncr): v = -0.90 + hgi*(1.80/(ncr-1)) yy = sky - 1.0 + v*ry wdt = rx*math.sqrt(max(0.0, 1-v*v)) aa = np.linspace(-1, 1, 9) hxs = skx + aa*wdt hys = yy + (1-np.abs(aa))*1.5 ink_ = np.clip(.90 - .60*(aa*0.5+0.5), .14, 1.0) pxs, pys = self.pts(hxs, hys) X, Y = cam.px(pxs, pys) ws = np.maximum(ink_*1.15*cam.z*sc*SS*.55, MINW*.8)*taper(9, .2, .2, .5) stroke(P, X, Y, np.maximum(ws, MINW*.72)) # orbits: a rim and a few sparse licks, not a solid block for ex in (-4.0, 4.0): a2 = np.linspace(0, 2*np.pi, 18) pxs, pys = self.pts(skx+ex+np.cos(a2)*2.9, sky-2.2+np.sin(a2)*2.5) contour(P, cam, pxs, pys, 1.6*sc, 1.6*sc, taper_ends=False) for q in range(7): # the socket: short strokes, not bars v = -0.86 + q*0.29 hh = 2.3*math.sqrt(max(0.0, 1-v*v)) pxs, pys = self.pts([skx+ex+v*2.7, skx+ex+v*2.7+0.5], [sky-2.2-hh, sky-2.2+hh]) contour(P, cam, pxs, pys, 1.15*sc, 1.15*sc, taper_ends=True) pxs, pys = self.pts([skx-1.5, skx, skx+1.5], [sky+3.0, sky+0.4, sky+3.0]) contour(P, cam, pxs, pys, 1.2*sc, 1.2*sc, taper_ends=False) # zygomatic arch + masseter for ex in (-1, 1): pxs, pys = self.pts([skx+ex*4.2, skx+ex*8.6, skx+ex*7.0], [sky+0.6, sky+1.6, sky+5.4]) contour(P, cam, pxs, pys, 1.0*sc, .7*sc) jaw = pose.get("jaw", 0.0) jy = sky + 6.6 + jaw*3.2 jxs = np.linspace(-6.4, 6.4, 9) jys = jy + (jxs/6.4)**2*1.5 pxs, pys = self.pts(skx+jxs, jys) contour(P, cam, pxs, pys, 1.2*sc, 1.2*sc) for q in range(7): tx0 = skx - 5.1 + q*1.7 pxs, pys = self.pts([tx0, tx0], [jy-2.2, jy-0.2]) contour(P, cam, pxs, pys, .75*sc, .75*sc, taper_ends=False) # ---------- hands & feet -------------------------------------------- for side in ("L", "R"): wx, wy = J["wri"+side]; hx2, hy2 = J["hand"+side] dx, dy = hx2-wx, hy2-wy L = math.hypot(dx, dy)+1e-9 ux, uy = dx/L, dy/L; nx, ny = -uy, ux pxs, pys = self.pts([wx, (wx+hx2)*.5, hx2], [wy, (wy+hy2)*.5, hy2]) hatch_belly(P, cam, pxs, pys, np.array([3.2*sc, 3.4*sc, 2.0*sc]), dens=dens*gw*.7, bow=.4, wmax=1.6, wmin=.4, cut=.20, cross=2.0, rng=rngs) contour(P, cam, *self.pts( [wx+nx*3.0, hx2+nx*2.0, hx2+ux*3.4, hx2-nx*2.0, wx-nx*3.0], [wy+ny*3.0, hy2+ny*2.0, hy2+uy*3.4, hy2-ny*2.0, wy-ny*3.0]), .95*sc, .95*sc, taper_ends=False) curl = 0.55 + 0.45*math.sin(pose.get("wri"+side, 0.0)*3.0) for q in range(4): off = (q-1.5)*1.55 ln = 3.4 - abs(q-1.2)*0.45 kx1 = hx2 + nx*off + ux*ln*0.55 ky1 = hy2 + ny*off + uy*ln*0.55 kx2 = kx1 + (ux*(1-curl) + nx*off*0.10)*ln ky2 = ky1 + (uy*(1-curl) + ny*off*0.10)*ln + curl*ln*0.55 pxs, pys = self.pts([hx2+nx*off*.8, kx1, kx2], [hy2+ny*off*.8, ky1, ky2]) contour(P, cam, pxs, pys, .95*sc, .45*sc) # thumb pxs, pys = self.pts([wx-nx*2.6, hx2-nx*4.2+ux*1.0], [wy-ny*2.6, hy2-ny*4.2+uy*1.0]) contour(P, cam, pxs, pys, 1.05*sc, .5*sc) # foot: a hatched wedge with toes ax0, ay0 = J["ankle"+side]; tx0, ty0 = J["toe"+side] pxs, pys = self.pts([ax0, (ax0+tx0)*.5, tx0], [ay0, ay0+2.0, ty0]) hatch_belly(P, cam, pxs, pys, np.array([4.0*sc, 3.0*sc, 1.5*sc]), dens=dens*gw*.7, bow=.35, wmax=1.6, wmin=.4, cut=.20, cross=2.0, rng=rngs) fdx, fdy = tx0-ax0, ty0-ay0 fl = math.hypot(fdx, fdy)+1e-9 fnx, fny = -fdy/fl, fdx/fl contour(P, cam, *self.pts( [ax0+fnx*3.6, tx0+fnx*1.4, tx0-fnx*1.4, ax0-fnx*3.6], [ay0+fny*3.6, ty0+fny*1.4, ty0-fny*1.4, ay0-fny*3.6]), 1.25*sc, 1.25*sc, taper_ends=False) # ---------- the shadow the plate casts under him -------------------- fy = max(J["ankle"+s2][1] for s2 in ("L", "R")) fx = (J["ankleL"][0] + J["ankleR"][0])*.5 for q in range(4): v = -0.75 + q*0.5 wdt = 17.0*math.sqrt(max(0.0, 1-v*v)) pxs, pys = self.pts([fx-wdt, fx+wdt], [fy+5.0+v*3.0, fy+5.0+v*3.0]) contour(P, cam, pxs, pys, 1.0*sc, 1.0*sc, taper_ends=True) for (kp, letter, direction) in keymarks: key_mark(P, cam, kp[0], kp[1], letter, direction) return J def key_mark(P, cam, px, py, letter, direction): """A figure key: a leader line out of the plate and a letterpress letter.""" dx, dy = direction ex, ey = px + dx, py + dy contour(P, cam, np.array([px, px+dx*.35, ex]), np.array([py, py+dy*.62, ey]), .95, .55) X, Y = cam.p1(ex + (5 if dx > 0 else -12), ey - 7) letterpress(P, letter, X, Y, int(min(PXf(46), max(PXf(11), 9*cam.z)))*SS, italic=True) # ---- pose vocabulary ------------------------------------------------------- REST = dict(px=0.0, py=0.0, pelvis_tilt=0.0, lumbar=0.0, thorax=0.0, neck=0.0, head=0.0, jaw=0.0, hipL=0.14, kneeL=-0.08, ankleL=0.05, hipR=-0.12, kneeR=0.07, ankleR=-0.04, shL=0.44, elbL=0.26, wriL=0.06, shR=-0.44, elbR=-0.26, wriR=-0.06) def contrapposto(t=0.0, amt=1.0): """The engraved standing pose — the plate's rest state.""" p = dict(REST) d = .012*math.sin(t*0.7)*amt p["lumbar"] += d; p["thorax"] -= d*.6; p["head"] += d*.4 return p def dance_pose(t, style=0, en=None, amp=1.0, phase=0.0): """Second line: a forward strut that leans into the BIG FOUR. The weight is still in the hips and knees and the shoulders still answer, but the whole bar is now shaped by one accent — the bass drum's heaviest note on beat 4 — so the cast dips, hitches and throws its arms up there. Everything is a function of the beat phase, so the whole parade locks.""" en = en or {"kick": .3, "horn": .3, "high": .3, "rms": .5} b = t/BEAT + phase q = b*math.pi bounce = math.sin(q*2) hipsway = math.sin(q) shimmy = math.sin(q*4 + style) # the big four: a half-sine hump across beat 4 of every bar bp = b % 4.0 big4 = max(0.0, math.sin(math.pi*(bp - 3.0))) if bp >= 3.0 else 0.0 horn = en.get("horn", 0.0) kickv = en.get("kick", 0.0) A = amp p = dict(REST) p["py"] = (-2.2 - 4.6*abs(bounce) - 3.0*kickv - 5.2*big4)*A p["px"] = (5.0*hipsway + 3.4*big4)*A p["pelvis_tilt"] = 0.14*hipsway*A p["lumbar"] = -0.10*hipsway*A + 0.05*shimmy*A p["thorax"] = (0.12*math.sin(q + 1.1 + style*.7) + 0.16*big4)*A p["neck"] = -0.05*math.sin(q*2)*A p["head"] = 0.10*math.sin(q + style)*A + 0.05*horn p["jaw"] = max(0.0, math.sin(q*2 + style))*0.7*horn # legs: a step-touch with a knee lift on the offbeat st = math.sin(q + style*1.3) lift = max(0.0, math.sin(q*2 + style*2.1)) p["hipL"] = (0.16 + 0.42*st - 0.30*lift)*A p["kneeL"] = (-0.10 - 0.75*lift - 0.20*max(0, st))*A p["ankleL"] = (0.06 + 0.42*lift)*A lift2 = max(0.0, math.sin(q*2 + style*2.1 + math.pi)) st2 = math.sin(q + style*1.3 + math.pi) p["hipR"] = (-0.14 + 0.42*st2 - 0.30*lift2)*A p["kneeR"] = (0.08 - 0.75*lift2 - 0.20*max(0, st2))*A p["ankleR"] = (-0.04 + 0.42*lift2)*A # arms: elbows work, which is what makes the biceps bulge on camera swing = math.sin(q + style*0.9) flex = 0.55 + 0.85*max(0.0, math.sin(q*2 + style)) p["shL"] = (0.44 + 0.82*swing + 0.40*horn + 0.75*big4) p["elbL"] = (0.26 + flex*1.05 - 0.35*big4) p["shR"] = (-0.44 + 0.82*swing - 0.40*horn - 0.75*big4) p["elbR"] = (-0.26 - flex*1.05 + 0.35*big4) p["wriL"] = 0.30*math.sin(q*3) p["wriR"] = -0.30*math.sin(q*3 + 1) if style % 3 == 1: # the raised-arm variant p["shL"] = 1.40 + 0.42*abs(swing) p["elbL"] = 1.35 + 0.45*flex p["shR"] = -0.70 - 0.35*swing p["elbR"] = -0.85 - 0.5*flex if style % 3 == 2: # the low crouch p["py"] += -3.0*A p["kneeL"] -= 0.28*A; p["kneeR"] -= 0.28*A for k in p: if k in REST and k not in ("px", "py"): p[k] = REST[k] + (p[k]-REST[k])*A return p def twitch_pose(t, kind=0, amt=1.0): """Still engraved — except for one part.""" p = contrapposto(t) ph = (t/BEAT) % 1.0 hit = max(0.0, 1.0 - ph*2.0)**1.5 * amt if kind == 0: p["hipR"] += 0.10*hit; p["kneeR"] -= 0.30*hit; p["ankleR"] += 0.40*hit elif kind == 1: p["thorax"] += 0.10*hit; p["shR"] -= 0.55*hit; p["elbR"] -= 0.45*hit elif kind == 2: p["wriR"] += 0.9*hit; p["elbR"] -= 0.16*hit; p["jaw"] = 0.4*hit else: p["head"] += 0.18*hit; p["neck"] -= 0.10*hit return p def blend_pose(a, b, u): u = float(np.clip(u, 0, 1)) return {k: a.get(k, 0.0)*(1-u) + b.get(k, 0.0)*u for k in set(a) | set(b)} def wave_pose(t): """The landing: the plate is engraved and still, and one hand waves.""" p = contrapposto(t, 0.4) p["shR"] = -2.30 p["elbR"] = -0.95 p["wriR"] = 0.60*math.sin(t*7.4) p["head"] = 0.06 return p # ════════════════════════════════════════════════════════════════════════════ # THE PAGE — plate mark, letterpress, laid paper # ════════════════════════════════════════════════════════════════════════════ # ── 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="Georgia.ttf"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] def letterpress(P, text, X, Y, size, italic=False, bold=False, center=False, track=0.0, fill=255): """Type, bitten into the plate. Drawn straight into the ink layer so it stays crisp — the caption is part of the print, and nothing in this piece channel-shifts (AESTHETIC 13b).""" nm = "Georgia Italic.ttf" if italic else ("Georgia Bold.ttf" if bold else "Georgia.ttf") f = font(max(6, int(size)), nm) if track: w = sum(P.d.textlength(c, font=f)+track for c in text) x = X - w/2 if center else X for c in text: P.d.text((x, Y), c, font=f, fill=fill) x += P.d.textlength(c, font=f)+track return w if center: w = P.d.textlength(text, font=f) X = X - w/2 P.d.text((X, Y), text, font=f, fill=fill) return P.d.textlength(text, font=f) PLATE_BOX = (78.0, 56.0, 1042.0, 552.0) # the copper's bite, in page units GROUND = 430.0 # where the écorchés stand def plate_rule(P, cam, x0, x1, y, w=1.5): contour(P, cam, np.array([x0, x1]), np.array([y, y]), w, w, taper_ends=False) def draw_plate_mark(P, cam): x0, y0, x1, y1 = PLATE_BOX for inset, w in ((0.0, 1.9), (5.0, .85)): xs = np.array([x0+inset, x1-inset, x1-inset, x0+inset, x0+inset]) ys = np.array([y0+inset, y0+inset, y1-inset, y1-inset, y0+inset]) contour(P, cam, xs, ys, w, w, taper_ends=False) CAPTION = "DE HVMANI CORPORIS FABRICA · LIBER SECVNDVS · TABVLA VIII" KEYLINES = [("A", "MVSCVLVS DELTOIDEVS"), ("B", "GASTROCNEMIVS"), ("C", "SARTORIVS")] def draw_caption(P, cam, extra=None, t=0.0): x0, y0, x1, y1 = PLATE_BOX cx = (x0+x1)*.5 X, Y = cam.p1(cx, y1+14) letterpress(P, "TABVLA VIII", X, Y, int(20*cam.z)*SS, bold=True, center=True, track=2.4*cam.z*SS) plate_rule(P, cam, x0+140, x1-140, y1+44, 0.9) X, Y = cam.p1(cx, y1+50) letterpress(P, CAPTION, X, Y, int(12.5*cam.z)*SS, italic=True, center=True) lines = list(KEYLINES) + (extra or []) txt = " ".join(f"{k} {v}" for k, v in lines) X, Y = cam.p1(cx, y1+76) letterpress(P, txt, X, Y, int(11.5*cam.z)*SS, center=True) # ---- landscape ------------------------------------------------------------- _LAND = {} def landscape(P, cam, t, e, beat_amp=0.0, seed=5, wide=True): """Renaissance background: hills, a broken colonnade, sky. All hatched. The ruins keep time — the columns breathe on the beat.""" x0, y0, x1, y1 = PLATE_BOX gy = GROUND xs = np.linspace(x0+8, x1-8, 130) # sky clouds = [(300, 122, 118), (690, 100, 146), (900, 158, 84)] hatch_sky(P, cam, x0+8, x1-8, y0+14, 232, sp=7.2, cloud=clouds, seed=seed, tone=.60) # far hills r1 = 252 + 30*np.sin(xs*0.0085) + 14*np.sin(xs*0.021+1.2) hatch_terrain(P, cam, xs, r1, 56, sp=4.4, tone=.48, seed=seed+1, wob=1.1, breaks=0.10) contour(P, cam, xs, r1, 1.0, 1.0, taper_ends=False) # near hills r2 = 316 + 20*np.sin(xs*0.013+2.4) + 8*np.sin(xs*0.031) hatch_terrain(P, cam, xs, r2, 112, sp=4.2, tone=.62, seed=seed+2, wob=1.5, breaks=0.08) contour(P, cam, xs, r2, 1.2, 1.2, taper_ends=False) # the ground the figures stand on hatch_terrain(P, cam, xs, np.full_like(xs, gy), 128, sp=5.4, tone=.44, seed=seed+3, wob=3.4, breaks=0.34) contour(P, cam, xs, np.full_like(xs, gy), 1.6, 1.6, taper_ends=False) # ruins: a broken colonnade that keeps time base = gy - 26.0 for i, cxp in enumerate((652, 702, 752, 806)): ph = i*0.6 bob = beat_amp*5.0*math.sin(t/BEAT*math.pi*2 + ph) htc = (86, 106, 64, 96)[i] + bob ax = np.array([cxp, cxp]); ay = np.array([base, base-htc]) hatch_belly(P, cam, ax, ay, np.array([7.4, 6.8]), dens=.85, bow=.35, wmax=1.9, wmin=.4, cut=.14, cross=.6, rng=np.random.RandomState(seed+10+i)) contour(P, cam, np.array([cxp-7.4, cxp-7.4]), np.array([base, base-htc]), 1.0, 1.0, taper_ends=False) contour(P, cam, np.array([cxp+7.4, cxp+7.4]), np.array([base, base-htc]), 1.0, 1.0, taper_ends=False) # capital contour(P, cam, np.array([cxp-11, cxp+11, cxp+11, cxp-11, cxp-11]), np.array([base-htc, base-htc, base-htc-6, base-htc-6, base-htc]), 1.1, 1.1, taper_ends=False) # an entablature across the two tallest, broken at the right top = base-106+beat_amp*5.0*math.sin(t/BEAT*math.pi*2+0.6) contour(P, cam, np.array([640, 766]), np.array([top-8, top-8]), 1.6, 1.6, taper_ends=False) contour(P, cam, np.array([640, 752]), np.array([top-18, top-15]), 1.2, 1.2, taper_ends=False) # a distant round temple tx = 218.0; ty = 348.0 aa = np.linspace(0, 2*np.pi, 24) contour(P, cam, tx+np.cos(aa)*30, ty+np.sin(aa)*9, .9, .9, taper_ends=False) for q in range(7): xq = tx-28+q*9.3 contour(P, cam, np.array([xq, xq]), np.array([ty, ty-34]), .9, .9, taper_ends=False) contour(P, cam, np.array([tx-32, tx+32]), np.array([ty-36, ty-36]), 1.2, 1.2, taper_ends=False) contour(P, cam, np.array([tx-34, tx, tx+34]), np.array([ty-38, ty-52, ty-38]), 1.1, 1.1, taper_ends=False) # foreground tufts rr = np.random.RandomState(seed+40) for q in range(34): gx = x0 + 16 + rr.rand()*(x1-x0-32) gyy = gy + 16 + rr.rand()*100 hgt = 3.5 + rr.rand()*5.5 for b in range(2): bx = gx + (-1.8 if b == 0 else 2.0) contour(P, cam, np.array([gx, (gx+bx)*.5-0.6, bx]), np.array([gyy, gyy-hgt*.62, gyy-hgt]), .42, .16) def draw_ghost(P, cam, ec, pose, alpha=1.0): """Where a figure used to be engraved: a broken residual outline.""" J = skeleton(pose) rr = np.random.RandomState(ec.seed+77) for a, b in (("hipL", "kneeL"), ("kneeL", "ankleL"), ("hipR", "kneeR"), ("kneeR", "ankleR"), ("pelvis", "thorax"), ("thorax", "shL"), ("thorax", "shR"), ("shL", "elbL"), ("elbL", "wriL"), ("shR", "elbR"), ("elbR", "wriR"), ("neck", "skull")): p0 = ec.to_page(*J[a]); p1 = ec.to_page(*J[b]) n = 14 us = np.linspace(0, 1, n) xs = p0[0] + (p1[0]-p0[0])*us ys = p0[1] + (p1[1]-p0[1])*us keep = rr.rand(n) < (0.62*alpha) for i0, i1 in runs(keep): if i1-i0 > 1: contour(P, cam, xs[i0:i1], ys[i0:i1], .7*ec.sc, .5*ec.sc) # ════════════════════════════════════════════════════════════════════════════ # PAPER + POST # ════════════════════════════════════════════════════════════════════════════ _PAPER = {} def paper(): if "p" in _PAPER: return _PAPER["p"] rng = np.random.RandomState(1543) yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) base = np.full((H, W), 1.0, np.float32) laid = 0.020*np.sin(yy*(2.05/SCL)) # laid lines chain = 0.016*(np.abs(((xx % PXf(62))-PXf(31))) < PXf(1.2)) # chain lines fib = rng.rand(HB, WB).astype(np.float32) fib = np.asarray(Image.fromarray((fib*255).astype(np.uint8)) .resize((W, H), Image.NEAREST) .filter(ImageFilter.GaussianBlur(PXf(0.7))), np.float32)/255.0 blob = rng.rand(HB//24+1, WB//24+1).astype(np.float32) blob = np.asarray(Image.fromarray((blob*255).astype(np.uint8)) .resize((W, H), Image.BICUBIC), np.float32)/255.0 v = base + laid - chain + (fib-0.5)*0.055 + (blob-0.5)*0.10 # foxing fox = np.zeros((H, W), np.float32) for _ in range(22): cx, cy = rng.randint(0, W), rng.randint(0, H) r = PXi(rng.randint(6, 26)) y0, y1 = max(0, cy-r), min(H, cy+r); x0, x1 = max(0, cx-r), min(W, cx+r) gy, gx = np.mgrid[y0:y1, x0:x1] d = ((gx-cx)**2 + (gy-cy)**2)/(r*r+1e-6) fox[y0:y1, x0:x1] += np.clip(1-d, 0, 1)*rng.uniform(.05, .13) v = np.clip(v - fox*0.55, 0.55, 1.15) PAPER_HI = np.array([236, 224, 196], np.float32) PAPER_LO = np.array([196, 178, 140], np.float32) arr = PAPER_LO[None, None, :] + (PAPER_HI-PAPER_LO)[None, None, :]*v[..., None] arr = np.clip(arr - fox[..., None]*np.array([18, 30, 52], np.float32), 0, 255) _PAPER["p"] = arr.astype(np.float32) return _PAPER["p"] _WEAR = {} def wear(): if "w" in _WEAR: return _WEAR["w"] rng = np.random.RandomState(1555) g = rng.rand(HB//34+2, WB//34+2).astype(np.float32) g = np.asarray(Image.fromarray((g*255).astype(np.uint8)) .resize((W, H), Image.BICUBIC), np.float32)/255.0 w = 0.80 + 0.22*g _WEAR["w"] = np.clip(w, 0, 1.05)[..., None] return _WEAR["w"] _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.34*r**2.1, 0, 1)[..., None] return _VIG["v"] INK_COL = np.array([32, 24, 19], np.float32) TITLE_T0, TITLE_T1 = 0.45, 3.30 def compose(P, i, e): # ── title flash. Bitten into the copper like everything else in this # film — Georgia, letterspaced, in the plate's own heading position # above the landscape, so it reads as part of the print. ─────────── t = i/FPS if TITLE_T0 <= t < TITLE_T1: al = (min(1.0, (t-TITLE_T0)/0.30) * min(1.0, (TITLE_T1-t)/0.55)) tc = Cam(560, 360, 1.0) ink = int(round(255*al)) X, Y = tc.p1(560, 74) letterpress(P, "VESALIVS DANCE · PLAYER COMPUTER", X, Y, int(23*tc.z)*SS, bold=True, center=True, track=2.4*tc.z*SS, fill=ink) cov = P.coverage() cov = np.clip(cov, 0, 1) pap = paper() ink = cov[..., None]*wear() out = pap*(1-ink) + INK_COL[None, None, :]*ink return out def post(arr, i, shot, e): a = np.asarray(arr, np.float32) # 1. tint — warm the paper, cool nothing; a printer's sepia a = a*np.array([1.020, 0.996, 0.958], np.float32)[None, None, :] a += np.array([4.0, 1.0, -3.0], np.float32)[None, None, :] # 2. vignette a = a*vignette() # 3. grain rng = np.random.RandomState(7100+i) if SCL == 1.0: a = a + rng.normal(0, 2.4, a.shape) else: # grain is a look, not a resolution: authored at 1280x720 and blown up # nearest-neighbour so a speck covers the same fraction of the frame. gn = rng.normal(0, 2.4, (HB, WB, 3))*8.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a = a + (np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0 im = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) # 4. letterbox d = ImageDraw.Draw(im) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(15, 12, 10)) d.rectangle([0, H-bh, W, H], fill=(15, 12, 10)) return im # ════════════════════════════════════════════════════════════════════════════ # ENGINES — every one of them re-engraves the plate from scratch # ════════════════════════════════════════════════════════════════════════════ NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2, "horn": .2} # where the cast stands on the page, and at what scale def _stand(sc): return GROUND - 90.0*sc # ankle lands on the ground line CAST = [ dict(x=552, sc=1.86, seed=101, flip=False, style=0, phase=0.00), dict(x=286, sc=1.62, seed=202, flip=True, style=1, phase=0.27), dict(x=826, sc=1.60, seed=303, flip=False, style=2, phase=0.53), dict(x=124, sc=1.22, seed=404, flip=False, style=1, phase=0.78), dict(x=986, sc=1.26, seed=505, flip=True, style=0, phase=0.14), ] for _c in CAST: _c["y"] = _stand(_c["sc"]) def cast_at(i): c = CAST[i % len(CAST)] return Ecorche(c["x"], c["y"], c["sc"], seed=c["seed"], flip=c["flip"]), c def full_cam(): return Cam(560, 348, 1.0) class Stage: """The plate, seen whole or nearly whole, with N figures on it. modes: still / twitch / dance / march""" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params self.n = self.p.get("n", 1) self.mode = self.p.get("mode", "still") self.zoom = self.p.get("zoom", 1.0) self.pan = self.p.get("pan", 0.0) def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() z = self.zoom*(1.0 + self.p.get("push", 0.0)*u) cam = Cam(560 + self.pan*(u-0.5)*2*self.p.get("pandist", 0.0), 348 + self.p.get("cy", 0.0), z) draw_plate_mark(P, cam) beat_amp = 1.0 if self.mode in ("dance", "march") else 0.0 landscape(P, cam, t, e, beat_amp=beat_amp*e.get("horn", 0), seed=5) for q in range(self.n): ec, c = cast_at(q) if self.mode == "march": span = 380.0 ec.x = ((c["x"] + t*88.0 + q*140) % (span*2.6)) + 150 if self.mode == "still": pose = contrapposto(t, 0.35) elif self.mode == "twitch": pose = twitch_pose(t, self.p.get("kind", 0), 1.0 if q == 0 else 0.0) else: pose = dance_pose(t, c["style"], e, self.p.get("amp", 1.0), c["phase"]) ec.draw(P, cam, pose, dens=self.p.get("dens", 1.0), keys=self.p.get("keys")) draw_caption(P, cam, t=t) return compose(P, self.s.i0+k, e) class Detail: """A magnified crop that TRACKS the anatomy: the region is resolved from the live skeleton, so the shot stays on the muscle while it works. The lines get thicker with the zoom, which is what happens when you put a loupe on a real engraving.""" # region -> (jointA, jointB, t along, zoom, follow weight, screen offset) REGION = dict( thigh=("hipR", "kneeR", .50, 6.0, .72, (-40, 0)), calf =("kneeR", "ankleR", .34, 6.8, .72, (-40, 20)), arm =("shR", "elbR", .42, 6.4, .72, (-70, 40)), torso=("lumbar","thorax", .45, 4.2, .58, (0, 10)), skull=("neck", "skull", 1.0, 6.2, .82, (-40, 70)), foot =("kneeR", "ankleR", .92, 5.4, .80, (-150, 40)), hand =("elbR", "wriR", .88, 5.4, .84, (-170, 60)), hatch=("hipR", "kneeR", .55, 11.0, .60, (0, 0)), leg =("hipR", "ankleR", .50, 3.4, .60, (-30, 0)), ) def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params self.reg = self.p.get("reg", "thigh") def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() drift = self.p.get("drift", 5.0) if self.reg == "keys": z = 2.05*(1.0 + self.p.get("push", 0.0)*u) cam = Cam(560 + math.sin(t*0.5)*drift, 566 + math.cos(t*0.4)*drift, z) landscape(P, cam, t, e, beat_amp=0.0, seed=5) draw_plate_mark(P, cam) draw_caption(P, cam, extra=self.p.get("extra"), t=t) return compose(P, self.s.i0+k, e) ec, c = cast_at(0) mv = self.p.get("move", "dance") if mv == "still": pose = contrapposto(t, .35) elif mv == "twitch": pose = twitch_pose(t, self.p.get("kind", 0), 1.0) else: pose = dance_pose(t, c["style"], e, self.p.get("amp", 1.0), 0.0) ja, jb, tt, z0, fw, off = self.REGION[self.reg] Jl = skeleton(pose); Jr = skeleton(REST) def mid(JJ): return (JJ[ja][0] + (JJ[jb][0]-JJ[ja][0])*tt, JJ[ja][1] + (JJ[jb][1]-JJ[ja][1])*tt) mr, ml = mid(Jr), mid(Jl) bx = mr[0]*(1-fw) + ml[0]*fw by = mr[1]*(1-fw) + ml[1]*fw px, py = ec.to_page(bx, by) z = z0*(1.0 + self.p.get("push", 0.0)*u) cam = Cam(px + off[0]/z + math.sin(t*0.55)*drift, py + off[1]/z + math.cos(t*0.4)*drift*.6, z) landscape(P, cam, t, e, beat_amp=0.0, seed=5) ec.draw(P, cam, pose, dens=self.p.get("dens", 1.15), keys=self.p.get("keys")) draw_plate_mark(P, cam) return compose(P, self.s.i0+k, e) class Peel: """The figure lifts off the page, leaving a ghost where it was engraved.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() z = 1.12*(1 + .08*u) cam = Cam(548, 322, z) draw_plate_mark(P, cam) landscape(P, cam, t, e, beat_amp=0.0, seed=5) ec, c = cast_at(0) rise = self.p.get("rise", 1.0) lift = (u**1.4)*rise ghost_pose = contrapposto(t, .2) draw_ghost(P, cam, ec, ghost_pose, alpha=min(1.0, .35 + lift*1.6)) ec2 = Ecorche(ec.x + 30*lift, ec.y - 44*lift, ec.sc*(1+.09*lift), seed=ec.seed) pose = blend_pose(ghost_pose, dance_pose(t, 0, e, 1.0, 0.0), min(1.0, lift*1.35)) ec2.draw(P, cam, pose, dens=1.05) draw_caption(P, cam, t=t) return compose(P, self.s.i0+k, e) class Ruins: """The landscape alone, keeping time, with the cast small and far.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() cam = Cam(560 + 200*u, 344, 1.70) landscape(P, cam, t, e, beat_amp=1.25*max(.35, e.get("horn", 0)), seed=5) for q in (2, 4, 0): ec, c = cast_at(q) ec.sc = c["sc"]*0.62 ec.y = _stand(ec.sc) ec.draw(P, cam, dance_pose(t, c["style"], e, 1.0, c["phase"]), dens=.80) draw_plate_mark(P, cam) return compose(P, self.s.i0+k, e) class Spin: """One figure turns. The rig's flip plus a squeeze on the scale re-lays every hatch line, which is the whole point of hatching the form.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() cam = Cam(560, 356, 1.55) landscape(P, cam, t, e, beat_amp=.6, seed=5) ang = t*1.9 sq = math.cos(ang) ec, c = cast_at(0) ec.x = 560; ec.y = 464 ec.flip = sq < 0 ec.sc = c["sc"]*(0.30 + 0.70*abs(sq)) ec.draw(P, cam, dance_pose(t, 0, e, 1.0, 0.0), dens=1.0) for q in (2, 4): ec2, c2 = cast_at(q) ec2.sc = c2["sc"]*.8 ec2.draw(P, cam, dance_pose(t, c2["style"], e, .9, c2["phase"]), dens=.7) draw_plate_mark(P, cam) return compose(P, self.s.i0+k, e) class Keys: """A letterpress shout: the caption becomes the response.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() cam = Cam(560, 360, 1.0) word = self.p.get("word", "MVSCVLVS") sub = self.p.get("sub", "") ph = (t/BEAT) % 1.0 pop = 1.0 + 0.055*max(0.0, 1-ph*3.0)**1.5 + 0.05*e.get("horn", 0) draw_plate_mark(P, cam) # a hatched ground so the page is never empty white xs = np.linspace(PLATE_BOX[0]+8, PLATE_BOX[2]-8, 110) hatch_terrain(P, cam, xs, np.full_like(xs, 90.0), 430, sp=11.0, tone=.26, seed=61, wob=2.6, breaks=0.36) plate_rule(P, cam, 150, 690, 168, 1.4) X, Y = cam.p1(420, 196) letterpress(P, word, X, Y, int(PXf(54*pop))*SS, bold=True, center=True, track=PXf(2.6)*SS) plate_rule(P, cam, 150, 690, 286, 2.4) if sub: X, Y = cam.p1(420, 300) letterpress(P, sub, X, Y, int(PXf(21*pop))*SS, italic=True, center=True) # the keyed figure, standing beside the type ec, c = cast_at(self.p.get("who", 0)) ec.sc = 1.46; ec.x = 858; ec.y = _stand(ec.sc) ec.flip = True ec.draw(P, cam, dance_pose(t, c["style"], e, 1.0, c["phase"]), dens=.95) contour(P, cam, np.array([700, 1010]), np.array([GROUND, GROUND]), 1.4, 1.4, taper_ends=True) draw_caption(P, cam, t=t) return compose(P, self.s.i0+k, e) class Finale: """The hit. Everyone slams into the engraved pose; the ghosts refill; the page becomes an ordinary anatomy plate again. Then the hand waves.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params self.mode = self.p.get("mode", "snap") def frame(self, k, u, e): t = (self.s.i0+k)/FPS P = Plate() z = 1.0 if self.mode == "snap": z = 1.10 - 0.10*min(1.0, (t-HIT_T)/0.55) if t > HIT_T else 1.10 cam = Cam(560, 348, z) draw_plate_mark(P, cam) landscape(P, cam, t, e, beat_amp=0.0, seed=5) snap = np.clip((t-HIT_T)/0.42, 0, 1) snap = 1-(1-snap)**3 for q in range(5): ec, c = cast_at(q) rest = contrapposto(t, .25) if self.mode == "snap": dp = dance_pose(min(t, HIT_T), c["style"], e, 1.0, c["phase"]) pose = blend_pose(dp, rest, snap) elif self.mode == "hold": pose = rest else: pose = wave_pose(t) if q == 2 else rest ec.draw(P, cam, pose, dens=1.0) extra = [("D", "MANVS SALVTANS")] if self.mode == "wave" else None draw_caption(P, cam, extra=extra, t=t) return compose(P, self.s.i0+k, e) ENGINES = {"stage": Stage, "detail": Detail, "peel": Peel, "ruins": Ruins, "spin": Spin, "keys": Keys, "finale": Finale} # ════════════════════════════════════════════════════════════════════════════ # THE CUT — an explicit score, in beats # ════════════════════════════════════════════════════════════════════════════ KEYS_R = {"A": (30, -26), "B": (26, 20), "C": (-30, 8)} SHOTPLAN = [ # beats, engine, params (8, "stage", dict(mode="still", n=1, zoom=1.0, dens=1.0)), (4, "detail", dict(reg="hatch", move="still", dens=1.3, push=.14)), (4, "detail", dict(reg="keys", drift=3.0, push=.10)), (4, "stage", dict(mode="twitch", n=1, kind=0, zoom=1.06)), (3, "detail", dict(reg="foot", move="twitch", kind=0, dens=1.2)), (5, "stage", dict(mode="twitch", n=1, kind=1, zoom=1.04, push=.06)), (4, "detail", dict(reg="hand", move="twitch", kind=2, dens=1.2)), (6, "peel", dict(rise=0.55)), (6, "peel", dict(rise=1.0)), (6, "stage", dict(mode="dance", n=1, zoom=1.18, cy=20, amp=.85)), (3, "detail", dict(reg="torso", dens=1.15)), (3, "stage", dict(mode="dance", n=1, zoom=1.05, cy=10)), # horns (4, "stage", dict(mode="dance", n=2, zoom=1.02)), (4, "ruins", dict()), (4, "stage", dict(mode="dance", n=3, zoom=1.0)), (2, "detail", dict(reg="leg", dens=1.1, push=.12)), (3, "stage", dict(mode="dance", n=3, zoom=1.06, pandist=90, pan=1.0)), (3, "spin", dict()), # call & response (2, "keys", dict(word="DELTOIDEVS", sub="musculus humeri", who=1)), (2, "detail", dict(reg="arm", dens=1.2, keys={"A": KEYS_R["A"]})), (2, "keys", dict(word="GASTROCNEMIVS", sub="musculus surae", who=2)), (2, "detail", dict(reg="calf", dens=1.2, keys={"B": KEYS_R["B"]})), (2, "keys", dict(word="SARTORIVS", sub="musculus femoris", who=3)), (2, "detail", dict(reg="thigh", dens=1.2, keys={"C": KEYS_R["C"]})), (4, "stage", dict(mode="dance", n=5, zoom=0.98)), # peak (4, "stage", dict(mode="dance", n=5, zoom=1.04, push=.06)), (4, "stage", dict(mode="march", n=4, zoom=1.0)), (3, "ruins", dict()), (3, "stage", dict(mode="dance", n=5, zoom=1.10, cy=16)), (2, "spin", dict()), (2, "detail", dict(reg="skull", dens=1.25, push=.10)), (3, "stage", dict(mode="march", n=4, zoom=1.06, cy=10)), (3, "stage", dict(mode="dance", n=5, zoom=1.0)), # land (3, "finale", dict(mode="snap")), (3, "finale", dict(mode="hold")), (2, "finale", dict(mode="wave")), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "params") def __init__(self, idx, i0, i1, engine, section, params): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.section = engine, section self.seed = 15430 + idx*7919 self.params = params def build_shots(): total_beats = sum(b for b, _, _ in SHOTPLAN) want = N_BARS*4 if total_beats != want: raise SystemExit(f"shot plan is {total_beats} beats, want {want}") shots = []; b = 0.0 for idx, (nb, eng, prm) in enumerate(SHOTPLAN): t0, t1 = b*BEAT, (b+nb)*BEAT i0, i1 = int(round(t0*FPS)), int(round(t1*FPS)) sec = sec_of(int(b//4)) shots.append(Shot(idx, i0, i1, eng, sec, prm)) b += nb shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() rng = np.random.RandomState(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 # engines are stateless: no sync trap e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) post(arr, i, shot, e).save(p, compress_level=1) made += 1 return (f"shot {shot.idx:02d} {shot.engine:7s} {shot.section:8s} " f"{made}/{shot.n}") def sheet_frame(sh): E = env() rng = np.random.RandomState(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(mid, mid/max(1, sh.n-1), e) return sh, post(arr, i, sh, e) def contact_sheet(shots, jobs_n): import multiprocessing as mp cols = 6; rows = (len(shots)+cols-1)//cols tw, th = PXi(320), PXi(180) lab = PXi(26) sheet = Image.new("RGB", (cols*tw, rows*(th+lab)), (12, 11, 10)) sd = ImageDraw.Draw(sheet) with mp.get_context("fork").Pool(jobs_n) as pool: res = pool.map(sheet_frame, shots) for sh, im in sorted(res, key=lambda r: r[0].idx): n = sh.idx cx, cy = (n % cols)*tw, (n//cols)*(th+lab) sheet.paste(im.resize((tw, th), Image.LANCZOS), (cx, cy)) lbl = (f"{sh.idx:02d} {sh.engine}" f"{'/'+str(sh.params.get('mode') or sh.params.get('reg') or sh.params.get('word',''))[:10] if (sh.params.get('mode') or sh.params.get('reg') or sh.params.get('word')) else ''}" f" · {sh.section} · {sh.i0/FPS:.1f}s") sd.text((cx+PXi(5), cy+th+PXi(5)), lbl, font=_load_font(_find_font("Menlo.ttc"), PXi(12)), fill=(198, 190, 172)) 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() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists(): 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, a.jobs); 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) if sel: print("partial render — rerun with --mux-only to reassemble") return missing = [i for i in range(N_FRAMES) if not (FRAMES/f"f{i:05d}.png").exists()] if missing: raise SystemExit(f"{len(missing)} frames missing, first={missing[0]}") print("[3/3] mux…") gen = 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"artist=poop / {gen}", "-metadata", f"description=generator: {gen}", "-metadata", f"comment=generator: {gen} | {MUSIC_DESC} | {ENGINE_DESC}", "-metadata", f"date={datetime.date.today().isoformat()}", str(out)], check=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: {gen}\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"shots: {len(shots)}\n" f"engines: {ENGINE_DESC} (shot-parallel, stateless per frame)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()