#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Twelve Breaths (07/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/twelve_breaths # # Twelve breaths before a free dive, each longer than the last, inside music that only speeds up. # # 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/twelve_breaths.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/twelve_breaths.mp4 # cover: https://genekogan.com/player_computer/media/twelve_breaths.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 twelve_breaths.py (writes frames/, audio/, and the final mp4 # next to the script; writes ~8 GB of frames, # takes 5-15 min on a modern machine) # ═════════════════════════════════════════════════════════════════════════════ """ player_computer_final — "TWELVE BREATHS" (final delivery cut) NARRATIVE STRUCTURE: **countdown, 12 → 0.** Twelve breaths on the surface before a free dive. Each breath is a section; the interval between them stretches geometrically (×1.09 each time) so the piece physically slows down inside a music that is speeding up. The last breath is held, the picture goes still and clean — nothing moving means nothing for the knife edge to see — and then the exhale at the surface fills the whole frame. MUSIC: **Gnawa.** Guembri (the three-string bass lute) with the sarraf buzz modelled as a real rattle off the string envelope, and the camel-skin body struck by the same hand that plucks. Qraqeb — iron castanets — running a relentless triplet. Call-and-response voices, formant-synthesized, the chorus answering the lead a bar later. A 6/8 whose accent grid keeps flipping between two groups of three and three groups of two, so the clave moves under you. Tbel and a low drone arrive as the count drops and it gets heavier. THE NEW SUBSTRATE — **schlieren imaging**, new to this repo: * A real incompressible fluid solver runs the invisible air: semi-Lagrangian advection of velocity and temperature, buoyancy, a Jacobi pressure projection to keep it divergence-free, and vorticity confinement so the plumes curl instead of smearing. * The picture is NOT the temperature field. It is what a knife-edge schlieren rig sees: the refractive index goes as the density, a ray crossing the test section is deflected by the index GRADIENT, and the knife cuts the deflected rays on one side only. So brightness is d(rho)/dx — a gradient, single-direction sensitive, mid-grey where nothing is happening — and the knife's cutoff fraction sets both the background level and the sensitivity. Both are rackable per shot; one shot rotates the knife and the whole picture changes character. * The field is the circular field of the parabolic mirror, with its own dust and a coma flare, and everything solid is a true silhouette: no light gets through, so it is pure black, and it is also an obstacle in the solver. * Sources are physical: an exhale is a warm moist jet from the mouth, a match is a small violent heat source, a struck hull is a radial pressure impulse that leaves as a shock ring. Composition: engine : audio-first × shot-parallel (tier 4-P, stateful within a shot) × NEW fluid + knife-edge optics content: audio-groove (Gnawa kit — guembri, qraqeb, voices, tbel) × schlieren substrate × effects-post FINAL CUT (player_computer_final). The Gnawa score and the countdown structure are untouched; the delivery is: * 1920x1080 native. S = RH/720 = 1.5. Two pixel spaces scale together. The FRAME (silhouettes, mirror stop, dust, type, letterbox) is authored in 1280x720 units and rasterised 1.5x larger through a ScaledDraw proxy. The SOLVER GRID scales with it — 256x144 authoring cells become a 384x216 lattice — so the field is resolved 1.5x finer and the knife image is blown up by the same 5x it always was rather than 7.5x. Because the solver keeps velocity in cells-per-step, every quantity carrying a length scales with the lattice: source positions and radii, jet/shock/turbulence amplitudes, buoyancy, vorticity confinement, the temperature-diffusion weight (x GS^2, since diffusivity goes as the square of the cell), the Jacobi iteration count (so pressure propagates the same distance), the schlieren blur, and the per-cell gradient the knife measures. Sources are still written in authoring cells; the Fluid converts on the way in. * DEBUG STRIPS REMOVED. The rig HUD — "BREATH nn/12 KNIFE +000deg CUTOFF 50% GAIN 3.0 74.0 BPM" — and the "Z-TYPE SCHLIEREN ·
" engine/section label are gone: those are renderer parameters, not the film. Three shot labels that quoted the same parameters ("KNIFE ROTATED 90 deg", "x2.4 · THE MOUTH", "x3 · CUTOFF 60%") are cut back to their poetic halves. The COUNT STAYS — the big numeral and its line are the piece's own countdown ritual, and they are its structure. * Title moment: the "TWELVE BREATHS" card keeps its sub-line and gains PLAYER COMPUTER under it, in the same shadowed Georgia. Run from repo root: python3 renders/player_computer_final/twelve_breaths/render.py --sheet python3 renders/player_computer_final/twelve_breaths/render.py --jobs 3 """ import argparse, datetime, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "twelve_breaths" TITLE = "TWELVE BREATHS" SUBT = "PLAYER COMPUTER" SETDIR = "player_computer_final" SETNUM = "B4" W, H, FPS = 1280, 720, 30 # AUTHORING frame — drawing coordinates RW, RH = 1920, 1080 # the delivered raster SR = 44100 DUR = 66.0 N_FRAMES = int(DUR * FPS) NX, NYG = 256, 144 # AUTHORING solver grid (16:9, like the frame) # ── delivery scale ────────────────────────────────────────────────────────── S = RH / H # 1.5 — the one number the look scales by GS = S # the solver lattice scales with the frame SNX, SNY = int(round(NX*GS)), int(round(NYG*GS)) # 384 x 216 real cells def P(v): return int(round(v*S)) def PF(v): return v*S def _scale_xy(v, s): if isinstance(v, (list, tuple)): return [_scale_xy(u, s) for u in v] return v*s class ScaledDraw: """ImageDraw proxy that multiplies geometry by S at rasterisation time. Silhouettes and mirror dust are authored in 1280x720 units; this puts them on the 1920x1080 sheet without hand-scaling every literal. Only the xy geometry and `width` are touched — arc/chord/pieslice take *angles* as positionals 2 and 3, which must pass through untouched. """ __slots__ = ("_d", "_s") _GEOM = frozenset(("line", "rectangle", "rounded_rectangle", "ellipse", "polygon", "arc", "chord", "pieslice", "point", "text")) def __init__(self, d, s): self._d, self._s = d, s def __getattr__(self, name): f = getattr(self._d, name) if name not in self._GEOM: return f s = self._s def wrapped(xy, *a, **kw): w = kw.get("width") if w is not None: kw["width"] = max(2, int(round(w*s))) r = kw.get("radius") if r is not None: kw["radius"] = r*s return f(_scale_xy(xy, s), *a, **kw) return wrapped def mkdraw(im): d = ImageDraw.Draw(im) return d if S == 1.0 else ScaledDraw(d, S) 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 = [ ("boat", 0.0, 10.0), ("count", 10.0, 30.0), ("heavy", 30.0, 46.5), ("hold", 46.5, 57.5), ("dive", 57.5, 60.8), ("surface", 60.8, 66.0), ] MUSIC_DESC = ("Gnawa — guembri with a modelled sarraf buzz, qraqeb triplets, " "call-and-response formant voices, tbel; 6/8 whose accent grid " "flips between 3+3 and 2+2+2; 176→236 bpm on the eighth") ENGINE_DESC = ("schlieren imaging — incompressible semi-Lagrangian solver " "with buoyancy, Jacobi projection and vorticity confinement, " "rendered through a knife edge so density GRADIENT is " "brightness; circular mirror field, true silhouettes") def sec_of_t(t): for nm, a, b in SECTIONS: if a <= t < b: return nm return SECTIONS[-1][0] def clamp01(x): return 0.0 if x < 0 else (1.0 if x > 1 else x) def ease_io(u): return u * u * (3 - 2 * u) def ease_out(u): return 1 - (1 - u) ** 3 def lerp(a, b, u): return a + (b - a) * u # ════════════════════════════════════════════════════════════════════════════ # THE PULSE — 6/8. The grid is the eighth; six of them make a bar. # ════════════════════════════════════════════════════════════════════════════ TEMPO = [(0.0, 172), (6.0, 176), (14.0, 186), (22.0, 196), (30.0, 208), (38.0, 220), (44.0, 232), (48.0, 236), (52.0, 236), (56.0, 190), (58.5, 140), (61.0, 112), (66.0, 104)] def bpm_at(t): if t <= TEMPO[0][0]: return TEMPO[0][1] for (t0, b0), (t1, b1) in zip(TEMPO, TEMPO[1:]): if t0 <= t <= t1: return b0 + (b1 - b0) * (t - t0) / max(1e-6, t1 - t0) return TEMPO[-1][1] def build_pulses(): out = []; t = 0.0 while t < DUR + 2.0: out.append(t); t += 60.0 / bpm_at(t) return out PULSE = build_pulses() def snap(t): return float(PULSE[int(np.argmin([abs(b - t) for b in PULSE]))]) # ── the countdown. Intervals stretch by 1.09 each breath. ------------------ _B0, _RATIO, _FIRST = 4.6, 1.09, 2.36 BREATH_T = [_B0] _iv = _FIRST for _k in range(11): BREATH_T.append(BREATH_T[-1] + _iv); _iv *= _RATIO BREATH_T = [snap(x) for x in BREATH_T] ZERO_T = snap(BREATH_T[-1] + 5.4) # the count reaches nothing DIVE_T = snap(57.6) SURF_T = snap(60.8) BREATHS = [ (12, "the boat rolls. the rope goes down out of the light"), (11, "cold on the back of the neck"), (10, "someone strikes a match to read the watch"), (9, "the hull knocks once and the sound goes out flat"), (8, "hands on the gunwale, not gripping"), (7, "the last of the coffee, on somebody's breath"), (6, "the line is vertical now. it was not, before"), (5, "count out, not in"), (4, "the chest stops arguing"), (3, "everything above the surface becomes weather"), (2, "nothing left to spend"), (1, "this one is not let go"), ] def breath_index(t): n = -1 for i, bt in enumerate(BREATH_T): if t >= bt: n = i return n def count_at(t): i = breath_index(t) if i < 0: return None if t >= ZERO_T: return 0 return 12 - i # ════════════════════════════════════════════════════════════════════════════ # AUDIO # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0 * 2.0 ** ((m - 69) / 12.0) def adsr(n, a, d, s, r): e = np.zeros(n) if n <= 0: return e 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): 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) _FORM = {} def formgain(n, specs, tilt=6000.0, hp=110.0, floor=0.10): key = (n, specs, tilt, hp, floor) if key not in _FORM: fq = np.maximum(np.fft.rfftfreq(n, 1 / SR), 1e-6) g = np.ones_like(fq) * floor for fc, q, amp in specs: g += amp * np.exp(-((np.log(fq / fc)) ** 2) / (2 * q * q)) g *= 1.0 / np.sqrt(1.0 + (fq / tilt) ** 4) g *= 1.0 / np.sqrt(1.0 + (hp / fq) ** 4) if len(_FORM) > 50: _FORM.clear() _FORM[key] = g return _FORM[key] _GB = {} def guembri(midi, dur, seed=0, gain=1.0, buzz=1.0, slap=0.0): """Three-string bass lute over a camel-skin body. The sarraf — a metal plate of rings on the neck — buzzes off the string's own envelope.""" key = (int(round(midi)), round(dur, 3), seed % 4, round(buzz, 2), round(slap, 2)) if key in _GB: return _GB[key] * gain n = int(dur * SR) if n < 64: _GB[key] = np.zeros(max(0, n)); return _GB[key] t = np.arange(n) / SR rng = np.random.RandomState((abs(int(midi * 41 + seed * 23)) % 99991) + 7) f = mtof(midi) y = np.zeros(n) for h, a, dk in ((1, 1.00, 2.1), (2, 0.62, 3.4), (3, 0.30, 5.0), (4, 0.18, 6.8), (5, 0.10, 9.0), (6, 0.06, 12.0), (8, 0.03, 16.0)): y += a * np.sin(2 * np.pi * f * h * (1 + 0.0004 * h) * t + rng.uniform(0, 6.28)) * np.exp(-t * dk) # the skin: a low, wide body resonance and the plectrum-hand thump body = (np.sin(2 * np.pi * 88 * t) * np.exp(-t * 15) * 0.34 + np.sin(2 * np.pi * 143 * t) * np.exp(-t * 22) * 0.18) y += body * (0.5 + slap) y += bandshape(rng.randn(n), lo=120, hi=900) * np.exp(-t * 46) * ( 0.16 + 0.55 * slap) envl = np.abs(y) k = max(1, int(0.005 * SR)) envl = np.convolve(envl, np.ones(k) / k, "same") envl /= envl.max() + 1e-9 if buzz > 0.01: # the sarraf rattle imp = np.zeros(n) tt = 0.0 while tt < dur: i = int(tt * SR) if i < n and envl[i] > 0.10: imp[i] += rng.uniform(0.3, 1.0) * envl[i] ** 1.3 tt += (1.0 / 140.0) * rng.uniform(0.5, 1.6) dec = np.exp(-np.arange(int(0.014 * SR)) / SR * 300) rat = np.convolve(imp, dec)[:n] rat = bandshape(rat * bandshape(rng.randn(n), lo=900, hi=6000) * 3.0, lo=1200, hi=5200) y += rat * 0.42 * buzz y *= adsr(n, 0.002, 0.03, 0.86, dur * 0.28) y /= np.max(np.abs(y)) + 1e-9 if len(_GB) > 500: _GB.clear() _GB[key] = y return y * gain _QR = {} def qraqeb(seed=0, gain=1.0, accent=0.0): """Iron castanets. Two plates meeting: a hard clack with inharmonic metal above it.""" key = (seed % 8, round(accent, 2)) if key in _QR: return _QR[key] * gain dur = 0.14 + 0.05 * accent n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed * 53) % 99991 + 13) y = bandshape(rng.randn(n), lo=2400, hi=13000) * np.exp( -t * (140 - 50 * accent)) for f, a, dk in ((2870, .55, 22), (4130, .42, 28), (5960, .30, 34), (8210, .18, 44), (1490, .26, 18)): y += a * np.sin(2 * np.pi * f * (1 + 0.02 * rng.randn()) * t) * \ np.exp(-t * dk) y *= (0.7 + 0.5 * accent) y /= np.max(np.abs(y)) + 1e-9 if len(_QR) > 40: _QR.clear() _QR[key] = y return y * gain def voice(midis, dur, seed=0, gain=1.0, vow="a", n_voice=1, spread=0.0): """Formant-shaped singing. n_voice > 1 is the chorus answering.""" n = int(dur * SR) if n < 256: return np.zeros(max(0, n)) t = np.arange(n) / SR rng = np.random.RandomState((seed * 29) % 99991 + 3) VOW = {"a": ((730, .26, 1.35), (1180, .22, 1.00), (2500, .30, .48)), "e": ((520, .24, 1.25), (1780, .22, 1.00), (2520, .30, .52)), "o": ((560, .26, 1.35), (860, .22, 1.00), (2450, .30, .34)), "u": ((330, .22, 1.05), (880, .22, .88), (2260, .30, .30))} seg = max(1, len(midis)) out = np.zeros(n) for vi in range(n_voice): det = (vi - (n_voice - 1) / 2.0) * spread f = np.zeros(n) for j, m in enumerate(midis): a = int(n * j / seg); b = int(n * (j + 1) / seg) f[a:b] = mtof(m + det) k = max(1, int(0.035 * SR)) f = np.convolve(f, np.ones(k) / k, "same") vib = 1 + 0.007 * np.sin(2 * np.pi * (5.0 + 0.5 * vi) * t + rng.uniform(0, 6.28)) * np.clip( (t - dur * 0.22) / max(1e-3, dur * 0.3), 0, 1) ph = 2 * np.pi * np.cumsum(f * vib) / SR + rng.uniform(0, 6.28) src = np.zeros(n) for h in range(1, 26): src += np.sin(ph * h) / (h ** 1.15) out += np.fft.irfft(np.fft.rfft(src) * formgain( n, VOW[vow], tilt=5200, hp=140, floor=0.06), n) out /= n_voice out += bandshape(rng.randn(n), lo=1600, hi=6200) * 0.030 out *= adsr(n, 0.05, 0.14, 0.86, dur * 0.24) out /= np.max(np.abs(out)) + 1e-9 return out * gain * 0.55 def tbel(seed=0, gain=1.0, dur=0.50, f0=72.0): n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState(seed % 99991) f = f0 * (1 + 1.5 * np.exp(-t * 30)) y = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t * 8) y += np.sin(2 * np.pi * f0 * 2.4 * t) * np.exp(-t * 18) * 0.28 y += bandshape(rng.randn(n), lo=200, hi=3400) * np.exp(-t * 70) * 0.26 return y * gain def drone(dur, midi=33, seed=0, gain=1.0): n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState(seed % 99991) f = mtof(midi) y = np.zeros(n) for h, a in ((1, 1.0), (2, .40), (3, .22), (5, .10)): y += a * np.sin(2 * np.pi * f * h * t * (1 + 0.0006 * h) + rng.uniform(0, 6.28)) y *= 0.85 + 0.15 * np.sin(2 * np.pi * 0.13 * t) y = bandshape(y, hi=900) return y * adsr(n, dur * 0.30, dur * 0.2, 0.9, dur * 0.35) * gain * 0.30 def water(dur, seed=0, gain=1.0): """Surface noise — never a flat hiss: filtered, moving, and ducked.""" n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState(seed % 99991) x = bandshape(rng.randn(n), lo=260, hi=2400) m = 0.35 + 0.65 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.7 * t)) ** 2 x *= m for _ in range(int(dur * 5)): at = rng.uniform(0, dur * 0.94) L = int(0.16 * SR); i = int(at * SR) if i + L < n: x[i:i + L] += bandshape(rng.randn(L), lo=700, hi=5200) * \ np.exp(-np.arange(L) / SR * 22) * 0.45 return x * gain * 0.35 def room(n, seed=811): rng = np.random.RandomState(seed) return (bandshape(rng.randn(n), lo=45, hi=380) * 0.019 + bandshape(rng.randn(n), lo=2800, hi=9000) * 0.004) def reverb(x, rt=2.0, mix=.26, seed=29, pre=0.016): 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=.28, fb=.32, mix=.11, taps=4): d = int(time * SR); out = x.copy() for i in range(1, taps + 1): s = d * i if s >= len(x): break out[s:] += x[:len(x) - s] * mix * (fb ** (i - 1)) return out class Song: def __init__(self, dur): self.n = int(dur * SR); self.tr = {} def put(self, track, sig, at, g=1.0, pan=0.0): if len(sig) == 0: return 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: return if i < 0: sig = sig[-i:]; i = 0; j = min(self.n, len(sig)) if 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 duck(self, tracks, times, depth=0.30, dur=0.16): env = np.ones(self.n) L = int(dur * SR); shape = 1.0 - depth * np.exp( -np.arange(L) / (L * 0.32)) for t in times: i = int(t * SR); j = min(self.n, i + L) if i < 0 or i >= self.n: continue env[i:j] = np.minimum(env[i:j], shape[:j - i]) for k in tracks: if k in self.tr: self.tr[k] *= env[:, None] def mixdown(self, gains, levels=None): mix = np.zeros((self.n, 2)) for k, b in self.tr.items(): mix += b * gains.get(k, 1.0) if levels: env = np.ones(self.n) for nm, a, b in SECTIONS: i0, i1 = int(a * SR), min(self.n, int(b * SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) kk = max(1, int(0.55 * SR)) env = np.convolve(env, np.ones(kk) / kk, "same") mix *= env[:, None] for c in range(2): mix[:, c] = bandshape(mix[:, c], lo=28.0, order=2) mix = np.tanh(mix * 1.22) / np.tanh(1.22) return mix / (np.max(np.abs(mix)) + 1e-9) * .94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1) * 32767).astype(" DUR: break pd = float(PULSE[pi + 1]) - t bar = pi // 6 c = pi % 6 cyc = pi % 12 heat = clamp01((t - 4.0) / 42.0) tail = clamp01((t - 55.0) / 4.0) grid = grid_for(bar, t) live = 1.4 < t < 57.0 # ── qraqeb: every eighth, plus the inner triplet as it heats ------- if 2.0 < t < 57.5: ac = 1.0 if c in grid else 0.0 g = (0.52 + 0.42 * heat) * (1 - 0.9 * tail) s.put("qraq", qraqeb(seed=pi, gain=g * (1.0 if ac else 0.58), accent=ac), t, g=1.0, pan=-0.34 if c % 2 else 0.34) if heat > 0.30: for sub in (1 / 3.0, 2 / 3.0): s.put("qraq", qraqeb(seed=pi * 3 + int(sub * 7), gain=g * 0.42 * heat, accent=0.0), t + pd * sub, g=1.0, pan=0.20 * (sub - 0.5) * 4) # ── guembri: the cell, dropping an octave as the count drops ------- if live: ci = cell(bar // 2) oct_ = 0 if t < 30 else (-0 if t < 44 else 0) for (pp, dd) in ci: if pp != cyc: continue m = ROOT + PENT[dd % 5] + 12 * (dd // 5) + oct_ s.put("gue", guembri(m, min(1.3, pd * 5.5), seed=pi, gain=(0.95 if c in grid else 0.72) * (0.8 + 0.3 * heat) * (1 - 0.8 * tail), buzz=0.7 + 0.6 * heat, slap=0.55 if c in grid else 0.12), t, g=1.0, pan=-0.10) if c in grid: hits.append(t) # ── tbel from the heavy section ------------------------------------ if 29.0 < t < 57.0 and c in grid: s.put("tbel", tbel(seed=pi, gain=(0.55 + 0.45 * heat) * (1 - 0.9 * tail)), t, g=1.0, pan=0.08) # ── voices: the lead calls at each breath, the chorus answers ---------- LEAD = [[57, 57, 60, 57, 55], [60, 62, 60, 57, 55], [55, 57, 55, 52], [64, 62, 60, 57], [57, 60, 62, 60, 57]] ANS = [[52, 55, 57, 55], [55, 52, 50, 48], [57, 55, 52, 52], [52, 50, 48, 45]] for i, bt in enumerate(BREATH_T): if bt > 50.0: break dl = min(2.1, (BREATH_T[i + 1] - bt) * 0.42 if i + 1 < len(BREATH_T) else 2.0) s.put("lead", voice(LEAD[i % len(LEAD)], dl, seed=100 + i, gain=0.85, vow="a"), bt + 0.10, g=1.0, pan=-0.22) s.put("chor", voice(ANS[i % len(ANS)], dl * 0.9, seed=200 + i, gain=0.75, vow="o", n_voice=4, spread=0.16), bt + dl + 0.16, g=1.0, pan=0.24) # ── the drone under the hold, and the water --------------------------- s.put("drone", drone(ZERO_T - 40.0 + 8.0, midi=33, seed=9, gain=1.0), 40.0) s.put("drone", drone(9.0, midi=33, seed=11, gain=0.9), ZERO_T - 1.0) s.put("water", water(12.0, seed=5, gain=1.0), 0.0) s.put("water", water(7.0, seed=7, gain=0.75), SURF_T - 1.2) # the hull knocked once — a shock, and the picture answers it s.put("tbel", tbel(seed=777, gain=1.15, dur=0.9, f0=58.0), BREATH_T[3]) # the dive: everything stops, one guembri note goes down s.put("gue", guembri(ROOT - 5, 4.0, seed=61, gain=1.0, buzz=1.5, slap=0.8), DIVE_T, g=1.0) s.put("lead", voice([50, 48, 45, 43], 3.4, seed=311, gain=0.7, vow="u"), DIVE_T + 0.5, g=1.0, pan=0.0) # the exhale at the surface s.put("water", water(5.4, seed=13, gain=1.3), SURF_T) s.put("tbel", tbel(seed=888, gain=1.0, dur=1.2, f0=48.0), SURF_T) s.put("chor", voice([57, 55, 57, 60], 4.2, seed=411, gain=0.9, vow="a", n_voice=5, spread=0.20), SURF_T + 0.3, g=1.0) s.put("room", room(s.n), 0.0, g=1.0) return s, hits def finish_song(): s, hits = build_song() s.duck(["qraq", "water", "drone"], hits, depth=0.24, dur=0.12) s.bus("gue", lambda x: reverb(x, rt=1.5, mix=.16, seed=401)) s.bus("qraq", lambda x: reverb(x, rt=1.1, mix=.16, seed=403)) s.bus("lead", lambda x: reverb(delay(x, 0.30, .30, .12), rt=3.0, mix=.34, seed=405)) s.bus("chor", lambda x: reverb(x, rt=3.4, mix=.40, seed=407)) s.bus("tbel", lambda x: reverb(x, rt=1.8, mix=.20, seed=409)) s.bus("drone", lambda x: reverb(x, rt=4.2, mix=.44, seed=411)) s.bus("water", lambda x: reverb(x, rt=2.4, mix=.30, seed=413)) mix = s.mixdown(dict(gue=1.00, qraq=0.78, lead=0.86, chor=0.80, tbel=0.92, drone=0.85, water=0.60, room=1.0), levels=dict(boat=.66, count=.92, heavy=1.08, hold=.62, dive=.52, surface=.90)) wav = AUD / "final.wav" s.write(wav, mix) return wav, mix def analyze(mix): x = mix.mean(1) hop = SR / FPS; win = int(hop * 1.7) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high")} for f in range(N_FRAMES): i = int(f * hop); seg = x[i:i + win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg ** 2).mean()) sp = np.abs(np.fft.rfft(seg * np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1 / SR) E["low"][f] = sp[fr < 200].sum() E["mid"][f] = sp[(fr >= 200) & (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["hit"] = 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 FLUID — incompressible, buoyant, with vorticity confinement # ════════════════════════════════════════════════════════════════════════════ _LG = {} def lgrid(): if "x" not in _LG: yy, xx = np.mgrid[0:SNY, 0:SNX].astype(np.float32) _LG["x"] = xx; _LG["y"] = yy return _LG["x"], _LG["y"] def _sample(f, x, y): x = np.clip(x, 0, SNX - 1.001); y = np.clip(y, 0, SNY - 1.001) x0 = x.astype(np.int32); y0 = y.astype(np.int32) x1 = x0 + 1; y1 = y0 + 1 fx = (x - x0)[..., None][..., 0]; fy = (y - y0)[..., None][..., 0] return ((f[y0, x0] * (1 - fx) + f[y0, x1] * fx) * (1 - fy) + (f[y1, x0] * (1 - fx) + f[y1, x1] * fx) * fy) def _lap4(p): return (np.roll(p, 1, 0) + np.roll(p, -1, 0) + np.roll(p, 1, 1) + np.roll(p, -1, 1)) class Fluid: # The PUBLIC API of this class is in AUTHORING CELLS (the 256x144 grid # every source in this film was hand-placed on). The lattice underneath # is GS times finer, so positions and radii are multiplied by GS on the # way in, and so is anything that is a VELOCITY — because velocity here is # carried in cells-per-step, and a finer cell means more of them per # second for the same physical wind. Temperature is not a length and # never scales. def __init__(self, seed): self.T = np.zeros((SNY, SNX), np.float32) self.u = np.zeros((SNY, SNX), np.float32) self.v = np.zeros((SNY, SNX), np.float32) self.rng = np.random.RandomState(seed) self.solid = np.zeros((SNY, SNX), np.float32) # a faint stratification so the field is never perfectly dead xx, yy = lgrid() self.T += (0.020 * np.sin(yy * (0.12/GS) + 0.4) * np.cos(xx * (0.07/GS))).astype(np.float32) # ── sources ----------------------------------------------------------- def blob(self, cx, cy, r, amp): cx *= GS; cy *= GS; r *= GS xx, yy = lgrid() g = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * r * r))) self.T += (g * amp).astype(np.float32) def jet(self, cx, cy, r, dx, dy, amp, heat): cx *= GS; cy *= GS; r *= GS; amp *= GS # amp is a wind speed xx, yy = lgrid() g = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * r * r))) self.u += (g * dx * amp).astype(np.float32) self.v += (g * dy * amp).astype(np.float32) self.T += (g * heat).astype(np.float32) def shock(self, cx, cy, amp): cx *= GS; cy *= GS xx, yy = lgrid() dx = xx - cx; dy = yy - cy r = np.sqrt(dx * dx + dy * dy) + 1e-3 g = np.exp(-((r - 2.0*GS) / (3.0*GS)) ** 2) self.u += (dx / r * g * amp * GS).astype(np.float32) self.v += (dy / r * g * amp * GS).astype(np.float32) self.T += (g * amp * 0.05).astype(np.float32) def turb(self, amp): amp *= GS n = bandshape_2d(self.rng.randn(SNY, SNX), 3.0*GS) self.u += (n * amp).astype(np.float32) n2 = bandshape_2d(self.rng.randn(SNY, SNX), 3.0*GS) self.v += (n2 * amp).astype(np.float32) # ── one step ---------------------------------------------------------- def step(self, dt=1.0, buoy=0.85, dissip=0.9955, vort=0.22): # buoyancy and vorticity confinement both ADD velocity, so both carry # the lattice factor; the Jacobi count carries it too, so pressure # propagates the same physical distance in a step. buoy *= GS; vort *= GS xx, yy = lgrid() # buoyancy (screen y grows downward, so hot air gets negative v) self.v -= buoy * self.T * dt # vorticity confinement — keeps the curls from smearing out if vort > 0: w = ((np.roll(self.v, -1, 1) - np.roll(self.v, 1, 1)) - (np.roll(self.u, -1, 0) - np.roll(self.u, 1, 0))) * 0.5 aw = np.abs(w) gx = (np.roll(aw, -1, 1) - np.roll(aw, 1, 1)) * 0.5 gy = (np.roll(aw, -1, 0) - np.roll(aw, 1, 0)) * 0.5 m = np.sqrt(gx * gx + gy * gy) + 1e-5 self.u += vort * (gy / m) * w * dt self.v -= vort * (gx / m) * w * dt # advect velocity px = xx - self.u * dt; py = yy - self.v * dt nu = _sample(self.u, px, py); nv = _sample(self.v, px, py) self.u, self.v = nu, nv # obstacles if self.solid.max() > 0: self.u *= (1 - self.solid); self.v *= (1 - self.solid) # project div = ((np.roll(self.u, -1, 1) - np.roll(self.u, 1, 1)) + (np.roll(self.v, -1, 0) - np.roll(self.v, 1, 0))) * 0.5 p = np.zeros_like(div) for _ in range(int(round(18*GS))): p = (_lap4(p) - div) * 0.25 self.u -= (np.roll(p, -1, 1) - np.roll(p, 1, 1)) * 0.5 self.v -= (np.roll(p, -1, 0) - np.roll(p, 1, 0)) * 0.5 if self.solid.max() > 0: self.u *= (1 - self.solid); self.v *= (1 - self.solid) # advect temperature, then let it lose contrast self.T = _sample(self.T, xx - self.u * dt, yy - self.v * dt) # diffusivity goes as cell^2, so the neighbour weight scales as GS^2 wn = 0.013 * GS * GS self.T = (self.T * (1.0 - 4.0*wn) + _lap4(self.T) * wn) * dissip self.u *= 0.995; self.v *= 0.995 def bandshape_2d(x, sigma): im = Image.fromarray(((np.clip(x, -3, 3) + 3) / 6 * 255).astype(np.uint8)) im = im.filter(ImageFilter.GaussianBlur(sigma)) return (np.asarray(im, np.float32) / 255.0 * 6 - 3) # ════════════════════════════════════════════════════════════════════════════ # THE OPTICS — a knife-edge schlieren rig # ════════════════════════════════════════════════════════════════════════════ _FG = {} def fgrid(): if "x" not in _FG: yy, xx = np.mgrid[0:RH, 0:RW].astype(np.float32) _FG["x"] = xx; _FG["y"] = yy return _FG["x"], _FG["y"] _MIRROR = {} def mirror(): """The parabolic mirror's own field: circular stop, dust, a coma flare.""" if "f" in _MIRROR: return _MIRROR["f"], _MIRROR["d"] xx, yy = fgrid() # the mirror is round, and at 16:9 we are standing close enough that # its field overfills the frame vertically instead of leaving pillars nx = (xx - RW * 0.5) / (RW * 0.462); ny = (yy - RH * 0.5) / (RW * 0.462) r = np.sqrt(nx * nx + ny * ny) stop = np.clip((0.985 - r) / 0.035, 0, 1) ** 0.7 stop = stop * (0.94 + 0.06 * np.clip(1 - r, 0, 1)) rng = np.random.RandomState(70707) im = Image.new("L", (RW, RH), 128) d = mkdraw(im) # dust is authored in 1280x720 units for _ in range(110): x, y = rng.uniform(0, W), rng.uniform(0, H) rr = rng.uniform(0.5, 1.7) d.ellipse([x - rr, y - rr, x + rr, y + rr], fill=int(rng.uniform(88, 122))) for _ in range(5): x = rng.uniform(0, W) d.line([(x, 0), (x + rng.uniform(-40, 40), H)], fill=int(rng.uniform(138, 158)), width=1) dust = (np.asarray(im.filter(ImageFilter.GaussianBlur(PF(0.7))), np.float32) - 128.0) / 128.0 _MIRROR["f"] = stop.astype(np.float32) _MIRROR["d"] = dust.astype(np.float32) return _MIRROR["f"], _MIRROR["d"] def knife(T, gain, cutoff, angle, sil, zoom=1.0, cx=0.5, cy=0.5, blur=0.0): """Render the temperature field the way the rig sees it: the index gradient in ONE direction, cut by the knife.""" # gradient at solver resolution, then resampled — this is the honest # order, since the deflection happens in the test section g = bandshape_2d(T, 1.1*GS) gx = (np.roll(g, -1, 1) - np.roll(g, 1, 1)) * 0.5 gy = (np.roll(g, -1, 0) - np.roll(g, 1, 0)) * 0.5 # the differences above are PER CELL; a finer cell reports a smaller step # across the same physical gradient, so put the lattice factor back d = (gx * math.cos(angle) + gy * math.sin(angle)) * GS im = Image.fromarray(np.clip(d * 300.0 + 128.0, 0, 255).astype(np.uint8)) if zoom != 1.0: zw, zh = int(SNX / zoom), int(SNY / zoom) x0 = int(np.clip(cx * SNX - zw / 2, 0, SNX - zw)) y0 = int(np.clip(cy * SNY - zh / 2, 0, SNY - zh)) im = im.crop((x0, y0, x0 + zw, y0 + zh)) im = im.resize((RW, RH), Image.BICUBIC) dd = (np.asarray(im, np.float32) - 128.0) / 300.0 # knife edge: the background level IS the cutoff fraction I = np.clip((1.0 - cutoff) + gain * dd, 0.0, 1.0) stop, dust = mirror() I = I * (1.0 + 0.055 * dust) I = I * stop + (1 - stop) * 0.015 if sil is not None: I = I * (1.0 - sil) col = np.stack([I * 214, I * 224, I * 236], -1).astype(np.float32) # a little glare where the field is brightest col += (np.clip(I - 0.82, 0, 1) ** 2)[..., None] * np.array( [70, 74, 82], np.float32) if blur > 0.02: col = np.asarray(Image.fromarray( np.clip(col, 0, 255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(blur)), np.float32) return col # ── the things in the field. Everything solid is a true silhouette. -------- def scene_sil(kind, t, u, p): """-> (full-res silhouette 0..1, low-res obstacle, source points).""" im = Image.new("L", (RW, RH), 0) d = mkdraw(im) # authored in 1280x720 units src = {} if kind in ("boat", "match", "rope", "knock"): # the gunwale, seen from just above the water gy = H * (0.78 + 0.014 * math.sin(t * 0.9)) d.polygon([(0, H), (0, gy + 40), (W * .22, gy + 8), (W * .62, gy - 6), (W, gy + 26), (W, H)], fill=255) d.rectangle([W * .07, gy - 34, W * .11, gy + 10], fill=255) # thole if kind in ("rope", "knock"): sw = 10 * math.sin(t * 0.8) d.line([(W * .70 + sw, gy - 4), (W * .74 + sw * 0.4, H)], fill=255, width=7) d.line([(W * .70 + sw, gy - 4), (W * .68, 0)], fill=255, width=5) if kind == "match": mx, my = W * .40, gy - 90 d.line([(mx - 60, my + 40), (mx, my)], fill=255, width=6) d.polygon([(mx - 150, H), (mx - 150, my + 34), (mx - 46, my + 52), (mx - 40, H)], fill=255) src["match"] = (mx / W * NX, my / H * NYG) src["gunwale"] = (W * .30 / W * NX, gy / H * NYG) if kind in ("diver", "hold", "exhale"): cxp = p.get("hx", 0.44); cyp = p.get("hy", 0.52) hx, hy = W * cxp, H * cyp rr = H * p.get("hr", 0.20) * 0.56 # head in profile, facing right d.ellipse([hx - rr, hy - rr * 1.14, hx + rr * .92, hy + rr * 1.02], fill=255) d.polygon([(hx + rr * .55, hy - rr * .30), (hx + rr * 1.30, hy + rr * .10), (hx + rr * .95, hy + rr * .48), (hx + rr * .40, hy + rr * .40)], fill=255) # nose / jaw d.polygon([(hx - rr * 3.4, H), (hx - rr * 2.6, hy + rr * 1.30), (hx - rr * 1.05, hy + rr * .78), (hx + rr * .80, hy + rr * .92), (hx + rr * 2.4, hy + rr * 1.55), (hx + rr * 3.6, H)], fill=255) # shoulders src["mouth"] = ((hx + rr * 1.02) / W * NX, (hy + rr * .40) / H * NYG) if kind in ("front", "frontplume"): # the diver square to camera. Everything the profile cannot show: # both nostrils, the set of the jaw, and a breath that comes AT the # lens instead of across it. cxp = p.get("hx", 0.50); cyp = p.get("hy", 0.50) hx, hy = W * cxp, H * cyp rr = H * p.get("hr", 0.22) * 0.58 d.ellipse([hx - rr * .82, hy - rr * 1.12, hx + rr * .82, hy + rr * 1.06], fill=255) # skull d.ellipse([hx - rr * .98, hy - rr * .22, hx - rr * .70, hy + rr * .24], fill=255) # ears d.ellipse([hx + rr * .70, hy - rr * .22, hx + rr * .98, hy + rr * .24], fill=255) d.polygon([(hx - rr * 3.6, H), (hx - rr * 2.5, hy + rr * 1.42), (hx - rr * .95, hy + rr * .96), (hx + rr * .95, hy + rr * .96), (hx + rr * 2.5, hy + rr * 1.42), (hx + rr * 3.6, H)], fill=255) # shoulders d.rectangle([hx - rr * .34, hy + rr * .86, hx + rr * .34, hy + rr * 1.10], fill=255) # neck src["mouth"] = (hx / W * NX, (hy + rr * .46) / H * NYG) src["nose"] = (hx / W * NX, (hy + rr * .16) / H * NYG) if kind == "hands": # both hands on the descent line, right up against the lens ry = 0.0 d.line([(W * .50 + 8 * math.sin(t * .7), 0), (W * .52 + 6 * math.sin(t * .5), H)], fill=255, width=16) for j, (hxf, hyf, sc) in enumerate(((0.42, 0.36, 1.00), (0.44, 0.66, 0.94))): hx, hy = W * hxf, H * hyf rr = H * 0.13 * sc d.polygon([(hx - rr * 1.5, hy + rr * .9), (hx - rr * 1.6, hy - rr * .5), (hx + rr * .9, hy - rr * .9), (hx + rr * 1.1, hy + rr * .6)], fill=255) for q in range(4): fy = hy - rr * .70 + q * rr * .46 d.rounded_rectangle([hx + rr * .5, fy - rr * .20, hx + rr * 1.9, fy + rr * .20], radius=rr * .18, fill=255) src["hand%d" % j] = (hx / W * NX, (hy - rr) / H * NYG) if kind in ("tea", "lantern"): gy = H * (0.80 + 0.012 * math.sin(t * 0.9)) d.polygon([(0, H), (0, gy + 34), (W * .30, gy + 4), (W * .72, gy - 8), (W, gy + 20), (W, H)], fill=255) if kind == "tea": cxp = W * 0.36 d.polygon([(cxp - 52, gy - 6), (cxp + 52, gy - 6), (cxp + 40, gy - 96), (cxp - 40, gy - 96)], fill=255) d.arc([cxp + 34, gy - 84, cxp + 96, gy - 30], -90, 90, fill=255, width=11) src["steam"] = (cxp / W * NX, (gy - 100) / H * NYG) else: lx = W * 0.30 d.rectangle([lx - 46, gy - 46, lx + 46, gy - 20], fill=255) d.polygon([(lx - 30, gy - 46), (lx + 30, gy - 46), (lx + 22, gy - 150), (lx - 22, gy - 150)], fill=0) d.rectangle([lx - 34, gy - 168, lx + 34, gy - 146], fill=255) d.line([(lx, gy - 168), (lx, gy - 250)], fill=255, width=8) src["flame"] = (lx / W * NX, (gy - 96) / H * NYG) src["gunwale"] = (W * .30 / W * NX, gy / H * NYG) if kind == "descend": yy0 = H * lerp(0.18, 1.05, ease_io(u)) rr = H * 0.10 d.ellipse([W * .46 - rr, yy0 - rr, W * .46 + rr, yy0 + rr * 1.1], fill=255) d.polygon([(W * .46 - rr * 1.5, yy0 + rr * .85), (W * .46 + rr * 1.5, yy0 + rr * .85), (W * .46 + rr * 3.4, H + 260), (W * .46 - rr * 3.4, H + 260)], fill=255) d.line([(W * .70, 0), (W * .72, H)], fill=255, width=6) src["mouth"] = (W * .46 / W * NX, yy0 / H * NYG) full = np.asarray(im, np.float32) / 255.0 low = np.asarray(im.resize((SNX, SNY), Image.BILINEAR), np.float32) / 255.0 return full, low, src # ════════════════════════════════════════════════════════════════════════════ # ENGINES — stateful within a shot, independent across shots (tier 4-P) # ════════════════════════════════════════════════════════════════════════════ class Rig: """Base: runs the fluid and hands it to the knife edge.""" scene = "boat" def __init__(self, shot, rng): self.s = shot; self.p = shot.p; self.rng = rng self.f = Fluid(shot.seed) self.src = {} self.warm() def warm(self): t0 = self.s.i0 / FPS kind = self.p.get("scene", self.scene) full, low, src = scene_sil(kind, t0, 0.0, self.p) self.f.solid = low; self.src = src e0 = {"rms": 0.4, "hit": 0.0, "low": 0.3, "mid": 0.3, "high": 0.3} for i in range(self.p.get("warm", 14)): self.source(i, 0.0, t0, e0) self.f.step(buoy=self.p.get("buoy", 0.85), dissip=self.p.get("dissip", 0.9955), vort=self.p.get("vort", 0.22)) def source(self, k, u, t, e): pass def frame(self, k, u, t, e): kind = self.p.get("scene", self.scene) full, low, src = scene_sil(kind, t, u, self.p) self.f.solid = low self.src = src self.source(k, u, t, e) self.f.step(buoy=self.p.get("buoy", 0.85), dissip=self.p.get("dissip", 0.9955), vort=self.p.get("vort", 0.22)) return knife(self.f.T, gain=self.p.get("gain", 3.0) * ( 1.0 + 0.30 * float(e["hit"])), cutoff=self.p.get("cut", 0.48), angle=self.p.get("knife", 0.0), sil=full, zoom=self.p.get("zoom", 1.0), cx=self.p.get("cx", 0.5), cy=self.p.get("cy", 0.5), blur=self.p.get("blur", 0.0)) def breath_env(t, bt, hold=False): """A breath: a fast exhale jet, then the inhale drawing air back.""" dt = t - bt if dt < -0.15 or dt > 2.4: return 0.0, 0.0 ex = math.exp(-((dt - 0.22) / 0.30) ** 2) inh = 0.0 if hold else math.exp(-((dt - 1.20) / 0.34) ** 2) return ex, inh class Deck(Rig): """On the boat: the gunwale, the sea's own convection, the rope.""" scene = "boat" def source(self, k, u, t, e): f = self.f # the sea is warmer than the air; a slow convective sheet off it for i in range(5): x = (self.rng.rand() * NX) f.blob(x, NYG * (0.76 + 0.14 * self.rng.rand()), 4.2, 0.14 + 0.16 * float(e["rms"])) f.jet(NX * 0.5, NYG * 0.86, NX * 0.42, 0.0, -0.30, 0.16, 0.0) if "steam" in getattr(self, "src", {}): sx, sy = self.src["steam"] f.jet(sx, sy, 3.0, 0.0, -1.5, 1.1, 0.55) f.blob(sx + self.rng.uniform(-1, 1), sy - 1.0, 2.0, 0.55) if "flame" in getattr(self, "src", {}): fx_, fy_ = self.src["flame"] fl = 1.0 + 0.30 * math.sin(t * 9.0) + 0.20 * self.rng.rand() f.blob(fx_, fy_, 1.8, 2.2 * fl) f.jet(fx_, fy_ - 1.5, 2.2, 0.0, -3.0 * fl, 1.2, 0.85) if "match" in getattr(self, "src", {}) and t > self.p.get("lit", 1e9): mx, my = self.src["match"] f.blob(mx, my, 1.5, 2.6 + 0.9 * self.rng.rand()) f.jet(mx, my + 1.5, 1.8, 0.0, -2.4, 0.9, 0.90) if self.p.get("knock") and abs(t - self.p["knock"]) < 1.0 / FPS: f.shock(NX * 0.22, NYG * 0.80, 3.2) f.turb(0.030 + 0.05 * float(e["mid"])) class Breather(Rig): """The diver. Every breath is a jet from the mouth and a plume off it.""" scene = "diver" def source(self, k, u, t, e): f = self.f mx, my = self.src.get("mouth", (NX * 0.6, NYG * 0.5)) bi = breath_index(t) strength = self.p.get("str", 1.0) if bi >= 0: hold = (bi == len(BREATH_T) - 1) ex, inh = breath_env(t, BREATH_T[bi], hold=hold) if ex > 0.01: s_ = (0.55 + 0.05 * (12 - (12 - bi))) * strength * ex f.jet(mx + 1.2, my, 2.4, 1.7 * ex, -0.30 * ex, 1.5 * s_, 0.90 * ex * strength) f.blob(mx + 2.5 + 3.0 * ex, my - 0.6, 2.2, 0.55 * ex * strength) if inh > 0.01: f.jet(mx + 3.0, my, 3.0, -1.5 * inh, 0.10, 1.1 * inh, 0.0) # body heat off the shoulders and the head f.blob(mx - 6.0, my + 8.0, 5.0, 0.20) f.blob(mx - 4.0, my - 4.0, 3.2, 0.16) f.jet(mx - 5.0, my + 3.0, 6.0, 0.0, -0.55, 0.30, 0.0) f.turb(0.022 + 0.04 * float(e["mid"])) class Front(Rig): """Square to camera. The breath comes AT the lens: a jet has no lateral component here, so it is modelled as a growing sphere of warm moist air with only buoyancy to move it — which is exactly what you see when somebody breathes at a schlieren mirror.""" scene = "front" def source(self, k, u, t, e): f = self.f mx, my = self.src.get("mouth", (NX * 0.5, NYG * 0.5)) nx_, ny_ = self.src.get("nose", (mx, my - 2)) bi = breath_index(t) strength = self.p.get("str", 1.0) if bi >= 0: hold = (bi == len(BREATH_T) - 1) ex, inh = breath_env(t, BREATH_T[bi], hold=hold) if ex > 0.01: r = 2.0 + 12.0 * ex f.blob(mx, my, r, 1.15 * ex * strength) f.jet(mx, my - 0.6, r * 0.8, 0.0, -0.9 * ex, 0.7 * ex, 0.8 * ex * strength) # the nose leaks too f.blob(nx_, ny_ + 1.0, 2.4, 0.28 * ex * strength) if inh > 0.01: f.jet(mx, my + 2.0, 4.0, 0.0, -0.7 * inh, 0.5 * inh, 0.0) f.blob(mx, my + 9.0, 6.0, 0.20) f.blob(mx, my - 5.0, 3.6, 0.14) f.jet(mx, my + 4.0, 7.0, 0.0, -0.50, 0.28, 0.0) f.turb(0.020 + 0.04 * float(e["mid"])) class Hands(Rig): """Two hands on the descent line. Skin is 10 K over the air and it shows.""" scene = "hands" def source(self, k, u, t, e): f = self.f for key in ("hand0", "hand1"): if key not in self.src: continue hx, hy = self.src[key] f.blob(hx, hy, 4.0, 0.24) f.jet(hx, hy, 5.0, 0.0, -0.75, 0.30, 0.0) f.turb(0.020 + 0.05 * float(e["mid"])) class Still(Rig): """The hold. No sources at all; the field runs down to nothing and the knife has nothing to cut — which is what a clean picture looks like.""" scene = "hold" def source(self, k, u, t, e): mx, my = self.src.get("mouth", (NX * .6, NYG * .5)) self.f.blob(mx - 6, my + 8, 5.0, 0.10 * (1 - u) ** 3) self.f.turb(0.004 * (1 - u) ** 2) class Descend(Rig): scene = "descend" def source(self, k, u, t, e): f = self.f mx, my = self.src.get("mouth", (NX * .46, NYG * .5)) f.jet(mx, my + 4, 5.0, 0.0, 1.9 * (0.4 + u), 0.70, 0.22 * (1 - u)) f.blob(mx - 2, my + 6, 4.0, 0.10 * (1 - u)) if u < 0.10: f.shock(mx, my, 3.0) f.turb(0.014) class Exhale(Rig): """The surface. One exhale, and it fills the frame.""" scene = "exhale" def source(self, k, u, t, e): f = self.f mx, my = self.src.get("mouth", (NX * .5, NYG * .5)) g = math.exp(-((u - 0.20) / 0.16) ** 2) f.jet(mx + 1.0, my, 3.2 + 8.0 * u, 3.4 * g, -1.2 * g, 2.6 * g, 2.4 * g) f.blob(mx + 6 + 30 * u, my - 4 * u, 4.0 + 16 * u, 1.1 * g) f.turb(0.016 + 0.05 * g) class Plume(Rig): """Macro on the plume alone — high gain, hard cutoff, no horizon.""" scene = "diver" def source(self, k, u, t, e): Breather.source(self, k, u, t, e) class FrontPlume(Front): """Macro, square on: the mouth fills the field.""" scene = "front" ENGINES = {"deck": Deck, "breath": Breather, "still": Still, "descend": Descend, "exhale": Exhale, "plume": Plume, "front": Front, "fplume": FrontPlume, "hands": Hands} # ════════════════════════════════════════════════════════════════════════════ # SHOT TABLE — the countdown, one section per breath # ════════════════════════════════════════════════════════════════════════════ def _b(i): return BREATH_T[i] SHOTPLAN = [ (0.0, "deck", dict(scene="boat", gain=2.4, cut=0.46, card=1, warm=26, label="SURFACE INTERVAL")), (_b(0), "breath", dict(gain=3.2, cut=0.50, hx=0.40, hy=0.50, hr=0.20)), (_b(0)+2.6, "front", dict(gain=3.4, cut=0.50, hx=0.50, hy=0.50, hr=0.22, label="SQUARE TO THE MIRROR")), (_b(1), "deck", dict(scene="rope", gain=2.6, cut=0.44, label="THE LINE")), (_b(1)+2.7, "front", dict(gain=3.6, cut=0.52, hx=0.52, hy=0.52, hr=0.25)), (_b(2), "deck", dict(scene="match", gain=3.0, cut=0.46, lit=_b(2) - 0.1, warm=8, label="A MATCH")), (_b(2)+3.0, "breath", dict(gain=3.4, cut=0.52, hx=0.58, hy=0.60, hr=0.24, knife=1.57, label="TURNED SIDEWAYS")), (_b(3), "hands", dict(gain=3.2, cut=0.48, warm=12, label="BOTH HANDS ON IT")), (_b(3)+3.3, "front", dict(gain=3.8, cut=0.52, hx=0.48, hy=0.50, hr=0.26)), (_b(4), "deck", dict(scene="knock", gain=3.6, cut=0.44, knock=_b(4), warm=10, label="THE HULL, ONCE")), (_b(4)+3.6, "breath", dict(gain=3.8, cut=0.54, hx=0.56, hy=0.62, hr=0.28, str=1.1)), (_b(5), "fplume", dict(gain=5.6, cut=0.58, zoom=2.4, cx=0.50, cy=0.56, hx=0.50, hy=0.44, hr=0.30, str=1.15, label="THE MOUTH")), (_b(5)+3.9, "deck", dict(scene="tea", gain=2.8, cut=0.44, warm=16, label="TEA, GOING COLD")), (_b(6), "breath", dict(gain=4.0, cut=0.54, hx=0.44, hy=0.54, hr=0.25, str=1.15)), (_b(6)+4.2, "plume", dict(gain=6.0, cut=0.60, zoom=3.0, cx=0.60, cy=0.42, hx=0.22, hy=0.60, hr=0.26, str=1.2, label="CLOSER, AND CLOSER")), (_b(7), "front", dict(gain=4.2, cut=0.52, hx=0.46, hy=0.50, hr=0.28, str=1.25, knife=-0.9)), (_b(7)+2.4, "hands", dict(gain=3.6, cut=0.50, zoom=1.5, cx=0.44, cy=0.46, warm=10)), (_b(8), "breath", dict(gain=4.4, cut=0.56, hx=0.34, hy=0.64, hr=0.30, str=1.3)), (_b(8)+2.6, "deck", dict(scene="lantern", gain=3.4, cut=0.46, warm=18, label="THE LAMP")), (_b(9), "front", dict(gain=4.6, cut=0.56, hx=0.52, hy=0.52, hr=0.30, str=1.35)), (_b(9)+2.8, "plume", dict(gain=7.0, cut=0.64, zoom=3.4, cx=0.62, cy=0.44, hx=0.20, hy=0.58, hr=0.28, str=1.4)), (_b(10), "breath", dict(gain=4.8, cut=0.58, hx=0.58, hy=0.63, hr=0.33, str=1.4)), (_b(10)+3.0,"deck", dict(scene="rope", gain=2.4, cut=0.44, blur=1.0, label="DOWN OUT OF THE LIGHT")), (_b(11), "front", dict(gain=5.0, cut=0.60, hx=0.50, hy=0.50, hr=0.34, str=1.5, label="HELD")), (_b(11)+3.4,"still", dict(gain=5.0, cut=0.52, hx=0.48, hy=0.50, hr=0.33, dissip=0.972, vort=0.0, warm=4)), (ZERO_T, "still", dict(gain=4.0, cut=0.50, hx=0.50, hy=0.48, hr=0.30, dissip=0.960, vort=0.0, warm=2, card=2)), (ZERO_T+3.6,"still", dict(gain=3.0, cut=0.50, hx=0.50, hy=0.48, hr=0.30, dissip=0.945, vort=0.0, warm=2, blur=0.6)), (DIVE_T, "descend",dict(gain=3.4, cut=0.48, warm=2, label="DOWN")), (SURF_T, "exhale", dict(gain=4.6, cut=0.54, hx=0.30, hy=0.56, hr=0.26, warm=2, buoy=1.15)), (SURF_T+3.0,"exhale", dict(gain=6.0, cut=0.62, hx=0.16, hy=0.60, hr=0.20, warm=2, buoy=1.30, card=3)), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "p", "section", "seed") def __init__(self, idx, i0, i1, engine, p): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.p = engine, p self.section = sec_of_t(i0 / FPS) self.seed = 33000 + idx * 7919 def build_shots(): times = [snap(t) for t, _, _ in SHOTPLAN] times[0] = 0.0 for i in range(1, len(times)): if times[i] <= times[i - 1] + 0.7: times[i] = times[i - 1] + 0.8 shots = [] for i, (t0, eng, p) in enumerate(SHOTPLAN): a = times[i] b = times[i + 1] if i + 1 < len(times) else DUR i0, i1 = int(round(a * FPS)), int(round(b * FPS)) if i1 <= i0: continue shots.append(Shot(len(shots), i0, i1, eng, p)) if shots: shots[-1].i1 = N_FRAMES shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> crisp 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.ttf"): # Scaled ONCE, here. Call sites always pass authoring sizes. key = (max(1, P(size)), name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, key[0]) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: xx, yy = fgrid() nx = (xx - RW / 2) / (RW / 2); ny = (yy - RH / 2) / (RH / 2) r = np.sqrt(nx ** 2 + ny ** 2) / 1.42 _VIG["v"] = np.clip(1.0 - 0.34 * r ** 2.0, 0, 1)[..., None].astype( np.float32) return _VIG["v"] def shadowed(d, xy, txt, f, fill, sh=(4, 6, 9), a=200): x, y = xy for ox, oy in ((-2, 2), (2, 2), (0, 3), (2, -1), (-2, -1)): d.text((x + PF(ox), y + PF(oy)), txt, font=f, fill=sh + (a,)) d.text((x, y), txt, font=f, fill=fill) def post(arr, i, e, shot): a = np.asarray(arr, np.float32).copy() t = i / FPS # 1. tint — schlieren silver: cool in the shadow, neutral in the light lum = a.mean(2, keepdims=True) / 255.0 a = a * np.array([0.968, 0.990, 1.026], np.float32) a = a + (1 - lum) * np.array([-5, -2, 7], np.float32) # 2. vignette a *= vignette() # 3. grain — the sensor, denser where the field is dark rng = np.random.RandomState(9400 + i) amp = (2.0 + 2.4 * (1 - lum[..., 0])[..., None] + 1.4 * float(e["high"])) if S == 1.0: a += rng.normal(0, 1.0, (H, W, 1)) * amp else: # grain is a look, not a resolution: drawn at 1280x720 and blown up gn = rng.normal(0, 1.0, (H, W)) * 32.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a += ((np.asarray(gi.resize((RW, RH), Image.NEAREST), np.float32) - 128.0) / 32.0)[..., None] * amp out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) # --- crisp text --- d = ImageDraw.Draw(out, "RGBA") age = i - shot.i0 cnt = count_at(t) bi = breath_index(t) # THE COUNT — a big numeral that lands on each breath and fades if bi >= 0 and t < DIVE_T: bt = ZERO_T if t >= ZERO_T else BREATH_T[bi] dt = t - bt life = 2.6 if t < ZERO_T else 5.0 if 0 <= dt < life: al = min(1.0, dt / 0.14) * min(1.0, (life - dt) / 0.7) n_ = cnt if cnt is not None else 0 f = font(196 if n_ else 240, "Georgia Bold.ttf") s_ = str(n_) lw = d.textlength(s_, font=f) shadowed(d, (RW * 0.845 - lw / 2, RH * 0.20), s_, f, (238, 244, 250, int(214 * al)), a=150) if t < ZERO_T: ln = BREATHS[bi][1] f2 = font(21, "Georgia Italic.ttf") lw2 = d.textlength(ln, font=f2) al2 = min(1.0, dt / 0.3) * min(1.0, (2.2 - dt) / 0.6) if al2 > 0: shadowed(d, (RW / 2 - lw2 / 2, RH * 0.845), ln, f2, (232, 238, 246, int(226 * max(0, al2)))) if shot.p.get("card") == 1 and age < FPS * 3.4: al = min(1.0, age / 10.0) * min(1.0, (FPS * 3.4 - age) / 14.0) # THE TITLE MOMENT — the card's own shadowed Georgia, with the show # name tracked out beneath the sub-line. f = font(62, "Georgia Bold.ttf") lw = d.textlength(TITLE, font=f) shadowed(d, (RW / 2 - lw / 2, RH * 0.30), TITLE, f, (240, 246, 252, int(244 * al))) f2 = font(20, "Georgia Italic.ttf") sub = "before the dive — counted down, twelve to nothing" lw2 = d.textlength(sub, font=f2) shadowed(d, (RW / 2 - lw2 / 2, RH * 0.30 + PF(74)), sub, f2, (206, 216, 228, int(206 * al)), a=150) f3 = font(14, "Georgia Bold.ttf") tr = PF(6.5) sw3 = sum(d.textlength(ch, font=f3) + tr for ch in SUBT) - tr x3 = RW / 2 - sw3 / 2; y3 = RH * 0.30 + PF(112) d.line([RW/2 - sw3/2 - PF(18), y3 - PF(9), RW/2 + sw3/2 + PF(18), y3 - PF(9)], fill=(190, 204, 218, int(150 * al)), width=max(2, P(1))) for ch in SUBT: shadowed(d, (x3, y3), ch, f3, (226, 234, 244, int(220 * al)), a=130) x3 += d.textlength(ch, font=f3) + tr if shot.p.get("card") == 2: al = min(1.0, age / 18.0) f2 = font(27, "Georgia Italic.ttf") ln = "nothing moves, so there is nothing to see" lw2 = d.textlength(ln, font=f2) shadowed(d, (RW / 2 - lw2 / 2, RH * 0.80), ln, f2, (234, 240, 248, int(228 * al))) if shot.p.get("card") == 3: al = min(1.0, age / 12.0) f2 = font(30, "Georgia Italic.ttf") ln = "and then all of it, at once" lw2 = d.textlength(ln, font=f2) shadowed(d, (RW / 2 - lw2 / 2, RH * 0.815), ln, f2, (240, 246, 252, int(234 * al))) # The rig's instrument HUD used to live here — BREATH nn/12, the knife # angle, the cutoff fraction, the gain, a heart rate. Every one of those # is a renderer parameter wearing a lab coat, so the final cut drops the # whole strip, and the "Z-TYPE SCHLIEREN ·
" engine label with # it. What is left of the count is the count itself: the big numeral and # its line, which are the film's structure rather than its telemetry. lab = shot.p.get("label") if lab and age < FPS * 2.6: fh = font(14, "Menlo.ttc") al = min(1.0, age / 8.0) * min(1.0, (FPS * 2.6 - age) / 12.0) d.text((PF(30), RH - PF(26)), lab, font=fh, fill=(180, 224, 248, int(226 * al))) # 4. letterbox bh = int(RH * 0.045) d.rectangle([0, 0, RW, bh], fill=(8, 9, 11)) d.rectangle([0, RH - bh, RW, RH], fill=(8, 9, 11)) return out # ════════════════════════════════════════════════════════════════════════════ # RENDER # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env(); made = 0 eng = ENGINES[shot.engine](shot, np.random.RandomState(shot.seed)) for k in range(shot.n): i = shot.i0 + k p = FRAMES / f"f{i:05d}.png" e = {kk: float(E[kk][min(i, N_FRAMES - 1)]) for kk in E} u = k / max(1, shot.n - 1) if p.exists() and not force: eng.frame(k, u, i / FPS, e) # keep the solver in step continue arr = eng.frame(k, u, i / FPS, e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {shot.section:8s} {made}/{shot.n}" def contact_sheet(shots): cols = 6 rows = (len(shots) + cols - 1) // cols tw, th = P(320), P(180) # 16:9 thumbs sheet = Image.new("RGB", (cols * tw, rows * (th + P(26))), (10, 10, 12)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): mid = sh.n // 2 eng = ENGINES[sh.engine](sh, np.random.RandomState(sh.seed)) arr = None for k in range(mid + 1): i = sh.i0 + k e = {kk: float(E[kk][min(i, N_FRAMES - 1)]) for kk in E} arr = eng.frame(k, k / max(1, sh.n - 1), i / FPS, e) i = sh.i0 + mid e = {kk: float(E[kk][min(i, N_FRAMES - 1)]) for kk in E} im = post(arr, i, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols) * tw, (n // cols) * (th + P(26)) sheet.paste(im, (cx, cy)) sd.text((cx + P(5), cy + th + P(5)), f"{sh.idx:02d} {sh.engine} · {sh.i0/FPS:5.1f}s " f"({sh.n/FPS:.1f}s)", font=font(13, "Menlo.ttc"), fill=(190, 195, 205)) p = OUT / "contact_sheet.png" sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def _git(*args): try: return subprocess.check_output(["git", "rev-parse", *args], 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("--audio", default=None) ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count() or 4)) a = ap.parse_args() wav = Path(a.audio) if a.audio else AUD / "final.wav" if not wav.exists() or not (AUD / "env.npz").exists(): print(f"[1/3] gnawa… {DUR:.1f}s, {len(PULSE)} eighths, " f"{bpm_at(0):.0f}→{bpm_at(50):.0f} bpm, " f"breaths at {[round(x, 1) for x in BREATH_T]}") wav, mix = finish_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return mirror(); fgrid(); lgrid() shots = build_shots() if a.sheet: contact_sheet(shots); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames on " f"{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, " f"first={missing[0]}") print("[3/3] mux…") 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={SUBT} — {TITLE}", "-metadata", f"artist=poop / {SETDIR}", "-metadata", f"date={datetime.date.today().isoformat()}", "-metadata", ("comment=generator=renders/" f"{SETDIR}/{NAME}/render.py; " f"git={_git('HEAD')[:12]}; {MUSIC_DESC}; " f"{ENGINE_DESC}"), "-metadata", (f"description={TITLE} — countdown 12 to 0 — " f"{MUSIC_DESC} — {ENGINE_DESC}"), str(out)], check=True, capture_output=True) (OUT / "PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {_git('HEAD')[:12]} branch: {_git('--abbrev-ref', 'HEAD')}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {RW}x{RH} (16:9)\n" f"scale: S={S} — native re-rasterisation from {W}x{H} authoring units\n" f"structure: countdown 12->0; intervals x{_RATIO} each breath\n" f"breaths: {[round(x, 2) for x in BREATH_T]} zero={ZERO_T:.2f}\n" f"music: {MUSIC_DESC}\n" f"tempo map: {TEMPO}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"substrate: {ENGINE_DESC}\n" f"solver: {SNX}x{SNY} ({NX}x{NYG} authoring cells x GS={GS}), " f"{int(round(18*GS))} Jacobi iterations, vorticity confinement\n" f"engines: {' '.join(sorted(ENGINES))} (shot-parallel, tier 4-P, " f"stateful within a shot)\n" f"shots: {len(build_shots())}\n" f"seeds: mirror=70707 shot=33000+idx*7919\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()