#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Sand Hands (25/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/sand_hands # # Sand on a lightbox: a caravan, a wind, a city, and two hands that sweep it all flat. # # 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/sand_hands.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/sand_hands.mp4 # cover: https://genekogan.com/player_computer/media/sand_hands.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 sand_hands.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) # ═════════════════════════════════════════════════════════════════════════════ """ night_watch_2 05 — "SAND HANDS" Qawwali (harmonium, dholak/tabla, group taali, a melismatic lead answered by a chorus), 84bpm accelerating to 156 and collapsing back to 86. 122 beats. open(6) dunes(14) caravan(28) storm(22) city(22) arrive(14) sweep(8) print(8) A caravan crosses a desert. The wind takes it. Out of the settling sand a city stands up — walls, domes, minarets, a gate — and the caravan walks in. Then the hands sweep the whole world flat and press one hand into it, and the print is the only thing left, burning white. THE NEW SUBSTRATE — sand on a backlit lightbox. Nothing in this repo has rendered by *transmission* before. The picture is a single scalar field `D` = grams of sand over each pixel, and the frame is Beer-Lambert light coming up through it: out_c = 255 * L * exp(-k_c * D) with k_R < k_G < k_B, so empty glass is pure white, a veil is amber, and a heap is black. Grain comes from a frozen speckle field that multiplies D (clumps, not antialiasing) and a one-tap relief term that lights the upslope of every ridge. Nothing is ever painted. Every operator on `Sand` is MASS-CONSERVING: · push() a finger drags: sand inside the finger's disc is lifted and re-deposited as a bow wave in front and two berms at the sides. Sum of the tap weights is exactly 1. · relax() the hand works a region toward a target tableau: excess sand (D > target) is moved into deficit (D < target), min(excess, deficit) exactly, only inside the hand's mask. Targets are pre-scaled so total target mass == total sand on the glass — which is *why* one image can become the next one. · advect() the sandstorm: bilinear backward warp of a curl-noise flow, renormalised to the mass it started with. · press() the handprint: sand under the hand mask is lifted and dropped into the blurred ring around it — a cleared, glowing hand with an amber berm. Only `sprinkle()` adds mass, and only from a visible fist. The other new tool is a TEMPO MAP. Everything — every drum, every clap, every cut — is placed in *beat* space and converted through t_of(beat), which integrates a piecewise-linear bpm curve. The qawwali build is literally the tempo curve; the shot list never knew about it. Composition: engine : audio-first x shot-parallel (tier 4-P) x variable-tempo beat map content: audio-groove (harmonium reeds, dholak, tabla, group taali) x tts-voices (Rishi + Lekha + Majed through a channel vocoder, driven by a continuous melismatic pitch path instead of notes) Run from repo root: python3 renders/night_watch_2/sand_hands/render.py --sheet python3 renders/night_watch_2/sand_hands/render.py python3 renders/night_watch_2/sand_hands/render.py --shots 12,13 --force python3 renders/night_watch_2/sand_hands/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 = "sand_hands" TITLE = "SAND HANDS" SETDIR = "player_computer_final" SETNUM = "B8" # Final cut: native 1080p. The lightbox plate scales with the frame (a crop of # a 2640x1485 plate, not an upscale of a 1760x990 one) and every quantity that # is a LENGTH IN PLATE PIXELS — a finger's radius, a hand's palm width, the # blur that makes the berm, the noise scale of the dunes, the wind's step — # is multiplied by SC. Most of it is scaled inside the primitive that consumes # it (`hand_poly`, `Sand.push`, `Sand.sprinkle`, `fbm`), so the shot table and # the engines are unchanged. W, H, FPS = 1920, 1080, 30 SC = H / 720.0 # 1.5 def sci(v): return max(1, int(round(v*SC))) def scf(v): return v*SC 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" # the lightbox plate; every delivered frame is a crop of it (16:9). # Widened for player_computer_2 so the wide shot still shows the FULL height # of the plate and simply reveals more sand left and right — never a crop. SW_S, SH_S = int(1760*SC), int(990*SC) ASPECT = W / H # ════════════════════════════════════════════════════════════════════════════ # THE TEMPO MAP — the qawwali build, as a curve # # Nothing here is in seconds. Beats are the clock; t_of() integrates the bpm # curve to get wall time. Accelerando is therefore free and exact: the drums, # the claps and the cuts all inherit it without knowing it exists. # ════════════════════════════════════════════════════════════════════════════ TEMPO = [(0, 84), (6, 96), (20, 102), (34, 106), (48, 112), (62, 124), (70, 128), (84, 140), (92, 146), (102, 156), (106, 152), (110, 116), (114, 92), (122, 86)] BEATS = 122 _TB = np.array([b for b, _ in TEMPO], float) _TV = np.array([v for _, v in TEMPO], float) _FINE = np.arange(0.0, BEATS + 1e-9, 0.005) _BPM = np.interp(_FINE, _TB, _TV) _TCUM = np.concatenate([[0.0], np.cumsum(60.0 / _BPM * 0.005)[:-1]]) def t_of(beat): return float(np.interp(beat, _FINE, _TCUM)) def beat_of(t): return float(np.interp(t, _TCUM, _FINE)) def bpm_at(beat): return float(np.interp(beat, _TB, _TV)) TAIL = 1.9 DUR = t_of(BEATS) + TAIL N_FRAMES = int(DUR * FPS) SECTIONS = [("open", 0, 6), ("dunes", 6, 20), ("caravan", 20, 48), ("storm", 48, 70), ("city", 70, 92), ("arrive", 92, 106), ("sweep", 106, 114), ("print", 114, 122)] def sec_of(beat): for nm, a, b in SECTIONS: if a <= beat < b: return nm return "print" MUSIC_DESC = (f"Qawwali, harmonium + dholak/tabla + group taali + vocoded " f"melismatic lead & chorus; {BEATS} beats, tempo map " f"{_TV.min():.0f}->{_TV.max():.0f}bpm; raga Bhairavi") ENGINE_DESC = "light / sprinkle / morph / walk / macro / storm / rise / sweep / press" # ════════════════════════════════════════════════════════════════════════════ # 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 — no raw full-band hiss anywhere in this piece.""" 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)[:n] def reverb(x, rt=1.6, mix=.3, seed=29, pre=0.02): n = int(rt*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) ir = rng.randn(n)*np.exp(-t*(5.0/rt)); ir[:int(pre*SR)] = 0 ir /= np.abs(ir).sum()/40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x) + n))) wet = irfft(rfft(x, L)*rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet)) + 1e-9 return x*(1-mix) + wet*mix*(np.max(np.abs(x)) + 1e-9) def delay(x, time=.25, fb=.38, mix=.25, taps=7): d = int(time*SR); out = x.copy() for i in range(1, taps+1): g = mix*(fb**i); s = d*i if s >= len(x): break out[s:] += x[:len(x)-s]*g return out # ── the harmonium: two hand-pumped reeds, slightly out with each other ────── def harmonium(freqs, dur, a=.035, d=.10, s=.85, r=.13, reed=1.0, seed=0, bellows=(0.055, 3.4), bright=1.0): """A reed organ. Inharmonic, buzzy, with a bellows wobble and the characteristic slow attack. `freqs` may be a list — the right hand plays chords the way a harmonium actually does.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR rng = np.random.RandomState(seed % (2**31-1)) out = np.zeros(n) for f0 in (freqs if isinstance(freqs, (list, tuple)) else [freqs]): for det in (-0.0032, 0.0035): # the two reeds f = f0*(1+det) for k in range(1, 26): fk = f*k*(1 + 0.00042*k*k*reed) # reed inharmonicity if fk > SR*0.45: break g = (1.0/(k**1.12))/np.sqrt(1.0 + (fk/(2100*bright))**3) if k % 2 == 0: g *= 0.62 out += np.sin(2*np.pi*fk*t + rng.uniform(0, 6.283))*g bd, br = bellows out *= (1.0 + bd*np.sin(2*np.pi*br*t + rng.uniform(0, 6.283))) breath = bandshape(rng.randn(n), lo=700, hi=4200)*0.035 return (out/(len(freqs) if isinstance(freqs, (list, tuple)) else 1) + breath) * adsr(n, a, d, s, r) # ── the drums ─────────────────────────────────────────────────────────────── def dholak_bass(dur=.44, f0=168, f1=74, punch=26, seed=1, g=1.0): """The left head — dhaa. Deep, woody, with a slack pitch drop.""" 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*8.0) body += .30*np.sin(2*np.pi*np.cumsum(f*1.93)/SR)*np.exp(-t*20) ck = np.random.RandomState(seed % (2**31-1)).randn(n)*np.exp(-t*230)*.30 return np.tanh((body + ck)*1.6)*.92*g def dholak_tin(dur=.15, f=430, seed=2, g=1.0): """The right head — tin/na. A dry crack with a short pitched ring.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed % (2**31-1)) nz = bandshape(rng.randn(n), lo=1100, hi=7000)*np.exp(-t*46) tone = (np.sin(2*np.pi*f*t) + .55*np.sin(2*np.pi*f*1.58*t))*np.exp(-t*34) return (nz*.62 + tone*.44)*g def tabla_na(dur=.30, f=560, seed=3, g=1.0): """Dayan rim stroke — the ringing 'na', long and metallic.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed % (2**31-1)) tone = sum(np.sin(2*np.pi*f*m*t)*(0.9/(1+i*1.4)) for i, m in enumerate((1.0, 1.51, 2.02, 2.68))) tone *= np.exp(-t*11.0) tick = bandshape(rng.randn(n), lo=2600, hi=9000)*np.exp(-t*140)*.35 return (tone*.5 + tick)*g def tabla_ge(dur=.55, seed=4, g=1.0): """Bayan with the heel — pitch bends UP under the palm.""" n = int(dur*SR); t = np.arange(n)/SR f = 82 + 66*np.clip(t*7.0, 0, 1)*np.exp(-t*3.0) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*5.2) return np.tanh(body*1.5)*.8*g def taali(dur=.34, seed=0, hands=8, spread=0.013, bright=1.0, g=1.0): """GROUP handclaps — the qawwali engine. N hands, each a few ms off, so the transient smears exactly the way a room of people does.""" n = int(dur*SR); out = np.zeros(n) rng = np.random.RandomState(seed % (2**31-1)) for i in range(hands): off = int(min(spread*3, abs(rng.normal(0, spread)))*SR) m = n - off if m < 32: continue t = np.arange(m)/SR nz = bandshape(rng.randn(m), lo=1000*bright, hi=6800*bright) body = nz*np.exp(-t*(48 + rng.uniform(-9, 12))) body += np.sin(2*np.pi*rng.uniform(1500, 2400)*t)*np.exp(-t*105)*.22 out[off:off+m] += body*rng.uniform(.65, 1.0) out /= hands**0.62 return np.tanh(out*1.7)*g def sub(f, dur, a=.006, d=.12, s=.8, r=.09, seed=0): n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR x = np.sin(2*np.pi*f*t) + .22*np.sin(2*np.pi*f*2*t) return np.tanh(x*1.25)*adsr(n, a, d, s, r) def windfx(dur=2.4, seed=27, lo0=280, hi0=2400, rise=1.0): """The sandstorm: pitched, filtered, EVOLVING noise — never a flat blast.""" n = int(dur*SR) rng = np.random.RandomState(seed % (2**31-1)); out = np.zeros(n); blk = 2048 for i in range(0, n, blk): m = min(blk, n-i); u = i/max(1, n) k = (0.25 + 0.75*np.sin(u*math.pi)**0.7)*rise seg = rng.randn(m+256) out[i:i+m] = bandshape(seg, lo=lo0*(0.6+k*1.9), hi=hi0*(0.5+k*2.2))[:m]*k t = np.arange(n)/SR whis = np.sin(2*np.pi*(180 + 90*np.sin(2*np.pi*0.7*t))*t)*0.10 return (out + whis*np.sin(np.arange(n)/n*math.pi))*0.8 def hiss_sweep(dur=.7, seed=31): """One hand sweeping the glass.""" n = int(dur*SR) rng = np.random.RandomState(seed % (2**31-1)); out = np.zeros(n); blk = 1024 for i in range(0, n, blk): m = min(blk, n-i); u = i/max(1, n) fc = 900 + 5200*(1-u)**1.4 out[i:i+m] = bandshape(rng.randn(m+128), lo=fc*.55, hi=fc*2.1)[:m] t = np.arange(n)/SR return out*np.clip(t*40, 0, 1)*np.exp(-t*3.2)*.75 # ════════════════════════════════════════════════════════════════════════════ # THE VOICE — say(1) through a channel vocoder, driven by a CONTINUOUS # melismatic pitch path instead of a note list. # # Voices verified with `say -v '?'`: # Rishi (en_IN) — the lead qawwal # Majed (ar_001) — the lead's lower octave double # Lekha (hi_IN) — the chorus, up an octave # `say` supplies formants only; every semitone of pitch comes from the # carrier, whose frequency is a smoothed raga path with gamak oscillation — # so a single held syllable can wander eight notes and still be one breath. # ════════════════════════════════════════════════════════════════════════════ def _h(*parts): return hashlib.md5("|".join(str(p) for p in parts).encode()).hexdigest()[:16] def read_wav(p): with wave.open(str(p)) as w: ch = w.getnchannels() x = np.frombuffer(w.readframes(w.getnframes()), " macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, vc, 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, vc, 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 carrier(f_per_sample, nh=30, detune=(0.0, -0.6, 0.65), seed=0): n = len(f_per_sample) out = np.zeros(n) for d in detune: f = f_per_sample*(1 + d*0.005) ph = 2*np.pi*np.cumsum(f)/SR for k in range(1, nh+1): live = (f*k) < SR*0.45 if not live.any(): break out += np.sin(ph*k)/k * live return out/len(detune) def vocode(mod, car, nfft=1024, hop=256, bands=24, lo=120, hi=6200, gmax=11.0, rel=0.55, sib=0.05, tilt=4000.0): n = max(len(mod), len(car)) mod = np.pad(mod, (0, n-len(mod))); car = np.pad(car, (0, n-len(car))) win = np.hanning(nfft); nfr = 1 + max(0, (n-nfft))//hop fr = np.fft.rfftfreq(nfft, 1/SR) edges = np.geomspace(lo, hi, bands+1) idx = [np.where((fr >= edges[b]) & (fr < edges[b+1]))[0] for b in range(bands)] keep = np.zeros(len(fr), bool) for ii in idx: keep[ii] = True out = np.zeros(n); wsum = np.zeros(n)+1e-9 prev = np.zeros(bands) for f in range(nfr): s = f*hop M = np.fft.rfft(mod[s:s+nfft]*win); C = np.fft.rfft(car[s:s+nfft]*win) am = np.abs(M); ac = np.abs(C) g = np.zeros(len(fr)) for b, ii in enumerate(idx): if not len(ii): continue em = np.sqrt((am[ii]**2).mean()); ec = np.sqrt((ac[ii]**2).mean()) gb = np.clip(em/(ec+1e-4), 0, gmax) gb = prev[b]*rel + gb*(1-rel) prev[b] = gb; g[ii] = gb out[s:s+nfft] += np.fft.irfft(C*g*keep)*win wsum[s:s+nfft] += win**2 ws = np.maximum(wsum, 0.35*np.median(wsum[nfft:max(nfft+1, n-nfft)])) y = out/ws y[:hop] = 0.0; y[-hop:] = 0.0 y /= (np.max(np.abs(y))+1e-9) hp = np.zeros_like(mod); hp[1:] = mod[1:]-mod[:-1] for _ in range(2): hp = np.convolve(hp, [1, -0.93], "same") hp = np.clip(hp/(np.percentile(np.abs(hp), 99.5)+1e-9), -1, 1) a = math.exp(-2*math.pi*tilt/SR); z = 0.0; lp = np.empty_like(y) for i in range(len(y)): z = (1-a)*y[i] + a*z; lp[i] = z y = 0.55*y + 0.85*lp + sib*hp return y/(np.max(np.abs(y))+1e-9) # raga Bhairavi — the qawwali raga. S r G m P d n S' BHAIRAVI = [0, 1, 4, 5, 7, 8, 10, 12, 13, 16, 17, 19] def melisma_path(dur, degs, root, gamak=0.7, seed=0, glide=0.055): """A CONTINUOUS pitch path through the raga — the melisma. `degs` = [(weight, scale_index, ornament)] where ornament is '-' plain, '~' gamak (the oscillating Indian shake), '/' slide up into the next note, '\\' slide down, '^' a quick upper touch (kan swar). The path is built at control resolution, smoothed hard (portamento), and only then integrated to phase — so nothing is ever a step. """ n = int(dur*SR) if n < 64: return np.full(max(n, 1), root) rng = np.random.RandomState(seed % (2**31-1)) tot = sum(w for w, _, _ in degs) or 1.0 semis = np.zeros(n); orn = np.zeros(n); at = 0 for i, (w, dg, o) in enumerate(degs): ln = int(n*w/tot) if i < len(degs)-1 else n-at if ln <= 0: continue sc = BHAIRAVI[dg % len(BHAIRAVI)] + 12*(dg//len(BHAIRAVI)) seg = np.full(ln, float(sc)) u = np.linspace(0, 1, ln) if o == "^": # kan swar: touch the note above nxt = BHAIRAVI[(dg+1) % len(BHAIRAVI)] + 12*((dg+1)//len(BHAIRAVI)) seg += (nxt-sc)*np.exp(-((u-0.14)/0.09)**2) elif o == "/": seg -= 2.0*np.exp(-u/0.22) elif o == "\\": seg += 2.0*np.exp(-u/0.22) if o == "~": orn[at:at+ln] = 1.0 semis[at:at+ln] = seg; at += ln k = max(3, int(glide*SR)) semis = np.convolve(semis, np.ones(k)/k, "same") semis[:k] = semis[k]; semis[-k:] = semis[-k-1] orn = np.convolve(orn, np.ones(k)/k, "same") t = np.arange(n)/SR shake = gamak*orn*np.sin(2*np.pi*(5.4 + 1.1*np.sin(2*np.pi*0.6*t))*t + rng.uniform(0, 6.283)) vib = 0.11*np.sin(2*np.pi*5.9*t + rng.uniform(0, 6.283))*np.clip(t*1.4, 0, 1) return root*2.0**((semis + shake + vib)/12.0) def sing(text, dur, degs, root, voice="Rishi", rate=150, gamak=0.7, seed=0, nh=30, detune=(0.0, -0.6, 0.65), **vk): n = int(dur*SR) mod = fit(say_wav(text, voice, rate, AUD/("say_"+_h(text, voice, rate)+".wav")), n) f = melisma_path(dur, degs, root, gamak=gamak, seed=seed) return vocode(mod, carrier(f, nh=nh, detune=detune, seed=seed), **vk) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ SA = nf("D3") # tonic of the harmonium class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] 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 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.28): env = np.ones(self.n) for nm, b0, b1 in SECTIONS: i0, i1 = int(t_of(b0)*SR), min(self.n, int(t_of(b1)*SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) env[int(t_of(SECTIONS[-1][2])*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=.22, 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 i < self.n: env[i:j] = np.minimum(env[i:j], shape[:j-i]) env = np.convolve(env, np.ones(280)/280, "same") mix *= env[:, None] for c in range(2): mix[:, c] = bandshape(mix[:, c], lo=30.0) mix = np.tanh(mix*1.25)/np.tanh(1.25) return mix/(np.max(np.abs(mix))+1e-9)*.94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1)*32767).astype("= 84): for j in (1, 3): s.put("clap", taali(seed=b*23+j, hands=7, bright=1.15, g=.7), t_of(b + j/4.0), g=.42, pan=(-.30 if j == 1 else .30)) # ── harmonium right hand: the naghma, chords on the raga ──────────── if sec != "open": deg = [0, 4, 7, 5, 2, 7, 4, 0][b % 8] ivs = ([0, 7] if not loud else [0, 5, 7] if not big else [0, 4, 7]) fr = [SA*2*2**((BHAIRAVI[(deg+i) % len(BHAIRAVI)])/12.0) for i in ivs] ln = t_of(b+1) - t_of(b) s.put("harm", harmonium(fr, ln*0.96, a=.02, d=.10, s=.8, r=.10, seed=b*29, bellows=(0.05, 3.1), bright=1.0 if loud else .7), t_of(b), g=(.075 if loud else .050)*(1.2 if big else 1.0), pan=-.10 + .05*(b % 3)) # ── sub, on the dholak downbeat ───────────────────────────────────── if sec not in ("open", "print"): deg = [0, 0, 7, 5, 0, 7, 3, 0][b % 8] ln = (t_of(b+1)-t_of(b))*0.9 s.put("bass", sub(SA*0.5*2**(BHAIRAVI[deg % len(BHAIRAVI)]/12.0), ln, seed=b*31), t_of(b), g=.34 if loud else .20) # ── the lead qawwal ───────────────────────────────────────────────────── for (b0, bl, text, degs, vc, g) in LEAD: dur = t_of(min(BEATS, b0+bl)) - t_of(b0) if dur <= 0.3: continue y = sing(text, dur, degs, SA*2, voice=vc, rate=140, gamak=0.75, seed=int(b0*17)) s.put("lead", y, t_of(b0), g=0.30*g, pan=-0.06) # the lower octave double — the second qawwal on the same phrase y2 = sing(text, dur, degs, SA, voice="Majed", rate=132, gamak=0.55, seed=int(b0*17)+5) s.put("lead2", y2, t_of(b0), g=0.13*g, pan=0.18) # ── chorus answers, three voices in unison + octave ───────────────────── for (b0, bl, text, degs) in CHORUS: dur = t_of(min(BEATS, b0+bl)) - t_of(b0) if dur <= 0.25: continue for k, (vc, rt, rf, pan, gg) in enumerate(( ("Rishi", 150, 1.0, -0.34, 0.20), ("Lekha", 165, 2.0, 0.34, 0.15), ("Majed", 140, 0.5, 0.02, 0.11))): y = sing(text, dur, degs, SA*2*rf, voice=vc, rate=rt, gamak=0.45, seed=int(b0*31)+k, detune=(0.0, -1.1, 1.2)) s.put("chor", y, t_of(b0), g=gg, pan=pan) # ── one-shot sound design, on the story beats ─────────────────────────── s.put("fx", hiss_sweep(0.8, seed=41), t_of(3.0), g=.30, pan=-.15) s.put("fx", hiss_sweep(0.9, seed=43), t_of(6.0), g=.26, pan=.15) s.put("fx", windfx(t_of(70)-t_of(47.6), seed=27, rise=1.0), t_of(47.6), g=.30, pan=0.0) s.put("fx", windfx(2.2, seed=29, lo0=180, hi0=3600, rise=1.3), t_of(48.0), g=.26, pan=-.2) s.put("fx", taali(0.6, seed=77, hands=14, bright=1.25), t_of(48.0), g=.55) s.put("fx", taali(0.6, seed=79, hands=14, bright=1.25), t_of(70.0), g=.55) s.put("fx", taali(0.7, seed=83, hands=16, bright=1.3), t_of(92.0), g=.62) for j in range(4): s.put("fx", hiss_sweep(0.9, seed=91+j), t_of(106.0+j*2.0), g=.38, pan=-.4 + .27*j) s.put("fx", hiss_sweep(1.1, seed=97), t_of(113.4), g=.34, pan=.1) s.put("fx", dholak_bass(.9, f0=150, f1=52, punch=9, seed=99), t_of(114.0), g=.55) s.put("fx", tabla_na(1.2, f=560, seed=101), t_of(114.0), g=.30, pan=.2) # the landing: the harmonium resolves onto Sa and just keeps breathing s.put("harm", harmonium([SA, SA*2**(7/12), SA*2, SA*2*2**(4/12)], DUR - t_of(114.0), a=.35, d=1.2, s=.72, r=1.5, seed=131, bellows=(0.075, 2.2), bright=0.85), t_of(114.0), g=.085, pan=-.05) for j, bb in enumerate((115.5, 117.4, 119.6)): s.put("fx", dholak_bass(.8, f0=140, f1=50, punch=8, seed=137+j), t_of(bb), g=.30 - .06*j) s.bus("lead", lambda x: reverb(delay(x, 0.26, .26, .18), rt=2.1, mix=.30, seed=301)) s.bus("lead2", lambda x: reverb(x, rt=2.6, mix=.34, seed=303)) s.bus("chor", lambda x: reverb(delay(x, 0.31, .30, .20), rt=2.4, mix=.40, seed=307)) s.bus("clap", lambda x: reverb(x, rt=1.0, mix=.20, seed=311)) s.bus("perc", lambda x: reverb(x, rt=0.7, mix=.12, seed=313)) s.bus("harm", lambda x: reverb(x, rt=1.9, mix=.28, seed=317)) s.bus("drone", lambda x: reverb(x, rt=3.4, mix=.36, seed=319)) s.bus("fx", lambda x: reverb(x, rt=2.2, mix=.32, seed=323)) mix = s.mixdown(dict(kit=1.0, perc=1.0, clap=1.0, bass=1.0, harm=1.0, lead=1.2, lead2=1.0, chor=1.1, drone=1.0, fx=1.0), pump_depth=.18, pump_rel=.12, levels=dict(open=.72, dunes=.86, caravan=.97, storm=1.0, city=1.03, arrive=1.12, sweep=.92, print=.96)) wav = AUD/"final.wav" s.write(wav, mix) vox = np.zeros(s.n) for k in ("lead", "lead2", "chor"): if k in s.tr: vox += s.tr[k].mean(1) clp = s.tr["clap"].mean(1) if "clap" in s.tr else np.zeros(s.n) return wav, mix, vox, clp def analyze(mix, vox, clp): x = mix.mean(1) hop = SR/FPS; win = int(hop*1.7) keys = ("rms", "low", "mid", "high", "voice", "clap") E = {k: np.zeros(N_FRAMES) for k in keys} for f in range(N_FRAMES): i = int(f*hop); seg = x[i:i+win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg**2).mean()) sp = np.abs(np.fft.rfft(seg*np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1/SR) E["low"][f] = sp[fr < 180].sum() E["mid"][f] = sp[(fr >= 180) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].sum() vs = vox[i:i+win]; cs = clp[i:i+win] E["voice"][f] = np.sqrt((vs**2).mean()) if len(vs) > 16 else 0.0 E["clap"][f] = np.sqrt((cs**2).mean()) if len(cs) > 16 else 0.0 for k in keys: 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 SAND — a mass field on backlit glass. The new substrate. # ════════════════════════════════════════════════════════════════════════════ def boxblur(a, k): """Integral-image box blur. Used for berms, hand shadows and settling.""" if k < 1: return a h, w = a.shape c = np.cumsum(np.cumsum(a.astype(np.float32), 0), 1) c = np.pad(c, ((1, 0), (1, 0))) ys = np.arange(h); xs = np.arange(w) y0 = np.clip(ys-k, 0, h); y1 = np.clip(ys+k+1, 0, h) x0 = np.clip(xs-k, 0, w); x1 = np.clip(xs+k+1, 0, w) S = (c[np.ix_(y1, x1)] - c[np.ix_(y0, x1)] - c[np.ix_(y1, x0)] + c[np.ix_(y0, x0)]) A = ((y1-y0)[:, None]*(x1-x0)[None, :]).astype(np.float32) return (S/A).astype(np.float32) def softblur(a, k): """Two box passes = a tent kernel. One pass alone leaves square corners on every glow, which reads as a rectangle drawn around the hand.""" return boxblur(boxblur(a, max(1, k//2)), max(1, k - k//2)) def value_noise(h, w, scale, seed): rng = np.random.RandomState(seed % (2**31-1)) 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).astype(np.float32) def fbm(h, w, scale, seed, oct=4): scale = scale*SC # feature size is a fraction of the plate out = np.zeros((h, w), np.float32); amp = 1.0; nrm = 0.0 for o in range(oct): out += amp*value_noise(h, w, max(2.0, scale/(2**o)), seed+o*13) nrm += amp; amp *= .5 return out/nrm class Sand: """A depth field of sand on glass. Every operator conserves mass except `sprinkle`, which is a hand dropping new sand onto the plate.""" __slots__ = ("h", "w", "D") def __init__(self, h, w, D=None): self.h, self.w = h, w self.D = np.zeros((h, w), np.float32) if D is None else D.astype(np.float32).copy() # ── the only operator that creates sand ──────────────────────────────── def sprinkle(self, cx, cy, amount, spread, rng, grains=600): # the fall widens with the plate, so the mass and the grain count go # as the AREA — sand per pixel is what the lightbox actually reads spread = spread*SC; amount = amount*SC*SC grains = int(round(grains*SC*SC)) r = np.abs(rng.normal(0, spread, grains)) a = rng.uniform(0, math.tau, grains) xs = np.clip(cx + r*np.cos(a), 0, self.w-1.01) ys = np.clip(cy + r*np.sin(a)*0.92, 0, self.h-1.01) amt = rng.uniform(0.4, 1.0, grains)*(amount/grains) x0 = xs.astype(np.int32); y0 = ys.astype(np.int32) np.add.at(self.D, (y0, x0), amt.astype(np.float32)) # ── conservative displacement: a finger pushing ──────────────────────── def _add_at(self, patch, y, x): ph, pw = patch.shape y0 = max(0, y); x0 = max(0, x) y1 = min(self.h, y+ph); x1 = min(self.w, x+pw) if y1 <= y0 or x1 <= x0: return self.D[y0:y1, x0:x1] += patch[y0-y:y1-y, x0-x:x1-x] def push(self, cx, cy, dx, dy, r, strength=0.85): """Drag a finger from (cx,cy) by (dx,dy): lift the sand inside the disc and drop it as a bow wave in front + two berms at the sides. Tap weights sum to 1, so not one grain is invented or lost.""" r = r*SC L = math.hypot(dx, dy) if L < 0.35 or r < 2: return ri = int(r)+2 y0 = max(0, int(cy)-ri); y1 = min(self.h, int(cy)+ri) x0 = max(0, int(cx)-ri); x1 = min(self.w, int(cx)+ri) if y1-y0 < 3 or x1-x0 < 3: return yy = np.arange(y0, y1, dtype=np.float32)[:, None] - cy xx = np.arange(x0, x1, dtype=np.float32)[None, :] - cx m = np.clip(1.0 - (xx*xx + yy*yy)/(r*r), 0, 1)**0.8 take = self.D[y0:y1, x0:x1]*m*strength self.D[y0:y1, x0:x1] -= take nx, ny = dx/L, dy/L px, py = -ny, nx fw = min(r*0.85, L*1.5 + r*0.35) taps = ((nx*fw, ny*fw, 0.46), (px*r*0.72 + nx*fw*0.35, py*r*0.72 + ny*fw*0.35, 0.27), (-px*r*0.72 + nx*fw*0.35, -py*r*0.72 + ny*fw*0.35, 0.27)) for ox, oy, wgt in taps: self._add_at(take*wgt, y0+int(round(oy)), x0+int(round(ox))) def stroke(self, p0, p1, r, strength=0.7, steps=None): d = math.hypot(p1[0]-p0[0], p1[1]-p0[1]) n = steps or max(1, int(d/(r*SC*0.5))) for i in range(n): u0 = i/n; u1 = (i+1)/n ax = p0[0]+(p1[0]-p0[0])*u0; ay = p0[1]+(p1[1]-p0[1])*u0 bx = p0[0]+(p1[0]-p0[0])*u1; by = p0[1]+(p1[1]-p0[1])*u1 self.push(ax, ay, bx-ax, by-ay, r, strength) # ── conservative morph: the hand working toward a tableau ────────────── def matched(self, tgt): """Rescale a target so it holds exactly the sand that is on the glass — one image really does become the next out of the same material.""" ts = float(tgt.sum()) if ts <= 1e-6: return tgt return tgt*(float(self.D.sum())/ts) def relax(self, tgt, rate, mask=None): d = tgt - self.D if mask is not None: d = d*mask dem = np.maximum(d, 0.0); exc = np.maximum(-d, 0.0) td = float(dem.sum()); te = float(exc.sum()) if td <= 1e-6 or te <= 1e-6: return m = min(td, te)*rate self.D += dem*(m/td) self.D -= exc*(m/te) # ── the storm ────────────────────────────────────────────────────────── def advect(self, vx, vy): m0 = float(self.D.sum()) ys, xs = np.mgrid[0:self.h, 0:self.w].astype(np.float32) sx = np.clip(xs - vx, 0, self.w-1.001) sy = np.clip(ys - vy, 0, self.h-1.001) x0 = sx.astype(np.int32); y0 = sy.astype(np.int32) fx = sx-x0; fy = sy-y0 x1 = np.minimum(x0+1, self.w-1); y1 = np.minimum(y0+1, self.h-1) D = self.D out = (D[y0, x0]*(1-fx)*(1-fy) + D[y0, x1]*fx*(1-fy) + D[y1, x0]*(1-fx)*fy + D[y1, x1]*fx*fy) m1 = float(out.sum()) self.D = (out*(m0/m1) if m1 > 1e-6 else out).astype(np.float32) def settle(self, k=2, amt=0.10): self.D = ((1-amt)*self.D + amt*boxblur(self.D, sci(k))).astype(np.float32) def press(self, mask, depth=0.96, ring=13): ring = int(round(ring*SC)) """Press a shape into the sand: everything under it is lifted and heaped into the blurred ring just outside it.""" take = self.D*mask*depth tot = float(take.sum()) self.D -= take if tot <= 1e-6: return halo = np.maximum(softblur(mask, ring*2) - mask, 0.0) hs = float(halo.sum()) if hs <= 1e-6: return self.D += (halo*(tot/hs)).astype(np.float32) # ── the lightbox: Beer-Lambert transmission, plus grain and relief ────────── KABS = np.array([1.72, 3.10, 5.70], np.float32) # per-channel absorption _SPK = {} def speck(seed): if seed not in _SPK: rng = np.random.RandomState(seed) bh, bw = int(round(SH_S/SC)), int(round(SW_S/SC)) a = rng.rand(bh, bw).astype(np.float32) b = (a + np.roll(a, 1, 0) + np.roll(a, 1, 1) + np.roll(np.roll(a, 1, 0), 1, 1))*0.25 sp = (0.42*a + 0.58*b).astype(np.float32) if SC != 1.0: # a grain of sand is a grain of sand sp = np.asarray(Image.fromarray(sp, "F") .resize((SW_S, SH_S), Image.NEAREST), np.float32) _SPK[seed] = sp return _SPK[seed] _LAMP = {} def lampfield(): """The glass is not evenly lit: a soft centre pool and the faint ghosts of the tubes underneath.""" if "L" not in _LAMP: yy, xx = np.mgrid[0:SH_S, 0:SW_S].astype(np.float32) nx = (xx-SW_S*0.5)/(SW_S*0.62); ny = (yy-SH_S*0.5)/(SH_S*0.70) pool = np.clip(1.06 - 0.30*(nx*nx+ny*ny), 0.55, 1.06) tubes = 1.0 + 0.022*np.sin(yy/(SH_S/6.0)*math.tau) _LAMP["L"] = (pool*tubes).astype(np.float32) return _LAMP["L"] def render_sand(D, gain=1.0, grain=0.50, relief=0.55, sp=None, sp2=None): sp = speck(4041) if sp is None else sp sp2 = speck(9092) if sp2 is None else sp2 De = D*(1.0 - grain*0.5 + grain*sp) De = De + (sp2-0.5)*0.055*np.clip(D*4.0, 0, 1) np.maximum(De, 0.0, out=De) L = lampfield()*gain out = np.empty((SH_S, SW_S, 3), np.float32) for c in range(3): out[..., c] = np.exp(-KABS[c]*De) out *= L[..., None] if relief: rr = sci(3) g = (np.roll(D, rr, 0) - np.roll(D, -rr, 0)) + \ (np.roll(D, rr, 1) - np.roll(D, -rr, 1)) rp = np.clip(g*0.45, 0.0, 0.30)*relief out[..., 0] += rp*0.30; out[..., 1] += rp*0.21; out[..., 2] += rp*0.10 return np.clip(out*255.0, 0, 255) # ════════════════════════════════════════════════════════════════════════════ # THE TABLEAUX — target depth fields. Sand becomes each of these in turn. # ════════════════════════════════════════════════════════════════════════════ def _sig(x): return 1.0/(1.0+np.exp(-np.clip(x, -30, 30))) _YY, _XX = np.mgrid[0:SH_S, 0:SW_S].astype(np.float32) _XN = _XX/SW_S _TC = {} def dune_field(crests, seed=1717, ripple=0.055, veil=0.11): key = ("d", tuple(crests), seed, ripple, veil) if key in _TC: return _TC[key] D = np.full((SH_S, SW_S), veil, np.float32) D += veil*0.55*(fbm(SH_S, SW_S, 260, seed, 3)-0.5) for (yb, amp, fr, ph, th, soft) in crests: cy = SH_S*(yb + amp*np.sin(_XN*fr*math.tau + ph) + 0.42*amp*np.sin(_XN*fr*1.83*math.tau + ph*1.7)) D = D + th*_sig((_YY-cy)/soft) D += (ripple*np.sin(_XN*7.5*math.tau + _YY/SH_S*34.0) * np.clip((D-0.3)*2, 0, 1)) D = np.maximum(D, 0.0).astype(np.float32) _TC[key] = D return D CRESTS_LOW = ((0.72, 0.030, 1.3, 0.4, 0.55, 26), (0.86, 0.022, 2.1, 2.2, 0.55, 20)) CRESTS_FULL = ((0.545, 0.052, 1.15, 0.6, 0.52, 20), (0.665, 0.040, 1.9, 2.4, 0.48, 17), (0.80, 0.030, 2.7, 4.1, 0.55, 14), (0.925, 0.020, 3.6, 1.1, 0.60, 12)) CRESTS_SETTLE = ((0.775, 0.036, 1.7, 1.4, 0.75, 30), (0.90, 0.026, 2.6, 3.3, 0.62, 24)) # the city ground: almost flat, so the arriving caravan is legible against it CRESTS_CITY = ((0.905, 0.016, 2.2, 1.0, 0.62, 22),) def _blank(): return Image.new("L", (SW_S, SH_S), 0) def _oval(dr, cx, cy, rx, ry, ang, fill, n=30): """Rotated ellipse as a polygon — PIL's ellipse() cannot rotate and detonates the moment a rotated bbox comes out unordered.""" a = np.linspace(0, math.tau, n, endpoint=False) x = rx*np.cos(a); y = ry*np.sin(a) c, s = math.cos(ang), math.sin(ang) dr.polygon([(cx + px*c - py*s, cy + px*s + py*c) for px, py in zip(x, y)], fill=fill) def _thick(d, pts, wid, fill=255): pts = [(float(x), float(y)) for x, y in pts] d.line(pts, fill=fill, width=max(1, int(wid)), joint="curve") for (x, y) in pts: r = wid/2.0 d.ellipse([x-r, y-r, x+r, y+r], fill=fill) def draw_camel(d, x, y, s, ph, rider=True, fill=255): """A dromedary in profile, right-facing. Local units are body-lengths.""" def P(u, v): return (x + u*s, y + v*s) lw = max(2, int(0.052*s)) # far legs first (they read as a hair thinner because they overlap less) for j, (lx, po) in enumerate(((-0.28, 0.00), (-0.20, 0.52), (0.14, 0.50), (0.22, 0.02))): a = math.sin(ph + po*math.tau) b = math.cos(ph + po*math.tau) hip = P(lx, 0.10) knee = P(lx + 0.075*a, 0.36 - 0.02*abs(b)) foot = P(lx + 0.15*a, 0.60 - 0.07*max(0.0, b)) _thick(d, [hip, knee, foot], lw, fill) # body + hump d.ellipse([*P(-0.40, -0.16), *P(0.34, 0.17)], fill=fill) d.ellipse([*P(-0.20, -0.34), *P(0.14, 0.02)], fill=fill) # neck to head _thick(d, [P(0.26, -0.06), P(0.40, -0.20), P(0.48, -0.38), P(0.50, -0.50)], max(2, int(0.085*s)), fill) d.ellipse([*P(0.44, -0.60), *P(0.60, -0.47)], fill=fill) d.ellipse([*P(0.55, -0.57), *P(0.70, -0.48)], fill=fill) d.polygon([P(0.47, -0.60), P(0.50, -0.70), P(0.53, -0.59)], fill=fill) _thick(d, [P(-0.40, -0.06), P(-0.49, 0.06), P(-0.50, 0.22)], max(1, int(0.022*s)), fill) if rider: d.ellipse([*P(-0.12, -0.56), *P(0.06, -0.30)], fill=fill) d.ellipse([*P(-0.09, -0.68), *P(0.03, -0.55)], fill=fill) _thick(d, [P(-0.05, -0.50), P(0.10, -0.44)], max(1, int(0.030*s)), fill) def caravan_mask(xs, y, s, ph, riders=None): im = _blank(); d = ImageDraw.Draw(im) for i, cx in enumerate(xs): draw_camel(d, cx, y, s*(1.0 - 0.03*(i % 3)), ph + i*1.9, rider=(True if riders is None else riders[i % len(riders)])) return np.asarray(im, np.float32)/255.0 def city_mask(rise, gate=True, seed=606): """Walls, domes, minarets and a pointed gate, standing UP out of the sand. `rise` scales every height about the ground line.""" im = _blank(); d = ImageDraw.Draw(im) gy = SH_S*0.70 r = max(0.0, min(1.0, rise)) def Y(h): return gy - h*r # h in pixels above the ground line # the wall d.rectangle([SW_S*0.12, Y(SH_S*0.16), SW_S*0.90, gy+SH_S*0.06], fill=255) for j in range(19): # crenellations cx = SW_S*(0.125 + j*0.0405) d.rectangle([cx, Y(SH_S*0.195), cx+SW_S*0.020, Y(SH_S*0.155)], fill=255) # domes for (dx, dw, dh) in ((0.30, 0.115, 0.135), (0.53, 0.150, 0.185), (0.735, 0.098, 0.115)): cx = SW_S*dx; rw = SW_S*dw*0.5; hh = SH_S*dh base = Y(SH_S*0.145) d.pieslice([cx-rw, base-hh, cx+rw, base+hh*0.55], 180, 360, fill=255) _thick(d, [(cx, base-hh), (cx, base-hh-SH_S*0.045*r)], max(2, int(SW_S*0.006)), 255) d.ellipse([cx-SW_S*0.010, base-hh-SH_S*0.062*r, cx+SW_S*0.010, base-hh-SH_S*0.040*r], fill=255) # minarets for (mx, mh) in ((0.185, 0.40), (0.395, 0.335), (0.655, 0.365), (0.855, 0.30)): cx = SW_S*mx; hw = SW_S*0.0135 top = Y(SH_S*mh) d.rectangle([cx-hw, top, cx+hw, gy], fill=255) d.rectangle([cx-hw*2.3, top+SH_S*0.030*r, cx+hw*2.3, top+SH_S*0.052*r], fill=255) # balcony d.pieslice([cx-hw*1.8, top-SH_S*0.030*r, cx+hw*1.8, top+SH_S*0.014*r], 180, 360, fill=255) # bulb _thick(d, [(cx, top-SH_S*0.028*r), (cx, top-SH_S*0.062*r)], max(2, int(SW_S*0.0045)), 255) # spire if gate and r > 0.35: gx = SW_S*0.53; gw = SW_S*0.058; gh = SH_S*0.125*r d.rectangle([gx-gw, gy-gh*0.55, gx+gw, gy+SH_S*0.06], fill=0) d.pieslice([gx-gw, gy-gh-gw*0.55, gx+gw, gy-gh+gw*0.55], 180, 360, fill=0) d.polygon([(gx-gw, gy-gh), (gx, gy-gh-SH_S*0.042*r), (gx+gw, gy-gh)], fill=0) # windows in the wall R = np.random.RandomState(seed) for j in range(16): wx = SW_S*(0.155 + j*0.0475) if abs(wx-gx) < gw*1.6: continue wy = gy - SH_S*0.085*r ww = SW_S*0.0085; wh = SH_S*0.030*r d.rectangle([wx-ww, wy-wh*0.4, wx+ww, wy+wh*0.6], fill=0) d.pieslice([wx-ww, wy-wh*0.4-ww, wx+ww, wy-wh*0.4+ww], 180, 360, fill=0) return np.asarray(im, np.float32)/255.0 # ── the hand: one shape function, used for the visible hand AND the print ── def hand_poly(d, hx, hy, ang, s, spread=1.0, curl=0.0, fill=255, wrist=1.0): """A hand seen from above, fingers pointing along `ang`. `s` is roughly the palm width in pixels.""" s = s*SC # palm width is a length on the plate ca, sa = math.cos(ang), math.sin(ang) def P(u, v): return (hx + (u*ca - v*sa)*s, hy + (u*sa + v*ca)*s) # wrist / forearm if wrist: wl = 1.75*wrist d.polygon([P(-0.62, -0.40), P(-wl*0.55, -0.44), P(-wl, -0.36), P(-wl, 0.36), P(-wl*0.55, 0.44), P(-0.62, 0.42)], fill=fill) # palm d.polygon([P(-0.72, -0.44), P(0.10, -0.50), P(0.16, 0.30), P(-0.30, 0.56), P(-0.74, 0.40)], fill=fill) _oval(d, *P(-0.225, 0.0), 0.325*s, 0.42*s, ang, fill) # four fingers fw = max(2, int(0.145*s)) for j, (fy, ln, fa) in enumerate(((-0.40, 0.62, -0.30), (-0.15, 0.72, -0.09), (0.09, 0.68, 0.11), (0.31, 0.55, 0.33))): fy *= spread; fa *= spread a1 = fa - curl*0.55; a2 = fa - curl*1.25 p0 = P(0.08, fy) m = (0.08 + ln*0.55*math.cos(a1), fy + ln*0.55*math.sin(a1)) e = (m[0] + ln*0.5*math.cos(a2), m[1] + ln*0.5*math.sin(a2)) _thick(d, [p0, P(*m), P(*e)], fw, fill) # thumb tw = max(2, int(0.19*s)) _thick(d, [P(-0.48, 0.40), P(-0.20, 0.74), P(0.14, 0.86)], tw, fill) def hand_mask(hands): im = _blank(); d = ImageDraw.Draw(im) for hh in hands: hand_poly(d, *hh[:3], hh[3], spread=hh[4] if len(hh) > 4 else 1.0, curl=hh[5] if len(hh) > 5 else 0.0, fill=255, wrist=hh[6] if len(hh) > 6 else 1.0) return np.asarray(im, np.float32)/255.0 HAND_FILL = (74, 46, 32) def draw_hands(im, hands, glow=1.0, contact=1.0): """Composite the hands over the lit plate. They are lit only from below, so: a hard little shadow where they touch the glass, a warm rim where the light leaks past them, and a dark warm body.""" if not hands: return im lay = Image.new("L", im.size, 0) d = ImageDraw.Draw(lay) for hh in hands: hand_poly(d, hh[0], hh[1], hh[2], hh[3], spread=hh[4] if len(hh) > 4 else 1.0, curl=hh[5] if len(hh) > 5 else 0.0, fill=255, wrist=hh[6] if len(hh) > 6 else 1.0) a = np.asarray(lay, np.float32)/255.0 base = np.asarray(im, np.float32) halo = np.maximum(softblur(a, sci(19)) - a, 0.0)*glow base += halo[..., None]*np.array([120, 74, 26], np.float32) sh = np.clip(softblur(a, sci(9))*1.30, 0, 1)*contact base *= (1.0 - 0.40*sh)[..., None] # the hand itself, with a warm gradient toward the fingertips grad = 0.72 + 0.55*(1.0 - np.clip(softblur(a, sci(25)), 0, 1)) hcol = np.array(HAND_FILL, np.float32)[None, None, :]*grad[..., None] base = base*(1-a[..., None]) + hcol*a[..., None] return Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) # ════════════════════════════════════════════════════════════════════════════ # CAMERA — every frame is a crop of the plate # ════════════════════════════════════════════════════════════════════════════ SHOT_W = {"wide": 1.00, "full": 0.78, "mid": 0.56, "cu": 0.34, "ins": 0.21, "macro": 0.12} def _box(anchors, shot): kind, _, target = shot.partition("_") fw = SHOT_W.get(kind, 1.0) cx, cy = anchors.get(target or "_", anchors.get("_", (SW_S/2, SH_S/2))) if kind == "wide": cx, cy = SW_S/2, SH_S/2 elif kind == "full": cx = (cx+SW_S/2)/2; cy = (cy+SH_S/2)/2 bw = SW_S*fw; bh = bw/ASPECT if bh > SH_S: bh = SH_S; bw = bh*ASPECT return cx, cy, bw, bh def _ease(u): return u*u*(3-2*u) def shoot(stage, anchors, shot, f01, jseed=0, push=0.05, drift=1.0, roll=0.0): if ">" in shot: a, b = shot.split(">", 1) ca = _box(anchors, a.strip()); cb = _box(anchors, b.strip()) e = _ease(min(max(f01, 0.0), 1.0)) cx, cy, bw, bh = (ca[i] + (cb[i]-ca[i])*e for i in range(4)) else: cx, cy, bw, bh = _box(anchors, shot) k = 1.0 - push*f01 bw *= k; bh *= k fw = bw/SW_S jx = math.sin(f01*1.6 + jseed)*7*(1-fw*.55)*drift jy = math.cos(f01*1.27 + jseed*1.7)*5*(1-fw*.55)*drift x0 = cx - bw/2 + jx; y0 = cy - bh/2 + jy x0 = max(0, min(SW_S-bw, x0)); y0 = max(0, min(SH_S-bh, y0)) if roll: a = abs(math.radians(roll)) k = math.cos(a) + math.sin(a)/ASPECT ow, oh = min(SW_S, bw*k), min(SH_S, bh*k) ox = max(0, min(SW_S-ow, cx-ow/2 + jx)) oy = max(0, min(SH_S-oh, cy-oh/2 + jy)) crp = stage.crop((int(ox), int(oy), int(ox+ow), int(oy+oh))) crp = crp.rotate(roll, resample=Image.BICUBIC, expand=False) iw, ih = crp.size crp = crp.crop((int((iw-iw/k)/2), int((ih-ih/k)/2), int((iw+iw/k)/2), int((ih+ih/k)/2))) return crp.resize((W, H), Image.LANCZOS) return stage.crop((int(x0), int(y0), int(x0+bw), int(y0+bh))).resize( (W, H), Image.LANCZOS) # ════════════════════════════════════════════════════════════════════════════ # TABLEAU REGISTRY — a named starting state for any shot # ════════════════════════════════════════════════════════════════════════════ CARAV_Y = SH_S*0.615 def caravan_xs(u, n=5, spacing=0.175, base=-0.12, speed=0.52): return [SW_S*(base + u*speed + i*spacing) for i in range(n)] def tableau(key, **kw): if key == "empty": return np.zeros((SH_S, SW_S), np.float32) if key == "veil": return (np.full((SH_S, SW_S), 0.16, np.float32) + 0.09*(fbm(SH_S, SW_S, 190, 55, 3)-0.5)) if key == "dunes0": return dune_field(CRESTS_LOW, seed=1717) if key == "dunes": return dune_field(CRESTS_FULL, seed=1717) if key == "settle": return dune_field(CRESTS_SETTLE, seed=2929, veil=0.16) if key == "carav_far": d = dune_field(CRESTS_FULL, seed=1717).copy() m = caravan_mask(caravan_xs(kw.get("u", 0.35), n=5, spacing=0.095, base=0.06, speed=0.30), SH_S*0.545, SW_S*0.055, kw.get("ph", 0.0)) return d + m*1.05 if key == "carav": d = dune_field(CRESTS_FULL, seed=1717).copy() m = caravan_mask(caravan_xs(kw.get("u", 0.3), n=4), CARAV_Y, SW_S*0.165, kw.get("ph", 0.0)) return d + m*1.15 if key == "storm": d = dune_field(CRESTS_SETTLE, seed=3131, veil=0.20) return d if key == "city": d = dune_field(CRESTS_CITY, seed=2929, veil=0.145).copy() return d + city_mask(kw.get("rise", 1.0))*1.25 if key == "flat": return (np.full((SH_S, SW_S), 0.52, np.float32) + 0.10*(fbm(SH_S, SW_S, 150, 71, 3)-0.5)) raise SystemExit(f"unknown tableau: {key}") def start_field(key, **kw): """A deterministic starting state for a shot, so `--shots N` is exact.""" D = tableau(key, **kw) return Sand(SH_S, SW_S, D) # ════════════════════════════════════════════════════════════════════════════ # ENGINES # ════════════════════════════════════════════════════════════════════════════ class Base: def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params self.sp = speck(4041); self.sp2 = speck(9092) self.setup() def setup(self): pass def beat(self, k): return beat_of((self.s.i0+k)/FPS) def plate(self, D, gain=1.0, grain=0.50, relief=0.55): return render_sand(D, gain=gain, grain=grain, relief=relief, sp=self.sp, sp2=self.sp2) def out(self, im, anchors, u, roll=0.0): return np.asarray(shoot(im.convert("RGB"), anchors, self.p["cam"], _ease(u), jseed=self.s.i0, push=self.p.get("push", 0.05), drift=self.p.get("drift", 1.0), roll=roll), np.float32) def hand_pair(kind, u, b, cx=None, cy=None): """Where the hands are this frame, for a given working motion.""" T = math.tau if kind == "lr": x = SW_S*(0.22 + 0.56*(0.5 - 0.5*math.cos(u*T*1.5))) y = SH_S*(0.60 + 0.10*math.sin(u*T*2.4)) return [(x, y, 0.35 + 0.5*math.sin(u*4.0), 210, 1.0, 0.25, 1.0), (SW_S*1.02 - x*0.55, y + SH_S*0.16, math.pi - 0.5, 190, 0.9, 0.5, 1.0)] if kind == "ripple": x = SW_S*(0.30 + 0.40*u) y = SH_S*(0.66 + 0.05*math.sin(b*T*0.5)) return [(x, y, -0.15 + 0.35*math.sin(b*T*0.5), 195, 1.25, 0.15, 1.0)] if kind == "dab": x = SW_S*(0.34 + 0.30*u + 0.03*math.sin(b*T)) y = SH_S*(0.50 + 0.06*math.sin(b*T*2)) return [(x, y, 0.9, 175, 0.55, 0.85, 1.0)] if kind == "lift": x = SW_S*(0.24 + 0.52*u) y = SH_S*(0.74 - 0.12*abs(math.sin(b*T*0.5))) return [(x, y, -1.35, 196, 1.1, 0.30, 0.9), (SW_S*1.34 - x*1.06, y + SH_S*0.07, -1.80, 178, 1.0, 0.35, 0.9)] if kind == "sweep": x = SW_S*(-0.05 + 1.15*u) y = SH_S*(0.55 + 0.22*math.sin(u*math.pi)) return [(x, y, 0.10, 250, 1.35, 0.05, 1.4)] return [] class Light(Base): """Bare glass. The hands arrive.""" def setup(self): self.sand = Sand(SH_S, SW_S) self.sand.D[:] = 0.012*fbm(SH_S, SW_S, 200, 5, 2) def frame(self, k, u, e): b = self.beat(k) img = self.plate(self.sand.D, gain=1.02, grain=0.30, relief=0.2) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") hs = [] if u > 0.30: q = (u-0.30)/0.70 hs = [(SW_S*(1.24 - 0.62*q), SH_S*(0.26 + 0.20*q), 2.55, 190*(0.85+0.25*q), 1.0, 0.15, 1.0), (SW_S*(-0.24 + 0.56*q), SH_S*(0.74 - 0.10*q), -0.30, 185*(0.85+0.25*q), 1.0, 0.20, 1.0)] im = draw_hands(im, hs, glow=1.0, contact=0.5) return self.out(im, {"_": (SW_S/2, SH_S/2)}, u) class Sprinkle(Base): """Sand falls from a fist. The only mass this piece ever gains.""" def setup(self): self.sand = Sand(SH_S, SW_S) self.sand.D[:] = 0.012*fbm(SH_S, SW_S, 200, 5, 2) self.b0 = t_of(3.0) self.done = 0 self._catch(self.s.i0/FPS) def _path(self, q): x = SW_S*(0.16 + 0.70*q + 0.03*math.sin(q*11)) y = SH_S*(0.34 + 0.34*q + 0.09*math.sin(q*7.5)) return x, y def _catch(self, t): n = max(0, int((t - self.b0)*FPS)) if n <= self.done: return rng = np.random.default_rng(4004) span = int((t_of(6.0)-self.b0)*FPS) for i in range(self.done, n): q = min(1.0, i/max(1, span)) x, y = self._path(q) self.sand.sprinkle(x, y, 26.0*(0.4+q), 26+58*q, rng, grains=520) self.done = n self.sand.settle(2, 0.30) def frame(self, k, u, e): t = (self.s.i0+k)/FPS self._catch(t) q = min(1.0, max(0.0, (t-self.b0)/max(0.01, t_of(6.0)-self.b0))) hx, hy = self._path(q) img = self.plate(self.sand.D, gain=1.0, grain=0.55, relief=0.5) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") # falling grains, still in the air rng = np.random.default_rng(7000 + self.s.i0 + k) d = ImageDraw.Draw(im, "RGBA") for _ in range(260): gx = hx + rng.normal(0, 30); gy = hy - abs(rng.normal(0, 130)) r = rng.uniform(1.4, 3.4) fall = np.clip(1.0 - (hy-gy)/240.0, 0.25, 1.0) d.ellipse([gx-r, gy-r*(1.0+0.9*(1-fall)), gx+r, gy+r*(1.0+0.9*(1-fall))], fill=(int(96*fall+18), int(60*fall+12), int(28*fall+6), 225)) im = draw_hands(im, [(hx, hy - SH_S*0.05, 1.15, 158, 0.35, 1.25, 1.0), (SW_S*0.11, SH_S*0.87, -0.65, 152, 1.0, 0.4, 1.0)], glow=1.1, contact=0.30) return self.out(im, {"_": (SW_S/2, SH_S/2), "hand": (hx, hy)}, u) class Morph(Base): """The workhorse: the hands work the sand from one tableau into the next. Nothing fades; the same mass is moved.""" def setup(self): self.sand = start_field(self.p["start"], **self.p.get("skw", {})) self.tgt = self.sand.matched(tableau(self.p["key"], **self.p.get("tkw", {}))) self.hk = self.p.get("hand", "lr") self.prev = None def frame(self, k, u, e): b = self.beat(k) hs = hand_pair(self.hk, u, b) rate = self.p.get("rate", 0.16)*(0.55 + 0.75*e["rms"]) if hs: hx, hy = hs[0][0], hs[0][1] if self.prev is not None: self.sand.stroke(self.prev, (hx, hy), self.p.get("r", 82), self.p.get("str", 0.30)) self.prev = (hx, hy) m = np.clip(1.0 - (((_XX-hx)**2 + (_YY-hy)**2) / ((self.p.get("mr", 330)*SC)**2)), 0, 1) self.sand.relax(self.tgt, rate*1.5, m) self.sand.relax(self.tgt, rate*0.35) self.sand.settle(2, 0.06) img = self.plate(self.sand.D, gain=0.96 + 0.14*e["rms"]) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") im = draw_hands(im, hs, glow=1.0, contact=0.85) anc = {"_": (SW_S/2, SH_S*0.58), "c": (SW_S*0.5, SH_S*0.62), "crest": (SW_S*0.52, SH_S*0.55), "hand": (hs[0][0], hs[0][1]) if hs else (SW_S/2, SH_S/2)} return self.out(im, anc, u, roll=self.p.get("roll", 0.0)) class Walk(Base): """The caravan crosses. The target moves every frame, so the sand is permanently chasing it — which is exactly what sand animation looks like.""" def setup(self): self.city = self.p.get("city", False) self.far = self.p.get("far", False) if self.city: self.base = (dune_field(CRESTS_CITY, seed=2929, veil=0.145) + city_mask(1.0)*1.25) else: self.base = dune_field(CRESTS_FULL, seed=1717) self.sand = Sand(SH_S, SW_S, self.base + self._camels(self.s.i0/FPS)) self.M0 = float(self.sand.D.sum()) self.prev = None def _uq(self, t): b = beat_of(t) b0, b1 = self.p.get("span", (20.0, 48.0)) return max(0.0, min(1.15, (b-b0)/(b1-b0))) def _camels(self, t): q = self._uq(t); ph = beat_of(t)*math.tau*0.5 if self.far: return caravan_mask(caravan_xs(q, n=5, spacing=0.095, base=0.02, speed=0.42), SH_S*0.545, SW_S*0.055, ph)*1.05 if self.city: return caravan_mask(caravan_xs(q, n=4, spacing=0.150, base=-0.30, speed=0.86), SH_S*0.845, SW_S*0.150, ph)*1.20 return caravan_mask(caravan_xs(q, n=4, spacing=0.175, base=-0.30, speed=0.62), CARAV_Y, SW_S*0.165, ph)*1.15 def frame(self, k, u, e): t = (self.s.i0+k)/FPS b = beat_of(t) tgt = self.base + self._camels(t) tgt = tgt*(self.M0/max(1e-6, float(tgt.sum()))) self.sand.relax(tgt, 0.34 + 0.30*e["rms"]) hs = hand_pair(self.p.get("hand", ""), u, b) if hs: hx, hy = hs[0][0], hs[0][1] if self.prev is not None: self.sand.stroke(self.prev, (hx, hy), 70, 0.22) self.prev = (hx, hy) self.sand.settle(2, 0.05) img = self.plate(self.sand.D, gain=self.p.get("gain", 0.95) + 0.16*e["rms"]) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") im = draw_hands(im, hs, glow=1.0, contact=0.8) q = self._uq(t) if self.far: lead = (caravan_xs(q, n=5, spacing=0.095, base=0.02, speed=0.42)[-1], SH_S*0.545) elif self.city: lead = (caravan_xs(q, n=4, spacing=0.150, base=-0.30, speed=0.86)[-1], SH_S*0.845) else: lead = (caravan_xs(q, n=4, spacing=0.175, base=-0.30, speed=0.62)[-1], CARAV_Y) sc = SW_S*(0.055 if self.far else (0.150 if self.city else 0.165)) anc = {"_": (SW_S/2, SH_S*0.58), "c": (SW_S*0.5, SH_S*0.60), "lead": (lead[0], lead[1]-sc*0.25), "foot": (lead[0]-sc*0.30, lead[1]+sc*0.16), "rider": (lead[0]-sc*0.03, lead[1]-sc*0.48), "gate": (SW_S*0.53, SH_S*(0.72 if self.city else 0.635))} return self.out(im, anc, u, roll=self.p.get("roll", 0.0)) class Macro(Base): """Extreme close on the glass. Three different views of the same grains: `pinch` — between two fingertips; `furrow` — one fingertip ploughing a berm; `blow` — the grains leaving. The point of the cut is the scale change, so no two macros are the same picture.""" SW, SH = W, H def setup(self): self.mode = self.p.get("mode", "pinch") rng = np.random.default_rng(self.p.get("mseed", 11)) n = int(round(self.p.get("dens", 3200)*SC*SC)) self.gx = rng.uniform(-0.2, 1.2, n) self.gy = rng.uniform(-0.2, 1.2, n) self.gr = rng.uniform(2.4, 8.2, n)*SC self.gd = rng.uniform(0.10, 1.0, n) self.gv = rng.uniform(0.25, 1.0, n) self.ga = rng.uniform(0, math.tau, n) self.tilt = self.p.get("tilt", 0.0) def _bed(self, b, occl): """The lit glass under the grains, as a depth veil.""" sw, sh = self.SW, self.SH yy, xx = np.mgrid[0:sh, 0:sw].astype(np.float32) base = np.empty((sh, sw, 3), np.float32) for c in range(3): base[..., c] = np.exp(-KABS[c]*np.maximum(occl, 0)) return base*255*1.03 def _grains(self, d, b, u): sw, sh = self.SW, self.SH mode = self.mode for i in range(len(self.gx)): gv, ga, r, v = self.gv[i], self.ga[i], self.gr[i], self.gd[i] if mode == "blow": x = ((self.gx[i] + (u*1.05 + 0.30)*gv) % 1.4 - 0.2)*sw y = (self.gy[i] + 0.05*math.sin(ga*3 + b*2))*sh elif mode == "furrow": # grains sit still; the ones near the furrow are shoved aside fy = self.p.get("fy0", 0.50) + 0.16*math.sin(b*math.tau*0.25) dy = self.gy[i] - fy shove = 0.30*np.sign(dy)*math.exp(-abs(dy)/0.11)*min(1.0, u*2.4) x = ((self.gx[i] + 0.05*u*gv) % 1.4 - 0.2)*sw y = (self.gy[i] + shove + 0.008*math.sin(b*math.tau + ga))*sh else: x = ((self.gx[i] + (u*0.15 + 0.04*math.sin(b*math.tau*0.5))*gv) % 1.4 - 0.2)*sw y = (self.gy[i]*0.88 + 0.06 + 0.014*math.sin(b*math.tau + ga))*sh col = (int(24+126*(1-v)), int(12+80*(1-v)), int(5+36*(1-v)), int(155+100*v)) if mode == "blow": d.line([(x, y), (x - r*(4.0 + 9.0*gv), y + r*0.5)], fill=col[:3] + (int(col[3]*0.55),), width=max(1, int(r*0.6))) d.ellipse([x-r, y-r*0.88, x+r, y+r*0.88], fill=col) d.ellipse([x-r*0.46, y-r*0.60, x-r*0.06, y-r*0.26], fill=(255, 226, 178, int(46*v))) def frame(self, k, u, e): b = self.beat(k) sw, sh = self.SW, self.SH yy, xx = np.mgrid[0:sh, 0:sw].astype(np.float32) lay = Image.new("L", (sw, sh), 0) ld = ImageDraw.Draw(lay) if self.mode == "pinch": gap = self.p.get("gap", 0.30) + 0.09*math.sin(b*math.tau*0.5) band = np.clip(1.0 - np.abs(yy/sh - 0.5)/0.34, 0, 1) occl = 0.34 - 0.30*band + 0.05*np.sin(xx/scf(41.0) + b) ld.ellipse([-sw*0.40, -sh*(1.24-gap*0.5), sw*0.92, sh*gap*0.60], fill=255) ld.ellipse([sw*0.14, sh*(1.0-gap*0.52), sw*1.50, sh*2.20], fill=255) elif self.mode == "furrow": fy = self.p.get("fy0", 0.50) + 0.16*math.sin(b*math.tau*0.25) band = np.clip(1.0 - np.abs(yy/sh - fy)/0.13, 0, 1) occl = 0.42 - 0.42*band + 0.16*np.clip( 1.0 - np.abs(np.abs(yy/sh - fy) - 0.17)/0.06, 0, 1) occl += 0.05*np.sin(xx/scf(33.0) + b*0.6) fx = sw*(self.p.get("fx0", 0.20) + 0.46*u) _oval(ld, fx, sh*fy, sw*0.115, sh*0.215, 0.0, 255) ld.polygon([(fx-sw*0.01, sh*fy-sh*0.215), (-sw*0.55, sh*fy-sh*0.34), (-sw*0.55, sh*fy+sh*0.34), (fx-sw*0.01, sh*fy+sh*0.215)], fill=255) else: # blow — nothing but grains occl = (0.24 + 0.40*np.clip((yy/sh - 0.42)*1.8, 0, 1) + 0.06*np.sin(xx/scf(29.0) + b)) base = self._bed(b, occl) im = Image.fromarray(np.clip(base, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im, "RGBA") self._grains(d, b, u) a = np.asarray(lay, np.float32)/255.0 arr = np.asarray(im, np.float32) if a.max() > 0: halo = np.maximum(softblur(a, sci(25)) - a, 0.0) arr += halo[..., None]*np.array([158, 96, 34], np.float32) grad = 0.62 + 0.66*(1.0 - np.clip(softblur(a, sci(47)), 0, 1)) arr = arr*(1-a[..., None]) + ( np.array(HAND_FILL, np.float32)[None, None, :] * grad[..., None])*a[..., None] if self.p.get("flip"): arr = arr[:, ::-1] return np.clip(arr, 0, 255) class Storm(Base): """The wind. A curl-noise flow advects every grain; total mass is exact.""" def setup(self): b0 = self.p.get("b0", 48.0) self.sand = Sand(SH_S, SW_S, tableau("carav", u=0.62, ph=1.2)) # CURL of a noise potential: divergence-free, so the wind swirls the # sand into eddies instead of just smearing it into a flat layer. psi = (fbm(SH_S, SW_S, 210, 8181, 4)*1.0 + 0.45*fbm(SH_S, SW_S, 78, 5252, 3)) gy, gx = np.gradient(psi.astype(np.float32)) n = max(np.abs(gy).max(), np.abs(gx).max()) + 1e-9 self.vx = (0.85 + 3.4*gy/n).astype(np.float32) # + mean drift right self.vy = (-3.4*gx/n).astype(np.float32) self.t0 = t_of(b0) self.done = 0 self._catch(self.s.i0/FPS) rng = np.random.default_rng(3113) self.ax = rng.uniform(0, 1, 2600); self.ay = rng.uniform(0, 1, 2600) self.ar = rng.uniform(1.4, 4.6, 2600)*SC; self.av = rng.uniform(.4, 1.6, 2600) def _step(self, i): b = beat_of(self.t0 + i/FPS) gust = 0.35 + 1.55*np.clip((b-48.0)/12.0, 0, 1) if b > 62: gust *= max(0.15, 1.0 - (b-62)/9.0) sw = math.sin(b*0.9)*0.5 self.sand.advect(self.vx*(8.0*gust*SC), self.vy*(8.0*gust*SC) + sw*1.6*SC) self.sand.settle(2, 0.035 + 0.045*gust) def _catch(self, t): n = max(0, int((t-self.t0)*FPS)) for i in range(self.done, n): self._step(i) self.done = max(self.done, n) def frame(self, k, u, e): t = (self.s.i0+k)/FPS self._catch(t) b = beat_of(t) img = self.plate(self.sand.D, gain=0.92 + 0.20*e["rms"], grain=0.62, relief=0.35) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") d = ImageDraw.Draw(im, "RGBA") gust = 0.35 + 1.55*min(1.0, max(0.0, (b-48.0)/12.0)) if b > 62: gust *= max(0.15, 1.0 - (b-62)/9.0) # airborne grains streaking across for i in range(len(self.ax)): x = ((self.ax[i] + (t-self.t0)*0.34*self.av[i]*gust) % 1.2 - 0.1)*SW_S y = (self.ay[i]*0.92 + 0.05 + 0.03*math.sin(self.ax[i]*22 + t*3.1))*SH_S r = self.ar[i]*(0.6+0.7*gust) al = int(120*min(1.0, gust)) d.line([(x, y), (x - r*7*gust, y + r*0.6)], fill=(46, 26, 12, al), width=max(1, int(r*0.6))) hs = hand_pair("sweep", (u*0.6 + 0.2), b) if self.p.get("hand") else [] im = draw_hands(im, hs, glow=0.8, contact=0.5) anc = {"_": (SW_S/2, SH_S*0.58), "c": (SW_S*0.5, SH_S*0.60), "lead": (SW_S*0.62, SH_S*0.56)} return self.out(im, anc, u, roll=self.p.get("roll", 0.0)) class Rise(Base): """The city stands up out of the settled sand. The hands pull upward and the ridges follow them.""" def setup(self): self.r0 = self.p.get("r0", 0.0); self.r1 = self.p.get("r1", 1.0) self.base = dune_field(CRESTS_CITY, seed=2929, veil=0.145) self.sand = Sand(SH_S, SW_S, self.base + city_mask(self.r0)*1.25) self.M0 = float(Sand(SH_S, SW_S, tableau("settle")).D.sum()) self.sand.D *= self.M0/max(1e-6, float(self.sand.D.sum())) self.prev = None def frame(self, k, u, e): b = self.beat(k) r = self.r0 + (self.r1-self.r0)*_ease(u) tgt = self.base + city_mask(r)*1.25 tgt = tgt*(self.M0/max(1e-6, float(tgt.sum()))) self.sand.relax(tgt, 0.24 + 0.34*e["rms"]) hs = hand_pair("lift", u, b) if hs: hx, hy = hs[0][0], hs[0][1] if self.prev is not None: self.sand.stroke(self.prev, (hx, hy), 74, 0.26) self.prev = (hx, hy) self.sand.settle(2, 0.05) img = self.plate(self.sand.D, gain=0.95 + 0.18*e["rms"]) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") im = draw_hands(im, hs, glow=1.05, contact=0.85) gy = SH_S*0.70 anc = {"_": (SW_S/2, SH_S*0.55), "c": (SW_S*0.5, SH_S*0.58), "min": (SW_S*0.185, gy - SH_S*0.40*r + SH_S*0.06), "dome": (SW_S*0.53, gy - SH_S*0.185*r - SH_S*0.05), "gate": (SW_S*0.53, gy - SH_S*0.05)} return self.out(im, anc, u, roll=self.p.get("roll", 0.0)) class Sweep(Base): """One arm across the glass. Everything the piece built is pushed flat.""" def setup(self): b0, b1 = 106.0, 114.0 self.t0, self.t1 = t_of(b0), t_of(b1) self.sand = Sand(SH_S, SW_S, dune_field(CRESTS_CITY, seed=2929, veil=0.145) + city_mask(1.0)*1.25) self.flat = self.sand.matched(tableau("flat")) self.done = 0 self.prev = None self._catch(self.s.i0/FPS) def _hand(self, q): # three passes across the plate, each lower than the last p = q*3.0 lane = min(2, int(p)); f = p - lane x = SW_S*(-0.12 + 1.24*(f if lane % 2 == 0 else 1.0-f)) y = SH_S*(0.34 + 0.22*lane + 0.05*math.sin(f*math.pi)) return x, y def _step(self, i, span): q = min(1.0, i/max(1, span)) x, y = self._hand(q) if self.prev is not None: self.sand.stroke(self.prev, (x, y), 175, 0.62) self.prev = (x, y) m = np.clip(1.0 - (((_XX-x)**2 + (_YY-y)**2)/((520.0*SC)**2)), 0, 1) self.sand.relax(self.flat, 0.44, m) self.sand.relax(self.flat, 0.009) self.sand.settle(3, 0.09) def _catch(self, t): span = int((self.t1-self.t0)*FPS) n = max(0, int((t-self.t0)*FPS)) for i in range(self.done, n): self._step(i, span) self.done = max(self.done, n) def frame(self, k, u, e): t = (self.s.i0+k)/FPS self._catch(t) span = max(1, int((self.t1-self.t0)*FPS)) q = min(1.0, max(0.0, (t-self.t0)/(self.t1-self.t0))) hx, hy = self._hand(q) img = self.plate(self.sand.D, gain=0.98 + 0.14*e["rms"]) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") ang = 0.10 if (int(q*3.0) % 2 == 0) else math.pi - 0.10 im = draw_hands(im, [(hx, hy, ang, 250, 1.35, 0.05, 1.5)], glow=1.0, contact=1.0) anc = {"_": (SW_S/2, SH_S*0.55), "c": (hx, hy)} return self.out(im, anc, u, roll=self.p.get("roll", 0.0)) class Press(Base): """The landing: one hand pressed into flat sand. The print is cleared to bare glass, so it is the brightest thing in the piece.""" HAND = (SW_S*0.50, SH_S*0.575, -1.5708, 215, 1.12, 0.0, 0.30) def setup(self): self.t0 = t_of(114.0) self.tp = t_of(117.4) # the press self.sand = Sand(SH_S, SW_S, tableau("flat")) self.mask = hand_mask([self.HAND]) self.pressed = False self._catch(self.s.i0/FPS) def _catch(self, t): if t >= self.tp and not self.pressed: self.sand.press(self.mask, depth=0.93, ring=14) self.sand.settle(2, 0.10) self.pressed = True def frame(self, k, u, e): t = (self.s.i0+k)/FPS self._catch(t) # the hand descends, presses, lifts away if t < self.tp: q = np.clip((t-self.t0)/max(0.01, self.tp-self.t0), 0, 1) lift = (1.0-_ease(q)) else: q = np.clip((t-self.tp)/1.30, 0, 1) lift = _ease(q)*1.6 hx, hy, ha, hs_, sp_, cu_, wr_ = self.HAND glow_gain = 1.0 + 0.26*np.clip((t-self.tp)/2.2, 0, 1) + 0.09*e["rms"] img = self.plate(self.sand.D, gain=glow_gain, grain=0.46, relief=0.75) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).convert("RGB") hands = [] if lift < 1.45: sc = hs_*(1.0 + 0.30*lift) hands = [(hx, hy - SH_S*0.02*lift, ha, sc, sp_, cu_, wr_)] im = draw_hands(im, hands, glow=1.0 - 0.4*lift, contact=max(0.0, 1.0-lift*1.4)) anc = {"_": (SW_S/2, SH_S*0.55), "c": (SW_S*0.5, SH_S*0.55), "print": (SW_S*0.50, SH_S*0.575)} return self.out(im, anc, u, roll=self.p.get("roll", 0.0)) ENGINES = {"light": Light, "sprinkle": Sprinkle, "morph": Morph, "walk": Walk, "macro": Macro, "storm": Storm, "rise": Rise, "sweep": Sweep, "press": Press} # ════════════════════════════════════════════════════════════════════════════ # THE SHOT LIST — in beats, so the accelerando cuts it for us # ════════════════════════════════════════════════════════════════════════════ SHOTPLAN = [ # (start_beat, len_beats, engine, params, card) (0.0, 2.4, "light", dict(cam="wide"), "SAND HANDS"), (2.4, 1.8, "sprinkle", dict(cam="wide"), None), (4.2, 1.8, "sprinkle", dict(cam="mid_hand", push=0.09), "A QAWWALI"), (6.0, 3.5, "morph", dict(cam="wide", start="veil", key="dunes0", hand="lr", rate=0.20), None), (9.5, 2.5, "morph", dict(cam="mid_c", start="dunes0", key="dunes", hand="lr", rate=0.22), None), (12.0, 2.0, "macro", dict(cam="macro", mseed=11, mode="furrow", fx0=0.16, dens=3000), None), (14.0, 3.0, "morph", dict(cam="wide", start="dunes", key="dunes", hand="ripple", rate=0.14), None), (17.0, 3.0, "morph", dict(cam="cu_crest", start="dunes", key="carav_far", tkw=dict(u=0.35, ph=0.4), hand="dab", rate=0.30), None), (20.0, 3.5, "walk", dict(cam="wide", far=True, span=(20.0, 30.0)), "THE CARAVAN"), (23.5, 2.5, "walk", dict(cam="mid_c", far=True, span=(20.0, 30.0)), None), (26.0, 2.0, "macro", dict(cam="macro", mseed=22, mode="pinch", gap=0.24, dens=4200), None), (28.0, 4.0, "walk", dict(cam="wide", span=(27.0, 48.0)), None), (32.0, 3.0, "walk", dict(cam="cu_lead", span=(27.0, 48.0)), None), (35.0, 2.5, "walk", dict(cam="cu_foot", span=(27.0, 48.0), gain=1.42, push=0.09), None), (37.5, 3.5, "walk", dict(cam="full_c", span=(27.0, 48.0)), None), (41.0, 3.5, "walk", dict(cam="wide", span=(27.0, 48.0), hand="ripple"), None), (44.5, 3.5, "walk", dict(cam="cu_rider", span=(27.0, 48.0), push=0.09), None), (48.0, 2.0, "storm", dict(cam="wide", b0=48.0), "THE WIND"), (50.0, 1.5, "storm", dict(cam="mid_c", b0=48.0, roll=-2.2), None), (51.5, 1.5, "storm", dict(cam="cu_lead", b0=48.0, roll=3.0), None), (53.0, 2.0, "macro", dict(cam="macro", mseed=33, mode="blow", dens=1500), None), (55.0, 3.0, "storm", dict(cam="wide", b0=48.0), None), (58.0, 2.0, "storm", dict(cam="full_c", b0=48.0, roll=-3.0), None), (60.0, 3.0, "storm", dict(cam="wide", b0=48.0, push=0.08), None), (63.0, 2.5, "morph", dict(cam="full_c", start="storm", key="settle", hand="ripple", rate=0.18), None), (65.5, 4.5, "morph", dict(cam="wide", start="settle", key="city", tkw=dict(rise=0.05), hand="lift", rate=0.10), None), (70.0, 4.0, "rise", dict(cam="wide", r0=0.05, r1=0.40), "AND OUT OF IT, A CITY"), (74.0, 3.0, "rise", dict(cam="mid_min", r0=0.40, r1=0.58), None), (77.0, 2.0, "macro", dict(cam="macro", mseed=44, mode="furrow", fx0=0.30, fy0=0.34, dens=2200, flip=True), None), (79.0, 4.0, "rise", dict(cam="wide", r0=0.58, r1=0.80), None), (83.0, 3.0, "rise", dict(cam="cu_dome", r0=0.80, r1=0.92), None), (86.0, 3.0, "rise", dict(cam="wide", r0=0.92, r1=1.0), None), (89.0, 3.0, "rise", dict(cam="full_gate", r0=1.0, r1=1.0, push=0.09), None), (92.0, 3.5, "walk", dict(cam="wide", city=True, span=(92.0, 106.0)), "THEY ARRIVE"), (95.5, 2.5, "walk", dict(cam="mid_gate", city=True, span=(92.0, 106.0)), None), (98.0, 2.0, "macro", dict(cam="macro", mseed=55, mode="pinch", gap=0.42, dens=3800), None), (100.0, 3.0, "walk", dict(cam="cu_gate", city=True, span=(92.0, 106.0)), None), (103.0, 3.0, "walk", dict(cam="wide", city=True, span=(92.0, 106.0), push=0.11), None), (106.0, 2.5, "sweep", dict(cam="wide"), None), (108.5, 2.0, "sweep", dict(cam="mid_c"), None), (110.5, 3.5, "sweep", dict(cam="wide", push=0.07), None), (114.0, 3.4, "press", dict(cam="full_c"), None), (117.4, 2.6, "press", dict(cam="mid_print"), None), (120.0, 2.0, "press", dict(cam="mid_print>cu_print"), None), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "params", "card", "b0") def __init__(self, idx, i0, i1, engine, section, params, card, b0): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.section = engine, section self.seed = 80808 + idx*7919 self.params, self.card, self.b0 = params, card, b0 def build_shots(): shots = [] for idx, (b0, lb, eng, params, card) in enumerate(SHOTPLAN): i0 = int(round(t_of(b0)*FPS)); i1 = int(round(t_of(b0+lb)*FPS)) shots.append(Shot(idx, i0, i1, eng, sec_of(b0), params, card, b0)) for i in range(len(shots)-1): shots[i].i1 = shots[i+1].i0 shots[i].n = shots[i].i1 - shots[i].i0 shots[0].i0 = 0; shots[0].n = shots[0].i1 shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return [s for s in shots if s.n > 0] # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> (text) -> letterbox # ════════════════════════════════════════════════════════════════════════════ # ── 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 Bold.ttf"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.40*r**2.0, 0, 1)[..., None] return _VIG["v"] CARD_COL = (36, 22, 14) def post(arr, i, e, shot): a = arr.astype(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) t = i/FPS; b = beat_of(t) # 1. tint — tungsten amber, cooling through the storm, hot at the landing heat = float(np.clip((b-70.0)/40.0, 0, 1)) cold = float(np.clip((b-48.0)/5.0, 0, 1))*float(np.clip((63.0-b)/5.0, 0, 1)) tint = (np.array([16, 6, -14], np.float32)*(0.55+0.6*heat) + np.array([-13, -5, 11], np.float32)*cold) lum = a.mean(2, keepdims=True)/255.0 a = a + (1.0-lum*0.60)*tint if shot.engine == "storm": # shear the picture only sh = int(scf(2 + 7*e["rms"])) a[..., 0] = np.roll(a[..., 0], sh, axis=1) a[..., 2] = np.roll(a[..., 2], -sh, axis=1) # 2. vignette a *= vignette() # 3. grain rng = np.random.RandomState((5100 + i) % (2**31-1)) if SC == 1.0: a += rng.normal(0, 2.2, a.shape) else: # 720p grain, NEAREST up g = rng.normal(0, 2.2, (int(H/SC), int(W/SC), 3)).astype(np.float32) a += np.stack([np.asarray(Image.fromarray(g[..., c], "F") .resize((W, H), Image.NEAREST), np.float32) for c in range(3)], -1) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out, "RGBA") # 4. text — crisp, after every channel operation (AESTHETIC 13b) if shot.card: age = i - shot.i0 hold = FPS*2.4 if age < hold: al = min(1.0, age/5.0)*min(1.0, (hold-age)/9.0) fc = font(sci(50), "Georgia Bold.ttf") lw = d.textlength(shot.card, font=fc) x0 = W*0.5 - lw/2; y0 = H*0.145 title = shot.card == TITLE bx1 = y0 + (scf(96) if title else scf(64)) d.rectangle([x0-scf(26), y0-scf(13), x0+lw+scf(26), bx1], fill=(250, 240, 220, int(180*al))) d.text((x0, y0), shot.card, font=fc, fill=CARD_COL + (int(255*al),)) if title: # the show, set small under the title in the same warm ink f9 = font(sci(17), "Georgia Bold.ttf") s9 = "P L A Y E R C O M P U T E R" lw9 = d.textlength(s9, font=f9) d.text((W*0.5-lw9/2, y0+scf(62)), s9, font=f9, fill=(96, 66, 44) + (int(240*al),)) # (final cut) the "nnn BPM" readout and its 30-segment tempo meter used to # sit in the bottom-right corner. That was the renderer describing the # tempo map; the accelerando is audible and the cutting rate shows it. # 5. letterbox bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(16, 11, 8)) d.rectangle([0, H-bh, W, H], fill=(16, 11, 8)) return out # ════════════════════════════════════════════════════════════════════════════ # RENDER # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() eng = ENGINES[shot.engine](shot, np.random.default_rng(shot.seed)) made = 0 for k in range(shot.n): i = shot.i0 + k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} p = FRAMES/f"f{i:05d}.png" u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) # ALWAYS step the engine if p.exists() and not force: continue post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {shot.section:7s} {made}/{shot.n}" def _sheet_one(sh): E = env() eng = ENGINES[sh.engine](sh, np.random.default_rng(sh.seed)) mid = max(0, int(sh.n*0.62)) arr = None; e = None for k in range(mid+1): i = sh.i0+k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(k, k/max(1, sh.n-1), e) return sh.idx, np.asarray(post(arr, sh.i0+mid, e, sh)) def contact_sheet(shots, jobs=8): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = 300, 193 sheet = Image.new("RGB", (cols*tw, rows*(th+26)), (12, 10, 9)) sd = ImageDraw.Draw(sheet) import multiprocessing as mp with mp.get_context("fork").Pool(jobs) as pool: res = dict(pool.imap_unordered(_sheet_one, shots)) for n, sh in enumerate(shots): im = Image.fromarray(res[sh.idx]).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+26) sheet.paste(im, (cx, cy)) sd.text((cx+5, cy+th+5), f"{sh.idx:02d} {sh.engine}·{sh.params.get('cam','')[:15]}·" f"b{sh.b0:.0f}·{sh.i0/FPS:.1f}s·{sh.n}f", font=font(12, "Menlo.ttc"), fill=(196, 186, 172)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def _gitsha(): try: return subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() except Exception: return "unknown" 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… {BEATS} beats, {DUR:.1f}s, tempo " f"{_TV.min():.0f}->{_TV.max():.0f}bpm, {len(LEAD)} lead phrases") wav, mix, vox, clp = build_song(); analyze(mix, vox, clp) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots, jobs=min(a.jobs, 8)); 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…") out = OUT/f"{NAME}.mp4" stamp = (f"generator=renders/{SETDIR}/{NAME}/render.py | git={_gitsha()} | " f"{datetime.date.today().isoformat()} | {MUSIC_DESC}") subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", "-metadata", f"artist=poop / {SETDIR}", "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", str(out)], check=True, capture_output=True) sha = _gitsha() try: br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: br = "unknown" (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"tempo map: {TEMPO}\n" f"voices: Rishi (lead), Majed (octave double), Lekha (chorus) " f"via channel vocoder + melismatic pitch path\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"shots: {len(build_shots())}\n" f"engines: {ENGINE_DESC} (shot-parallel, stateful per shot)\n" f"substrate: sand depth field on a backlit lightbox — Beer-Lambert\n" f" transmission (k={tuple(float(v) for v in KABS)}),\n" f" mass-conserving push/relax/advect/press\n" f" operators (class Sand)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()