#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Chapa N.º 9 (17/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/chapa_no_9 # # A portrait photographer's last day in wet collodion, and the ninth plate is an empty chair. # # 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/chapa_no_9.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/chapa_no_9.mp4 # cover: https://genekogan.com/player_computer/media/chapa_no_9.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 chapa_no_9.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 — "CHAPA N.º 9" (plate no. 9) — final delivery cut Fado, 76bpm, B minor. 21 bars of rubato — a shared tempo map, so the cut and the song breathe together. Guitarra portuguesa (twelve strings in six courses, tremolo and teardrop ornaments) over a nylon viola, no drums, and a half-sung Portuguese line from Joana. A portrait photographer's last day, plate by plate. The sitters who could not hold still blur away; a boy who turned his head is two boys and a veil between them; a widow who did not move at all is razor sharp. The ninth plate is the empty chair, and it is the most beautiful one. THE CONCEIT: every frame in this film is a wet plate developing. Not a picture of a plate — a plate. There is one substrate, `Plate`, holding a latent exposure, a developer coverage field, a silver density and a field of exhausted developer; each shot pours developer onto a fresh plate and the image climbs out of the black at whatever rate the singing allows. The studio, the tray, the photographer's own hands, the drying rack — all of them are plates too. Composition: engine : audio-first x shot-parallel (tier 4-P) — the chemistry is recursive within a shot and thrown away at every cut content: audio-groove (vectorised Karplus-Strong guitarra, viola, body taps, no kit) x tts-voices (Joana pt_PT through a channel vocoder) x effects-post (tint -> vignette -> grain -> letterbox) FINAL CUT (player_computer_final). The film is unchanged; the delivery is: * 1920x1080 native. RS = H/720 = 1.5 (the scale factor is RS, not S — `S` is already this file's shot-spec builder). The plate stage grows with it, 2160x1215, and so does every quantity measured in plate pixels: the per-column developer velocity, the pour smoothing radius, the comet radii, the tray rock, the spread blur, the edge-ripple noise scale, dust motes, scratch widths and the silver-grain noise scale. Dust and comet COUNTS do not scale — it is the same physical plate, photographed larger. Film grain in post is generated at 1280x720 and NEAREST-blown-up so a speck of it stays the size it was. * No renderer-debug overlays. The CHAPA I..IX cards are diegetic — they are the plate numbering the photographer wrote, part of the fiction — and they stay, as do the sung Portuguese captions with their translations. * Title moment: the "CHAPA N.º 9 / the last plate" card gains the show subtitle PLAYER COMPUTER, set in the same Georgia and the same silver. Run from repo root: python3 renders/player_computer_final/plate_no_9/render.py --sheet python3 renders/player_computer_final/plate_no_9/render.py --jobs 3 """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont NAME = "plate_no_9" TITLE = "CHAPA N.º 9" SUBT = "PLAYER COMPUTER" SETDIR = "player_computer_final" SETNUM = "09" W, H, FPS = 1920, 1080, 30 # ── delivery scale ────────────────────────────────────────────────────────── # Named RS because `S` in this file is the shot-spec builder. Everything is # authored against a 1280x720 delivery frame; RS = H/720 = 1.5 turns authoring # pixels into real ones. The plate simulation is a PIXEL simulation, so the # rule is: lengths scale by RS, counts do not. RS = H / 720.0 def RSi(v): return int(round(v*RS)) def RSf(v): return v*RS BPM = 76.0 BEAT = 60.0 / BPM SR = 44100 OUT = Path(__file__).resolve().parent FRAMES = OUT / "frames"; FRAMES.mkdir(exist_ok=True) AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = Path(__file__).resolve().parent # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" SECTIONS = [ ("escuro", 0, 2), ("primeira", 2, 7), ("menino", 7, 11), ("viuva", 11, 15), ("prata", 15, 17), ("cadeira", 17, 21), ] N_BARS = SECTIONS[-1][2] N_BEATS = N_BARS * 4 TAIL = 2.4 MUSIC_DESC = (f"fado, {BPM:.0f}bpm rubato, B minor, {N_BARS} bars, " "guitarra portuguesa + viola, no drums, sung (Joana pt_PT vocoder)") ENGINE_DESC = "wet-plate collodion development: latent / wet / silver / bromide fields" # ════════════════════════════════════════════════════════════════════════════ # RUBATO — one tempo map shared by the song and the cut # # Fado does not tick. Each four-bar phrase leans back at its start, presses # through its middle and lays down at the cadence. The map is a per-beat # duration curve integrated into an absolute beat->seconds table; every note # and every shot boundary is placed through T(), so the picture rubatos with # the music instead of against it. The curve is normalised so the mean tempo # is exactly BPM — the pulse is felt even where it is bent. # ════════════════════════════════════════════════════════════════════════════ def _tempo_map(): R = np.random.RandomState(7609) b = np.arange(N_BEATS, dtype=np.float64) u = (b % 16) / 16.0 # position in the phrase dur = 1.0 + 0.115 * np.cos(2 * np.pi * u) # slow / press / slow dur *= 1.0 + 0.030 * np.cos(2 * np.pi * (b % 4) / 4.0) # breath per bar dur *= 1.0 + R.normal(0, 0.010, N_BEATS) # a hand, not a clock dur[-6:] *= np.linspace(1.02, 1.30, 6) # the closing ritard dur *= N_BEATS / dur.sum() # mean tempo preserved return np.concatenate([[0.0], np.cumsum(dur * BEAT)]) BT = _tempo_map() def T(bar, step=0.0): """Absolute seconds of 16th-step `step` inside `bar`, through the rubato.""" b = bar * 4 + step / 4.0 i = int(min(max(b, 0.0), N_BEATS - 1e-6)) return float(BT[i] + (b - i) * (BT[i + 1] - BT[i])) def TB(beat): i = int(min(max(beat, 0.0), N_BEATS - 1e-6)) return float(BT[i] + (beat - i) * (BT[i + 1] - BT[i])) DUR = float(BT[N_BEATS]) + TAIL N_FRAMES = int(DUR * FPS) # ════════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0 * 2.0 ** ((m - 69) / 12.0) _PC = {"C":0,"C#":1,"Db":1,"D":2,"D#":3,"Eb":3,"E":4,"F":5,"F#":6,"Gb":6, "G":7,"G#":8,"Ab":8,"A":9,"A#":10,"Bb":10,"B":11} def nf(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return mtof(12 * (int(name[i:]) + 1) + _PC[name[:i]]) def adsr(n, a, d, s, r): e = np.zeros(n) ai, di, ri = max(1, int(a*SR)), max(1, int(d*SR)), max(1, int(r*SR)) ai = min(ai, n); e[:ai] = np.linspace(0, 1, ai) if ai < n: dd = min(di, n - ai) e[ai:ai+dd] = np.linspace(1, s, dd); e[ai+dd:] = s if ri < n: e[-ri:] *= np.linspace(1, 0, ri) return e def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping — every noise source in the piece goes through it, so nothing is ever a full-band blast (AESTHETIC 13a).""" n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) g = np.ones_like(fq) if lo: g *= 1.0/np.sqrt(1.0 + (lo/fq)**order) if hi: g *= 1.0/np.sqrt(1.0 + (fq/hi)**order) return np.fft.irfft(X*g, n) # ── the string model ──────────────────────────────────────────────────────── # A *vectorised* Karplus-Strong. The classic implementation walks the delay # line one sample at a time in Python; here the whole delay line is a numpy # vector and one loop iteration advances it a full period, so a note costs # `dur * freq` block operations instead of `dur * 44100` scalar ones. That is # what makes a tremolo — six hundred plucks in a minute — affordable. # # Fractional tuning comes from a linear-interpolated read of the loop-filtered # line, which is also where the string's brightness decay lives. _KSC = {} def pluck(freq, dur, damp=0.9965, bright=1.0, seed=0, pick=0.55): key = (round(freq, 2), round(dur, 3), round(damp, 5), round(bright, 2), seed % 64, round(pick, 2)) hit = _KSC.get(key) if hit is not None: return hit n = max(8, int(dur*SR)) Lt = max(3.0, SR/max(freq, 20.0) - 0.5) L = int(Lt); fr = Lt - L rng = np.random.RandomState(1000 + seed) exc = rng.uniform(-1, 1, L) # pick position comb: a plucked string is silent in the harmonics that # have a node under the pick j = int(L*pick) exc = exc - np.roll(exc, j)*0.6 # pick hardness: a fingerpick on steel keeps the top octave, a nail on # nylon does not k = max(1, int(L*(1.0 - bright)*0.30)) if k > 1: ker = np.ones(k)/k exc = np.convolve(np.concatenate([exc, exc]), ker, "same")[:L] buf = exc.astype(np.float64) out = np.empty(((n // L) + 2) * L) at = 0 while at < n: out[at:at+L] = buf; at += L avg = 0.5*(buf + np.roll(buf, -1)) buf = damp*((1-fr)*avg + fr*np.roll(avg, -1)) y = out[:n] # the pick attack itself — metal on metal for the guitarra ny = min(n, int(0.006*SR)) tt = np.arange(ny)/SR y[:ny] += bandshape(np.random.RandomState(seed+77).randn(ny), lo=2200*bright, hi=9000)*np.exp(-tt*420)*0.45*bright y = y*adsr(n, 0.0008, 0.02, 0.92, min(0.10, dur*0.4)) y = y/(np.max(np.abs(y))+1e-9) if len(_KSC) < 4000: _KSC[key] = y return y def course(freq, dur, detune=3.4, octave=False, seed=0, seed2=3, **kw): """One *course* of the guitarra: two strings, never quite the same. The beating between them is the instrument's whole shimmer.""" a = pluck(freq*(1 - detune*0.0005), dur, seed=seed, **kw) f2 = freq*(2.0 if octave else 1.0)*(1 + detune*0.0005) b = pluck(f2, dur, seed=seed2, **kw) n = min(len(a), len(b)) return a[:n]*0.62 + b[:n]*0.48 def body_tap(dur=0.16, f0=104, seed=5): """Fingers on the soundboard. Not a drum — but it is the pulse.""" n = int(dur*SR); t = np.arange(n)/SR thud = np.sin(2*np.pi*f0*t)*np.exp(-t*34) + 0.5*np.sin(2*np.pi*f0*2.4*t)*np.exp(-t*52) nz = bandshape(np.random.RandomState(seed).randn(n), lo=180, hi=1500) return (thud*0.8 + nz*np.exp(-t*70)*0.55)*0.9 def slosh(dur=1.4, seed=11, rate=0.9): """Developer moving in a rocked tray.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=380, hi=2600) am = 0.5 + 0.5*np.sin(2*np.pi*rate*t - 1.2) return nz*am*np.exp(-t*1.1)*0.5 def drip(seed=13): n = int(0.26*SR); t = np.arange(n)/SR f = 900*(1 + 1.4*np.exp(-t*60)) return np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*26)*0.5 def roomtone(n, seed=17): rng = np.random.RandomState(seed) return bandshape(rng.randn(n), lo=70, hi=900)*0.020 def reverb(x, rt=2.4, mix=0.32, seed=29, pre=0.022): 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=0.3, fb=0.34, mix=0.16, taps=6): 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 class Song: """A multitrack canvas on the rubato grid.""" def __init__(self, dur): self.n = int(dur*SR) self.tr = {} 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*0.5 + 0.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.55): env = np.ones(self.n) for nm, b0, b1 in SECTIONS: i0, i1 = int(T(b0)*SR), min(self.n, int(T(b1)*SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) env[int(T(N_BARS)*SR):] = levels.get(SECTIONS[-1][0], 1.0)*0.7 k = max(1, int(glide*SR)) return np.convolve(env, np.ones(k)/k, "same") 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: mix *= self.sec_env(levels)[:, None] # sub-30Hz trim a = math.exp(-2*math.pi*30.0/SR) for c in range(2): col = mix[:, c]; lp = np.empty(self.n); z = 0.0 for i in range(self.n): z = (1-a)*col[i] + a*z; lp[i] = z mix[:, c] = col - lp mix = np.tanh(mix*1.15)/np.tanh(1.15) return mix/(np.max(np.abs(mix)) + 1e-9)*0.94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1)*32767).astype(" macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, voice, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, voice, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def carrier(f_ps, nh=34, detune=(0.0, -0.6, 0.7), vib=(0.014, 4.8)): n = len(f_ps); t = np.arange(n)/SR out = np.zeros(n) for d in detune: f = f_ps*(1 + d*0.004) if vib[0]: ramp = np.clip(np.linspace(0, 1.7, n), 0, 1) # vibrato blooms late f = f*(1 + vib[0]*ramp*np.sin(2*np.pi*vib[1]*t)) 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=28, lo=110, hi=6200, gmax=12.0, rel=0.5, sib=0.055, 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, ac = np.abs(M), 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) def sing(text, degrees, dur, base, cache, voice="Joana", rate=142, gemido=0.55, gliss=0.055): """A half-sung fado line. `degrees` are semitones above `base`.""" n = int(dur*SR) key = cache/("say_"+_h(text, voice, rate)+".wav") mod = fit(say_wav(text, voice, rate, key), n) f = np.zeros(n); at = 0 for i, dg in enumerate(degrees): ln = int(n/len(degrees)) if i < len(degrees)-1 else n-at f[at:at+ln] = base*2.0**(dg/12.0); at += ln k = max(3, int(gliss*SR)) f = np.convolve(f, np.ones(k)/k, "same"); f[:k] = f[k]; f[-k:] = f[-k-1] amp = np.ones(n) if gemido > 0: # the catch in the throat for u0 in (0.30, 0.66): i0 = int(n*u0); wgm = max(8, int(0.10*SR)) sl = slice(max(0, i0-wgm//2), min(n, i0+wgm//2)) wlen = sl.stop - sl.start if wlen < 4: continue bump = np.sin(np.linspace(0, np.pi, wlen)) f[sl] *= 1.0 - 0.055*gemido*bump amp[sl] *= 1.0 - 0.42*gemido*bump car = carrier(f) y = vocode(mod, car) return y*fit(amp, len(y)) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ GBASE = nf("B4") # guitarra, bright register VBASE = nf("B2") # viola bass # Bm F#7 Bm Bm Em Bm F#7 Bm — the fado menor turn PROG = [("B2", (0, 3, 7, 12)), ("F#2", (0, 4, 7, 10)), ("B2", (0, 3, 7, 12)), ("B2", (0, 3, 7, 12)), ("E2", (0, 3, 7, 12)), ("B2", (0, 3, 7, 12)), ("F#2", (0, 4, 7, 10)), ("B2", (0, 3, 7, 12))] # guitarra melody: (degree above B, beats). Degrees >= 1.0 beat get tremolo. PH = [ [(12, 1.5), (10, 0.5), (8, 1.0), (7, 1.0)], [(11, 1.25), (7, 0.75), (5, 1.0), (2, 1.0)], [(3, 1.0), (5, 1.0), (7, 1.5), (3, 0.5)], [(0, 2.0), (2, 1.0), (3, 1.0)], [(8, 1.5), (7, 0.5), (5, 1.0), (3, 1.0)], [(2, 1.0), (3, 1.0), (5, 1.0), (7, 1.0)], [(11, 1.0), (10, 1.0), (8, 1.5), (7, 0.5)], [(5, 1.5), (3, 0.5), (2, 1.0), (0, 1.0)], ] TEARDROP = {2: [12, 10, 8, 7], 6: [14, 12, 11, 10], 7: [7, 5, 3, 2]} # (bar, beats, portuguese, english, degrees above B4) LINES = [ (0.5, 3.0, "É o meu último dia", "it is my last day", [7, 5, 3, 2, 0]), (2.5, 3.0, "Não se mexa, por favor", "do not move, please", [3, 5, 3, 2, 0]), (4.5, 2.5, "Conte até vinte", "count to twenty", [7, 7, 5, 3]), (7.0, 3.0, "O menino mexeu-se", "the boy moved", [10, 8, 7, 5, 3]), (9.0, 3.0, "Ficou um fantasma", "he came out a ghost", [8, 7, 5, 3, 2]), (11.0, 3.5, "A viúva não se mexeu", "the widow did not move", [0, 3, 7, 5, 3, 2, 0]), (13.0, 2.5, "Nem uma vez", "not once", [3, 2, 0, -1]), (15.0, 3.0, "A prata sobe no escuro", "the silver rises in the dark", [0, 2, 3, 5, 7, 8]), (17.0, 3.0, "A última chapa", "the last plate", [7, 5, 3, 2]), (18.5, 3.0, "A cadeira vazia", "the empty chair", [3, 5, 7, 5, 3]), (20.0, 3.5, "É a mais bonita", "it is the most beautiful one", [7, 8, 7, 5, 3, 2, 0]), ] def build_song(): s = Song(DUR) R = np.random.RandomState(760076) for bar in range(N_BARS): rootn, ivs = PROG[bar % 8] rootf = nf(rootn) b0 = bar*4 quiet = bar < 2 or (15 <= bar < 17) # ── viola: bass on 1, pushed chords on the & of 2 and on 4 ───────── s.put("viola", pluck(rootf, TB(min(b0+2.2, N_BEATS-0.01)) - TB(b0), damp=0.9945, bright=0.42, seed=bar*7, pick=0.28)*0.95, TB(b0), g=0.50, pan=-0.16) s.put("viola", pluck(rootf*2**(7/12.0), 1.4, damp=0.994, bright=0.40, seed=bar*7+1, pick=0.3)*0.6, TB(b0+2), g=0.24, pan=-0.20) for at, strength, dr in ((b0+1.5, 0.30, 1), (b0+3.0, 0.36, -1), (b0+3.5, 0.20, 1)): if quiet and strength < 0.34: continue for k, iv in enumerate(ivs): off = k*0.013*dr + R.uniform(0, 0.004) s.put("viola", pluck(rootf*2*2**(iv/12.0), 0.9, damp=0.9930, bright=0.34, seed=bar*31+k*3+int(at*2), pick=0.36)*0.55, TB(at) + off, g=strength, pan=-0.28 + 0.06*k) # the pulse, without a kit s.put("tap", body_tap(seed=bar*3+1), TB(b0), g=0.42 if not quiet else 0.24, pan=-0.1) s.put("tap", body_tap(f0=138, dur=0.11, seed=bar*3+2), TB(b0+2), g=0.22 if not quiet else 0.12, pan=0.12) # ── guitarra portuguesa ───────────────────────────────────────────── if bar < 1: continue # chord roll on the downbeat — six courses, brushed if not quiet or bar == 1: for k, iv in enumerate((0, 7, 12, 15, 19, 24)): s.put("gtr", course(rootf*2*2**(iv/12.0), 1.8, damp=0.9975, bright=0.95, seed=bar*53+k, seed2=bar*53+k+900, pick=0.18)*0.45, TB(b0) + k*0.020, g=0.15, pan=0.20 - 0.04*k) ph = PH[bar % 8] at = 0.0 for j, (dg, nb) in enumerate(ph): f0 = GBASE*2**(dg/12.0) t0, t1 = TB(b0+at), TB(min(b0+at+nb, N_BEATS-0.001)) span = max(0.12, t1 - t0) if quiet and j % 2: at += nb; continue if nb >= 1.0: # TREMOLO — the guitarra's signature. Rate drifts a little and # the velocity swells across the note; a metronomic tremolo is # a mandolin, a breathing one is fado. rate = 11.4 + 1.5*math.sin(bar*0.7 + j) k2 = 0 tt = 0.0 while tt < span - 0.02: u = tt/span vel = (0.45 + 0.55*math.sin(math.pi*min(1.0, u*1.25))**0.7) vel *= 1.0 + 0.10*R.uniform(-1, 1) s.put("gtr", course(f0, 0.30, damp=0.9968, bright=0.92, seed=(bar*97+j*13+k2) % 61, seed2=(bar*97+j*13+k2+400) % 61, pick=0.22)*vel, t0+tt, g=0.155, pan=0.22) tt += (1.0/rate)*(1.0 + 0.06*R.uniform(-1, 1)) k2 += 1 else: s.put("gtr", course(f0, span+0.7, damp=0.9974, bright=0.95, seed=bar*29+j, seed2=bar*29+j+700, pick=0.2), t0, g=0.20, pan=0.22) at += nb # teardrop ornament — a fast descending run off the cadence td = TEARDROP.get(bar % 8) if td and not quiet: for j, dg in enumerate(td): s.put("gtr", course(GBASE*2**(dg/12.0), 0.5, damp=0.9962, bright=0.97, seed=bar*17+j, seed2=bar*17+j+300, pick=0.16)*(0.9 - 0.12*j), TB(b0+3.05) + j*0.052, g=0.17, pan=0.26) # ── the sung line ─────────────────────────────────────────────────────── for (bar, nb, pt, en, degs) in LINES: t0 = T(bar); t1 = TB(min(bar*4 + nb, N_BEATS-0.001)) y = sing(pt, degs, max(0.8, t1-t0), nf("B3"), AUD) s.put("voz", y*0.9, t0, g=0.60, pan=0.0) # ── the darkroom ──────────────────────────────────────────────────────── s.put("room", roomtone(s.n), 0.0, g=1.0, pan=0.0) for bar, rt in ((6.1, 1.1), (10.2, 0.85), (15.4, 1.3), (18.1, 0.75)): s.put("room", slosh(1.6, seed=200+int(bar*10), rate=rt), T(bar), g=0.30, pan=0.22) for bar in (2.9, 8.6, 14.1, 19.4): s.put("room", drip(seed=300+int(bar*10)), T(bar), g=0.22, pan=-0.3) s.bus("gtr", lambda x: reverb(delay(x, 0.42, 0.28, 0.10), rt=2.5, mix=0.30, seed=401)) s.bus("viola", lambda x: reverb(x, rt=2.0, mix=0.22, seed=403)) s.bus("voz", lambda x: reverb(delay(x, 0.55, 0.22, 0.08), rt=3.0, mix=0.34, seed=407)) s.bus("tap", lambda x: reverb(x, rt=1.4, mix=0.16, seed=409)) s.bus("room", lambda x: reverb(x, rt=2.6, mix=0.40, seed=411)) voz_only = s.tr.get("voz", np.zeros((s.n, 2))).mean(1).copy() mix = s.mixdown(dict(gtr=1.0, viola=1.0, voz=1.0, tap=1.0, room=1.0), levels=dict(escuro=0.50, primeira=0.90, menino=0.96, viuva=1.0, prata=0.62, cadeira=0.88)) wav = AUD/"final.wav" s.write(wav, mix) return wav, mix, voz_only def analyze(mix, voz): x = mix.mean(1) hop = SR/FPS; win = int(hop*1.8) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high", "voz")} 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 < 2000)].sum() E["high"][f] = sp[fr >= 2000].sum() vseg = voz[i:i+win] if len(vseg) >= 16: E["voz"][f] = np.sqrt((vseg**2).mean()) for k in E: p = np.percentile(E[k], 96) + 1e-9 E[k] = np.clip(E[k]/p, 0, 1.3) # smooth the voice envelope — the silver should swell, not flicker k = np.hanning(11); k /= k.sum() E["voz"] = np.convolve(E["voz"], k, "same") 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 PLATE — the substrate this film is made of # # Four fields on a japanned iron plate: # lat the latent image, in exposure units. Fixed the instant the lens cap # went back on; it already contains whatever the sitter did during the # eight seconds the shutter was open. # wet developer coverage. Poured at an edge, it runs across the plate under # gravity at a *per-column* velocity, which is where pour lines and # comet streaks come from. # D silver density. dD = k · wet · lat · (1-D) · dt — silver can only form # where the developer has actually arrived, so the picture climbs out of # the black in the order the liquid reached it, not all at once. # exh exhausted developer. Every grain of silver poisons the solution around # it, and the poison runs downhill: that is bromide drag, the streak # that hangs below every bright thing on a real wet plate. # ════════════════════════════════════════════════════════════════════════════ SW, SH = RSi(1440), RSi(810) # the stage — the plate lives here (16:9) PW = RSi(1260) # plate scale: 1 plate unit = PW px # round 2 recompose: the delivery frame went 14:9 -> 16:9. The stage canvas # widened by 180px but the PLATE SCALE is held at the old 1260 so nothing is # enlarged or cropped vertically — the extra width is more plate, not a # tighter frame. Camera math below scales by PW, canvas texture by SW. PY = SH/PW # plate coords: x in [0,1], y in [0,PY] _YY, _XX = np.mgrid[0:SH, 0:SW] _YN = (_YY/(SH-1)).astype(np.float32) _XN = (_XX/(SW-1)).astype(np.float32) def _blur1(a, r, axis): r = int(r) if r < 1: return a a = np.moveaxis(a, axis, 0) pad = np.concatenate([np.repeat(a[:1], r, 0), a, np.repeat(a[-1:], r, 0)], 0) c = np.concatenate([np.zeros((1,)+a.shape[1:], np.float32), np.cumsum(pad, 0)], 0) out = ((c[2*r+1:] - c[:-(2*r+1)])/(2*r+1)).astype(np.float32) return np.moveaxis(out, 0, axis) def box_blur(a, r): return _blur1(_blur1(a.astype(np.float32), r, 0), r, 1) def gauss(a, r): a = box_blur(a, max(1, int(r*0.6))) return box_blur(a, max(1, int(r*0.45))) def smooth1d(n, r, seed): v = np.random.RandomState(seed).rand(n).astype(np.float32) k = np.hanning(max(3, int(r)*2+1)); k = (k/k.sum()).astype(np.float32) v = np.convolve(v, k, "same") v -= v.mean(); sd = v.std() + 1e-9 return v/sd def value_noise(h, w, scale, seed): rng = np.random.RandomState(seed) gh, gw = int(h/scale)+2, int(w/scale)+2 g = rng.rand(gh, gw).astype(np.float32) 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].astype(np.float32); fx = (xs-x0)[None, :].astype(np.float32) sy = fy*fy*(3-2*fy); sx = fx*fx*(3-2*fx) g00 = g[np.ix_(y0, x0)]; g01 = g[np.ix_(y0, x0+1)] g10 = g[np.ix_(y0+1, x0)]; g11 = g[np.ix_(y0+1, x0+1)] return (g00*(1-sx)+g01*sx)*(1-sy) + (g10*(1-sx)+g11*sx)*sy def fbm(h, w, scale, seed, octaves=4): out = np.zeros((h, w), np.float32); amp = 1.0; nrm = 0.0 for o in range(octaves): out += amp*value_noise(h, w, max(2.0, scale/(2**o)), seed+o) nrm += amp; amp *= 0.5 return out/nrm SILVER = np.array([ (10, 10, 13), (18, 18, 22), (33, 33, 38), (56, 55, 60), (86, 85, 89), (120, 119, 120), (154, 154, 152), (186, 187, 182), (211, 213, 206), (230, 232, 224), (241, 243, 236), ], np.float32) def _lut(stops, n=512): xs = np.linspace(0, 1, len(stops)); g = np.linspace(0, 1, n) return np.stack([np.interp(g, xs, stops[:, c]) for c in range(3)], 1).astype(np.float32) SILVER_LUT = _lut(SILVER) def apply_lut(v01, lut): i = np.clip(v01*(len(lut)-1), 0, len(lut)-1).astype(np.int32) return lut[i] class Plate: def __init__(self, latent, spec, seed): R = np.random.RandomState(seed) self.lat = np.clip(latent, 0, 1.6).astype(np.float32) self.D = np.zeros((SH, SW), np.float32) self.wet = np.zeros((SH, SW), np.float32) # the streaming surface layer self.film = np.zeros((SH, SW), np.float32) # what stays wet behind the front self.exh = np.zeros((SH, SW), np.float32) self.t = 0.0 self.spec = spec self.k = float(spec.get("k", 3.0)) self.spread = int(spec.get("spread", 1)) self.evap = float(spec.get("evap", 0.9965)) self.dry = float(spec.get("dry", 0.9965)) self.bromide = float(spec.get("bromide", 1.25)) self.rock = float(spec.get("rock", 0.0)) self.pour = spec.get("pour", "corner") self.pour_t = float(spec.get("pour_t", 0.55)) self.pour_x = float(spec.get("pour_x", 0.18)) # ── per-column flow: this is the entire look of a poured plate ────── base = float(spec.get("flow", 30.0)) v = smooth1d(SW, RSf(spec.get("flow_r", 26)), seed+11) self.vcol = (base*(1.0 + 0.34*v)).astype(np.float32) self.vcol = np.clip(self.vcol, base*0.42, base*1.9) self._gather(1.0/FPS) if self.pour == "flood": self.wet[:] = 1.0; self.film[:] = 1.0 # dust that will drag a comet tail behind it for q in range(int(spec.get("comets", 3))): cx = int(R.uniform(0.10, 0.90)*SW); cy = int(R.uniform(0.04, 0.55)*SH) rr = RSi(R.uniform(4, 11)) yy, xx = np.mgrid[-rr:rr+1, -rr:rr+1] blob = np.exp(-(yy**2 + xx**2)/(0.45*rr*rr + 1e-6)).astype(np.float32) y0, x0 = max(0, cy-rr), max(0, cx-rr) sub = self.exh[y0:cy+rr+1, x0:cx+rr+1] sub += blob[:sub.shape[0], :sub.shape[1]]*R.uniform(1.6, 3.0) def _gather(self, dt): sh = self.vcol*dt rows = (_YY.astype(np.float32) - sh[None, :]) r0 = np.floor(rows).astype(np.int32) self.frac = (rows - r0).astype(np.float32) self.valid = ((r0 >= 0) & (r0 <= SH-2)).astype(np.float32) self.i0 = np.clip(r0, 0, SH-1); self.i1 = np.clip(r0+1, 0, SH-1) def _advect(self, a): return (np.take_along_axis(a, self.i0, 0)*(1-self.frac) + np.take_along_axis(a, self.i1, 0)*self.frac)*self.valid def _source(self, dt): if self.pour in ("flood", "none"): return None u = self.t/max(1e-6, self.pour_t) if u > 1.0: return None src = np.zeros((SH, SW), np.float32) if self.pour == "edge": band = np.exp(-((_YN - 0.006)/0.020)**2) src += band*(1.0 - 0.4*u) else: if self.pour == "corner": px = self.pour_x + 0.66*u py = 0.02 + 0.02*math.sin(u*4.0) elif self.pour == "sweep": px = 0.06 + 0.90*u py = 0.02 + 0.30*u*PY else: # "spill" — down one side px = self.pour_x + 0.05*math.sin(u*7.0) py = 0.02 + 0.72*u*PY rr = 0.055 xs = np.abs(_XN - px); ys = np.abs(_YN*PY - py) src += np.exp(-((xs/rr)**2 + (ys/(rr*0.8))**2))*1.9 # The wake the stream has already laid down above the pour point — # and it FANS. Poured down one side, developer does not stay a # stripe; it puddles sideways behind its own head. Without this the # `spill` shots wet a fifth of the plate and stayed black. fan = rr*(2.2 + 9.0*u**1.4) src += np.exp(-(xs/fan)**2)*np.clip(1.0 - _YN*PY/max(py, 1e-3), 0, 1)*(0.5 + 1.7*u) return src*dt*26.0 def step(self, dt, gain): src = self._source(dt) if src is not None: self.wet += src # the surface layer streams; the film is what the plate holds onto self.wet = self._advect(self.wet) self.exh = self._advect(self.exh) if self.rock: sh = int(round(RSf(self.rock)*math.sin(self.t*3.1))) if sh: self.wet = np.roll(self.wet, sh, axis=1) if self.spread: sp = max(1, RSi(self.spread)) self.wet = box_blur(self.wet, sp) self.exh = box_blur(self.exh, sp) self.wet *= self.evap np.clip(self.wet, 0.0, 1.8, out=self.wet) self.film = np.maximum(self.film*self.dry, np.minimum(self.wet, 1.0)) Wt = np.maximum(self.wet, self.film) # silver forms until the local density reaches what the light left # there — never past it. Everything else is timing. rate = self.k*gain*Wt*(1.0 - 0.88*np.clip(self.exh, 0, 1)) dD = rate*np.maximum(0.0, self.lat - self.D)*dt self.D += dD self.exh += dD*self.bromide self.exh *= 0.997 self.t += dt def render(self, veil, sheen, border, warm): # tintype tonality: deep japanned black with the silver laid on top of # it, so the shadows fall away and the highlights are the only object v = np.clip(self.D, 0, 1)**1.06 rgb = apply_lut(v, SILVER_LUT) if veil > 0: # un-fixed collodion: a creamy halide veil over the shadows. When # the fixer clears it the blacks arrive all at once — the pop. rgb += (veil*(1.0 - v)*0.9)[..., None]*np.array([56, 53, 47], np.float32) if sheen > 0: band = np.exp(-((_YN - sheen)/0.14)**2) wetv = np.maximum(self.wet, self.film*0.55) rgb += (wetv*band)[..., None]*np.array([26, 30, 38], np.float32)*2.4 if warm: # the safelight. Red, and it does nothing to the plate. rgb += warm*np.array([26, 5, 2], np.float32)*(0.35 + 0.65*(1-v))[..., None] if border: m = self.plate_mask tray = np.array([16, 6, 5], np.float32)*(1.0 + 0.5*warm) rgb = rgb*m[..., None] + tray*(1-m)[..., None] return rgb # ── the plate's physical edge: collodion thins at the corners, the metal # shows a bevel, and whoever poured it left two thumbprints ────────────── def edge_field(seed, inset=0.045, ripple=1.0): R = np.random.RandomState(seed) x, y = _XN, _YN*PY d = np.minimum.reduce([x - inset, (1-inset) - x, y - inset*PY/0.64, (PY - inset*PY/0.64) - y]) wob = fbm(SH, SW, RSf(90.0), seed+5)*0.024*ripple d = d + wob - 0.012 m = np.clip(d/0.035, 0, 1) m = m*m*(3-2*m) thin = np.clip(d/0.10, 0.55, 1.0) return m.astype(np.float32), thin.astype(np.float32) def defects(seed, scale=1.0, grain=1.0): """Dust, pinholes, scratches, thumbprints and the silver grain itself — all multiplicative on the latent, because every one of them is something that stopped silver from forming.""" R = np.random.RandomState(seed) m = np.ones((SH, SW), np.float32) for q in range(int(34*scale)): # dust motes / pinholes cx, cy = R.randint(0, SW), R.randint(0, SH) r = max(1, RSi(R.uniform(1.0, 3.4)*scale)) yy, xx = np.mgrid[-r:r+1, -r:r+1] blob = np.exp(-(yy**2 + xx**2)/(0.42*r*r + 1e-6)).astype(np.float32) y0, x0 = max(0, cy-r), max(0, cx-r) sub = m[y0:cy+r+1, x0:cx+r+1] sub *= 1.0 - blob[:sub.shape[0], :sub.shape[1]]*R.uniform(0.45, 0.92) for q in range(3): # scratches x0, y0 = R.uniform(0, SW), R.uniform(0, SH) ang = R.uniform(-0.35, 0.35) + (0 if R.rand() < 0.5 else math.pi/2) n = int(R.uniform(0.12, 0.45)*SW) xs = np.clip((x0 + np.cos(ang)*np.arange(n)).astype(int), 0, SW-1) ys = np.clip((y0 + np.sin(ang)*np.arange(n)).astype(int), 0, SH-1) sw2 = max(1, RSi(1)) # the scratch keeps its width for dy in range(-sw2, sw2+1): m[np.clip(ys+dy, 0, SH-1), xs] *= 0.62 if dy else 0.42 # two thumbprints at the left edge, where the plate was held for ty in (0.30, 0.58): cx, cy = 0.022*SW, ty*SH rr = 0.030*SW d2 = ((_XX-cx)/rr)**2 + ((_YY-cy)/(rr*1.7))**2 ridge = 0.5 + 0.5*np.sin(np.sqrt(np.maximum(d2, 0))*11.0) m *= np.where(d2 < 1.0, 0.60 + 0.36*ridge, 1.0).astype(np.float32) m = box_blur(m, max(1, RSi(1))) # silver grain — physical, so it gets bigger as the camera gets closer g = fbm(SH, SW, RSf(max(2.2, 2.6*grain)), seed+41, octaves=3) m *= (0.90 + 0.22*(g - g.mean())/(g.std() + 1e-6)*0.5 + 0.055) return np.clip(m, 0.0, 1.15).astype(np.float32) # ════════════════════════════════════════════════════════════════════════════ # WHAT IS ON THE PLATE — latent scenes, drawn in exposure units # ════════════════════════════════════════════════════════════════════════════ BACKDROP, FLOOR = 0.44, 0.25 SKIN, SKIN_SH = 0.92, 0.40 LINEN, BLACKCLOTH = 1.05, 0.055 HAIR, WOOD = 0.075, 0.35 VARNISH, IRON = 0.76, 0.34 BRASS, GLASS = 0.68, 1.35 # ── the north light ───────────────────────────────────────────────────────── # Everything above is a *reflectance*; a plate records reflectance times the # light that fell on it. A portrait studio of 1870 had exactly one light — a # glazed north skylight over the sitter's left shoulder — and the whole # modelling of a wet plate is that one lamp falling off across the room. The # field is computed in *scene* coordinates, so a close-up is lit by the same # window as the wide, and the gradient rides through every cut. _LIGHTC = {} def light_field(cam, lx=0.15, ly=0.11, amb=0.30, gain=1.55, soft=1.00): key = (round(cam[0], 4), round(cam[1], 4), round(cam[2], 4)) hit = _LIGHTC.get(key) if hit is not None: return hit cx, cy, z = cam k = z*PW ox = SW/2 - cx*k; oy = SH/2 - cy*k xs = (_XX - ox)/k; ys = (_YY - oy)/k d2 = (xs - lx)**2 + (ys - ly)**2 f = amb + gain/(1.0 + d2/(soft*soft)) # a shallow horizontal wash too — the wall opposite the window is darker f *= 1.0 - 0.16*np.clip((xs - lx)/1.9, 0, 1) f = f.astype(np.float32) if len(_LIGHTC) > 48: _LIGHTC.pop(next(iter(_LIGHTC))) _LIGHTC[key] = f return f class Canvas: """Plate-space drawing. x in [0,1] across the plate, y down; one unit of y is one unit of x, so nothing is squashed by the camera.""" def __init__(self, cam, flat=None): self.im = Image.new("F", (SW, SH), 0.0) self.d = ImageDraw.Draw(self.im) cx, cy, z = cam self.k = z*PW self.ox = SW/2 - cx*self.k self.oy = SH/2 - cy*self.k self.flat = flat def v(self, val): return 1.0 if self.flat is not None else float(val) def p(self, x, y): return (x*self.k + self.ox, y*self.k + self.oy) def s(self, u): return u*self.k def ell(self, x, y, rx, ry, val): a = self.p(x-rx, y-ry); b = self.p(x+rx, y+ry) self.d.ellipse([a, b], fill=self.v(val)) def poly(self, pts, val): self.d.polygon([self.p(*q) for q in pts], fill=self.v(val)) def rect(self, x0, y0, x1, y1, val): self.d.rectangle([self.p(x0, y0), self.p(x1, y1)], fill=self.v(val)) def line(self, pts, val, wdt): self.d.line([self.p(*q) for q in pts], fill=self.v(val), width=max(1, int(self.s(wdt)))) def arr(self): return np.asarray(self.im, np.float32) def rot(pts, cx, cy, a): c, s = math.cos(a), math.sin(a) return [(cx + (x-cx)*c - (y-cy)*s, cy + (x-cx)*s + (y-cy)*c) for x, y in pts] # ── furniture ─────────────────────────────────────────────────────────────── def draw_chair(c, x=0.5, y=0.62, sc=1.0): """The studio chair — a Victorian balloon-back. The first cut of this film drew it in "wood" and it was invisible in every frame, because a mid-brown reflectance sits within a few percent of a painted muslin backdrop and collodion cannot tell them apart. A polished chair on a wet plate is not brown: it is a BLACK SILHOUETTE carrying two or three merciless varnish highlights along whichever edge faces the window. So it is drawn that way — mass in `DARK`, speculars in `LIT`, and the hole in the balloon back punched back to `BACKDROP` so the backdrop shows through it and the chair reads as a thing with air around it.""" w = 0.152*sc DARK, LIT, MID = 0.085, 0.94, 0.34 ey = y - 0.205*sc # the shadow it throws on the backdrop, away from the window c.poly([(x-w*0.30, y+0.020*sc), (x+w*1.95, y+0.020*sc), (x+w*2.30, y+0.225*sc), (x-w*0.05, y+0.225*sc)], 0.20) for sx in (-1, 1): # legs, turned c.poly([(x+sx*w*0.76, y+0.03*sc), (x+sx*w*0.94, y+0.03*sc), (x+sx*w*0.71, y+0.240*sc), (x+sx*w*0.56, y+0.240*sc)], LIT) c.poly([(x+sx*w*0.80, y+0.03*sc), (x+sx*w*0.91, y+0.03*sc), (x+sx*w*0.685, y+0.240*sc), (x+sx*w*0.585, y+0.240*sc)], DARK) for q in range(2): c.ell(x+sx*w*(0.755-0.048*q), y+0.088*sc+q*0.064*sc, 0.016*sc, 0.011*sc, MID) # the balloon back: a lit ellipse, the dark ring over it, the hole punched c.ell(x-0.010*sc, ey-0.007*sc, w*0.90, 0.151*sc, LIT) c.ell(x, ey, w*0.88, 0.147*sc, DARK) c.ell(x-0.005*sc, ey+0.005*sc, w*0.70, 0.116*sc, BACKDROP) c.poly([(x-0.018*sc, ey-0.130*sc), (x+0.018*sc, ey-0.130*sc), # the splat (x+0.028*sc, ey+0.142*sc), (x-0.028*sc, ey+0.142*sc)], DARK) c.line([(x-0.011*sc, ey-0.122*sc), (x-0.019*sc, ey+0.132*sc)], MID*1.8, 0.006*sc) c.ell(x-0.004*sc, ey-0.152*sc, w*0.31, 0.023*sc, LIT*0.88) # carved crest for sx in (-1, 1): # back-to-seat uprights c.poly([(x+sx*w*0.74, y-0.078*sc), (x+sx*w*0.87, y-0.078*sc), (x+sx*w*0.85, y+0.004*sc), (x+sx*w*0.72, y+0.004*sc)], DARK) # the seat, with the front lip taking the window straight on c.poly([(x-w, y-0.006*sc), (x+w, y-0.006*sc), (x+w*0.84, y+0.054*sc), (x-w*0.84, y+0.054*sc)], DARK) c.poly([(x-w*0.99, y-0.018*sc), (x+w*0.99, y-0.018*sc), (x+w*0.965, y+0.002*sc), (x-w*0.965, y+0.002*sc)], LIT) c.ell(x, y+0.014*sc, w*0.73, 0.021*sc, MID) # worn velvet def draw_headrest(c, x=0.5, y=0.62, sc=1.0): """The iron posing stand. Its feet always show under the sitter.""" c.line([(x+0.005, y-0.46*sc), (x+0.005, y+0.20*sc)], IRON*0.30, 0.011*sc) c.line([(x+0.001, y-0.46*sc), (x+0.001, y+0.20*sc)], 0.62, 0.003*sc) c.ell(x+0.005, y-0.472*sc, 0.028*sc, 0.019*sc, IRON*0.28) # the clamp c.ell(x-0.004*sc, y-0.478*sc, 0.013*sc, 0.008*sc, 0.90) # its one glint for a in (-0.9, 0.0, 0.9): c.line([(x+0.005, y+0.195*sc), (x+0.005+math.sin(a)*0.055*sc, y+0.230*sc)], IRON*0.30, 0.008*sc) def draw_backdrop(c, seed): c.rect(-2, -2, 3, 3, BACKDROP) c.rect(-2, 0.615, 3, 3, FLOOR) def draw_bigcamera(c, x=0.62, y=0.36, sc=1.0): """Whole-plate camera on its stand: bellows, brass barrel, dark cloth.""" c.poly([(x-0.20*sc, y-0.10*sc), (x+0.02*sc, y-0.13*sc), (x+0.02*sc, y+0.10*sc), (x-0.20*sc, y+0.13*sc)], WOOD*0.9) for q in range(7): # bellows ribs xx = x - 0.19*sc + q*0.030*sc c.line([(xx, y-0.115*sc), (xx, y+0.115*sc)], WOOD*1.5, 0.006*sc) c.rect(x+0.02*sc, y-0.085*sc, x+0.07*sc, y+0.085*sc, WOOD*1.1) c.ell(x+0.085*sc, y, 0.038*sc, 0.038*sc, BRASS) c.ell(x+0.085*sc, y, 0.026*sc, 0.026*sc, 0.10) c.ell(x+0.078*sc, y-0.010*sc, 0.008*sc, 0.006*sc, 1.10) # the glint c.line([(x-0.09*sc, y+0.13*sc), (x-0.10*sc, y+0.52*sc)], WOOD*0.7, 0.014*sc) for a in (-1, 1): c.line([(x-0.10*sc, y+0.50*sc), (x-0.10*sc+a*0.09*sc, y+0.62*sc)], WOOD*0.7, 0.011*sc) c.poly([(x-0.23*sc, y-0.16*sc), (x-0.01*sc, y-0.15*sc), (x-0.04*sc, y+0.02*sc), (x-0.26*sc, y-0.01*sc)], BLACKCLOTH*1.6) def draw_skylight(c, x=0.17, y=0.16, sc=1.0): c.poly([(x-0.19*sc, y-0.20*sc), (x+0.17*sc, y-0.24*sc), (x+0.19*sc, y+0.21*sc), (x-0.17*sc, y+0.24*sc)], GLASS) for q in range(4): u = q/3.0 c.line([(x-0.19*sc+0.36*sc*u, y-0.20*sc-0.04*sc*u), (x-0.17*sc+0.36*sc*u, y+0.24*sc-0.03*sc*u)], 0.16, 0.008*sc) for q in range(3): u = q/2.0 c.line([(x-0.19*sc+0.01*sc, y-0.20*sc+0.44*sc*u), (x+0.17*sc, y-0.24*sc+0.45*sc*u)], 0.16, 0.008*sc) # ── people ────────────────────────────────────────────────────────────────── def draw_figure(c, kind, j, R, x=0.5, y=0.62, sc=1.0): """A seated sitter, drawn from the chair up. `j` = (dx, dy, tilt, turn).""" dx, dy, tilt, turn = j coat = {"soldier": 0.14, "boy": 0.30, "widow": BLACKCLOTH, "groom": 0.11, "bride": 0.86, "baby": 0.95, "mother": BLACKCLOTH*1.4, "photog": 0.20}.get(kind, 0.2) small = kind in ("boy", "baby") S = sc*(0.76 if small else 1.0) bx, by = x + dx, y + dy - (0.085*sc if small else 0.0) hx = bx + turn*0.024*S hy = by - 0.360*S # torso sh = 0.172*S c.poly(rot([(bx-sh*0.72, by-0.255*S), (bx+sh*0.72, by-0.255*S), (bx+sh, by-0.16*S), (bx+sh*1.04, by+0.07*S), (bx-sh*1.04, by+0.07*S), (bx-sh, by-0.16*S)], bx, by-0.10*S, tilt), coat) # collar / shirt front if kind in ("soldier", "groom", "photog", "boy"): c.poly(rot([(bx-0.026*S, by-0.262*S), (bx+0.026*S, by-0.262*S), (bx+0.046*S, by-0.155*S), (bx, by-0.105*S), (bx-0.046*S, by-0.155*S)], bx, by-0.10*S, tilt), LINEN) if kind == "soldier": for q in range(4): c.ell(bx+0.046*S, by-0.185*S+q*0.052*S, 0.008*S, 0.008*S, 0.98) c.poly(rot([(bx-sh*0.92, by-0.235*S), (bx-sh*0.30, by-0.13*S), (bx-sh*0.48, by-0.04*S), (bx-sh*0.96, by-0.14*S)], bx, by-0.10*S, tilt), 0.78) # a sash if kind == "bride": # silk is the brightest thing in the room and clips to blank paper # unless it is given folds to be dark in — so it gets folds c.poly([(bx-sh*1.25, by-0.29*S), (bx+sh*0.35, by-0.33*S), (bx+sh*0.18, by+0.07*S), (bx-sh*1.45, by+0.07*S)], 0.78) for q, fx in enumerate((-1.14, -0.90, -0.65, -0.40, -0.14, 0.10)): c.line([(bx+sh*fx, by-0.30*S + 0.007*S*q), (bx+sh*fx*1.17, by+0.06*S)], 0.30 + 0.10*(q % 3), 0.014*S) c.poly([(bx-sh*1.30, by-0.300*S), (bx+sh*0.30, by-0.340*S), # lace collar (bx+sh*0.24, by-0.258*S), (bx-sh*1.20, by-0.218*S)], 1.05) c.ell(bx-sh*0.60, by-0.02*S, 0.054*S, 0.042*S, 0.26) # her bouquet for q in range(6): c.ell(bx-sh*0.60+0.032*S*math.cos(q*1.05), by-0.02*S+0.027*S*math.sin(q*1.05), 0.017*S, 0.014*S, 0.98) if kind == "widow": # the veil: crepe from the crown to the shoulders, and nothing under it c.poly([(hx-0.062*S, hy-0.055*S), (hx+0.062*S, hy-0.055*S), (hx+0.098*S, hy+0.16*S), (bx+sh*1.06, by+0.07*S), (bx-sh*1.06, by+0.07*S), (hx-0.098*S, hy+0.16*S)], BLACKCLOTH) if kind == "mother": # the "hidden mother": a woman under a drape, holding the child still. # It is a void, but a void with folds — otherwise the plate is a hole. c.poly([(bx-0.25*S, by-0.46*S), (bx+0.25*S, by-0.46*S), (bx+0.30*S, by+0.09*S), (bx-0.30*S, by+0.09*S)], BLACKCLOTH*1.25) for q, fx in enumerate((-0.20, -0.09, 0.06, 0.19)): c.line([(bx+fx*S, by-0.44*S + 0.03*S*q), (bx+(fx*1.35)*S, by+0.07*S)], 0.26 - 0.035*q, 0.010*S) c.ell(bx-0.02*S, by-0.455*S, 0.16*S, 0.045*S, 0.20) # the crown of it # hands in the lap if kind not in ("baby",): for sx in (-1, 1): c.ell(bx+sx*0.070*S, by-0.005*S, 0.028*S, 0.019*S, SKIN*0.88) for q in range(3): c.ell(bx+sx*0.088*S, by-0.014*S+q*0.011*S, 0.017*S, 0.005*S, SKIN*0.80) # head if kind == "mother": return # the hidden mother has none c.ell(hx, hy, 0.058*S, 0.073*S, SKIN) c.ell(hx - 0.026*S*(1 - turn*0.8), hy+0.006*S, 0.034*S, 0.062*S, SKIN_SH) c.ell(hx, hy+0.085*S, 0.026*S, 0.030*S, SKIN*0.80) # neck if kind != "widow": c.poly([(hx-0.062*S, hy-0.020*S), (hx+0.062*S, hy-0.024*S), (hx+0.048*S, hy-0.080*S), (hx-0.048*S, hy-0.082*S)], HAIR) else: c.poly([(hx-0.072*S, hy-0.012*S), (hx+0.072*S, hy-0.016*S), (hx+0.050*S, hy-0.092*S), (hx-0.050*S, hy-0.094*S)], BLACKCLOTH*1.6) for sx in (-1, 1): # eyes — orthochromatic ex = hx + sx*0.024*S + turn*0.015*S c.ell(ex, hy-0.006*S, 0.0125*S, 0.0072*S, SKIN*0.62) c.ell(ex, hy-0.006*S, 0.0062*S, 0.0068*S, 0.11) c.ell(ex-0.0022*S, hy-0.0092*S, 0.0022*S, 0.0016*S, 1.15) c.line([(ex-0.016*S, hy-0.018*S), (ex+0.016*S, hy-0.020*S)], HAIR*1.7, 0.0035*S) c.line([(hx, hy-0.002*S), (hx-0.005*S, hy+0.020*S)], SKIN_SH*1.20, 0.005*S) c.ell(hx, hy+0.040*S, 0.016*S, 0.0055*S, 0.17) # lips: red reads black if kind == "soldier": c.poly([(hx-0.028*S, hy+0.028*S), (hx+0.028*S, hy+0.028*S), (hx+0.013*S, hy+0.044*S), (hx-0.013*S, hy+0.044*S)], HAIR*1.5) if kind == "baby": c.ell(hx, hy-0.070*S, 0.066*S, 0.038*S, LINEN) # a bonnet def motion_path(kind, R, n=12): """How much the sitter failed to hold still, and *where they dwelt*. A real long exposure is not a uniform smear — it is two solid ghosts with a veil strung between them, because people pause at both ends of a move.""" if kind in ("widow", "mother", "chaironly"): return [(0.0, 0.0, 0.0, 0.0)], [1.0] if kind == "soldier": return ([(R.uniform(-1, 1)*0.0016, R.uniform(-1, 1)*0.0012, R.uniform(-1, 1)*0.004, R.uniform(-1, 1)*0.05) for _ in range(n)], [1.0/n]*n) if kind == "boy": poses, wts = [], [] for q in range(4): poses.append((-0.004, 0.0, -0.02, -0.85)); wts.append(0.085) for q in range(4): u = (q+0.5)/4.0 poses.append((-0.004 + 0.020*u, 0.006*math.sin(u*math.pi), -0.02 + 0.13*u, -0.85 + 1.75*u)); wts.append(0.030) for q in range(4): poses.append((0.016, 0.002, 0.11, 0.90)); wts.append(0.070) s = sum(wts) return poses, [w/s for w in wts] if kind == "baby": return ([(R.uniform(-1, 1)*0.045, R.uniform(-1, 1)*0.030, R.uniform(-1, 1)*0.20, R.uniform(-1, 1)*1.6) for _ in range(n)], [1.0/n]*n) if kind == "groom": return ([(R.uniform(-1, 1)*0.010, R.uniform(-1, 1)*0.006, R.uniform(-1, 1)*0.03, R.uniform(-1, 1)*0.45) for _ in range(n)], [1.0/n]*n) return ([(R.uniform(-1, 1)*0.006, R.uniform(-1, 1)*0.004, R.uniform(-1, 1)*0.02, R.uniform(-1, 1)*0.22) for _ in range(n)], [1.0/n]*n) def scene_portrait(cam, kind, seed, bgblur=9, room=False): """Backdrop + chair, then the sitter accumulated over eight seconds of shutter. Where the sitter was not, the backdrop printed through — so the ghost is transparent, exactly as it is on a real plate.""" R = np.random.RandomState(seed) bg = Canvas(cam) draw_backdrop(bg, seed) if room: draw_skylight(bg, 0.13, 0.13, 1.0) draw_bigcamera(bg, 0.85, 0.34, 0.85) if kind != "nochair": draw_headrest(bg, 0.5, 0.62) draw_chair(bg, 0.5, 0.62) lat = gauss(bg.arr(), bgblur) if kind in ("chaironly", "nochair"): return lat*light_field(cam) acc = np.zeros((SH, SW), np.float32) cov = np.zeros((SH, SW), np.float32) poses, wts = motion_path(kind, R) partner = {"couple": ("bride", "groom"), "hidden": ("mother", "baby")}.get(kind) for j, w in zip(poses, wts): fc = Canvas(cam); ac = Canvas(cam, flat=1.0) if partner: for pk in partner: jj = j if pk in ("groom", "baby") else (0.0, 0.0, 0.0, 0.0) xo = {"bride": -0.115, "groom": 0.115, "mother": 0.0, "baby": 0.0}[pk] s2 = {"bride": 1.0, "groom": 1.0, "mother": 1.10, "baby": 0.95}[pk] draw_figure(fc, pk, jj, R, x=0.5+xo, sc=s2) draw_figure(ac, pk, jj, R, x=0.5+xo, sc=s2) else: draw_figure(fc, kind, j, R) draw_figure(ac, kind, j, R) acc += fc.arr()*w cov += ac.arr()*w cov = np.clip(cov, 0, 1) return (lat*(1.0 - cov) + acc)*light_field(cam) def scene_studio(cam, who, seed): R = np.random.RandomState(seed) bg = Canvas(cam) draw_backdrop(bg, seed) draw_skylight(bg, 0.15, 0.14, 1.15) lat = gauss(bg.arr(), 11) # the midground has to be COMPOSITED, not maximum'd against the backdrop: # a black chair is darker than the muslin behind it, and `maximum` keeps # only its highlights — which is how the studio wides lost their chair. mid = Canvas(cam); mc = Canvas(cam, flat=1.0) for cv in (mid, mc): draw_bigcamera(cv, 0.83, 0.33, 0.95) draw_headrest(cv, 0.40, 0.60) draw_chair(cv, 0.40, 0.60) mcov = np.clip(gauss(mc.arr(), 3), 0, 1) lat = lat*(1.0 - mcov) + gauss(mid.arr(), 3) if who not in (None, "empty"): acc = np.zeros((SH, SW), np.float32); cov = np.zeros((SH, SW), np.float32) poses, wts = motion_path(who, R) for j, w in zip(poses, wts): fc = Canvas(cam); ac = Canvas(cam, flat=1.0) draw_figure(fc, who, j, R, x=0.40, y=0.60, sc=0.95) draw_figure(ac, who, j, R, x=0.40, y=0.60, sc=0.95) acc += fc.arr()*w; cov += ac.arr()*w cov = np.clip(cov, 0, 1) lat = lat*(1-cov) + acc return lat*light_field(cam) def scene_tray(cam, seed, empty=False): """Looking down into the developing tray under the safelight.""" R = np.random.RandomState(seed) c = Canvas(cam) c.rect(-2, -2, 3, 3, 0.11) c.poly([(0.10, 0.10), (0.90, 0.10), (0.95, 0.58), (0.05, 0.58)], 0.38) # tray rim c.poly([(0.145, 0.145), (0.855, 0.145), (0.905, 0.545), (0.095, 0.545)], 0.27) if not empty: c.poly([(0.29, 0.20), (0.72, 0.20), (0.75, 0.49), (0.26, 0.49)], 0.62) c.poly([(0.315, 0.225), (0.695, 0.225), (0.722, 0.465), (0.288, 0.465)], 0.34) c.ell(0.50, 0.315, 0.055, 0.070, 0.88) # a face under the fluid c.ell(0.50, 0.40, 0.11, 0.055, 0.24) else: # nothing in it — so the tray is just a dish of developer with the # safelight lying on top of it, which is the whole point of the shot c.poly([(0.160, 0.200), (0.845, 0.200), (0.880, 0.500), (0.125, 0.500)], 0.48) c.ell(0.50, 0.295, 0.215, 0.062, 0.86) c.ell(0.50, 0.295, 0.120, 0.032, 1.05) for q in range(7): # liquid meniscus lines yy = 0.17 + q*0.055 c.line([(0.11+0.01*q, yy), (0.90-0.01*q, yy+0.012)], 0.60, 0.004) for sx, hx in ((-1, 0.06), (1, 0.94)): # the hands on the rim c.ell(hx, 0.33, 0.075, 0.055, SKIN*0.86) for q in range(4): c.ell(hx - sx*0.055, 0.26 + q*0.042, 0.042, 0.017, SKIN*0.76) c.ell(0.50, -0.06, 0.10, 0.06, 1.15) # the safelight above return gauss(c.arr(), 4)*light_field(cam, lx=0.50, ly=0.02, amb=0.26, gain=1.30, soft=0.72) def scene_holder(cam, seed, u=0.5): """The dark slide drawn out of the plate holder — the one gesture that turns a coated plate into an exposed one.""" c = Canvas(cam) c.rect(-2, -2, 3, 3, 0.13) c.poly([(0.18, 0.14), (0.82, 0.14), (0.82, 0.54), (0.18, 0.54)], WOOD*1.25) c.poly([(0.215, 0.175), (0.785, 0.175), (0.785, 0.505), (0.215, 0.505)], 0.07) sx = 0.215 + (0.785-0.215)*u c.poly([(0.215, 0.175), (sx, 0.175), (sx, 0.505), (0.215, 0.505)], 0.50) c.line([(sx, 0.155), (sx, 0.525)], 1.05, 0.008) # the slide's bright edge c.poly([(sx, 0.205), (sx+0.115, 0.205), (sx+0.115, 0.305), (sx, 0.305)], 0.62) for q in range(3): # the brass thumb-catch c.ell(sx - 0.055, 0.215 + q*0.105, 0.020, 0.014, BRASS) for q in range(5): c.line([(0.20, 0.155+q*0.093), (0.80, 0.155+q*0.093)], WOOD*1.6, 0.003) c.ell(0.50, 0.615, 0.32, 0.055, 0.22) return gauss(c.arr(), 3)*light_field(cam, lx=0.30, ly=0.16, amb=0.28, gain=1.35, soft=0.62) def scene_rack(cam, seed): """The day's work, drying. Nine slots; the ninth is empty.""" R = np.random.RandomState(seed) c = Canvas(cam) c.rect(-2, -2, 3, 3, 0.09) c.poly([(0.04, 0.10), (0.96, 0.10), (0.96, 0.56), (0.04, 0.56)], WOOD*0.85) for q in range(9): gx = 0.085 + q*0.098 c.poly([(gx, 0.155), (gx+0.078, 0.155), (gx+0.078, 0.49), (gx, 0.49)], 0.06) if q == 8: c.line([(gx, 0.49), (gx+0.078, 0.49)], VARNISH, 0.004) continue c.poly([(gx+0.006, 0.165), (gx+0.072, 0.165), (gx+0.072, 0.478), (gx+0.006, 0.478)], BACKDROP*0.9) hy = 0.235 + R.uniform(-0.006, 0.006) blur = q in (2, 3) c.ell(gx+0.039, hy, 0.019 + (0.010 if blur else 0), 0.024, SKIN*(0.6 if blur else 1.0)) c.poly([(gx+0.014, 0.30), (gx+0.064, 0.30), (gx+0.068, 0.47), (gx+0.010, 0.47)], [0.14, 0.05, 0.30, 0.11, 0.08, 0.22, 0.06, 0.16][q]) for q in range(9): gx = 0.085 + q*0.098 c.line([(gx-0.006, 0.10), (gx-0.006, 0.56)], WOOD*1.4, 0.005) return gauss(c.arr(), 3)*light_field(cam, lx=0.20, ly=0.10, amb=0.34, gain=1.15, soft=0.90) # ════════════════════════════════════════════════════════════════════════════ # THE SHOT LIST # ════════════════════════════════════════════════════════════════════════════ def S(scene, **kw): d = dict(scene=scene); d.update(kw); return d SHOTS = [ # (key, beats, spec) — no absolute flow speeds any more: `cross` is the # fraction of the shot the developer takes to run the plate, `pour_frac` # how long the stream is held on it. Chemistry is timed in shot-units. ("abertura", 3, S("portrait", kind="chaironly", cam=(0.50, 0.560, 1.06), dev=0.40, pour="corner", cross=0.60, pour_frac=0.62, warm=1.0, border=True, sheen=1, title=True, k=1.6)), ("chegada", 2, S("portrait", kind="chaironly", cam=(0.50, 0.470, 1.75), dev=0.80, pre=2.3, pour="corner", cross=0.45, warm=0.9, sheen=1, k=2.2, push=-0.08)), ("frente", 2, S("portrait", kind="soldier", cam=(0.500, 0.300, 1.85), dev=1.0, pre=0.9, pour="spill", cross=0.22, pour_frac=0.40, warm=0.8, sheen=1, grain=2.2, k=3.0, comets=5)), ("estudio", 3, S("studio", who="empty", cam=(0.50, 0.435, 0.84), dev=1.0, pre=2.6, pour="flood", warm=0.2, border=True, push=0.10)), ("lente", 2, S("studio", who="empty", cam=(0.845, 0.335, 3.10), dev=1.0, pre=2.2, pour="flood", warm=0.15, grain=1.6, push=0.09)), ("soldado", 4, S("portrait", kind="soldier", cam=(0.50, 0.395, 1.10), dev=1.0, pour="edge", cross=0.40, pour_frac=0.32, warm=0.55, border=True, card="CHAPA I · O SOLDADO", sheen=1, k=2.8)), ("rosto1", 2, S("portrait", kind="soldier", cam=(0.50, 0.262, 2.70), dev=1.0, pre=2.7, pour="flood", warm=0.5, k=3.0, push=0.07)), ("tabuleiro", 2, S("tray", cam=(0.50, 0.33, 1.02), dev=1.0, pre=2.0, pour="flood", rock=5, warm=1.0, push=-0.08)), ("chassi", 2, S("holder", u=0.46, cam=(0.50, 0.34, 1.15), dev=1.0, pre=1.9, pour="flood", warm=0.9, push=0.12)), ("noivos", 4, S("portrait", kind="couple", cam=(0.50, 0.400, 1.06), dev=1.0, pour="sweep", cross=0.38, pour_frac=0.36, warm=0.5, border=True, card="CHAPA II · OS NOIVOS", sheen=1, k=2.8)), ("maos", 2, S("portrait", kind="couple", cam=(0.47, 0.545, 1.85), dev=1.0, pre=2.8, pour="flood", warm=0.4, grain=1.8, k=3.0, push=-0.08)), ("prata1", 2, S("portrait", kind="couple", cam=(0.385, 0.272, 2.20), dev=1.0, pre=0.55, pour="spill", cross=0.20, pour_frac=0.34, warm=0.7, grain=2.6, k=3.6, comets=7)), ("posa", 3, S("studio", who="boy", cam=(0.44, 0.435, 1.06), dev=1.0, pre=2.4, pour="flood", warm=0.2, border=True, card="CHAPA III · O MENINO", push=0.08)), ("menino", 4, S("portrait", kind="boy", cam=(0.50, 0.400, 1.10), dev=1.0, pour="corner", cross=0.40, pour_frac=0.34, warm=0.5, border=True, sheen=1, k=2.8)), ("fantasma", 3, S("portrait", kind="boy", cam=(0.505, 0.272, 2.85), dev=1.0, pre=3.0, pour="flood", warm=0.45, grain=1.5, push=0.10, k=3.0)), ("balanco", 2, S("tray", cam=(0.50, 0.31, 1.32), dev=1.0, pre=2.1, pour="flood", rock=8, warm=1.0, push=0.10)), ("crianca", 3, S("portrait", kind="hidden", cam=(0.50, 0.420, 1.50), dev=1.0, pour="spill", cross=0.34, pour_frac=0.34, warm=0.5, border=True, card="CHAPA IV · A CRIANÇA", k=3.0)), ("cometa", 2, S("portrait", kind="hidden", cam=(0.500, 0.288, 2.60), dev=1.0, pre=0.6, pour="spill", cross=0.18, pour_frac=0.32, warm=0.7, grain=2.4, comets=10, k=3.8)), ("viuva1", 3, S("studio", who="widow", cam=(0.44, 0.430, 1.04), dev=1.0, pre=2.5, pour="flood", warm=0.2, border=True, card="CHAPA V · A VIÚVA", push=0.07)), ("viuva2", 5, S("portrait", kind="widow", cam=(0.50, 0.390, 1.10), dev=1.0, pour="edge", cross=0.52, pour_frac=0.44, warm=0.5, border=True, sheen=1, push=0.07, k=2.6, spread=1)), ("fixador", 3, S("portrait", kind="widow", cam=(0.50, 0.390, 1.10), dev=1.0, pre=4.6, pour="flood", warm=0.45, border=True, fix=(0.18, 0.62), veil0=0.95, k=2.6)), ("olho", 3, S("portrait", kind="widow", cam=(0.492, 0.256, 3.90), dev=1.0, pre=3.4, pour="flood", warm=0.3, grain=2.2, push=0.09, k=3.0)), ("grao", 1, S("portrait", kind="widow", cam=(0.428, 0.612, 4.20), dev=1.0, pre=2.0, pour="flood", warm=0.5, grain=3.2, k=3.4)), ("estante", 3, S("rack", cam=(0.50, 0.33, 1.02), dev=1.0, pre=2.4, pour="flood", warm=0.85, border=True, push=0.09)), ("vazio", 2, S("tray", empty=True, cam=(0.50, 0.345, 0.94), dev=1.0, pre=2.0, pour="flood", rock=4, warm=1.0, push=-0.07)), ("cadeira1", 3, S("studio", who="empty", cam=(0.41, 0.500, 1.08), dev=1.0, pre=2.6, pour="flood", warm=0.2, border=True, card="CHAPA IX · A CADEIRA VAZIA", push=0.10)), ("cadeira2", 5, S("portrait", kind="chaironly", cam=(0.50, 0.560, 1.04), dev=1.0, pour="corner", cross=0.46, pour_frac=0.44, warm=0.5, border=True, sheen=1, k=2.6, spread=1)), ("cadeira3", 4, S("portrait", kind="chaironly", cam=(0.50, 0.520, 1.24), dev=1.0, pre=5.0, pour="flood", warm=0.4, border=True, fix=(0.14, 0.58), veil0=1.0, k=2.6, spread=1)), ("fim", 5, S("portrait", kind="chaironly", cam=(0.50, 0.548, 1.00), dev=1.0, pre=5.2, pour="flood", warm=0.10, border=True, sheen=1, push=-0.17, k=2.6, spread=1)), ] CAPTION_PAD = 0.9 # how long a subtitle lingers past the sung line class Shot: __slots__ = ("idx", "key", "i0", "i1", "n", "spec", "section", "seed") def __init__(self, idx, key, i0, i1, spec, section): self.idx, self.key = idx, key self.i0, self.i1, self.n = i0, i1, i1-i0 self.spec, self.section = spec, section self.seed = 90900 + idx*7919 def section_of(beat): bar = beat/4.0 for nm, b0, b1 in SECTIONS: if b0 <= bar < b1: return nm return SECTIONS[-1][0] def build_shots(): tot = sum(b for _, b, _ in SHOTS) assert tot == N_BEATS, f"shot beats {tot} != {N_BEATS}" shots = []; beat = 0 for idx, (key, nb, spec) in enumerate(SHOTS): i0 = int(TB(beat)*FPS); i1 = int(TB(beat+nb)*FPS) shots.append(Shot(idx, key, i0, max(i0+2, i1), spec, section_of(beat))) beat += nb shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ # THE ENGINE — one shot = one plate # ════════════════════════════════════════════════════════════════════════════ _LATC = {} def build_latent(spec, seed): key = (spec["scene"], spec.get("kind"), spec.get("who"), spec.get("u"), spec.get("empty"), spec["cam"], seed) hit = _LATC.get(key) if hit is not None: return hit sc = spec["scene"] if sc == "portrait": lat = scene_portrait(spec["cam"], spec.get("kind", "soldier"), seed) elif sc == "studio": lat = scene_studio(spec["cam"], spec.get("who"), seed) elif sc == "tray": lat = scene_tray(spec["cam"], seed, empty=spec.get("empty", False)) elif sc == "holder": lat = scene_holder(spec["cam"], seed, u=spec.get("u", 0.5)) elif sc == "rack": lat = scene_rack(spec["cam"], seed) else: raise SystemExit(f"unknown scene {sc}") g = float(spec.get("grain", 1.0)) m, thin = edge_field(seed+3) lat = lat*thin*defects(seed+9, scale=max(0.7, g*0.7), grain=g) # a hair of unsharp: collodion resolves absurdly well, but a portrait lens # at f/4 does not lat = lat*0.97 + gauss(lat, 3)*0.06 _LATC[key] = (lat.astype(np.float32), m) if len(_LATC) > 40: _LATC.pop(next(iter(_LATC))) return _LATC[key] class Engine: def __init__(self, shot): spec = shot.spec lat, mask = build_latent(spec, shot.seed) secs = shot.n/FPS dev = float(spec.get("dev", 1.0)) # ── everything chemical is expressed as a FRACTION OF THE SHOT ─────── # Hand-tuning `flow` in plate-pixels-per-second against a rubato cut is # how half this film ended up still black at its own out-point. Instead: # `cross` is the fraction of the shot the developer front takes to run # the length of the plate, and `pour_frac` how long the stream is held # over it. Change a shot's length and its chemistry re-times itself. spec = dict(spec) cross = float(spec.get("cross", 0.42)) spec["flow"] = SH/max(0.25, secs*cross) spec["pour_t"] = secs*float(spec.get("pour_frac", 0.50)) # base rate is normalised so the plate arrives at `dev` by the cut, # whatever the shot's length. The music decides *when* inside that. spec["k"] = spec.get("k", 3.0)*(3.4/max(0.35, secs))*max(0.20, dev) self.plate = Plate(lat, spec, shot.seed) self.plate.plate_mask = mask self.spec = shot.spec self.shot = shot self.grain = float(shot.spec.get("grain", 1.0)) self.warm = float(shot.spec.get("warm", 0.4)) self.border = bool(shot.spec.get("border", False)) self.usheen = bool(shot.spec.get("sheen", False)) self.fix = shot.spec.get("fix") self.veil0 = float(shot.spec.get("veil0", 0.0)) self.push = float(shot.spec.get("push", 0.05)) pre = float(shot.spec.get("pre", 0.0)) if pre > 0: dt = 1.0/FPS for _ in range(int(pre*FPS)): self.plate.step(dt, 1.0) def frame(self, k, u, e): dt = 1.0/FPS # the sung line pulls the silver up out of the plate gain = 0.42 + 1.35*e["voz"] + 0.55*e["mid"] + 0.25*e["rms"] self.plate.step(dt, gain) veil = 0.0 if self.veil0 > 0: if self.fix: a, b = self.fix veil = self.veil0*(1.0 - min(1.0, max(0.0, (u-a)/(b-a)))**0.7) else: veil = self.veil0 sheen = -1.0 if self.usheen: sheen = (0.12 + 0.80*((self.shot.i0/FPS*0.28 + u*0.9) % 1.0)) rgb = self.plate.render(veil, sheen if self.usheen else 0.0, self.border, self.warm) return rgb def crop_frame(stage, u, spec, seed): """The delivered frame is a moving crop of the plate.""" push = float(spec.get("push", 0.05)) # positive push tightens across the shot; negative one starts tight and # opens out, which is the only way to *reveal* a plate rather than close in z = 1.0 - push*u if push >= 0 else 1.0 + push*(1.0 - u) z = min(1.0, max(0.42, z)) cw = SW*z; ch = cw*(H/W) if ch > SH: ch = SH; cw = ch*(W/H) R = np.random.RandomState(seed % (2**31-1)) ph = R.uniform(0, 6.28) jx = math.sin(u*1.9 + ph)*RSf(6)*z jy = math.cos(u*1.5 + ph*1.7)*RSf(4)*z x0 = (SW-cw)/2 + jx; y0 = (SH-ch)/2 + jy x0 = max(0, min(SW-cw, x0)); y0 = max(0, min(SH-ch, y0)) im = Image.fromarray(np.clip(stage, 0, 255).astype(np.uint8)) return im.crop((int(x0), int(y0), int(x0+cw), int(y0+ch))).resize((W, H), Image.LANCZOS) # ════════════════════════════════════════════════════════════════════════════ # 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.ttf"): key = (size, name) if key not in _FC: p = _find_font(name) # Scaled ONCE, here. Call sites always pass authoring sizes. _FC[key] = _load_font(p, max(1, RSi(size))) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2 + ny**2)/1.42 _VIG["v"] = np.clip(1.0 - 0.46*r**2.0, 0, 1).astype(np.float32)[..., None] return _VIG["v"] CAPS = [(T(bar), TB(min(bar*4+nb, N_BEATS-0.001)) + CAPTION_PAD, pt, en) for (bar, nb, pt, en, _) in LINES] def post(img, i, shot, e): a = np.asarray(img, np.float32) # 1 tint — silver is cool, the plate's japanning is warm, and the safelight # bleeds a little red into everything the darkroom sees lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([7, 1, -3], np.float32) + lum*np.array([2, 1, -4], np.float32) # 2 vignette a *= vignette() # 3 grain — silver grain lives in the midtones, not in the blacks g = float(shot.spec.get("grain", 1.0)) rng = np.random.RandomState(4400 + i) amp = 3.0*g*(0.30 + 1.35*(lum*(1-lum)*4.0)) if RS == 1.0: a += rng.normal(0, 1.0, a.shape)*amp else: # Grain is a look, not a resolution: drawn at 1280x720 and blown up # nearest-neighbour so a speck covers the same fraction of the frame. gn = rng.normal(0, 1.0, (720, 1280, 3))*8.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a += ((np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0)*amp out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) t = i/FPS # ── text, composited crisply and never through the tint work ─────────── if shot.spec.get("title") and i - shot.i0 < FPS*3.2: age = (i - shot.i0)/FPS al = min(1.0, age/0.8)*min(1.0, (3.2-age)/0.6) # THE TITLE MOMENT — the plate's own label, in silver, with the show # name struck small underneath the sub-line. f = font(58, "Georgia.ttf") lw = d.textlength(TITLE, font=f) col = tuple(int(c*al) for c in (226, 224, 212)) d.text((W/2-lw/2, H*0.42), TITLE, font=f, fill=col) f2 = font(19, "Georgia Italic.ttf") sub = "the last plate" lw2 = d.textlength(sub, font=f2) d.text((W/2-lw2/2, H*0.42+RSf(76)), sub, font=f2, fill=tuple(int(c*al) for c in (150, 148, 140))) f3 = font(14, "Georgia.ttf") tr = RSf(5.5); sy = H*0.42 + RSf(134) sw3 = sum(d.textlength(ch, font=f3) + tr for ch in SUBT) - tr x3 = W/2 - sw3/2 d.line([W/2-RSf(78), sy-RSf(15), W/2+RSf(78), sy-RSf(15)], fill=tuple(int(c*al*0.55) for c in (150, 148, 140)), width=max(2, RSi(1))) for ch in SUBT: d.text((x3, sy), ch, font=f3, fill=tuple(int(c*al*0.85) for c in (176, 174, 164))) x3 += d.textlength(ch, font=f3) + tr card = shot.spec.get("card") if card: age = (i - shot.i0)/FPS if age < 2.6: al = min(1.0, age/0.5)*min(1.0, (2.6-age)/0.5) f = font(21, "Georgia.ttf") d.text((int(W*0.055)+RSi(1), int(H*0.845)+RSi(1)), card, font=f, fill=(0, 0, 0)) d.text((int(W*0.055), int(H*0.845)), card, font=f, fill=tuple(int(c*al) for c in (222, 218, 202))) for (t0, t1, pt, en) in CAPS: if t0 <= t < t1: al = min(1.0, (t-t0)/0.30)*min(1.0, (t1-t)/0.55) fp = font(30, "Georgia Italic.ttf") fe = font(17, "Georgia.ttf") lp = d.textlength(pt, font=fp); le = d.textlength(en, font=fe) y0 = int(H*0.775) d.text((W/2-lp/2+RSi(2), y0+RSi(2)), pt, font=fp, fill=(0, 0, 0)) d.text((W/2-lp/2, y0), pt, font=fp, fill=tuple(int(c*al) for c in (232, 228, 214))) d.text((W/2-le/2+RSi(1), y0+RSi(43)), en, font=fe, fill=(0, 0, 0)) d.text((W/2-le/2, y0+RSi(42)), en, font=fe, fill=tuple(int(c*al*0.72) for c in (198, 194, 182))) break # 4 letterbox bh = int(H*0.052) d.rectangle([0, 0, W, bh], fill=(8, 7, 9)) d.rectangle([0, H-bh, W, H], fill=(8, 7, 9)) return out def render_shot(job): shot, force = job E = env() eng = Engine(shot) 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} u = k/max(1, shot.n-1) stage = eng.frame(k, u, e) p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue img = crop_frame(stage, u, shot.spec, shot.seed) post(img, i, shot, e).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.key:10s} {shot.section:9s} {made}/{shot.n}" def sheet_one(args): shot, = args E = env() eng = Engine(shot) mid = max(0, int(shot.n*0.72)) stage = None; e = None for k in range(mid+1): i = shot.i0 + k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} stage = eng.frame(k, k/max(1, shot.n-1), e) img = crop_frame(stage, mid/max(1, shot.n-1), shot.spec, shot.seed) return shot.idx, post(img, shot.i0+mid, shot, e).resize( (RSi(320), RSi(180)), Image.LANCZOS) def contact_sheet(shots, jobs): import multiprocessing as mp cols = 6; rows = (len(shots)+cols-1)//cols tw, th = RSi(320), RSi(180) sheet = Image.new("RGB", (cols*tw, rows*(th+RSi(26))), (12, 11, 14)) sd = ImageDraw.Draw(sheet) with mp.get_context("fork").Pool(jobs) as pool: for idx, im in pool.imap_unordered(sheet_one, [(s,) for s in shots]): sh = shots[idx] cx, cy = (idx % cols)*tw, (idx//cols)*(th+RSi(26)) sheet.paste(im, (cx, cy)) sd.text((cx+RSi(5), cy+th+RSi(5)), f"{sh.idx:02d} {sh.key} · {sh.section} · {sh.i0/FPS:.1f}s " f"({sh.n/FPS:.1f}s)", font=font(13, "Georgia.ttf"), fill=(190, 190, 198)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots, {DUR:.1f}s)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(12, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() or a.force: print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm rubato = {DUR:.1f}s") wav, mix, voz = build_song(); analyze(mix, voz) shots = build_shots() if a.audio_only: print(f"audio -> {wav} ({DUR:.2f}s)"); return if a.sheet: contact_sheet(shots, a.jobs); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames 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) print("[3/3] mux…") out = OUT/f"{NAME}.mp4" try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" gen = f"renders/{SETDIR}/{NAME}/render.py @ {sha} ({br})" # the mp4 muxer silently DROPS unknown keys, so a `generator=` tag never # survived — the generator path rides in the tags mp4 actually keeps 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 / {gen}", "-metadata", f"description=generator: {gen}", "-metadata", f"comment=generator: {gen} | {MUSIC_DESC} | {ENGINE_DESC}", "-metadata", f"date={datetime.date.today().isoformat()}", str(out)], check=True, capture_output=True) (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"scale: RS={RS} — native re-rasterisation, plate stage {SW}x{SH}\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel, chemistry stateful per shot)\n" f"shots: {len(shots)}\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()