#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Ea-nāṣir (28/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/ea_nasir # # The oldest customer complaint in the world, 1750 BC, bad copper, performed as a drill diss track. # # 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/ea_nasir.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/ea_nasir.mp4 # cover: https://genekogan.com/player_computer/media/ea_nasir.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 ea_nasir.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 10) — "EA-NĀṢIR" UK drill, 140bpm, F# minor. 36 bars. Rapped, subtitled. cold(4) v1(8) hook1(4) v2(8) hook2(4) bake(4) land(4) Around 1750 BC a merchant in Ur named Nanni sent a clay tablet to a copper dealer called Ea-nāṣir. He had sent his servant with the money; he was shown inferior ingots and told to take them or leave them; his messenger was sent back empty-handed through hostile territory; he wanted every shekel back and he wanted the whole Persian Gulf to know about it. The tablet is in the British Museum. It is the oldest known customer complaint. Performed here as a diss track, because that is what it is. THE CONCEIT / THE NEW SUBSTRATE: every frame of this film is a slab of clay. Not a picture of a tablet — a height field. `Field` builds a pillow-shaped clay surface in tablet coordinates (superellipse dome, fBm grog texture, thumbprints rolled into the rim); `stamp()` presses triangular styluses into it, each impression a tapering hollow with clay displaced up into a raised lip around its rim; `shade()` lights the whole thing with a single raking light eleven degrees off the surface, marches a real shadow ray across the height field so every wedge throws its own hard shadow, and grades the result from wet grey-brown to sun-baked ochre as the piece dries out. Nothing is drawn. Everything is pressed. The four cuneiform stroke types — vertical, horizontal, oblique, Winkelhaken — are wedge parameters. Signs are wedge clusters. One wedge lands per 16th note for the whole record: the stylus is the hi-hat. Composition: engine : audio-first x shot-parallel (tier 4-P) — clay accumulates within a shot and is thrown away at every cut; camera push/shake happen in image space so the field is built once per shot content: audio-groove (gliding 808 with continuous phase, triplet hat rolls, rimshot on the 3, dark FM bell) x tts-voices (Rocko en_GB as Nanni, Reed en_GB as Ea-nāṣir, Daniel as the museum) x effects-post (tint -> vignette -> grain -> letterbox) Run from repo root: python3 renders/player_computer_final/ea_nasir/render.py --sheet python3 renders/player_computer_final/ea_nasir/render.py python3 renders/player_computer_final/ea_nasir/render.py --shots 7,8 --force python3 renders/player_computer_final/ea_nasir/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 NAME = "ea_nasir" TITLE = "EA-NĀṢIR" SETDIR = "player_computer_final" SETNUM = "10" W, H, FPS = 1920, 1080, 30 # ── delivery scale ─────────────────────────────────────────────────────────── # FINAL CUT: native 1920x1080. The whole picture is a height field at delivery # resolution, so the port is a uniform 3-D scaling: every lateral distance AND # every clay depth is multiplied by SCL = H/720. Surface gradients — and hence # the raking light, the lips and the cast shadows — come out identical, which # is why the shadow march runs SCL times as many steps and the two places that # read a HEIGHT DIFFERENCE (the cast-shadow occlusion and the ambient term) # divide it back out by SCL. Nothing is upscaled after the fact. WB, HB = 1280, 720 # the authoring frame SCL = H/720.0 def PXi(v): return max(1, int(round(v*SCL))) # unit -> real pixel (>=1) def PXf(v): return v*SCL BPM = 140.0 BEAT = 60.0 / BPM BAR = 4 * BEAT STEP = BEAT / 4 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" SECTIONS = [ ("cold", 0, 4), ("v1", 4, 12), ("hook1", 12, 16), ("v2", 16, 24), ("hook2", 24, 28), ("bake", 28, 32), ("land", 32, 36), ] N_BARS = SECTIONS[-1][2] TAIL = 2.6 DUR = N_BARS * BAR + TAIL N_FRAMES = int(DUR * FPS) MUSIC_DESC = (f"UK drill, {BPM:.0f}bpm, F# minor, {N_BARS} bars — gliding 808 " "(continuous phase), triplet hat rolls, rimshot on the 3, dark FM bell") ENGINE_DESC = ("cuneiform impression: clay height field, wedge stamping with " "displaced-clay lip, raking-light shadow march " "(slab / macro / stylus / emboss / ingot / rim / fire / column / vitrine)") # ════════════════════════════════════════════════════════════════════════════ # PITCH # ════════════════════════════════════════════════════════════════════════════ 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]]) # ════════════════════════════════════════════════════════════════════════════ # SYNTH PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ def adsr(n, a, d, s, r): e = np.zeros(n, np.float64) 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) def fm(freq, dur, ratio=2.0, index=4.0, idec=6.0, a=.002, d=.4, s=.0, r=.2): n = int(dur*SR); t = np.arange(n)/SR mod = np.sin(2*np.pi*freq*ratio*t) * index * np.exp(-t*idec) return np.sin(2*np.pi*freq*t + mod) * adsr(n, a, d, s, r) def dkick(dur=.26, f0=210, f1=44, punch=44, click=.55, seed=1): """Drill kick: short, hard, mostly transient — the 808 carries the weight.""" n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*punch) body = np.sin(2*np.pi*np.cumsum(f)/SR) * np.exp(-t*17) ck = bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=6000) \ * np.exp(-t*260) * click return np.tanh((body + ck) * 2.1) * .82 def rimshot(dur=.13, seed=2): """The 3. Dry, woody, cracks.""" n = int(dur*SR); t = np.arange(n)/SR tone = (np.sin(2*np.pi*1690*t) + .7*np.sin(2*np.pi*2530*t) + .4*np.sin(2*np.pi*410*t)) nz = bandshape(np.random.RandomState(seed).randn(n), lo=1800, hi=8800) return (tone*np.exp(-t*64)*.55 + nz*np.exp(-t*90)*.45) def dsnare(dur=.20, seed=3): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=420, hi=7200) body = np.sin(2*np.pi*205*t)*np.exp(-t*38) return nz*np.exp(-t*24)*.60 + body*.30 def dhat(dur=.036, openh=False, tone=1.0, seed=7): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=6200*tone, hi=13500*tone) return nz * np.exp(-t*(16 if openh else 120)) * .40 def clayknock(dur=.075, pitch=1.0, seed=11): """The stylus meeting wet clay. Doubles the 16th grid at low level so the writing is audible as percussion, not just visible.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=380*pitch, hi=2600*pitch) thud = np.sin(2*np.pi*168*pitch*t)*np.exp(-t*90) return (nz*np.exp(-t*74)*.7 + thud*.5) * .5 def crackle(dur=1.4, seed=13): """Fired clay ticking as it cools / a tablet breaking.""" rng = np.random.RandomState(seed) n = int(dur*SR); out = np.zeros(n) for i in rng.choice(n, size=max(2, n//1500), replace=False): L = min(n-i, 900) tt = np.arange(L)/SR out[i:i+L] += (np.sin(2*np.pi*rng.uniform(900, 4200)*tt) * np.exp(-tt*180) * rng.uniform(.2, 1.0)) return bandshape(out, lo=600, hi=9000) * .55 def sandwind(dur, seed=17): """Pitched, evolving desert air — never a static hiss.""" rng = np.random.RandomState(seed) n = int(dur*SR); out = np.zeros(n); blk = 4096 for i in range(0, n, blk): u = i/max(1, n) fc = 420 + 300*math.sin(u*7.3) + 240*math.sin(u*2.1) seg = rng.randn(min(blk, n-i)+512) out[i:i+min(blk, n-i)] = bandshape(seg, lo=fc*.55, hi=fc*2.4)[:min(blk, n-i)] return out * .30 def sub808(events, n): """A single continuously-phased sub voice for the whole record. `events` = [(t_start, freq, dur, glide_seconds), …]. The frequency track is piecewise constant and then *smoothed per segment* so pitch moves in a real portamento between notes instead of stepping — that glide is the entire point of drill 808s, and you only get it by keeping one phase accumulator across the whole track rather than synthesising per-note. """ f = np.full(n, events[0][1] if events else 60.0, np.float64) amp = np.zeros(n, np.float64) for (t0, fr, dur, gl) in events: i0 = int(t0*SR); i1 = min(n, int((t0+dur)*SR)) if i0 >= n: continue f[i0:] = fr gn = max(2, int(gl*SR)) j1 = min(n, i0+gn) if j1 > i0: # smooth the leading edge prev = f[i0-1] if i0 > 0 else fr f[i0:j1] = prev + (fr-prev)*np.linspace(0, 1, j1-i0)**0.55 ln = i1-i0 if ln > 8: env = adsr(ln, .004, dur*0.92, .10, min(.14, dur*0.3)) amp[i0:i1] = np.maximum(amp[i0:i1], env) ph = 2*np.pi*np.cumsum(f)/SR core = np.sin(ph) # a touch of the octave and the fifth so it survives small speakers body = core + .22*np.sin(2*ph) + .09*np.sin(3*ph) y = np.tanh(body*amp*2.6) * .95 return bandshape(y, lo=26, hi=1900) def reverb(x, rt=1.3, mix=.28, seed=29, pre=0.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=.40, mix=.24, taps=8): 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 BOARD — a multitrack canvas on an absolute bar/step grid # ════════════════════════════════════════════════════════════════════════════ class Board: def __init__(self, dur): self.n = int(dur*SR) self.tr = {} self.kick_t = [] def t(self, bar, step=0.0): return bar*BAR + step*STEP def put(self, track, sig, at, g=1.0, pan=0.0): b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR); j = min(self.n, i+len(sig)) if i >= self.n or j <= i: return th = (pan*.5+.5) * (np.pi/2) b[i:j] += np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1) * g def whole(self, track, sig, g=1.0): b = self.tr.setdefault(track, np.zeros((self.n, 2))) m = min(self.n, len(sig)) b[:m, 0] += sig[:m]*g; b[:m, 1] += sig[:m]*g 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.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[min(self.n-1, 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=.34, 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: # sidechain pump env = np.ones(self.n); rl = int(pump_rel*SR) shape = 1 - pump_depth*np.exp(-np.arange(rl)/(pump_rel*SR/4)) for at in self.kick_t: i = int(at*SR); j = min(self.n, i+rl) if i < self.n: env[i:j] = np.minimum(env[i:j], shape[:j-i]) env = np.convolve(env, np.ones(256)/256, "same") mix *= env[:, None] mix = np.tanh(mix*1.30)/np.tanh(1.30) return mix / (np.max(np.abs(mix))+1e-9) * .94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1)*32767).astype(" macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, voice, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, voice, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def speak(text, voice, rate=180, pitch=1.0, cache=None, maxdur=None, drive=0.0): """One spoken line, optionally pitched down (drill vocals sit low).""" key = cache/("say_"+_h(text, voice, rate)+".wav") x = say_wav(text, voice, rate, key) if pitch != 1.0: x = fit(x, max(2, int(len(x)/pitch))) if maxdur and len(x) > int(maxdur*SR): x = fit(x, int(maxdur*SR)) x = x/(np.max(np.abs(x))+1e-9) if drive: x = np.tanh(x*(1+drive*4))/np.tanh(1+drive*4) return x # ════════════════════════════════════════════════════════════════════════════ # THE WORDS # # Nanni's letter, compressed into bars. The substance is the real substance: # the servant sent with the money, the inferior ingots, "take it or leave it", # the messenger sent back empty-handed through hostile country, the demand for # full repayment, and the promise to inspect every ingot personally from now # on. The Dilmun / Telmun line is his. # ════════════════════════════════════════════════════════════════════════════ # On-screen lettering is Impact (no ṣ / ā in that face — verified), so every # subtitle string is ASCII. The diacritics survive in the museum label, which # is set in Georgia, and in the spoken text, which `say` handles. COLD = [ ("ana Ea-nasir qibima. umma Nanni-ma.", "SPEAK TO EA-NASIR. THUS SAYS NANNI."), ] V1 = [ "You said fine copper. Fine was not the word.", "I sent my man with silver — every shekel, as agreed.", "He stood there in your yard, in the land of Telmun.", "You showed him ugly ingots. Said: take it or leave it.", "Who do you think I am? Who do you take me for?", "You sent my man back empty. Through enemy country. Alone.", "You have treated me with contempt, and you did it twice.", "I do not take that. Not from you. Not from anyone.", ] V2 = [ "Send my money back. All of it. Full weight.", "I will not accept one broken ingot at my gate.", "From this day I inspect them in my own yard.", "One by one. Each ingot. I will pick them out myself.", "Who among the traders of Dilmun has done this to me?", "Not one of them. Only you. You alone.", "You alone hold my money in the enemy's land.", "So everybody hear it: this is how Ea-nasir deals.", ] HOOK = [("Ea-nasir.", "EA-NASIR."), ("Bad copper.", "BAD COPPER."), ("Send it back.", "SEND IT BACK."), ("Full weight.", "FULL WEIGHT.")] HOOK2 = [("Ea-nasir.", "EA-NASIR."), ("One by one.", "ONE BY ONE."), ("Every ingot.", "EVERY INGOT."), ("Full weight.", "FULL WEIGHT.")] EANASIR = [ ("Take it, or leave it.", "TAKE IT, OR LEAVE IT."), ] LAND = [ "Complaint tablet. Ur. About seventeen fifty B.C.", "Clay. Ten centimetres. British Museum, room fifty six.", "It is the oldest known customer complaint.", "It survived three thousand seven hundred and fifty years.", "The copper was still bad.", ] # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ # 808 roots, one per bar: i - VI - VII - iv, F# natural minor ROOTS = ["F#1", "D1", "E1", "B1"] # a menacing bell figure — F# minor with the flat second leaning on it BELL = [(0, "F#5"), (6, "C#5"), (10, "G4"), (14, "F#4")] def eight08_pattern(bar, R): """Per-bar 808 events. Drill 808s do not sit on the grid; they enter late, glide up into the next root, and hold through the bar.""" root = nf(ROOTS[bar % 4]); nxt = nf(ROOTS[(bar+1) % 4]) pats = [ [(0.0, root, 1.55, .012), (10.0, root*2**(-2/12), .95, .085)], [(0.0, root, 1.05, .012), (6.0, root*2**(3/12), .55, .075), (10.0, root, .95, .090)], [(0.0, root, 1.85, .012), (12.0, nxt, .70, .130)], [(2.0, root, 1.20, .055), (9.0, root*2**(5/12), .45, .070), (11.0, root, 1.05, .095)], ] return pats[R.randint(len(pats))] def build_song(): b = Board(DUR) R = np.random.RandomState(1750) sec_of = lambda bar: next((n for n, a, c in SECTIONS if a <= bar < c), "land") sub_ev = [] stamp_times = [] # every 16th that gets a wedge — picture reads this for bar in range(N_BARS): sec = sec_of(bar) quiet = sec == "cold" empty = (sec == "bake" and bar < 30) # the beat drops out to fire land = sec == "land" # ---- kick: syncopated, never four-on-the-floor ---------------------- if not empty: kp = [(0, 12), (0, 7, 12), (0, 10), (0, 6, 11)][bar % 4] for st in kp: if quiet and st: continue at = b.t(bar, st) b.put("dr", dkick(seed=31+bar*3+st), at, g=.90 if not quiet else .5) b.kick_t.append(at) # ---- the 3: rimshot, plus a wide snare on section ends -------------- if not empty: b.put("dr", rimshot(seed=41+bar), b.t(bar, 8), g=.72 if not quiet else .42, pan=.06) if bar % 8 == 7 and not land: b.put("dr", dsnare(seed=51+bar), b.t(bar, 8), g=.38, pan=-.05) b.put("dr", dsnare(seed=52+bar), b.t(bar, 14), g=.28, pan=.10) # ---- hats: 16ths, triplet skitters, 32nd rolls ----------------------- if not empty: for st in range(16): if quiet and st % 4: continue g = .17 + .05*R.rand() + (.06 if st % 4 == 0 else 0) b.put("hat", dhat(tone=.95+.12*R.rand(), seed=61+bar*17+st), b.t(bar, st), g=g, pan=-.28+.56*R.rand()) if not quiet: for st in ([3, 11] if bar % 2 == 0 else [7]): # triplet skitter for k in range(3): b.put("hat", dhat(dur=.028, seed=201+bar*7+st+k), b.t(bar, st + k*(1/3.0)), g=.15, pan=.3-.2*k) if bar % 4 == 3: # 32nd roll out for k in range(6): b.put("hat", dhat(dur=.024, seed=301+bar+k), b.t(bar, 13 + k*0.5), g=.10+.028*k, pan=-.2+.07*k) if bar % 8 in (3, 7): b.put("hat", dhat(dur=.30, openh=True, seed=401+bar), b.t(bar, 14), g=.16, pan=.22) # ---- 808 ------------------------------------------------------------- if not empty: for (st, fr, dur, gl) in eight08_pattern(bar, R): sub_ev.append((b.t(bar, st), fr, dur*BEAT, gl)) # ---- dark bell ------------------------------------------------------- if not empty and bar % 2 == 0: for st, note in BELL: if quiet and st not in (0, 10): continue f = nf(note) * (2**((0 if bar % 8 < 4 else -2)/12.0)) b.put("bell", fm(f, BEAT*1.5, ratio=3.01, index=5.5, idec=9.0, a=.002, d=.9, s=.05, r=.4), b.t(bar, st), g=.115 if not quiet else .085, pan=-.30 + .18*(st/6.0)) if land and bar % 2 == 0: # the landing keeps a bell for st, note in ((0, "F#4"), (8, "C#4")): b.put("bell", fm(nf(note), BEAT*2.4, ratio=2.0, index=3.0, idec=4.0, a=.004, d=1.6, s=.05, r=.8), b.t(bar, st), g=.13, pan=0.0) # ---- the stylus: one clay knock per 16th ----------------------------- for st in range(16): at = b.t(bar, st) stamp_times.append(at) if quiet and st % 2: continue b.put("clay", clayknock(pitch=.85+.35*R.rand(), seed=501+bar*19+st), at, g=(.15 if not empty else .30) * (1.5 if st % 4 == 0 else 1.0), pan=-.35+.70*R.rand()) b.whole("sub", sub808(sub_ev, b.n), g=1.0) b.whole("air", sandwind(DUR, seed=97), g=.55) for bar in (28, 29, 30): # the firing / the millennia b.put("fx", crackle(1.4, seed=131+bar), b.t(bar, 2), g=.55, pan=-.15) b.put("fx", crackle(2.0, seed=151), b.t(31, 8), g=.40, pan=.2) # ---- vocals ------------------------------------------------------------ SUBS = [] S0 = {n: a for n, a, c in SECTIONS} NANNI = "Rocko (English (UK))" MERCH = "Reed (English (UK))" MUSE = "Daniel" def bar_line(text, sub, bar, voice, rate, g, pitch=1.0, off=0.34, pan=0.0, maxdur=None, drive=0.25, hold=0.30): at = bar*BAR + off*BEAT sig = speak(text, voice, rate, pitch=pitch, cache=AUD, maxdur=maxdur or (BAR*1.02), drive=drive) b.put("vox", sig, at, g=g, pan=pan) SUBS.append((at - 0.10, at + len(sig)/SR + hold, sub)) return at for i, (line, sub) in enumerate(COLD): bar_line(line, sub, 1 + i*2, NANNI, 152, .60, pitch=0.94, off=0.6, maxdur=BAR*1.85, drive=.18, hold=.9) for i, line in enumerate(V1): bar_line(line, line.upper(), S0["v1"]+i, NANNI, 205, .58, pitch=0.955, pan=-.04) for i, line in enumerate(V2): bar_line(line, line.upper(), S0["v2"]+i, NANNI, 208, .58, pitch=0.955, pan=.04) # Ea-nāṣir answers once, in the middle of verse one — flat, bored for (line, sub) in EANASIR: at = (S0["v1"]+3)*BAR + 2.55*BEAT sig = speak(line, MERCH, 168, pitch=0.90, cache=AUD, maxdur=BAR*0.95, drive=.05) b.put("vox2", sig, at, g=.46, pan=.30) SUBS.append((at, at + len(sig)/SR + .25, '"' + sub + '"')) # hooks — chanted, doubled an octave down for hb, words in ((S0["hook1"], HOOK), (S0["hook2"], HOOK2)): for i, (text, sub) in enumerate(words): at = (hb+i)*BAR + 0.20*BEAT sig = speak(text, NANNI, 168, pitch=0.93, cache=AUD, maxdur=BAR*0.90, drive=.55) b.put("vox", sig, at, g=.66, pan=0.0) low = speak(text, NANNI, 168, pitch=0.62, cache=AUD, maxdur=BAR*0.92, drive=.35) b.put("vox", low, at + 0.012, g=.22, pan=0.0) SUBS.append((at-0.08, at + len(sig)/SR + .30, sub)) # the landing — the museum, unhurried, over the bake + land bars land_at = [30*BAR + 0.5*BEAT, 31*BAR + 1.0*BEAT, 32*BAR + 0.4*BEAT, 33*BAR + 0.4*BEAT, 34*BAR + 1.4*BEAT] for i, line in enumerate(LAND): at = land_at[i] sig = speak(line, MUSE, 152, cache=AUD, maxdur=BAR*1.75, drive=0.0) b.put("vox3", sig, at, g=.62, pan=0.0) SUBS.append((at-0.10, at + len(sig)/SR + .55, line.upper())) b.bus("bell", lambda x: reverb(delay(x, BEAT*.75, .42, .26), rt=2.3, mix=.36, seed=181)) b.bus("clay", lambda x: reverb(x, rt=0.85, mix=.20, seed=191)) b.bus("vox", lambda x: reverb(delay(x, BEAT*.5, .22, .11), rt=0.9, mix=.15, seed=193)) b.bus("vox2", lambda x: reverb(x, rt=1.6, mix=.30, seed=197)) b.bus("vox3", lambda x: reverb(x, rt=1.9, mix=.26, seed=199)) b.bus("fx", lambda x: reverb(x, rt=1.8, mix=.34, seed=211)) b.bus("air", lambda x: reverb(x, rt=2.4, mix=.40, seed=223)) mix = b.mixdown(dict(dr=1.0, hat=1.0, sub=1.05, bell=1.0, clay=1.0, vox=1.0, vox2=1.0, vox3=1.0, fx=1.0, air=1.0), pump_depth=.26, pump_rel=.12, levels=dict(cold=.55, v1=.92, hook1=1.0, v2=.94, hook2=1.0, bake=.62, land=.70)) np.savez(AUD/"subs.npz", t0=np.array([a for a, c, x in SUBS]), t1=np.array([c for a, c, x in SUBS]), tx=np.array([x for a, c, x in SUBS], dtype=object)) np.save(AUD/"stamps.npy", np.array(stamp_times)) wav = AUD/"final.wav" b.write(wav, mix) return wav, mix def analyze(mix): x = mix.mean(1) hop = SR/FPS; win = int(hop*1.7) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high")} for f in range(N_FRAMES): i = int(f*hop); seg = x[i:i+win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg**2).mean()) sp = np.abs(np.fft.rfft(seg*np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1/SR) E["low"][f] = sp[fr < 150].sum() E["mid"][f] = sp[(fr >= 150) & (fr < 2600)].sum() E["high"][f] = sp[fr >= 2600].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 # ════════════════════════════════════════════════════════════════════════════ # THE CLAY — a height field you press things into # # Everything below builds ONE array: `h`, the height of the clay surface in # arbitrary depth units, at delivery resolution. A wedge is not drawn; it is # subtracted, and the clay it displaces is added back as a lip around the rim. # The picture is what a single raking light does to that surface. # ════════════════════════════════════════════════════════════════════════════ TA, TB = 520.0, 340.0 # tablet half-extents, tablet units SQ = 0.86 # foreshortening of a slab lying on a table DOME = 15.0 # crown of the pillow BASE = 9.0 # tablet sits this far above the table _XY = {} def screen_grid(): if "x" not in _XY: yy, xx = np.mgrid[0:H, 0:W] _XY["x"] = xx.astype(np.float32); _XY["y"] = yy.astype(np.float32) return _XY["x"], _XY["y"] def value_noise(h, w, scale, seed): rng = np.random.RandomState(seed) gh, gw = int(h/scale)+2, int(w/scale)+2 g = rng.rand(gh, gw) ys = np.linspace(0, gh-1-1e-3, h); xs = np.linspace(0, gw-1-1e-3, w) y0 = ys.astype(int); x0 = xs.astype(int) fy = (ys-y0)[:, None]; fx = (xs-x0)[None, :] sy = fy*fy*(3-2*fy); sx = fx*fx*(3-2*fx) g00 = g[np.ix_(y0, x0)]; g01 = g[np.ix_(y0, x0+1)] g10 = g[np.ix_(y0+1, x0)]; g11 = g[np.ix_(y0+1, x0+1)] return (g00*(1-sx)+g01*sx)*(1-sy) + (g10*(1-sx)+g11*sx)*sy def fbm(h, w, scale, seed, oct=4): out = np.zeros((h, w)); amp = 1.0; nrm = 0.0 for o in range(oct): out += amp*value_noise(h, w, max(2, scale/(2**o)), seed+o) nrm += amp; amp *= .5 return (out/nrm).astype(np.float32) _GRIT = {} def _grit(): """Fine grit in SCREEN space — magnification-independent surface.""" if "g" not in _GRIT: _GRIT["g"] = (fbm(H, W, PXf(4), 733, oct=3)*0.7 + fbm(H, W, PXf(13), 739, oct=2)*0.3).astype(np.float32) return _GRIT["g"] _GROG = {} def grog(): """Clay body texture in TABLET space, sampled through the camera. Two scales: coarse throwing marks and a fine grit of sand temper.""" if "t" not in _GROG: coarse = fbm(360, 540, 46, 401, oct=4) fine = fbm(360, 540, 5, 409, oct=2) _GROG["t"] = ((coarse-0.5)*2.1 + (fine-0.5)*0.55).astype(np.float32) return _GROG["t"] def box_blur(a, r): """Separable box blur via cumulative sums — used to find the ring of clay displaced around an impression.""" if r < 1: return a k = 2*r+1 p = np.pad(a, ((r, r), (r, r)), mode="edge") c = np.cumsum(p, 0); c = np.vstack([np.zeros((1, c.shape[1]), c.dtype), c]) a1 = (c[k:, :] - c[:-k, :])/k c = np.cumsum(a1, 1); c = np.hstack([np.zeros((c.shape[0], 1), c.dtype), c]) return ((c[:, k:] - c[:, :-k])/k).astype(np.float32) class Field: """A slab of clay seen through a camera. camera = (cx, cy, s, ang): screen position of the tablet centre, scale (tablet units -> pixels) and in-plane rotation. The light never rotates with the tablet, which is why the tablet coords are computed by INVERSE mapping per pixel rather than by rotating a finished picture. """ def __init__(self, cx, cy, s, ang, seed=0, rim=True, table=True): X, Y = screen_grid() px = (X-cx)/s; py = (Y-cy)/(s*SQ) c, sn = math.cos(-ang), math.sin(-ang) self.tx = (px*c - py*sn).astype(np.float32) self.ty = (px*sn + py*c).astype(np.float32) self.s = s; self.ang = ang; self.cx = cx; self.cy = cy r = (np.abs(self.tx)/TA)**5 + (np.abs(self.ty)/TB)**5 self.inside = (r < 1.0) dome = np.clip(1.0 - r, 0, 1) ** 0.42 g = grog() gi = np.clip(((self.tx+TA)/(2*TA)*539), 0, 539).astype(np.int32) gj = np.clip(((self.ty+TB)/(2*TB)*359), 0, 359).astype(np.int32) tex = g[gj, gi] # tablet-space grog gives the body its throwing marks; a second, # screen-space grit keeps the 6x close-ups from looking like plastic h = (PXf(BASE) + PXf(DOME)*dome + tex*PXf(1.5)*dome + (_grit()-0.5)*(PXf(0.9) + 0.5*min(s, PXf(8.0)))*dome ).astype(np.float32) if table: tb = fbm(H, W, PXf(70), 811)*PXf(1.6) - PXf(0.8) self.h = np.where(self.inside, h, tb.astype(np.float32)) else: self.h = h self.h = self.h.astype(np.float32) self.ref = self.h.copy() # the pristine surface, never carved if rim: self.thumbprints(seed) def thumbprints(self, seed): """Where the potter held it. Shallow oval depressions with concentric ridges, rolled into the edge of the slab.""" R = np.random.RandomState(seed*17+5) for _ in range(5): side = R.randint(4) if side < 2: wx = R.uniform(-TA*.8, TA*.8); wy = (TB-26)*(1 if side else -1) else: wx = (TA-24)*(1 if side == 2 else -1); wy = R.uniform(-TB*.7, TB*.7) self.press_print(wx, wy, R.uniform(0, math.pi), R.uniform(30, 44)) def to_screen(self, wx, wy): c, sn = math.cos(self.ang), math.sin(self.ang) return (self.cx + self.s*(wx*c - wy*sn), self.cy + self.s*SQ*(wx*sn + wy*c)) # --- the two primitives ------------------------------------------------- def _slice(self, cx, cy, rad): x0 = max(0, int(cx-rad)); x1 = min(W, int(cx+rad)+1) y0 = max(0, int(cy-rad)); y1 = min(H, int(cy+rad)+1) if x1 <= x0 or y1 <= y0: return None return x0, x1, y0, y1 def impress(self, core, x0, x1, y0, y1, depth, lipk, blur_r): """Push `core` (0..1) into the clay and pile the displaced clay up in a ring around it. This one function is the whole look. The hollow is cut relative to `ref` — the pristine surface — and combined with `min`, so two wedges crossing each other do not carve twice as deep; the lip is additive, so where they cross the clay genuinely heaps up. That asymmetry is what makes dense script read as script and not as a dented sheet.""" sl = self.h[y0:y1, x0:x1] rf = self.ref[y0:y1, x0:x1] b = box_blur(core, blur_r) lip = np.clip(b - core, 0, 1) * (lipk*depth) new = np.minimum(sl, rf - depth*core) + lip self.h[y0:y1, x0:x1] = np.minimum(new, rf + 4.2*max(self.s, PXf(0.4))) def wedge(self, wx, wy, wang, kind="vert", size=1.0, depth=1.0, press=1.0): """One stylus impression, in TABLET coordinates. kind: vert | horiz | obl | wink — the four cuneiform strokes. All four are the same triangular stylus at different attitudes; `wink` is the corner of the stylus punched straight down, which is why it has almost no tail. """ if press <= 0.01: return ANG = {"vert": math.pi/2, "horiz": 0.0, "obl": -0.62, "wink": -0.79} a = ANG.get(kind, 0.0) + wang + self.ang L = self.s * size * (26.0 if kind != "wink" else 15.0) w0 = self.s * size * (7.4 if kind != "wink" else 9.6) if L < PXf(2.0) or w0 < PXf(0.7): return sx, sy = self.to_screen(wx, wy) rad = int(L + w0 + PXf(6)) sl = self._slice(sx, sy, rad) if sl is None: return x0, x1, y0, y1 = sl X, Y = screen_grid() dx = X[y0:y1, x0:x1] - sx; dy = Y[y0:y1, x0:x1] - sy ca, sa = math.cos(a), math.sin(a) u = dx*ca + dy*sa v = -dx*sa + dy*ca t = np.clip(u/L, 0, 1) taper = (1.0 - t) ** (0.95 if kind != "wink" else 1.6) cap = np.clip((u + w0*0.42)/(w0*0.42), 0, 1) # rounds the head hw = w0*taper*cap + PXf(0.55) core = np.clip(1.0 - np.abs(v)/hw, 0, 1) ** 0.65 core = core * (taper*cap) ** 0.30 core = core * (u > -w0*0.5) * (u < L) core = core.astype(np.float32)*press self.impress(core, x0, x1, y0, y1, depth*self.s*2.6, 0.62, max(1, int(w0*0.62))) def press_print(self, wx, wy, ang, size): """A thumbprint: shallow oval with ridge lines.""" sx, sy = self.to_screen(wx, wy) rr = self.s*size sl = self._slice(sx, sy, int(rr*1.5)+PXi(4)) if sl is None: return x0, x1, y0, y1 = sl X, Y = screen_grid() dx = (X[y0:y1, x0:x1]-sx)/rr; dy = (Y[y0:y1, x0:x1]-sy)/(rr*0.68) ca, sa = math.cos(ang), math.sin(ang) u = dx*ca + dy*sa; v = -dx*sa + dy*ca d = np.sqrt(u*u+v*v) core = np.clip(1.0-d, 0, 1)**0.7 ridge = 0.35*np.sin(d*13.0)*core self.impress((core*0.55+ridge*0.5).astype(np.float32), x0, x1, y0, y1, self.s*1.5, 0.30, max(1, int(rr*0.2))) def impress_mask(self, mask, depth, lipk=0.55, blur_r=4): """Impress an arbitrary full-frame 0..1 mask — used for embossed lettering and for the crack.""" self.impress(mask.astype(np.float32), 0, W, 0, H, depth, lipk, blur_r) def relief_mask(self, mask, height, blur_r=3): """The inverse: clay standing proud of the surface (a seal, an ingot).""" b = box_blur(mask.astype(np.float32), blur_r)*height self.h = self.h + b self.ref = self.ref + b # later wedges cut into the raised clay def rule(self, n=9, depth=1.0): """Case-lines: the shallow grooves a scribe rules with a straightedge before writing. Drawn as one mask, not as wedges — a ruled line is dragged, not stamped.""" m = Image.new("L", (W, H), 0) dm = ImageDraw.Draw(m) wdt = max(1, int(self.s*3.0)) for i in range(1, n): wy = -TB + 2*TB*i/n dm.line([self.to_screen(-TA*0.965, wy), self.to_screen(TA*0.965, wy)], fill=255, width=wdt) self.impress_mask(box_blur(np.asarray(m, np.float32)/255.0, PXi(2)), depth*self.s*2.2, lipk=0.30, blur_r=PXi(3)) # ---- lighting -------------------------------------------------------------- LIGHT_AZ = math.radians(206.0) # from upper-left, raking LIGHT_EL = math.radians(11.0) CLAY_WET = np.array([88, 86, 82], np.float32) CLAY_BAKE = np.array([214, 162, 104], np.float32) def shade(h, inside, bake=0.0, gain=1.0, tone=(1.0, 1.0, 1.0), shadow=1.0, steps=22): """One raking light, real cast shadows, ambient occlusion from the local mean. Every wedge in the picture is legible because of this function.""" lx, ly = math.cos(LIGHT_AZ), math.sin(LIGHT_AZ) lz = math.tan(LIGHT_EL) ln = math.sqrt(lx*lx+ly*ly+lz*lz) lx, ly, lz = lx/ln, ly/ln, lz/ln steps = PXi(steps) gy, gx = np.gradient(h) inv = 1.0/np.sqrt(gx*gx + gy*gy + 1.0) # N = (-dh/dx, -dh/dy, 1)/|…| ; a flat surface returns sin(elev) — dim — # which is exactly why every lip and every hollow wall reads. lam = np.clip((-gx*lx - gy*ly + lz)*inv, 0, None).astype(np.float32) # cast shadow: march the height field toward the light one pixel at a time rx = int(round(math.copysign(1, lx))); ry = int(round(math.copysign(1, ly))) rise = math.tan(LIGHT_EL)*math.sqrt(2.0) occ = np.zeros_like(h) for k in range(1, steps+1): s = np.roll(np.roll(h, -k*ry, 0), -k*rx, 1) occ = np.maximum(occ, s - (h + k*rise)) sh = 1.0 - np.clip(occ*0.55/SCL, 0, 1)*0.80*shadow blur = box_blur(h, PXi(7)) ao = np.clip(1.0 - (blur - h)*(0.22/SCL), 0.30, 1.20) col = CLAY_WET*(1-bake) + CLAY_BAKE*bake v = (0.20 + 2.30*lam) * sh * ao spec = np.clip(lam-0.55, 0, 1)**2 * (1.0-bake)*1.4 img = col[None, None, :]*v[..., None]*gain + spec[..., None]*70.0 # the table under the slab tablecol = np.array([44, 38, 34], np.float32) img = np.where(inside[..., None], img, tablecol[None, None, :]*(0.25+0.75*v[..., None])*gain) img = img*np.array(tone, np.float32)[None, None, :] return np.clip(img, 0, 255) # ════════════════════════════════════════════════════════════════════════════ # THE SCRIPT — wedge clusters that read as signs # # Twelve cluster templates in the shapes real cuneiform uses (a vertical, a # stack of horizontals, the AN star, a Winkelhaken with a tail, a "gate", …). # They are shapes, not transliterations: this is a music video, and I am not # claiming to spell anything. # ════════════════════════════════════════════════════════════════════════════ SIGNS = [ [("vert", 0, -8, 1.0), ("vert", 11, -6, .9)], [("horiz", -10, -10, 1.0), ("horiz", -10, 2, 1.0), ("horiz", -10, 14, .9)], [("obl", -8, -10, 1.0), ("obl", -8, 4, 1.0), ("vert", 14, -6, 1.0)], [("wink", -6, -8, 1.0), ("horiz", 2, 0, 1.0), ("vert", 16, -8, .85)], [("horiz", -12, -8, 1.1), ("vert", 6, -12, 1.0), ("vert", 16, -10, .9), ("horiz", -12, 8, .9)], [("obl", -10, -12, .9), ("obl", -2, -2, .9), ("obl", 6, 8, .9)], [("wink", 0, -4, 1.2), ("wink", 12, 4, 1.0)], [("vert", -6, -12, 1.0), ("horiz", -2, 2, 1.0), ("wink", 14, -6, .9)], [("horiz", -12, -6, 1.2), ("horiz", -12, 6, 1.2), ("vert", 14, -10, 1.0), ("wink", 4, 12, .8)], [("vert", -4, -10, 1.1), ("vert", 6, -8, .95), ("vert", 16, -10, .85), ("horiz", -8, 10, 1.0)], [("obl", -8, -8, 1.0), ("wink", 8, 2, 1.0), ("horiz", -6, 12, .85)], [("horiz", -10, -2, 1.3), ("wink", 12, -10, 1.0), ("vert", 14, 4, .9)], ] def layout_wedges(rows=9, seed=606): """Every wedge in the record, in writing order: left to right, top to bottom, in ruled rows. One is pressed per 16th note.""" R = np.random.RandomState(seed) out = [] for row in range(rows): wy = -TB + 2*TB*(row+0.5)/rows x = -TA + 46 while x < TA - 70: sg = SIGNS[R.randint(len(SIGNS))] jx, jy = R.uniform(-2.5, 2.5), R.uniform(-3.0, 3.0) wob = R.uniform(-0.05, 0.05) for (kind, dx, dy, sc) in sg: out.append((x+dx+jx, wy+dy+jy, wob, kind, sc*R.uniform(0.92, 1.06))) x += 46 + R.uniform(0, 16) return out _WEDGES = {} def wedges(): if "w" not in _WEDGES: _WEDGES["w"] = layout_wedges() return _WEDGES["w"] _STAMPS = {} def stamp_times(): if "t" not in _STAMPS: p = AUD/"stamps.npy" _STAMPS["t"] = np.load(p) if p.exists() else np.arange(0, DUR, STEP) return _STAMPS["t"] WEDGE_HEAD = 60 # Nanni was already mid-letter when we walked in def wedge_index_at(t): """How many wedges have been pressed by time t. One per 16th, plus the head start — so the slab fills up right through the second hook and is complete by the time it goes into the fire.""" st = stamp_times() return WEDGE_HEAD + int(np.searchsorted(st, t)) def stamp_of(i): """Absolute time wedge `i` gets pressed (head-start wedges are prehistory).""" st = stamp_times() j = i - WEDGE_HEAD if j < 0: return -1e9 return float(st[j]) if j < len(st) else 1e9 # ════════════════════════════════════════════════════════════════════════════ # ENGINES — nine ways to look at a slab of clay # ════════════════════════════════════════════════════════════════════════════ def ease_io(u): return u*u*(3-2*u) def lerp(a, b, u): return a + (b-a)*u def _crop_push(img, zoom, dx, dy): """Camera push + shake done in image space, so the height field only has to be built once per shot.""" dx *= SCL; dy *= SCL # shake is authored in 720p pixels if zoom <= 1.0005 and abs(dx) < .5 and abs(dy) < .5: return img bw, bh = W/zoom, H/zoom x0 = np.clip(W/2 - bw/2 + dx, 0, W-bw); y0 = np.clip(H/2 - bh/2 + dy, 0, H-bh) return img.crop((x0, y0, x0+bw, y0+bh)).resize((W, H), Image.LANCZOS) class Slab: """The tablet, being written. The default shot.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng p = shot.params self.zoom0 = p.get("zoom", 1.0); self.zoom1 = p.get("zoom1", self.zoom0) self.spin = p.get("spin", 0.0) ang = p.get("ang", 0.0) + float(rng.uniform(-0.05, 0.05)) s = PXf(p.get("scale", 1.02)) cx = W/2 + PXf(p.get("ox", 0.0)); cy = H/2 + PXf(p.get("oy", 0.0)) self.cam = (cx, cy, s, ang) self.bake = shot.bake self.n0 = wedge_index_at(shot.i0/FPS) self.static = (abs(self.spin) < 1e-6) if self.static: self.f = self._build(ang) self.base = self.f.h.copy() self.mask_cache = None def _build(self, ang): cx, cy, s, _ = self.cam f = Field(cx, cy, s, ang, seed=self.s.seed) if self.s.params.get("ruled", True): f.rule(9) WS = wedges() for i in range(min(self.n0, len(WS))): wx, wy, wob, kind, sc = WS[i] f.wedge(wx, wy, wob, kind, size=sc, depth=1.0, press=1.0) return f def frame(self, k, u, e): t = (self.s.i0+k)/FPS ang = self.cam[3] + self.spin*u if self.static: f = self.f; f.h = self.base.copy() else: f = self._build(ang) WS = wedges() for i in range(self.n0, min(wedge_index_at(t)+1, len(WS))): press = np.clip((t - stamp_of(i))/0.075, 0, 1) if press <= 0: continue wx, wy, wob, kind, sc = WS[i] f.wedge(wx, wy, wob, kind, size=sc, depth=1.0, press=float(press)) img = shade(f.h, f.inside, bake=self.bake, gain=0.92+0.30*e["rms"], steps=20) im = Image.fromarray(img.astype(np.uint8)) z = lerp(self.zoom0, self.zoom1, ease_io(u)) sh = 5.0*e["kick"] return _crop_push(im, z, math.sin(t*41.0)*sh, math.cos(t*33.0)*sh) class Macro: """Two or three wedges filling the frame. The lip, the shadow, the grit.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng WS = wedges() # frame the wedge being written RIGHT NOW. The first cut of this took # the mean of a span of wedges, which straddled a row boundary and # aimed the camera straight into an empty gutter. i = min(wedge_index_at(shot.i0/FPS), len(WS)-2) # keep the close-up on the writing surface, off the rounded rim wx = float(np.clip(WS[i][0] + 14.0, -TA*0.78, TA*0.78)) wy = float(np.clip(WS[i][1] + 6.0, -TB*0.74, TB*0.74)) sc = PXf(shot.params.get("scale", 5.4)) ang = shot.params.get("ang", float(rng.uniform(-0.42, 0.42))) ca, sa = math.cos(ang), math.sin(ang) self.cam = (W/2 - sc*(wx*ca - wy*sa), H/2 - sc*SQ*(wx*sa + wy*ca), sc, ang) self.bake = shot.bake self.n0 = i f = Field(*self.cam, seed=shot.seed, rim=False) WSa = wedges() for j in range(max(0, self.n0-70), self.n0): wx2, wy2, wob, kind, s2 = WSa[j] f.wedge(wx2, wy2, wob, kind, size=s2, depth=1.0, press=1.0) self.f = f; self.base = f.h.copy() def frame(self, k, u, e): t = (self.s.i0+k)/FPS f = self.f; f.h = self.base.copy() WS = wedges() for i in range(self.n0, min(wedge_index_at(t)+1, len(WS))): press = np.clip((t - stamp_of(i))/0.070, 0, 1) if press <= 0: continue wx, wy, wob, kind, sc = WS[i] f.wedge(wx, wy, wob, kind, size=sc, depth=1.0, press=float(press)) img = shade(f.h, f.inside, bake=self.bake, gain=0.94+0.34*e["rms"], steps=26) im = Image.fromarray(img.astype(np.uint8)) sh = 7.0*e["kick"] return _crop_push(im, 1.0 + 0.06*u, math.sin(t*47)*sh, math.cos(t*38)*sh) class Stylus: """The reed itself, coming down. Its shadow and its impression meet.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng WS = wedges() i = wedge_index_at(shot.i0/FPS) % max(1, len(WS)-6) self.n0 = i wx = WS[i][0] if i < len(WS) else 0.0 wy = WS[i][1] if i < len(WS) else 0.0 sc = PXf(4.2) self.cam = (W/2 - wx*sc, H*0.62 - wy*sc*SQ, sc, 0.0) self.bake = shot.bake f = Field(*self.cam, seed=shot.seed, rim=False) for j in range(max(0, i-30), i): a, b2, c2, d2, e2 = WS[j] f.wedge(a, b2, c2, d2, size=e2, depth=1.0, press=1.0) self.f = f; self.base = f.h.copy() def frame(self, k, u, e): t = (self.s.i0+k)/FPS f = self.f; f.h = self.base.copy() WS = wedges() n_now = min(wedge_index_at(t)+1, len(WS)) for i in range(self.n0, n_now): press = np.clip((t - stamp_of(i))/0.070, 0, 1) if press <= 0: continue a, b2, c2, d2, e2 = WS[i] f.wedge(a, b2, c2, d2, size=e2, depth=1.0, press=float(press)) img = shade(f.h, f.inside, bake=self.bake, gain=0.90+0.30*e["rms"], steps=24) im = Image.fromarray(img.astype(np.uint8)) d = ImageDraw.Draw(im, "RGBA") # the stylus: a three-sided reed, tip down, riding the 16th grid phase = (t % STEP)/STEP lift = PXf((1-min(1.0, phase*2.2))**2 * 130 + 6) if n_now-1 < len(WS): a, b2, _, _, _ = WS[max(0, n_now-1)] else: a = b2 = 0.0 sx, sy = self.f.to_screen(a, b2) tipx, tipy = sx, sy - lift L = PXf(460) dxa, dya = -0.42, -0.90 top = (tipx + dxa*L, tipy + dya*L) wid = PXf(44) body = [(tipx-PXf(6), tipy), (top[0]-wid, top[1]), (top[0]+wid, top[1]), (tipx+PXf(16), tipy-PXf(4))] # cast shadow of the reed first offx, offy = lift*1.35, lift*0.55 d.polygon([(p[0]+offx, p[1]+offy) for p in body], fill=(24, 18, 14, 150)) d.polygon(body, fill=(196, 172, 126)) d.polygon([(tipx-PXf(6), tipy), (top[0]-wid, top[1]), (top[0]-wid+PXf(26), top[1]), (tipx+PXf(2), tipy-PXf(2))], fill=(224, 202, 156)) d.polygon([(tipx+PXf(4), tipy-PXf(2)), (top[0]+wid-PXf(22), top[1]), (top[0]+wid, top[1]), (tipx+PXf(16), tipy-PXf(4))], fill=(148, 124, 88)) sh = 6.0*e["kick"] return _crop_push(im, 1.0+0.05*u, math.sin(t*43)*sh, math.cos(t*31)*sh) class Emboss: """A word pressed into the clay in our alphabet. The hook cards. Not text drawn over a picture — the letters are an impression in the same height field, so they carry the same lip and the same raking shadow. """ def __init__(self, shot, rng): self.s = shot; self.rng = rng self.text = shot.params.get("text", "EA-NASIR") self.bake = shot.bake sc = PXf(shot.params.get("scale", 1.06)) self.cam = (W/2, H/2, sc, float(rng.uniform(-0.03, 0.03))) f = Field(*self.cam, seed=shot.seed) WS = wedges() n0 = wedge_index_at(shot.i0/FPS) for j in range(max(0, n0-150), min(n0, len(WS))): a, b2, c2, d2, e2 = WS[j] f.wedge(a, b2, c2, d2, size=e2, depth=0.8, press=1.0) self.f = f self.base = f.h.copy() self.mask = self._mask(self.text) def _mask(self, text): m = Image.new("L", (W, H), 0) dm = ImageDraw.Draw(m) size = 190 fo = font(size, "Impact.ttf") while dm.textlength(text, font=fo) > W*0.86 and size > 40: size -= 8; fo = font(size, "Impact.ttf") lw = dm.textlength(text, font=fo) dm.text((W/2-lw/2, H/2-PXf(size*0.68)), text, font=fo, fill=255) a = np.asarray(m, np.float32)/255.0 return box_blur(a, PXi(2)) def frame(self, k, u, e): t = (self.s.i0+k)/FPS f = self.f; f.h = self.base.copy() press = min(1.0, u*5.0) beat = 1.0 + 0.20*e["kick"] f.impress_mask(self.mask*press, depth=PXf(13.0)*beat, lipk=0.72, blur_r=PXi(6)) img = shade(f.h, f.inside, bake=self.bake, gain=0.95+0.30*e["rms"], steps=20) im = Image.fromarray(img.astype(np.uint8)) sh = 8.0*e["kick"] return _crop_push(im, 1.02+0.05*u, math.sin(t*51)*sh, math.cos(t*37)*sh) class Ingot: """The copper itself, modelled in relief on the slab and then struck out one per beat with two gouges. Metal under a raking light wants a specular that clay does not have, so the ingots get their own grade.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng self.bake = shot.bake self.cam = (W/2, H/2, PXf(1.0), 0.0) f = Field(*self.cam, seed=shot.seed) self.n = 6 R = np.random.RandomState(shot.seed+3) self.pos = [] cop = np.zeros((H, W), np.float32) for i in range(self.n): wx = -TA*0.62 + (i % 3)*TA*0.62 wy = -TB*0.34 + (i//3)*TB*0.62 self.pos.append((wx + R.uniform(-14, 14), wy + R.uniform(-10, 10))) mk = self._ingot_mask(f, wx, wy) cop = np.maximum(cop, mk) f.relief_mask(mk, PXf(12.0), blur_r=PXi(4)) self.copper = box_blur(cop, PXi(3))[..., None] self.f = f; self.base = f.h.copy() self.cross = [self._cross_mask(f, wx, wy) for (wx, wy) in self.pos] def _ingot_mask(self, f, wx, wy): m = Image.new("L", (W, H), 0) dm = ImageDraw.Draw(m) sx, sy = f.to_screen(wx, wy) w2, h2 = PXf(132), PXf(52) dm.polygon([(sx-w2, sy-h2*0.5), (sx-w2*0.62, sy-h2), (sx+w2*0.62, sy-h2), (sx+w2, sy-h2*0.42), (sx+w2*0.72, sy+h2), (sx-w2*0.72, sy+h2)], fill=255) return np.asarray(m, np.float32)/255.0 def _cross_mask(self, f, wx, wy): m = Image.new("L", (W, H), 0) dm = ImageDraw.Draw(m) sx, sy = f.to_screen(wx, wy) w2, h2 = PXf(104), PXf(44) dm.line([(sx-w2, sy-h2), (sx+w2, sy+h2)], fill=255, width=PXi(15)) dm.line([(sx+w2, sy-h2), (sx-w2, sy+h2)], fill=255, width=PXi(15)) return box_blur(np.asarray(m, np.float32)/255.0, PXi(3)) def frame(self, k, u, e): t = (self.s.i0+k)/FPS f = self.f; f.h = self.base.copy() for i in range(self.n): grow = float(np.clip(u*self.n*1.2 - i, 0, 1)) if grow <= 0.01: continue f.impress_mask(self.cross[i]*grow, depth=PXf(17.0), lipk=0.85, blur_r=PXi(6)) img = shade(f.h, f.inside, bake=self.bake, gain=0.92+0.30*e["rms"], steps=22) # the metal: warm copper going green where the light does not reach lum = (img.mean(2, keepdims=True)/255.0) metal = (np.array([176, 108, 62], np.float32)*lum + np.array([96, 132, 112], np.float32)*(1-lum)*0.9 + np.clip(lum-0.62, 0, 1)**2*420.0) hi = self.copper img = img*(1-hi) + metal*hi im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) sh = 6.0*e["kick"] return _crop_push(im, 1.0+0.06*u, math.sin(t*39)*sh, math.cos(t*29)*sh) class Rim: """The edge of the slab, turning under the light. The tablet is pushed off-centre so the right-hand rim — thickness, thumbprints, the way the dome falls away — sits in frame while the whole thing rotates. The light stays put, so the shadows sweep. """ def __init__(self, shot, rng): self.s = shot; self.rng = rng self.bake = shot.bake self.spin = shot.params.get("spin", 0.55) self.a0 = float(rng.uniform(-0.28, 0.28)) self.sc = PXf(shot.params.get("scale", 1.45)) # look at a point on the written face near the right-hand rim, and pin # it on screen — the slab then rotates about what we are looking at self.look = shot.params.get("look", (TA*0.52, -TB*0.30)) self.pin = (W*shot.params.get("px", 0.40), H*shot.params.get("py", 0.46)) self.n0 = min(wedge_index_at(shot.i0/FPS), len(wedges())) def frame(self, k, u, e): t = (self.s.i0+k)/FPS ang = self.a0 + self.spin*u ca, sa = math.cos(ang), math.sin(ang) lx, ly = self.look cx = self.pin[0] - self.sc*(lx*ca - ly*sa) cy = self.pin[1] - self.sc*SQ*(lx*sa + ly*ca) f = Field(cx, cy, self.sc, ang, seed=self.s.seed) WS = wedges() for j in range(self.n0): # off-screen wedges cost ~nothing a, b2, c2, d2, e2 = WS[j] f.wedge(a, b2, c2, d2, size=e2, depth=1.0, press=1.0) img = shade(f.h, f.inside, bake=self.bake, gain=0.90+0.28*e["rms"], steps=24) im = Image.fromarray(img.astype(np.uint8)) sh = 5.0*e["kick"] return _crop_push(im, 1.0+0.04*u, math.sin(t*35)*sh, math.cos(t*27)*sh) class Fire: """Three thousand seven hundred and fifty years, compressed. The clay bakes (wet grey to ochre), a crack walks across it, dust settles into the wedges. The one shot where the substrate itself is the event. """ def __init__(self, shot, rng): self.s = shot; self.rng = rng self.cam = (W/2, H/2, PXf(1.02), float(rng.uniform(-0.04, 0.04))) f = Field(*self.cam, seed=shot.seed) f.rule(9) WS = wedges() n0 = min(wedge_index_at(shot.i0/FPS), len(WS)) for j in range(n0): a, b2, c2, d2, e2 = WS[j] f.wedge(a, b2, c2, d2, size=e2, depth=1.0, press=1.0) self.f = f; self.base = f.h.copy() R = np.random.RandomState(shot.seed+77) pts = [(W*0.14, H*0.18)] for i in range(11): pts.append((pts[-1][0] + W*0.075 + PXf(R.uniform(-20, 20)), pts[-1][1] + H*0.055 + PXf(R.uniform(-46, 46)))) self.crack = pts self.b0 = shot.bake self.b1 = 1.0 def frame(self, k, u, e): t = (self.s.i0+k)/FPS f = self.f; f.h = self.base.copy() n = max(2, int(len(self.crack)*min(1.0, u*1.35))) m = Image.new("L", (W, H), 0) dm = ImageDraw.Draw(m) dm.line(self.crack[:n], fill=255, width=PXi(9), joint="curve") f.impress_mask(box_blur(np.asarray(m, np.float32)/255.0, PXi(3)), depth=PXf(16.0), lipk=0.9, blur_r=PXi(7)) bake = self.b0 + (self.b1-self.b0)*ease_io(u) img = shade(f.h, f.inside, bake=bake, gain=0.86+0.30*e["rms"], steps=24, shadow=1.0) im = Image.fromarray(img.astype(np.uint8)) return _crop_push(im, 1.0+0.10*ease_io(u), 0, 0) class Column: """The drop. Everything Nanni has already written, plus a storm of new wedges landing several per 16th — the one place where the stylus outruns the hand and the clay just fills.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng self.bake = shot.bake sc = PXf(shot.params.get("scale", 1.55)) self.cam = (W/2, H/2, sc, float(rng.uniform(-0.02, 0.02))) f = Field(*self.cam, seed=shot.seed, rim=False) f.rule(9) self.n0 = min(wedge_index_at(shot.i0/FPS), len(wedges())) WS = wedges() for j in range(self.n0): a, b2, c2, d2, e2 = WS[j] f.wedge(a, b2, c2, d2, size=e2, depth=1.0, press=1.0) self.f = f; self.base = f.h.copy() # the storm is confined to what the camera can actually see, which the # first cut of this shot was not — half of it fell off the tablet vx = min(TA*0.94, (W/2 - PXf(40))/sc) vy = min(TB*0.94, (H/2 - PXf(40))/(sc*SQ)) R = np.random.RandomState(shot.seed+9) self.extra = [(R.uniform(-vx, vx), R.uniform(-vy, vy), R.uniform(-.12, .12), ["vert", "horiz", "obl", "wink"][R.randint(4)], R.uniform(.9, 1.25)) for _ in range(320)] def frame(self, k, u, e): t = (self.s.i0+k)/FPS f = self.f; f.h = self.base.copy() cnt = int(np.clip((wedge_index_at(t)-self.n0)*7.0, 0, len(self.extra))) for i in range(cnt): press = float(np.clip((cnt-i)/6.0, 0, 1)) a, b2, c2, d2, e2 = self.extra[i] f.wedge(a, b2, c2, d2, size=e2, depth=1.15, press=press) img = shade(f.h, f.inside, bake=self.bake, gain=0.94+0.36*e["rms"], steps=20) im = Image.fromarray(img.astype(np.uint8)) sh = 9.0*e["kick"] return _crop_push(im, 1.04+0.10*u, math.sin(t*53)*sh, math.cos(t*44)*sh) class Vitrine: """The landing. Museum light from above, the slab behind glass, a printed label. The drill lighting gives way to a flat vertical spot — the joke is that this furious man is now an exhibit.""" def __init__(self, shot, rng): self.s = shot; self.rng = rng wide = shot.params.get("wide", False) self.cam = ((W*0.34, H*0.52, PXf(0.70), -0.05) if wide else (W*0.40, H*0.50, PXf(0.88), 0.03)) f = Field(*self.cam, seed=shot.seed) f.rule(9) WS = wedges() for j in range(len(WS)): a, b2, c2, d2, e2 = WS[j] f.wedge(a, b2, c2, d2, size=e2, depth=1.0, press=1.0) R = np.random.RandomState(shot.seed+5) pts = [(W*0.10, H*0.16)] for i in range(11): pts.append((pts[-1][0] + W*0.062 + PXf(R.uniform(-16, 16)), pts[-1][1] + H*0.050 + PXf(R.uniform(-40, 40)))) m = Image.new("L", (W, H), 0) ImageDraw.Draw(m).line(pts, fill=255, width=PXi(8), joint="curve") f.impress_mask(box_blur(np.asarray(m, np.float32)/255.0, PXi(3)), depth=PXf(14.0), lipk=0.8, blur_r=PXi(6)) self.f = f self.label = shot.params.get("label", "") def frame(self, k, u, e): img = shade(self.f.h, self.f.inside, bake=1.0, gain=0.88, tone=(1.0, 0.99, 1.02), shadow=0.55, steps=18) # museum vitrine: a cool ground, a soft pool of light, a glass reflection X, Y = screen_grid() pool = np.exp(-(((X-W*0.40)/(W*0.52))**2 + ((Y-H*0.50)/(H*0.62))**2)) img = img*(0.30+0.85*pool)[..., None] img += np.array([16, 20, 30], np.float32)[None, None, :]*(1-pool)[..., None] per = PXf(900) glass = np.clip((X*0.55 + Y*1.5 - W*0.30) % per, 0, per)/per img += (np.clip(1-np.abs(glass-0.5)*7.0, 0, 1)*10.0)[..., None] im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) if self.label: fo = font(18, "Georgia.ttf") fb = font(20, "Georgia Bold.ttf") lines = self.label.split("|") x0, y0 = int(W*0.655), int(H*0.30) lh = PXf(22) wmax = max(d.textlength(l, font=fb if i == 0 else fo) for i, l in enumerate(lines)) d.rectangle([x0-PXf(16), y0-PXf(16), x0+wmax+PXf(18), y0+lh*len(lines)+PXf(16)], fill=(238, 236, 230)) for i, l in enumerate(lines): d.text((x0, y0+i*lh), l, font=fb if i == 0 else fo, fill=(30, 28, 26)) return _crop_push(im, 1.0+0.045*ease_io(u), 0, 0) ENGINES = {"slab": Slab, "macro": Macro, "stylus": Stylus, "emboss": Emboss, "ingot": Ingot, "rim": Rim, "fire": Fire, "column": Column, "vitrine": Vitrine} # ════════════════════════════════════════════════════════════════════════════ # THE CUT # ════════════════════════════════════════════════════════════════════════════ # (engine, params) pools per section, and a menu of shot lengths in 16ths PLAN = { "cold": ([("slab", dict(scale=1.02, zoom=1.0, zoom1=1.10)), ("stylus", dict()), ("macro", dict(scale=5.0))], [8, 12, 16]), "v1": ([("slab", dict(scale=1.05)), ("macro", dict(scale=5.6)), ("stylus", dict()), ("rim", dict(scale=1.42, spin=0.42, look=(TA*0.22, -TB*0.70))), ("slab", dict(scale=1.6, ox=-120, oy=300, zoom=1.0, zoom1=1.12)), ("macro", dict(scale=7.0)), ("ingot", dict())], [8, 8, 12, 16]), "hook1": ([("emboss", dict()), ("column", dict(scale=1.5))], [12, 16]), "v2": ([("slab", dict(scale=1.08)), ("macro", dict(scale=6.2)), ("ingot", dict()), ("slab", dict(scale=1.9, ox=140, oy=-60)), ("stylus", dict()), ("rim", dict(scale=1.55, spin=-0.5, cxf=0.84)), ("column", dict(scale=1.7))], [8, 8, 12, 16]), "hook2": ([("emboss", dict()), ("column", dict(scale=1.32)), ("rim", dict(scale=1.30, spin=0.7))], [12, 16]), "bake": ([("fire", dict()), ("rim", dict(scale=1.35, spin=0.28)), ("macro", dict(scale=5.0))], [16, 24, 32]), "land": ([("vitrine", dict(label="Letter from Nanni to Ea-nāṣir|" "Clay tablet, Old Babylonian|" "Ur, southern Iraq, about 1750 BC|" "British Museum, 131236")), ("macro", dict(scale=5.4)), ("vitrine", dict(label="Letter from Nanni to Ea-nāṣir|" "Clay tablet, Old Babylonian|" "Ur, southern Iraq, about 1750 BC|" "British Museum, 131236", wide=True))], [24, 32, 48]), } CARDS = {"cold": "UR \u00b7 c. 1750 BC", "bake": "3,750 YEARS LATER"} # the embossed hook card follows whichever word is being chanted in that bar HOOK_CARDS = {"hook1": [h[1].rstrip(".") for h in HOOK], "hook2": [h[1].rstrip(".") for h in HOOK2]} class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "params", "section", "seed", "card", "bake") def __init__(self, idx, i0, i1, engine, params, section, card=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.params, self.section = engine, params, section self.seed = 17500 + idx*7919 self.card = card # the clay dries across the record: wet grey at the top, and it does not # finish drying until the fire, which is where the arc pays off prog = (i0/FPS)/(N_BARS*BAR) self.bake = float(np.clip((prog-0.05)/1.35, 0, 1)) ** 1.05 def build_shots(): """Verses cut on a menu of shot lengths so the rhythm breathes; the hooks cut on the bar, because the embossed card has to land on the word being chanted; the landing is composed by hand, because it is the landing.""" R = np.random.RandomState(1750) shots = []; idx = 0; last = None def add(i0, i1, eng, par, nm, card=None): nonlocal idx if i1 <= i0: return shots.append(Shot(idx, i0, i1, eng, dict(par), nm, card)); idx += 1 for nm, b0, b1 in SECTIONS: pool, menu = PLAN[nm] if nm in HOOK_CARDS: # hook: emboss + cutaway, per bar cards = HOOK_CARDS[nm] alt = [("column", dict(scale=1.5)), ("rim", dict(scale=1.34, spin=0.6)), ("macro", dict(scale=6.6)), ("column", dict(scale=1.28))] for i in range(b1-b0): bt = (b0+i)*BAR cut = bt + 10*STEP add(int(bt*FPS), int(cut*FPS), "emboss", dict(text=cards[i % len(cards)]), nm, CARDS.get(nm) if i == 0 else None) ae, ap = alt[(i + b0) % len(alt)] add(int(cut*FPS), int((bt+BAR)*FPS), ae, ap, nm) last = "emboss" continue if nm == "land": # vitrine, one wedge, vitrine lab = PLAN["land"][0][0][1] b = b0*BAR add(int(b*FPS), int((b+1.4*BAR)*FPS), "vitrine", lab, nm) add(int((b+1.4*BAR)*FPS), int((b+2.0*BAR)*FPS), "macro", dict(scale=5.4), nm) add(int((b+2.0*BAR)*FPS), int(b1*BAR*FPS), "vitrine", dict(lab, wide=True), nm) last = "vitrine" continue t = b0*BAR; j = 0 while t < b1*BAR - 1e-6: step = menu[R.randint(len(menu))]*STEP t2 = min(t+step, b1*BAR) if (b1*BAR - t2) < BEAT*1.2: t2 = b1*BAR if nm == "bake" and j == 0: eng, par = pool[0] # the fire opens the section else: avail = pool[1:] if nm == "bake" else pool # the fire happens once cand = [q for q in avail if q[0] != last] or list(avail) eng, par = cand[R.randint(len(cand))] last = eng add(int(t*FPS), int(t2*FPS), eng, par, nm, CARDS.get(nm) if j == 0 else None) j += 1; t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 # once the tablet has been through the fire it stays fired fired = next((i for i, sh in enumerate(shots) if sh.engine == "fire"), None) if fired is not None: for sh in shots[fired+1:]: sh.bake = 1.0 return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> letterbox, then text, crisply, last # ════════════════════════════════════════════════════════════════════════════ # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) _FC = {} def font(size, name="Menlo.ttc"): """Size is in authoring units; the loaded face is scaled once by SCL.""" key = (size, name, SCL) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size if SCL == 1.0 else max(2, PXi(size))) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.46*r**2.0, 0, 1)[..., None].astype(np.float32) return _VIG["v"] _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 def wrap(d, text, fo, maxw): words = text.split(); lines = []; cur = "" for w2 in words: trial = (cur+" "+w2).strip() if d.textlength(trial, font=fo) <= maxw or not cur: cur = trial else: lines.append(cur); cur = w2 if cur: lines.append(cur) return lines[:3] def post(img, i, e, shot): a = np.asarray(img, np.float32) if a.shape[0] != H or a.shape[1] != W: a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((W, H), Image.LANCZOS), np.float32) # 1 tint — cold shadows, hot highlights: sun on mud lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([-10, -6, 8], np.float32) \ + lum*np.array([10, 2, -12], np.float32) # 2 vignette a *= vignette() # 3 grain rng = np.random.RandomState(9100+i) if SCL == 1.0: a += rng.normal(0, 2.4, a.shape) else: # grain is a look, not a resolution: authored at 1280x720, 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 += (np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) # 4 letterbox bh = int(H*0.055) d.rectangle([0, 0, W, bh], fill=(9, 8, 7)) d.rectangle([0, H-bh, W, H], fill=(9, 8, 7)) # 5 text, composited crisply last (AESTHETIC 13b) if shot.card: age = i - shot.i0 if age < FPS*2.4: al = min(1.0, age/5.0)*min(1.0, (FPS*2.4-age)/10.0) fo = font(30, "Georgia.ttf") lw = d.textlength(shot.card, font=fo) d.text((W-lw-PXf(34), bh+PXf(22)), shot.card, font=fo, fill=tuple(int(v*al) for v in (226, 208, 172))) # ── title flash — the museum-label register the piece already speaks in: # Georgia, cream on the black letterbox ground, left of the date card. if i < FPS*3.0: al = min(1.0, i/6.0)*min(1.0, (FPS*3.0-i)/12.0) ft = font(38, "Georgia Bold.ttf"); fs = font(17, "Georgia.ttf") d.text((PXf(34), bh+PXf(18)), "EA-NASIR", font=ft, fill=tuple(int(v*al) for v in (232, 214, 178))) d.text((PXf(36), bh+PXf(70)), "P L A Y E R C O M P U T E R", font=fs, fill=tuple(int(v*al) for v in (170, 148, 116))) SB = subs(); t = i/FPS idx = np.where((SB["t0"] <= t) & (t < SB["t1"]))[0] if len(idx): line = str(SB["tx"][idx[-1]]) big = line.isupper() and len(line) < 16 fo = font(38 if big else 27, "Impact.ttf" if big else "Helvetica.ttc") lines = wrap(d, line, fo, W*0.84) lh = PXf(44 if big else 32) y0 = H - bh - PXf(26) - lh*len(lines) ol = PXi(2) for n, ln in enumerate(lines): lw = d.textlength(ln, font=fo) x = W/2-lw/2; y = y0+n*lh for ox, oy in ((-ol, 0), (ol, 0), (0, -ol), (0, ol)): d.text((x+ox, y+oy), ln, font=fo, fill=(8, 7, 6)) d.text((x, y), ln, font=fo, fill=(246, 232, 198) if big else (238, 232, 220)) return out # ════════════════════════════════════════════════════════════════════════════ # RENDER # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) eng = ENGINES[shot.engine](shot, rng) made = 0 for k in range(shot.n): i = shot.i0 + k p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue # engines here are stateless per frame e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} u = k/max(1, shot.n-1) img = eng.frame(k, u, e) post(img, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {shot.section:6s} {made}/{shot.n}" def contact_sheet(shots): 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)), (10, 10, 12)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) eng = ENGINES[sh.engine](sh, rng) mid = sh.n//2 i = sh.i0+mid e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} img = eng.frame(mid, mid/max(1, sh.n-1), e) im = post(img, i, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+lab) sheet.paste(im, (cx, cy)) sd.text((cx+PXi(5), cy+th+PXi(5)), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:.1f}s " f"({sh.n}f)", font=font(13), fill=(190, 190, 200)) 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(12, 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); 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…") 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" stamp = (f"renders/{SETDIR}/{NAME}/render.py · git {sha} ({br}) · " f"{datetime.datetime.now().astimezone().isoformat()}") 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", f"artist=poop / {SETDIR}", str(out)], check=True, capture_output=True) (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel, tier 4-P)\n" f"voices: Rocko (English (UK)) = Nanni; Reed (English (UK)) = Ea-nasir; " f"Daniel = museum\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()