#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — The Key Block (08/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/the_key_block # # A woodblock print pulled color by color, and each pull brings in one more instrument. # # 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/the_key_block.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/the_key_block.mp4 # cover: https://genekogan.com/player_computer/media/the_key_block.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 the_key_block.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 — "THE KEY BLOCK" (final delivery cut) Salsa dura, 180 bpm, A minor, 48 bars on a 2-3 son clave. carve(4) p1(4) p2(4) p3(4) p4(4) p5(4) slip(4) fresh(4) fast(6) mambo(7) coda(3) A printer pulls a print. Each pull lays down ONE colour block AND brings in ONE instrument — the picture and the band assemble together, pull by pull. On the sixth pull the sheet is not seated in the kento and the prussian blue lands 4 mm off; the sheet is ruined and the band collapses to clave and bass. He sets it aside without stopping, takes a fresh damp sheet, and the pulls come faster. The last pull lands dead in register, the trumpets finally enter, and the print is Hokusai's Great Wave. The misregistered reject is pinned on the wall behind him. It is also beautiful. Composition: engine : audio-first x shot-parallel (tier 4-P) x mm-space camera content: audio-groove (salsa dura conjunto) x tts-voices (es_MX coro/pregon) x effects-post NEW SUBSTRATE — relief printing, built from scratch for this piece. Nothing in this repo has printed by pressure before (panini is offset CMYK litho — a flat screen; the_sandwich_job is riso — flat spot colour). Here the block is a physical object and every mark on the paper is the result of a transfer: carve() a block is a HEIGHT FIELD. Raised land takes ink, cleared field does not. The clearing carries gouge scoops, the cut walls carry a knife bevel, and every block carries the same two kento notches (kagi + hikitsuke) at the same millimetres. woodgrain() anisotropic ring grain sampled in BLOCK coordinates, per block, with its own seed and direction. It starves the ink in the flat areas, so the grain prints through the colour. ink_field() a maru-bake brush loading the land: a sweep front with a ragged wet edge, per-bristle striations along the stroke, more pigment where the brush set down than where it lifted. bokashi() a hand-wiped gradient — the ramp position itself wobbles with a low-frequency field, because it is wiped with a damp cloth. baren() the PRESSURE FIELD. Overlapping circular rub strokes deposited in the printer's own order, accumulating; the sheet edge gets less. Pressure is what decides how much ink crosses, and the burnish rings stay visible on the back of the sheet. transfer() ink x transfer_curve(pressure), bled into damp fibre by a radius that scales with dampness, wicked by the kozo fibre field, then stacked into the sheet as real Beer-Lambert optical density — transparent pigment over transparent pigment, so yellow under blue makes green on its own. Registration every block draws at its own millimetre offset. The misregistration is a genuine 4.1 mm geometric slip of one block's artwork, not a channel shift painted on afterwards. FINAL CUT (player_computer_final). The film is unchanged; the delivery is: * 1920x1080 native, and this substrate makes that almost free: every mark in the press is drawn in MILLIMETRES through Cam, and every material field (grain, fibre, wipe) is sampled in millimetres too. So the whole port is one number — WREF, the pixels the composition is laid out across, goes 1120 -> 1680 — and the mm-to-pixel ratio, the 2x supersampled block raster (now 3840x2160), the carved line widths, the cartouche type and the baren burnish all follow it. The pressure map gets 1.5x more samples per mm to match, the caption type starts 1.5x larger, and film grain is drawn at 1280x720 and NEAREST-blown-up so a speck keeps its size. * No renderer-debug overlays. The captions are the print shop talking — YAMAZAKURA, KENTO, BOKASHI, the pull cards naming each pigment and the instrument it brings in, OUT OF REGISTER / 6.6 mm. That is the film's own commentary and it stays. * Title moment: this piece had no card of its own — the "KANAGAWA OKI NAMI URA" caption at the end is the PRINT'S title, not the film's. A title slab now opens the piece, set in the caption system's own ink-black with the vermilion edge bar, carrying THE KEY BLOCK over PLAYER COMPUTER. Run from repo root: python3 renders/player_computer_final/the_key_block/render.py --sheet python3 renders/player_computer_final/the_key_block/render.py --jobs 3 """ import argparse, datetime, hashlib, json, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "the_key_block" TITLE = "THE KEY BLOCK" SUBT = "PLAYER COMPUTER" SETDIR = "player_computer_final" SETNUM = "B9" W, H, FPS = 1920, 1080, 30 # ── delivery scale ────────────────────────────────────────────────────────── # Almost everything in this file is measured in millimetres of cherry and # kozo, so S enters in exactly one place that matters: WREF, the pixel width # the composition is laid out across. ppm = WREF/wmm, so raising WREF by S # raises the mm-to-pixel ratio, and with it every carved line, every sampled # grain ring and every burnish stroke. Only the delivery-space post chain — # caption type sizes, blur floors, film grain — needs to be told separately. S = H/720.0 def PX(v): return int(round(v*S)) def PF(v): return v*S WREF = PX(1120) # composed at 1120 wide; 16:9 reveals more table, no zoom SS = 2 SW, SH = W*SS, H*SS BPM = 180.0 BEAT = 60.0/BPM BAR = 4*BEAT STEP = BEAT/4 SR = 44100 TAU = math.tau SEED0 = 20260827 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 = [ ("carve", 0, 4), ("p1", 4, 8), ("p2", 8, 12), ("p3", 12, 16), ("p4", 16, 20), ("p5", 20, 24), ("slip", 24, 28), ("fresh", 28, 32), ("fast", 32, 38), ("mambo", 38, 45), ("coda", 45, 48), ] N_BARS = SECTIONS[-1][2] TAIL = 1.70 DUR = N_BARS*BAR + TAIL N_FRAMES = int(DUR*FPS) MUSIC_DESC = (f"salsa dura, {BPM:.0f}bpm, A minor, {N_BARS} bars, 2-3 son clave, " "conjunto: clave/congas/timbales-cascara/campana/guiro/tumbao bass/" "piano montuno/trombones/trumpets") ENGINE_DESC = ("plank/ink/lay/rub/peel/sheet/macro/blocks/stack/slip/reject/" "bench/quick/wave/wall — relief woodblock press") # ═══════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ═══════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0*2.0**((m-69)/12.0) def adsr(n, a, d, s, r): e = np.zeros(n) ai, di, ri = max(1, int(a*SR)), max(1, int(d*SR)), max(1, int(r*SR)) ai = min(ai, n); e[:ai] = np.linspace(0, 1, ai) if ai < n: dd = min(di, n-ai) e[ai:ai+dd] = np.linspace(1, s, dd); e[ai+dd:] = s if ri < n: e[-ri:] *= np.linspace(1, 0, ri) return e def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Every noise source in the kit runs through this — nothing here is a raw full-band blast (AESTHETIC 13a).""" n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) g = np.ones_like(fq) if lo: g *= 1.0/np.sqrt(1.0 + (lo/fq)**order) if hi: g *= 1.0/np.sqrt(1.0 + (fq/hi)**order) return np.fft.irfft(X*g, n) def peak(x, f0, q=3.0, g=2.0): """One resonant formant bump. The brass section lives on this.""" n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) bw = f0/max(0.4, q) return np.fft.irfft(X*(1.0 + (g-1.0)*np.exp(-((fq-f0)/bw)**2)), n) def drive(x, amt=2.0, asym=0.0): return np.tanh(x*amt + asym*x*x)/np.tanh(amt) def rs(seed): return np.random.RandomState(int(seed) % (2**31-1)) # ── the conjunto ─────────────────────────────────────────────────────────── def clave(dur=0.16, f=1240.0, seed=0): """Two rosewood sticks. Woody fundamental, two hard inharmonic partials, and a very short pop of air. The whole piece hangs off this.""" n = int(dur*SR); t = np.arange(n)/SR tone = (np.sin(TAU*f*t)*np.exp(-t*34) + 0.62*np.sin(TAU*f*2.63*t)*np.exp(-t*52) + 0.30*np.sin(TAU*f*4.11*t)*np.exp(-t*74) + 0.22*np.sin(TAU*f*0.51*t)*np.exp(-t*26)) pop = bandshape(rs(seed+3).randn(n), lo=2600, hi=10000)*np.exp(-t*330)*0.45 return drive(tone*0.72 + pop, 1.7)*0.95 def conga(kind="open", f0=205.0, seed=0): """Quinto/conga/tumba by mode. heel & toe are the muted hand strokes that carry the marcha; open is the singing tone on the and-of-4.""" spec = {"open": (.42, 1.00, 8.5, .30, .22), "heel": (.10, 0.72, 46.0, .16, .10), "toe": (.07, 0.94, 62.0, .14, .12), "slap": (.20, 1.24, 26.0, .20, 1.05), "bass": (.48, 0.55, 6.2, .34, .06)}[kind] dur, fm, dec, skin, slap = spec n = int(dur*SR); t = np.arange(n)/SR f = f0*fm*(1.0 + 0.24*np.exp(-t*44)) ph = TAU*np.cumsum(f)/SR body = (np.sin(ph) + 0.44*np.sin(ph*1.58) + 0.17*np.sin(ph*2.12)) body *= np.exp(-t*dec) R = rs(seed) sk = bandshape(R.randn(n), lo=f0*1.7, hi=4600)*np.exp(-t*dec*2.4)*skin sl = bandshape(R.randn(n), lo=2400, hi=9000)*np.exp(-t*175)*slap return drive(body*0.82 + sk*0.55 + sl*0.65, 1.75)*0.9 def cascara(dur=0.055, seed=0, bright=1.0): """The stick on the metal SHELL of the timbal — dry, glassy, no skin.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(rs(seed).randn(n), lo=2600*bright, hi=11000) tn = (np.sin(TAU*1880*t) + .5*np.sin(TAU*3120*t))*np.exp(-t*180)*.28 return (nz*np.exp(-t*230) + tn)*0.62 def timbal(hi=True, seed=0, g=1.0): n = int(0.24*SR); t = np.arange(n)/SR f0 = 392.0 if hi else 288.0 f = f0*(1.0 + 0.16*np.exp(-t*60)) ph = TAU*np.cumsum(f)/SR body = (np.sin(ph) + .38*np.sin(ph*1.72) + .2*np.sin(ph*2.41))*np.exp(-t*17) R = rs(seed) shell = bandshape(R.randn(n), lo=2000, hi=9500)*np.exp(-t*70)*.4 return drive(body*.9 + shell, 1.7)*g def campana(low=False, seed=0): """Cencerro. Struck sheet metal — inharmonic, hand-damped.""" n = int(0.19*SR); t = np.arange(n)/SR f0 = 520.0 if low else 782.0 out = np.zeros(n) for r, g, d in ((1.0, 1.0, 15), (1.51, .78, 20), (2.13, .62, 26), (2.74, .44, 33), (3.46, .30, 41), (4.7, .18, 55)): out += g*np.sin(TAU*f0*r*t)*np.exp(-t*d) cl = bandshape(rs(seed).randn(n), lo=3200, hi=12000)*np.exp(-t*260)*.32 return drive(out*0.42 + cl, 2.0)*0.9 def guiro(dur=0.30, notches=15, seed=0, short=False): """The scrape. Filtered noise gated by the ridges under the stick.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(rs(seed).randn(n), lo=2400, hi=9500) rate = notches/max(dur, 1e-6) rip = 0.30 + 0.70*np.clip(np.sin(TAU*rate*t)*2.2, 0, 1) env = np.clip(t*140, 0, 1)*np.exp(-t*(3.0 if not short else 22.0)) return nz*rip*env*0.52 def maraca(dur=0.05, seed=0, bright=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(rs(seed).randn(n), lo=4800*bright, hi=13000) return nz*np.exp(-t*110)*np.clip(t*900, 0, 1)*0.42 def knifecut(dur=0.11, seed=0, deep=1.0): """The to going through cherry. A short woody scrape with a resonance in the plank — the intro's only rhythm section is the carving itself.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(rs(seed).randn(n), lo=900, hi=6200) rip = 0.45 + 0.55*np.clip(np.sin(TAU*260.0*t)*2.0, 0, 1) body = (np.sin(TAU*690*deep*t)*0.5 + np.sin(TAU*1180*deep*t)*0.3) body *= np.exp(-t*46) return drive(nz*rip*np.exp(-t*36)*0.7 + body*0.5, 1.6)*0.7 def crash(dur=1.6, seed=0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(rs(seed).randn(n), lo=1500, hi=11000) return nz*(np.exp(-t*2.6) + .22*np.exp(-t*.7))*0.5 def bassnote(freq, dur, seed=0, pluck=1.0): """Tumbao bass — round, fingered, a touch of gut on the attack.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR x = (np.sin(TAU*freq*t) + .46*np.sin(TAU*freq*2*t) + .2*np.sin(TAU*freq*3*t) + .09*np.sin(TAU*freq*4*t)) co = 190 + 900*np.exp(-t*17) x = bandshape(x, hi=float(np.mean(co))) at = bandshape(rs(seed).randn(n), lo=500, hi=2600)*np.exp(-t*130)*.12*pluck return (x + at)*adsr(n, .005, .09, .78, .07)*0.95 def piano(freq, dur, seed=0, g=1.0): """Bright conjunto upright. Inharmonic partials + a hammer transient.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR B = 0.00042 out = np.zeros(n) for k in range(1, 15): fk = freq*k*math.sqrt(1.0 + B*k*k) if fk > SR*0.45: break out += (1.0/k**1.28)*np.sin(TAU*fk*t + (k*1.7) % TAU)*np.exp(-t*(2.6+k*0.85)) ham = bandshape(rs(seed).randn(n), lo=900, hi=5200)*np.exp(-t*170)*.16 return (out*0.42 + ham)*adsr(n, .003, .12, .55, .09)*g def brass(freq, dur, kind="tpt", seed=0, g=1.0): """Trumpet / trombone. Additive saw through a filter that OPENS with the attack (brass gets brighter as it gets louder), a pitch scoop on the lip, delayed vibrato, and the section's formant.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR if kind == "tpt": at, form, fq, hi0, hi1, nh, scoop = .010, 1250.0, 2.2, 1500, 5200, 26, .040 else: at, form, fq, hi0, hi1, nh, scoop = .020, 620.0, 2.0, 900, 3000, 22, .055 f = freq*(1.0 - scoop*np.exp(-t*42)) vib = 1.0 + 0.0055*np.sin(TAU*5.4*t)*np.clip((t-0.10)*7, 0, 1) ph = TAU*np.cumsum(f*vib)/SR out = np.zeros(n) for k in range(1, nh+1): if freq*k > SR*0.45: break out += np.sin(ph*k)/k env = adsr(n, at, .07, .82, .055) co = hi0 + (hi1-hi0)*np.clip(env*1.15, 0, 1) out = bandshape(out, lo=freq*0.6, hi=float(np.mean(co))) out = peak(out, form, q=fq, g=2.3) br = bandshape(rs(seed).randn(n), lo=1800, hi=7000)*np.exp(-t*90)*.05 return drive((out*0.34 + br)*env, 1.8)*g def reverb(x, rt=1.1, mix=.18, seed=29, pre=0.012): n = int(rt*SR); t = np.arange(n)/SR ir = rs(seed).randn(n)*np.exp(-t*(5.0/rt)) ir[:int(pre*SR)] = 0 ir /= np.abs(ir).sum()/40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x)+n))) wet = irfft(rfft(x, L)*rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet)) + 1e-9 return x*(1-mix) + wet*mix*(np.max(np.abs(x))+1e-9) def delay(x, time=.25, fb=.30, mix=.16, taps=5): 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) return out # ═══════════════════════════════════════════════════════════════════════════ # VOICE — es_MX coro / pregon, verified with `say -v '?'` # ═══════════════════════════════════════════════════════════════════════════ def _h(*parts): return hashlib.md5("|".join(str(p) for p in parts).encode()).hexdigest()[:16] def read_wav(p): with wave.open(str(p)) as w: ch = w.getnchannels() x = np.frombuffer(w.readframes(w.getnframes()), " macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, vc, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, vc, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def pitchshift(x, ratio): return fit(x, max(2, int(len(x)/ratio))) def trim_speech(x, thr=0.02): a = np.abs(x) idx = np.where(a > thr*np.max(a+1e-9))[0] if len(idx) < 2: return x i0 = max(0, idx[0]-int(0.008*SR)); i1 = min(len(x), idx[-1]+int(0.03*SR)) return x[i0:i1] VOX = {"lead": ("Rocko (Spanish (Mexico))", 200), "coro": ("Paulina", 205), "coro2": ("Reed (Spanish (Mexico))", 205), "viejo": ("Grandpa (Spanish (Mexico))", 185)} def shout(text, who="lead", semis=0.0, gain=1.0, lo=180, hi=6400): vc, rate = VOX[who] key = AUD/("say_"+_h(text, vc, rate)+".wav") x = trim_speech(say_wav(text, vc, rate, key)) x = x/(np.max(np.abs(x))+1e-9) if semis: x = pitchshift(x, 2.0**(semis/12.0)) x = bandshape(x, lo=lo, hi=hi) x = drive(x, 2.2) return x/(np.max(np.abs(x))+1e-9)*gain def coro(text, seed=1, n=3): """The chorus: one line stacked at a few pitches with human lateness.""" R = rs(seed) who = ["coro", "coro2", "lead"] parts = [] for i in range(n): p = shout(text, who[i % 3], semis=R.uniform(-2.2, 2.2) + (i-1)*1.4) lag = int(R.uniform(0.004, 0.028)*SR) parts.append(np.pad(p, (lag, 0))) m = max(len(p) for p in parts) out = np.zeros(m) for p in parts: out[:len(p)] += p out = drive(out/n, 2.2) return out/(np.max(np.abs(out))+1e-9) # ═══════════════════════════════════════════════════════════════════════════ # THE SONG # ═══════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {} def t(self, bar, step=0): return bar*BAR + step*STEP def put(self, track, sig, at, g=1.0, pan=0.0): b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = max(0, int(at*SR)); j = min(self.n, i+len(sig)) if i >= self.n or j <= i: return th = (pan*.5+.5)*(np.pi/2) b[i:j] += np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1)*g def bus(self, track, fn): if track in self.tr: b = self.tr[track] self.tr[track] = np.stack([fn(b[:, 0]), fn(b[:, 1])], 1) def sec_env(self, levels, glide=0.22): env = np.ones(self.n) for nm, b0, b1 in SECTIONS: i0, i1 = int(b0*BAR*SR), min(self.n, int(b1*BAR*SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) env[min(self.n-1, int(SECTIONS[-1][2]*BAR*SR)):] = levels.get("coda", 1.0) 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] a = math.exp(-2*math.pi*28.0/SR) for c in range(2): col = mix[:, c]; lp = np.empty(self.n); z = 0.0 for i in range(0, self.n, 8192): blk = col[i:i+8192] for j in range(len(blk)): z = (1-a)*blk[j] + a*z; lp[i+j] = z mix[:, c] = col - lp mix = np.tanh(mix*1.28)/np.tanh(1.28) 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("= 26: # the sheet is ruined a = {"clave", "bass"} if sec == "fast": # pulls come faster if bar >= 33: a |= {"pno"} if bar >= 34: a |= {"bell", "guiro"} if bar >= 35: a |= {"tbn"} if bar >= 37: a |= {"tpt"} return a def build_song(): s = Song(DUR) R = rs(SEED0) for bar in range(N_BARS): sec = sec_of(bar); act = active(bar) dg = {"carve": .98, "p1": .86, "p2": .90, "p3": .94, "p4": .98, "p5": 1.02, "slip": 1.00, "fresh": .90, "fast": 1.00, "mambo": 1.08, "coda": 1.04}[sec] if bar in (26, 27): dg *= 0.58 # the sheet is ruined ch = chord_of(bar, sec); rt = root_of(bar, sec) nxt = root_of(bar+1, sec_of(min(bar+1, N_BARS-1))) # ── clave: never stops ────────────────────────────────────────── for st in clave_steps(bar): s.put("perc", clave(seed=bar*13+st), s.t(bar, st), g=.60*dg, pan=.22) # ── the knife. Only during the carve, where it IS the kit. ────── if sec == "carve": cell = ([0, 3, 6, 8, 10, 13, 14] if bar % 2 == 0 else [0, 2, 4, 6, 7, 10, 11, 14]) for st in cell: s.put("tmb", knifecut(0.13 if st % 3 == 0 else 0.085, seed=bar*127+st, deep=1.0 if st % 3 == 0 else 1.5), s.t(bar, st) + STEP*R.uniform(-.01, .01), g=(1.15 if st in clave_steps(bar) else .78)*dg, pan=-.18+.36*((st % 4)/3.0)) for st in range(0, 16, 4): # the chips being brushed off the plank s.put("perc", maraca(0.09, seed=bar*131+st, bright=.7), s.t(bar, st+2), g=.24*dg, pan=.30) # ── congas ────────────────────────────────────────────────────── if "cga" in act: for st, kd in CONGA: v = R.uniform(.86, 1.0) mt = STEP*R.uniform(-.006, .006) # human lateness s.put("cga", conga(kd, 205 + (14 if st >= 8 else 0), seed=bar*29+st), s.t(bar, st)+mt, g=(.52 if kd in ("heel", "toe") else .78)*dg*v, pan=-.20) if bar % 4 == 3: s.put("cga", conga("bass", 190, seed=bar*31), s.t(bar, 15), g=.55*dg, pan=-.10) # ── timbal cascara + abanico ──────────────────────────────────── if "casc" in act: cell = CASC_A if bar % 2 == 0 else CASC_B acc = set(clave_steps(bar)) for st in cell: s.put("tmb", cascara(seed=bar*37+st, bright=1.1 if st in acc else .92), s.t(bar, st), g=(.40 if st in acc else .25)*dg, pan=.28) if bar % 8 == 7: for j in range(6): s.put("tmb", timbal(j % 2 == 0, seed=bar*41+j, g=.5), s.t(bar, 10+j*0.9), g=.32*dg, pan=.18) if "mar" in act: for st in range(0, 16, 2): s.put("perc", maraca(seed=bar*43+st, bright=1.0 if st % 4 == 0 else .86), s.t(bar, st), g=.17*dg*(1.0 if st % 4 == 0 else .7), pan=-.32) # ── campana ───────────────────────────────────────────────────── if "bell" in act: for st, low in BELL: s.put("bell", campana(bool(low), seed=bar*47+st), s.t(bar, st), g=(.30 if low else .21)*dg, pan=.34) if "guiro" in act: s.put("perc", guiro(BEAT*0.9, 13, seed=bar*53), s.t(bar, 0), g=.20*dg, pan=-.26) s.put("perc", guiro(BEAT*0.34, 5, seed=bar*59, short=True), s.t(bar, 6), g=.17*dg, pan=-.26) s.put("perc", guiro(BEAT*0.34, 5, seed=bar*61, short=True), s.t(bar, 10), g=.17*dg, pan=-.26) # ── tumbao bass: the and-of-2 bombo, and beat 4 ANTICIPATING the # next bar's root. That anticipation is the whole feel. ─────── if "bass" in act: s.put("bass", bassnote(mtof(rt), BEAT*0.60, seed=bar*67), s.t(bar, 6), g=.62*dg) s.put("bass", bassnote(mtof(nxt), BEAT*0.90, seed=bar*71), s.t(bar, 12), g=.68*dg) if bar % 4 == 0: s.put("bass", bassnote(mtof(rt-12), BEAT*0.30, seed=bar*73, pluck=.6), s.t(bar, 0), g=.34*dg) # ── piano montuno ─────────────────────────────────────────────── if "pno" in act: cell = MONT_A if bar % 2 == 0 else MONT_B for j, e8 in enumerate(cell): idx = (j + bar) % 4 m = ch[idx] dur2 = BEAT*(0.30 if j < len(cell)-1 else 0.55) g = .19*dg*(1.12 if e8 in (0, 3, 6) else .92) s.put("pno", piano(mtof(m+12), dur2, seed=bar*79+j), s.t(bar, e8*2), g=g, pan=-.14) s.put("pno", piano(mtof(m), dur2, seed=bar*83+j), s.t(bar, e8*2), g=g*.72, pan=-.06) s.put("pno", piano(mtof(ch[(idx+2) % 4]+12), dur2, seed=bar*89+j), s.t(bar, e8*2), g=g*.55, pan=-.20) # ── horns ─────────────────────────────────────────────────────── cellA = MAMBO_A if bar % 2 == 0 else MAMBO_B cellT = TBN_A if bar % 2 == 0 else TBN_B if "tbn" in act: for (st, si, bl) in cellT: m = ch[si % 4] - 12 s.put("tbn", brass(mtof(m), BEAT*bl, "tbn", seed=bar*97+st, g=.30*dg), s.t(bar, st), g=1.0, pan=-.30) s.put("tbn", brass(mtof(m+7 if si % 2 == 0 else m+5), BEAT*bl, "tbn", seed=bar*101+st, g=.22*dg), s.t(bar, st), g=1.0, pan=-.18) if "tpt" in act: for (st, si, bl) in cellA: m = ch[si % 4] + 12 s.put("tpt", brass(mtof(m), BEAT*bl, "tpt", seed=bar*103+st, g=.26*dg), s.t(bar, st), g=1.0, pan=.30) s.put("tpt", brass(mtof(m-5), BEAT*bl, "tpt", seed=bar*107+st, g=.19*dg), s.t(bar, st), g=1.0, pan=.16) # ── section-head crashes ──────────────────────────────────────── if bar in (4, 20, 28, 32, 38, 45): s.put("tmb", crash(1.5, seed=200+bar), s.t(bar, 0), g=.24, pan=.1) if bar in (26,): # a choked crash — the sound of a hand on the cymbal s.put("tmb", crash(0.36, seed=311), s.t(bar, 0), g=.34, pan=-.05) # ── coro / pregon ─────────────────────────────────────────────────── for (bar, st, kind, text, g, pan) in VOCALS: at = bar*BAR + st*STEP sig = coro(text, seed=1000+int(bar*7)) if kind == "coro" \ else shout(text, kind, semis=(-1.5 if kind == "viejo" else 1.2)) s.put("vox", sig, at, g=g*.80, pan=pan) s.bus("vox", lambda x: delay(reverb(x, rt=.85, mix=.15, seed=303), BEAT*.75, .26, .13)) s.bus("tpt", lambda x: reverb(x, rt=1.0, mix=.16, seed=307)) s.bus("tbn", lambda x: reverb(x, rt=1.1, mix=.14, seed=311)) s.bus("pno", lambda x: reverb(x, rt=.8, mix=.13, seed=313)) s.bus("bell", lambda x: reverb(x, rt=.9, mix=.12, seed=317)) s.bus("cga", lambda x: reverb(x, rt=.6, mix=.10, seed=319)) s.bus("tmb", lambda x: reverb(x, rt=.8, mix=.13, seed=323)) s.bus("perc", lambda x: reverb(x, rt=.7, mix=.11, seed=329)) mix = s.mixdown(dict(perc=1.0, cga=1.05, tmb=1.0, bell=1.0, bass=1.05, pno=1.0, tbn=1.0, tpt=1.0, vox=1.0), levels=dict(carve=.94, p1=.82, p2=.90, p3=.96, p4=1.00, p5=1.04, slip=1.00, fresh=.86, fast=1.02, mambo=1.12, coda=1.06)) wav = AUD/"final.wav" s.write(wav, mix) (AUD/"events.json").write_text(json.dumps( dict(bpm=BPM, bars=N_BARS, clave=[round(bar*BAR + st*STEP, 5) for bar in range(N_BARS) for st in clave_steps(bar)]))) return wav, mix VOCALS = [ (4.0, 0, "lead", "¡DALE!", .95, 0.0), (8.0, 0, "coro", "¡OTRA!", .90, 0.0), (12.0, 0, "lead", "¡ECHALE!", .92, -.10), (16.0, 0, "coro", "¡UNA MAS!", .92, .10), (20.0, 0, "lead", "¡AZUL!", .90, -.08), (24.0, 0, "coro", "¡LA ULTIMA!", .95, 0.0), (25.5, 0, "lead", "¡AY!", 1.00, .06), (26.2, 0, "viejo", "se movió.", .82, 0.0), (28.0, 0, "lead", "¡OTRA HOJA!", .95, 0.0), (32.0, 0, "coro", "¡DALE DURO!", .98, 0.0), (35.0, 0, "lead", "¡MÁS RÁPIDO!", .92, -.10), (38.0, 0, "coro", "¡MAMBO!", 1.05, 0.0), (41.0, 0, "lead", "¡EN REGISTRO!", .95, .08), (43.0, 0, "coro", "¡LA OLA!", 1.02, 0.0), (45.0, 0, "lead", "KANAGAWA.", .90, 0.0), (46.6, 0, "coro", "¡ESO!", .95, 0.0), ] 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 < 190].sum() E["mid"][f] = sp[(fr >= 190) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].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 # ═══════════════════════════════════════════════════════════════════════════ # FONTS # ═══════════════════════════════════════════════════════════════════════════ _FC = {} MINPT = max(9, PX(9)) # ── 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) def font(size, name="Helvetica.ttc"): size = int(max(MINPT, min(2000, size))) key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] def fit_text(s, px, name="Helvetica.ttc", start=None, minsz=MINPT): sz = int(start or px*0.6) while sz > minsz: f = font(sz, name) if f.getlength(s) <= px: return f sz = int(sz*0.88) if sz > 14 else sz-1 return font(minsz, name) # ═══════════════════════════════════════════════════════════════════════════ # ═══ T H E P R E S S ═══════════════════════════════════════════════════ # # Millimetres of cherry and kozo. The sheet is 380 x 258 mm (oban yoko-e); the # block is a little larger all round and carries the kento notches. Every field # below — grain, fibre, pressure, ink — is sampled in these coordinates, so it # is fixed to the WOOD or to the PAPER and the camera moves over it. # ═══════════════════════════════════════════════════════════════════════════ SHEET_W, SHEET_H = 380.0, 258.0 BLK = (-15.0, -13.0, 395.0, 272.0) # block outline in mm KAGI = (384.0, 262.0) # right-angle registration notch HIKI = (150.0, 262.0, 232.0, 268.0) # straight registration ledge PAPER_RGB = np.float32([0.957, 0.931, 0.864]) WOOD_RGB = np.float32([0.741, 0.588, 0.412]) WOOD_CUT = np.float32([0.633, 0.489, 0.331]) # pigment absorbance at full density, per RGB channel ABSORB = { "sumi": np.float32([3.00, 3.05, 2.95]), "nezumi":np.float32([0.62, 0.63, 0.60]), "kihada":np.float32([0.06, 0.22, 1.05]), "beni": np.float32([0.05, 0.72, 0.50]), "ai": np.float32([0.75, 0.42, 0.10]), "bero": np.float32([1.95, 1.05, 0.22]), } # the ink as it sits WET on the block, for the block-side shots WET_RGB = { "sumi": np.float32([0.11, 0.11, 0.12]), "nezumi":np.float32([0.52, 0.53, 0.55]), "kihada":np.float32([0.86, 0.74, 0.30]), "beni": np.float32([0.84, 0.36, 0.42]), "ai": np.float32([0.40, 0.52, 0.68]), "bero": np.float32([0.11, 0.22, 0.46]), } # the six blocks, in pull order. Each pull = one colour AND one instrument. BLOCKS = [ # key, romaji, english, pigment, grain seed/angle, instrument ("key", "SHUHAN", "KEY BLOCK", "sumi", (11, 0.05), "CONGA + BAJO"), ("grey", "NEZUMI", "GREY", "nezumi", (23, 1.52), "TIMBAL"), ("yell", "KIHADA", "YELLOW", "kihada", (37, 0.11), "PIANO"), ("pink", "BENI", "PINK", "beni", (53, 1.48), "CAMPANA"), ("blue", "AI", "BLUE", "ai", (71, 0.02), "TROMBON"), ("deep", "BERO-AI", "PRUSSIAN", "bero", (97, 1.55), "TROMPETA"), ] BOKASHI = {1: ("gtop", 4.0, 50.0, 1.0), # nezumi: grey sky wiped downward 2: ("band", 40.0, 122.0, 1.0), # kihada: soft on both edges 3: ("y", 108.0, 170.0, 1.0), # beni: dawn band at the horizon 5: ("crest", 0.0, 0.0, 1.0)} # bero-ai: wiped off the crest BAD = 5 # the block that slips BAD_OFF = (6.6, -4.2) # millimetres. A real offset. # ═══════════════════════════════════════════════════════════════════════════ # CAMERA # ═══════════════════════════════════════════════════════════════════════════ _XX = np.arange(W, dtype=np.float32)[None, :].repeat(H, 0) _YY = np.arange(H, dtype=np.float32)[:, None].repeat(W, 1) class Cam: __slots__ = ("cx", "cy", "wmm", "rot", "ppm", "pps", "c", "s", "_mx", "_my") def __init__(self, cx, cy, wmm, rot=0.0): self.cx, self.cy, self.rot = cx, cy, rot self.wmm = max(5.0, wmm) self.ppm = WREF/self.wmm self.pps = self.ppm*SS self.c, self.s = math.cos(rot), math.sin(rot) self._mx = self._my = None def p(self, x, y): dx, dy = (x-self.cx)*self.pps, (y-self.cy)*self.pps return (SW/2 + dx*self.c - dy*self.s, SH/2 + dx*self.s + dy*self.c) def pl(self, pts): return [self.p(x, y) for x, y in pts] def box(self, x0, y0, x1, y1): return self.pl([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) def sc(self, mm): return mm*self.pps def mm(self): """Inverse map: every output pixel -> millimetres. Cached per camera.""" if self._mx is None: dx = (_XX-W/2)/self.ppm; dy = (_YY-H/2)/self.ppm self._mx = self.cx + dx*self.c + dy*self.s self._my = self.cy - dx*self.s + dy*self.c return self._mx, self._my def lerp(a, b, u): return a + (b-a)*u def ease(u): u = min(max(u, 0.0), 1.0); return u*u*(3-2*u) def ease_out(u): return 1-(1-min(max(u, 0.0), 1.0))**3 def ease_in(u): return min(max(u, 0.0), 1.0)**2.6 def sstep(a, b, x): t = np.clip((x-a)/max(1e-9, b-a), 0, 1); return t*t*(3-2*t) def cam_move(a, b, u, shake=0.0, seed=0): e = ease(u) cx = lerp(a.cx, b.cx, e); cy = lerp(a.cy, b.cy, e) wm = math.exp(lerp(math.log(a.wmm), math.log(b.wmm), e)) rt = lerp(a.rot, b.rot, e) if shake: j = wm*0.0035*shake cx += math.sin(u*21.0+seed)*j; cy += math.cos(u*17.0+seed*1.7)*j return Cam(cx, cy, wm, rt) # ═══════════════════════════════════════════════════════════════════════════ # NOISE TILES — grain, fibre, wipe. Sampled in material coordinates. # ═══════════════════════════════════════════════════════════════════════════ def _fbm_tile(ts, seed, octaves=4, sx=1.0, sy=1.0): R = rs(seed) t = np.zeros((ts, ts), np.float32); amp = 1.0; nrm = 0.0 sc = ts//4 for _ in range(octaves): gw = max(2, int(ts/max(1.0, sc*sx))+2); gh = max(2, int(ts/max(1.0, sc*sy))+2) g = R.rand(gh, gw).astype(np.float32) im = Image.fromarray((g*255).astype(np.uint8)).resize((ts, ts), Image.BICUBIC) t += amp*(np.asarray(im, np.float32)/255.0); nrm += amp amp *= 0.52; sc = max(1, sc//2) t /= nrm return (t - t.mean())/(t.std()+1e-6) _TILES = {} def tile(kind, seed): key = (kind, seed) if key not in _TILES: if kind == "grain": # cherry: long fine rings. Stretch hard ALONG the grain, then fold # a dense phase ramp through a sine so it reads as RINGS, not # clouds — 44 rings across the tile, ~1.9 mm apart in the wood. base = _fbm_tile(512, seed, 4, sx=14.0, sy=0.5) ramp = np.linspace(0, TAU*44.0, 512)[None, :].repeat(512, 0) rings = np.sin(ramp + base*11.0) fig = _fbm_tile(512, seed+7, 3, sx=1.0, sy=1.0) t = 0.74*rings*(0.55+0.45*np.abs(fig)) + 0.26*base _TILES[key] = (t/(np.abs(t).max()+1e-6)).astype(np.float32) elif kind == "fibre": # kozo: long fibres laid down every which way, plus a fine speck. a = _fbm_tile(512, seed, 3, sx=14.0, sy=0.28) b = _fbm_tile(512, seed+3, 3, sx=0.28, sy=14.0) c = _fbm_tile(512, seed+5, 4, sx=1.0, sy=1.0) R = rs(seed+11) spec = (R.rand(512, 512) > 0.9982).astype(np.float32) t = 0.34*a + 0.30*b + 0.30*c + 1.1*spec _TILES[key] = (t/(np.abs(t).max()+1e-6)).astype(np.float32) elif kind == "bristle": base = _fbm_tile(512, seed, 3, sx=30.0, sy=0.30) ramp = np.linspace(0, TAU*70.0, 512)[:, None].repeat(512, 1) t = 0.6*np.sin(ramp + base*2.4) + 0.4*base _TILES[key] = (t/(np.abs(t).max()+1e-6)).astype(np.float32) else: # soft, low frequency t = _fbm_tile(256, seed, 3, sx=1.0, sy=1.0) _TILES[key] = (t/(np.abs(t).max()+1e-6)).astype(np.float32) return _TILES[key] def sample_tile(cam, kind, seed, per_mm, ang=0.0, ox=0.0, oy=0.0): """Sample a tile in MATERIAL millimetres, at an angle.""" mx, my = cam.mm() ca, sa = math.cos(ang), math.sin(ang) u = (mx*ca + my*sa + ox)*per_mm v = (-mx*sa + my*ca + oy)*per_mm t = tile(kind, seed); ts = t.shape[0] return t[(v.astype(np.int32) % ts), (u.astype(np.int32) % ts)] def woodgrain(cam, bi): seed, ang = BLOCKS[bi][4] return sample_tile(cam, "grain", seed, 6.0, ang, ox=seed*3.0, oy=seed*1.7) def fibrefield(cam): return sample_tile(cam, "fibre", 5, 8.0, 0.0) def blurpx(a, r): if r < 0.35: return a lo, hi = float(a.min()), float(a.max()) if hi-lo < 1e-6: return a q = ((a-lo)/(hi-lo)*255.0).astype(np.uint8) q = np.asarray(Image.fromarray(q).filter(ImageFilter.GaussianBlur(r)), np.float32)/255.0 return q*(hi-lo)+lo # ═══════════════════════════════════════════════════════════════════════════ # THE BAREN — an accumulating pressure field in sheet millimetres # # The printer works the baren in overlapping circular strokes, left to right # and top to bottom, easing off at the edges of the sheet so the paper does not # stretch. Pressure is what decides how much ink crosses; the same field, high # passed, is the burnish you see on the back of the sheet. # ═══════════════════════════════════════════════════════════════════════════ PP = 2.0*S # pressure-map pixels per mm PX0, PY0 = -30.0, -26.0 PMW = int((SHEET_W+60)*PP); PMH = int((SHEET_H+52)*PP) NB = 16 # progress buckets def _rub_path(): """The printer's order of work. Deterministic.""" R = rs(SEED0+5) arcs = [] rows = 8 for r in range(rows): y = 26 + r*(SHEET_H-52)/(rows-1) cols = 13 xs = range(cols) if r % 2 == 0 else range(cols-1, -1, -1) for c in xs: x = 26 + c*(SHEET_W-52)/(cols-1) arcs.append((x + R.uniform(-5, 5), y + R.uniform(-4, 4), R.uniform(30.0, 40.0))) return arcs _ARCS = _rub_path() _PMAPS = None def press_maps(): """All NB progress snapshots in one accumulating pass.""" global _PMAPS if _PMAPS is not None: return _PMAPS yy = (np.arange(PMH, dtype=np.float32)/PP + PY0) xx = (np.arange(PMW, dtype=np.float32)/PP + PX0) # the printer eases off at the sheet edge so the damp paper does not stretch ex = np.clip(np.minimum(xx+4, SHEET_W+4-xx)/22.0, 0, 1) ey = np.clip(np.minimum(yy+4, SHEET_H+4-yy)/20.0, 0, 1) base = (0.76 + 0.24*(ex[None, :]**0.6)*(ey[:, None]**0.6)).astype(np.float32) acc = np.zeros((PMH, PMW), np.float32) maps = [] na = len(_ARCS) cuts = [int(round(na*(b+1)/NB)) for b in range(NB)] j = 0 for b in range(NB): while j < cuts[b]: cx, cy, r = _ARCS[j]; j += 1 i0 = max(0, int((cx-r*1.7-PX0)*PP)); i1 = min(PMW, int((cx+r*1.7-PX0)*PP)) k0 = max(0, int((cy-r*1.7-PY0)*PP)); k1 = min(PMH, int((cy+r*1.7-PY0)*PP)) if i1 <= i0 or k1 <= k0: continue gx = xx[i0:i1][None, :]-cx; gy = yy[k0:k1][:, None]-cy d = np.sqrt(gx*gx+gy*gy) ring = (np.exp(-((d-r*0.70)/(r*0.34))**2)*0.55 + np.exp(-(d/(r*0.98))**2)*1.10) acc[k0:k1, i0:i1] += ring.astype(np.float32) # each pass SATURATES the paper against the block. Overlapping strokes # converge on full contact; they do not keep piling up. The rings stay # as burnish on the surface, not as holes in the ink. maps.append((1.0 - np.exp(-acc*2.2))*base) _PMAPS = [np.clip(m, 0, 1.0).astype(np.float32) for m in maps] return _PMAPS def arc_near(cam, prog): """Of the strokes the printer has already made, the one his hand is nearest to — so a tight camera still finds the baren.""" n = max(1, min(len(_ARCS), int(prog*len(_ARCS))+1)) tx = cam.cx + 0.19*cam.wmm # off-centre, where a working hand sits ty = cam.cy - 0.11*cam.wmm best, bd = _ARCS[0], 1e18 for a in _ARCS[:n]: d = (a[0]-tx)**2 + (a[1]-ty)**2 if d < bd: bd, best = d, a return best def pressure(cam, prog): """Sample the baren field at rub progress 0..1 into camera space.""" prog = float(np.clip(prog, 0.0, 1.0)) m = press_maps()[min(NB-1, max(0, int(prog*NB - 1e-6)))] mx, my = cam.mm() ix = np.clip(((mx-PX0)*PP).astype(np.int32), 0, PMW-1) iy = np.clip(((my-PY0)*PP).astype(np.int32), 0, PMH-1) return m[iy, ix] # ═══════════════════════════════════════════════════════════════════════════ # THE ARTWORK — Kanagawa oki nami ura, separated into six blocks # # Everything is millimetres on the sheet. The blocks are carved from these same # paths, so the colour blocks and the key block genuinely register (and # genuinely fail to, when told to). # ═══════════════════════════════════════════════════════════════════════════ HORIZON = 170.0 # 0 1 2 3 4 5 WAVE = [(-22, 268), (-22, 196), (-4, 176), (16, 150), (38, 120), (62, 90), # 6 7 8 9 10 11 (88, 62), (116, 41), (146, 30), (176, 30), (200, 39), (215, 55), # 12 13 14 15 16 17 (221, 74), (218, 93), (206, 108), (188, 116), (168, 115), (152, 106), # 18 19 20 21 22 23 (141, 93), (132, 80), (118, 73), (100, 76), (82, 90), (64, 114), # 24 25 26 27 (49, 146), (39, 184), (33, 224), (31, 268)] SWELL = [(0, 268), (18, 252), (44, 238), (74, 227), (108, 218), (144, 213), (180, 215), (216, 222), (254, 231), (296, 238), (340, 243), (398, 247), (398, 268)] SEA_R = [(-22, 178), (60, 173), (140, 170), (220, 168), (300, 170), (398, 172), (398, 247), (340, 243), (296, 238), (254, 231), (216, 222), (180, 215), (144, 213), (108, 218), (74, 227), (44, 238), (18, 252), (0, 268), (-22, 268)] FUJI = [(252, 126), (274, 148), (298, 170), (206, 170), (232, 146)] FUJI_SNOW = [(252, 126), (263, 138), (257, 142), (265, 147), (255, 149), (247, 143), (241, 148), (235, 141), (243, 137)] # the boats: (cx, cy, deg, length mm, rowers) BOATS = [(96, 228, -16, 112, 8), (52, 200, -33, 92, 7), (208, 204, 6, 100, 8)] FLOWS = (9.0, 19.0, 30.0, 42.0) # foam curl lines inside the wave def _norms(pts): n = len(pts); out = [] for i in range(n): x0, y0 = pts[(i-1) % n]; x1, y1 = pts[(i+1) % n] dx, dy = x1-x0, y1-y0 L = math.hypot(dx, dy) + 1e-9 out.append((dy/L, -dx/L)) return out def offset_poly(pts, d): ns = _norms(pts) return [(x + nx*d, y + ny*d) for (x, y), (nx, ny) in zip(pts, ns)] def _claw(x, y, ang, n, r0, seed, bend=0.30): """One foam finger: a tapering chain of circles that curls.""" R = rs(seed) out = []; a = ang; r = r0 for i in range(n): out.append((x, y, r)) step = r*0.78 a += bend + R.uniform(-0.10, 0.10) x += math.cos(a)*step; y += math.sin(a)*step r *= 0.855 return out def claws(): """Foam along the crest and along the under-curl, curling into the hollow. Deterministic, generated once.""" out = [] # along the top of the crest, thrown forward and curling right and down crest = [(5, -1.55, 7.0, 11), (6, -1.40, 8.6, 12), (7, -1.15, 10.2, 12), (8, -0.80, 11.0, 12), (9, -0.35, 10.2, 11), (10, 0.10, 8.8, 10), (11, 0.55, 7.4, 9)] for k, (i, ang, r0, n) in enumerate(crest): x, y = WAVE[i] out.append(_claw(x, y, ang, n, r0, 900+k*13, 0.29)) # hanging off the under-curl, reaching down into the barrel under = [(13, 2.05, 8.2, 10), (14, 2.30, 8.8, 11), (15, 2.60, 8.0, 10), (16, 2.95, 7.2, 9), (17, 3.25, 6.4, 9), (18, 3.55, 5.4, 8)] for k, (i, ang, r0, n) in enumerate(under): x, y = WAVE[i] out.append(_claw(x, y, ang, n, r0, 1300+k*17, 0.31)) # a little spray up the wave's back back = [(3, -1.9, 4.2, 6), (4, -1.8, 5.0, 7)] for k, (i, ang, r0, n) in enumerate(back): x, y = WAVE[i] out.append(_claw(x, y, ang, n, r0, 1500+k*23, 0.26)) # and along the foreground swell sw = [(2, 4.35, 5.0, 7), (3, 4.30, 6.0, 8), (4, 4.35, 5.6, 7), (5, 4.45, 4.8, 6), (7, 4.55, 4.0, 6)] for k, (i, ang, r0, n) in enumerate(sw): x, y = SWELL[i] out.append(_claw(x, y, ang, n, r0, 1700+k*19, 0.27)) return out CLAWS = claws() def _rot_pts(pts, cx, cy, deg): a = math.radians(deg); 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] def boat_hull(cx, cy, deg, L): h = L*0.125 pts = [] for i in range(15): u = i/14.0 pts.append((cx-L/2 + L*u, cy - h*math.sin(math.pi*u)*0.55)) for i in range(15): u = 1-i/14.0 pts.append((cx-L/2 + L*u, cy + h*(0.35+0.65*math.sin(math.pi*u)))) return _rot_pts(pts, cx, cy, deg) def boat_rowers(cx, cy, deg, L, n): out = [] for i in range(n): u = 0.16 + 0.68*i/max(1, n-1) x = cx-L/2 + L*u y = cy - L*0.055 - L*0.012*math.sin(math.pi*u) out.append(_rot_pts([(x, y)], cx, cy, deg)[0]) return out # ── carving each block ───────────────────────────────────────────────────── def _poly(d, cam, pts, v): d.polygon(cam.pl(pts), fill=v) def _lines(d, cam, pts, v, wmm, closed=False): p = cam.pl(pts + ([pts[0]] if closed else [])) d.line(p, fill=v, width=max(2, int(cam.sc(wmm))), joint="curve") def claw_poly(ch, grow=0.0): """The ENVELOPE of a claw chain — a tapering finger, not a train of circles. Offset each spine point along its normal by the local radius.""" pts = [(x, y) for (x, y, r) in ch] rr = [r+grow for (_, _, r) in ch] left, right = [], [] n = len(pts) for i, (x, y) in enumerate(pts): if i == 0: dx, dy = pts[1][0]-x, pts[1][1]-y elif i == n-1: dx, dy = x-pts[-2][0], y-pts[-2][1] else: dx, dy = pts[i+1][0]-pts[i-1][0], pts[i+1][1]-pts[i-1][1] L = math.hypot(dx, dy)+1e-9 nx, ny = -dy/L, dx/L left.append((x+nx*rr[i], y+ny*rr[i])) right.append((x-nx*rr[i], y-ny*rr[i])) tipx, tipy = pts[-1] tip = [(tipx+math.cos(a)*rr[-1], tipy+math.sin(a)*rr[-1]) for a in np.linspace(0, TAU, 9)] return left + tip + right[::-1] def _claw_fill(d, cam, v, grow=0.0): for ch in CLAWS: d.polygon(cam.pl(claw_poly(ch, grow)), fill=v) def draw_block(bi, cam, off=(0.0, 0.0)): """Carve one block: return its RAISED-LAND mask at output resolution. `off` is the block's registration offset in millimetres — a real translation of the artwork, which is what a misregistration is.""" im = Image.new("L", (SW, SH), 0) d = ImageDraw.Draw(im) ox, oy = off c = Cam(cam.cx-ox, cam.cy-oy, cam.wmm, cam.rot) name = BLOCKS[bi][0] if name == "key": for ch in CLAWS: # claw outlines, drawn first _lines(d, c, claw_poly(ch), 255, 1.05) _lines(d, c, WAVE, 255, 1.7, closed=True) _lines(d, c, SWELL, 255, 1.4) _lines(d, c, [(220, 168), (300, 170), (398, 172)], 255, 0.9) for k, dd in enumerate(FLOWS): _lines(d, c, offset_poly(WAVE, dd)[3:24], 255, 1.0 - k*0.13) _lines(d, c, FUJI, 255, 1.3, closed=True) _lines(d, c, FUJI_SNOW, 255, 0.9, closed=True) for (bx, by, dg, L, n) in BOATS: _lines(d, c, boat_hull(bx, by, dg, L), 255, 1.15, closed=True) for (rx, ry) in boat_rowers(bx, by, dg, L, n): rr = L*0.032 x0, y0 = c.p(rx-rr, ry-rr*1.6); x1, y1 = c.p(rx+rr, ry+rr*1.6) d.ellipse([min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)], fill=255) # title cartouche, top left — the one place the print speaks d.rectangle(_bb(c.box(12, 10, 62, 80)), outline=255, width=max(2, int(c.sc(1.3)))) _cart_text(d, c) elif name == "grey": _poly(d, c, [(-26, -16), (400, -16), (400, 50), (-26, 50)], 255) for (bx, by, dg, L, n) in BOATS: _poly(d, c, boat_hull(bx, by, dg, L), 255) _poly(d, c, WAVE, 0) _poly(d, c, SWELL, 0) _poly(d, c, FUJI, 0) elif name == "yell": _poly(d, c, [(-26, 40), (400, 44), (400, 122), (-26, 118)], 255) for (bx, by, dg, L, n) in BOATS: _poly(d, c, boat_hull(bx, by, dg, L)[:15], 255) _poly(d, c, WAVE, 0) _poly(d, c, SWELL, 0) _poly(d, c, FUJI, 0) elif name == "pink": _poly(d, c, [(-26, 112), (400, 114), (400, HORIZON+2), (-26, HORIZON+2)], 255) _poly(d, c, WAVE, 0) _poly(d, c, SWELL, 0) _poly(d, c, FUJI, 0) d.rectangle(_bb(c.box(352, 226, 372, 250)), fill=255) # the seal elif name == "blue": _poly(d, c, WAVE, 255) _poly(d, c, SWELL, 255) _poly(d, c, SEA_R, 255) _poly(d, c, FUJI, 255) # Fuji, pale _poly(d, c, FUJI_SNOW, 0) # snow = paper _claw_fill(d, c, 0, grow=1.0) for k, dd in enumerate(FLOWS): _lines(d, c, offset_poly(WAVE, dd)[3:24], 0, 2.4) elif name == "deep": _poly(d, c, WAVE, 255) _poly(d, c, SWELL, 255) _poly(d, c, SEA_R, 255) _claw_fill(d, c, 0, grow=1.5) for k, dd in enumerate(FLOWS): _lines(d, c, offset_poly(WAVE, dd)[3:24], 0, 3.0) return np.asarray(im.resize((W, H), Image.BOX), np.float32)/255.0 def _bb(quad): xs = [p[0] for p in quad]; ys = [p[1] for p in quad] return [min(xs), min(ys), max(xs), max(ys)] def _cart_text(d, cam): """Romaji stacked in the cartouche. Bundled fonts have no kana, so the print signs itself in the Latin the workshop can actually set.""" lines = ["KANAGAWA", "OKI", "NAMI", "URA"] bw = cam.sc(44.0) y = cam.p(40, 22)[1] for i, s in enumerate(lines): f = fit_text(s, bw, "Georgia.ttf", start=int(max(PF(10), cam.sc(7.5)))) px, py = cam.p(40, 22 + i*15.0) d.text((px, py), s, font=f, fill=255, anchor="mm") # ── kento notches: carved into every block at the same millimetres ───────── def draw_kento(d, cam, seated=True, gap=0.0): kx, ky = KAGI d.polygon(cam.pl([(kx-1.5, ky-16), (kx+9, ky-16), (kx+9, ky+7), (kx-1.5, ky+7)]), fill=0) d.polygon(cam.pl([(HIKI[0], HIKI[1]-1.5), (HIKI[2], HIKI[1]-1.5), (HIKI[2], HIKI[3]), (HIKI[0], HIKI[3])]), fill=0) # ═══════════════════════════════════════════════════════════════════════════ # INKING THE BLOCK # ═══════════════════════════════════════════════════════════════════════════ def bokashi_ramp(cam, bi, amt=1.0): """A hand-wiped gradient. The ramp position is itself perturbed by a low-frequency field, because it is wiped with a damp cloth by a hand.""" if bi not in BOKASHI: return None kind, a, b, _ = BOKASHI[bi] mx, my = cam.mm() wob = sample_tile(cam, "soft", 41, 0.055)*7.0 if kind == "y": r = sstep(a, b, my + wob) elif kind == "gtop": r = 1.0 - sstep(a, b, my + wob*0.9) elif kind == "band": r = sstep(a-4.0, a+28.0, my + wob) * (1.0 - sstep(b-30.0, b+4.0, my + wob)) else: # bero-ai is wiped off the middle: full at the crest, full again in the # deep foreground, taken away in between r = np.maximum(1.0 - sstep(34.0, 116.0, my + wob*1.4 + (mx-110.0)*0.07), 0.52*sstep(218.0, 262.0, my + wob*1.2)) return np.clip(1.0 - amt + amt*np.clip(r, 0, 1), 0, 1) def ink_field(cam, bi, load=1.0, sweep=1.0, bok=1.0, grain_bite=0.24): """Pigment on the LAND of the block. A maru-bake brush, loaded, swept.""" seed = BLOCKS[bi][4][0] # per-bristle striation along the stroke direction bris = sample_tile(cam, "bristle", seed+2, 6.0, ang=0.10, ox=seed) f = 0.80 + 0.32*bris # the brush sets down heavy on the left and lifts as it crosses mx, my = cam.mm() f *= (1.06 - 0.16*np.clip(mx/SHEET_W, 0, 1)) # the sweep front, ragged and wet if sweep < 0.999: edge = sweep*(SHEET_W+90.0) - 45.0 rag = sample_tile(cam, "soft", 67, 0.12)*11.0 f *= np.clip((edge - mx + rag)/16.0 + 0.5, 0, 1) # wood grain starves the ink in the broad flats — the grain prints through g = woodgrain(cam, bi) f *= (1.0 - grain_bite*np.clip(g, 0, 1)) * (1.0 + 0.07*np.clip(-g, 0, 1)) r = bokashi_ramp(cam, bi, bok) if r is not None: f = f*r return np.clip(f*load, 0, 1.4).astype(np.float32) # ═══════════════════════════════════════════════════════════════════════════ # THE TRANSFER — pressure decides, damp fibre spreads, density stacks # ═══════════════════════════════════════════════════════════════════════════ def transfer(mask, ink, pres, damp, ppm): d = mask*ink tc = np.clip((pres - 0.11)/0.62, 0, 1)**0.78 d = d*tc # bleed into damp fibre — a real spread in millimetres of paper r = ppm*(0.05 + 0.20*damp) if r > 0.35: d = blurpx(d, r)*1.04 return np.clip(d, 0, 1.5) class Pull: __slots__ = ("bi", "off", "load", "sweep", "bok", "prog", "damp") def __init__(self, bi, off, load=1.0, sweep=1.0, bok=1.0, prog=1.0, damp=0.55): self.bi, self.off, self.load = bi, off, load self.sweep, self.bok, self.prog, self.damp = sweep, bok, prog, damp def reg_off(sheet_seed, bi, bad=None): """Hand slop — a fifth of a millimetre, every time, never the same twice. Unless the block is the one that slips.""" if bad is not None and bi == bad: return BAD_OFF R = rs(sheet_seed*131 + bi*17) return (R.uniform(-0.20, 0.20), R.uniform(-0.16, 0.16)) def pulls_for(n_done, sheet=0, bad=None, partial=None): """n_done completed pulls on sheet `sheet`; `partial` = (bi, prog, sweep).""" out = [] for bi in range(min(n_done, len(BLOCKS))): out.append(Pull(bi, reg_off(sheet, bi, bad))) if partial is not None: bi, prog, sweep = partial if bi < len(BLOCKS): out.append(Pull(bi, reg_off(sheet, bi, bad), prog=prog, sweep=sweep)) return out _PAPER_CACHE = {} def paper_base(cam): """Damp kozo: warm, fibrous, cockled where the water went in.""" fib = fibrefield(cam) cock = sample_tile(cam, "soft", 19, 0.030) gy, gx = np.gradient(cock) shade = 1.0 + 0.11*(gx*0.7 - gy*1.0)*min(6.0, cam.ppm) tone = 1.0 + 0.055*fib rgb = PAPER_RGB[None, None, :]*(tone*shade)[..., None] return np.clip(rgb, 0, 1.25).astype(np.float32) def print_sheet(cam, pulls, rubprog=1.0, e=None): """The sheet, built pull by pull as real optical density.""" out = paper_base(cam) T = np.ones((H, W, 3), np.float32) fib = fibrefield(cam) for ps in pulls: mask = draw_block(ps.bi, cam, ps.off) ink = ink_field(cam, ps.bi, ps.load, ps.sweep, ps.bok) pres = pressure(cam, ps.prog if ps.prog < 1.0 else 1.0) d = transfer(mask, ink, pres, ps.damp, cam.ppm) d = d*(0.90 + 0.20*np.clip(fib*0.5+0.5, 0, 1)) T *= np.exp(-d[..., None]*ABSORB[BLOCKS[ps.bi][3]][None, None, :]) return np.clip(out*T, 0, 1) # ═══════════════════════════════════════════════════════════════════════════ # THE BLOCK AS AN OBJECT — wood, cut walls, gouge scoops, wet ink on the land # ═══════════════════════════════════════════════════════════════════════════ def render_block(cam, bi, inked=0.0, sweep=1.0, load=1.0, carved=1.0, chips=0.0, bok=1.0): mask = draw_block(bi, cam, (0.0, 0.0)) if carved < 0.999: # carving in progress: the cut spreads across the block left to right mx, _ = cam.mm() rag = sample_tile(cam, "soft", 83, 0.09)*9.0 front = np.clip((carved*(SHEET_W+80) - 40 - mx + rag)/14.0 + .5, 0, 1) mask = mask*front + (1.0-front)*1.0 g = woodgrain(cam, bi) wood = WOOD_RGB[None, None, :]*(1.0 + 0.20*g)[..., None] cut = WOOD_CUT[None, None, :]*(1.0 + 0.13*g)[..., None] # gouge scoops in the cleared field scoop = sample_tile(cam, "soft", 29, 0.22) sg = np.sin(scoop*5.4) cut = cut*(1.0 + 0.16*sg)[..., None] # the knife bevel: light off the cut wall, shadow in the trench sm = blurpx(mask, max(PF(0.6), cam.ppm*0.30)) gy, gx = np.gradient(sm) lit = np.clip((-gx*0.8 - gy*1.0)*min(58.0, cam.ppm*9.0), -1, 1) m3 = mask[..., None] out = wood*m3 + cut*(1-m3) out = out*(1.0 + 0.42*lit[..., None]) out = out*(1.0 - 0.30*np.clip(-lit, 0, 1)[..., None]) if inked > 0.01: ink = ink_field(cam, bi, load, sweep, bok, grain_bite=0.20)*mask*inked wet = WET_RGB[BLOCKS[bi][3]][None, None, :] a = np.clip(ink, 0, 1)[..., None] out = out*(1-a) + wet*a # wet sheen sh = np.clip(lit, 0, 1)*np.clip(ink, 0, 1) out = out + sh[..., None]*0.28*np.float32([1, 1, .97]) if chips > 0.01: R = rs(int(bi*77+11)) # cherry chips curling off the knife pass return np.clip(out, 0, 1) # ═══════════════════════════════════════════════════════════════════════════ # THE BENCH — the workshop, in the same millimetres # ═══════════════════════════════════════════════════════════════════════════ BENCH_RGB = np.float32([0.412, 0.318, 0.216]) WALL_RGB = np.float32([0.556, 0.508, 0.438]) WALL_Y = -74.0 # above this, the shop's plaster wall def bench_bg(cam, t=0.0): g = sample_tile(cam, "grain", 5, 2.2, 0.02, ox=13.0) fine = sample_tile(cam, "bristle", 9, 3.0, 0.02) out = BENCH_RGB[None, None, :]*(1.0 + 0.26*g + 0.08*fine)[..., None] _, my = cam.mm() plank = np.abs(((my+31.0) % 122.0) - 61.0) out = out*(1.0 - 0.34*np.clip((plank-56.0)/5.0, 0, 1))[..., None] # the wall behind the bench — same room, same millimetres, so a camera can # tilt up to the pinned reject without leaving the space wl = sample_tile(cam, "soft", 71, 0.09) wf = sample_tile(cam, "fibre", 73, 5.0) wall = WALL_RGB[None, None, :]*(1.0 + 0.13*wl + 0.05*wf)[..., None] up = sstep(WALL_Y+3.0, WALL_Y-3.0, my)[..., None] out = out*(1-up) + wall*up # the shadow the bench throws on the wall out = out*(1.0 - 0.34*np.exp(-((my-(WALL_Y-3.0))/13.0)**2) * (my < WALL_Y))[..., None] return np.clip(out, 0, 1) def sheet_mask(cam, ox=0.0, oy=0.0, rot=0.0): """The deckled edge of a sheet of washi.""" im = Image.new("L", (SW, SH), 0) d = ImageDraw.Draw(im) R = rs(SEED0+3) pts = [] per = 26 for i in range(per): u = i/per; pts.append((u*SHEET_W, R.uniform(-1.2, 0.9))) for i in range(per): u = i/per; pts.append((SHEET_W + R.uniform(-0.9, 1.2), u*SHEET_H)) for i in range(per): u = 1-i/per; pts.append((u*SHEET_W, SHEET_H + R.uniform(-0.9, 1.2))) for i in range(per): u = 1-i/per; pts.append((R.uniform(-1.2, 0.9), u*SHEET_H)) pts = [(x+ox, y+oy) for x, y in pts] if rot: pts = _rot_pts(pts, SHEET_W/2+ox, SHEET_H/2+oy, rot) d.polygon(cam.pl(pts), fill=255) return np.asarray(im.resize((W, H), Image.BOX), np.float32)/255.0 def lay_sheet(base, sheet_rgb, smask, cam, shadow=1.0): """Composite a sheet over the bench with a lift shadow.""" sh = blurpx(np.clip(smask, 0, 1), max(PF(1.0), cam.ppm*0.9)) off = max(2, int(cam.ppm*1.1)) sh = np.roll(np.roll(sh, off, 0), int(off*0.7), 1) sh = np.clip(sh - smask, 0, 1) out = base*(1.0 - 0.46*shadow*sh[..., None]*np.float32([1.0, .96, .90])) a = smask[..., None] return out*(1-a) + sheet_rgb*a # ═══════════════════════════════════════════════════════════════════════════ # TOOLS AND HANDS — drawn as an overlay, in the same millimetres # ═══════════════════════════════════════════════════════════════════════════ SKIN = (196, 148, 106, 255) SKIN2 = (162, 116, 78, 255) HANDLE= (140, 96, 52, 255) BAMBOO= (206, 178, 118, 255) def _ov(): im = Image.new("RGBA", (SW, SH), (0, 0, 0, 0)) return im, ImageDraw.Draw(im) def ov_out(ov): return np.asarray(ov.resize((W, H), Image.BOX), np.float32)/255.0 def comp_ov(rgb, ov): o = ov_out(ov) a = o[..., 3:4] return rgb*(1-a) + o[..., :3]*a # A hand seen from the back, fingers reaching AWAY from the wrist (toward -y). # One silhouette, so it strokes cleanly — the way a key block would have cut it. HAND_SIL = [ (-0.50, 0.98), (-0.53, 0.34), (-0.49, 0.04), (-0.46, -0.08), (-0.45, -0.60), (-0.41, -0.69), (-0.33, -0.69), (-0.29, -0.60), (-0.28, -0.10), (-0.25, -0.16), (-0.23, -0.26), (-0.21, -0.82), (-0.16, -0.91), (-0.08, -0.91), (-0.04, -0.82), (-0.03, -0.20), (0.00, -0.26), (0.03, -0.34), (0.05, -0.80), (0.10, -0.88), (0.18, -0.88), (0.22, -0.80), (0.23, -0.24), (0.26, -0.20), (0.29, -0.16), (0.32, -0.58), (0.37, -0.66), (0.44, -0.66), (0.48, -0.57), (0.50, 0.02), (0.53, 0.34), (0.51, 0.98), ] HAND_THUMB = [(-0.48, 0.06), (-0.74, -0.12), (-0.88, -0.02), (-0.88, 0.16), (-0.70, 0.28), (-0.48, 0.36)] def hand(d, cam, x, y, size, ang=0.0, flip=False): """A printer's hand: one carved silhouette, outlined like everything else in this shop.""" s = size def place(pts): q = [(-a, b) for a, b in pts] if flip else pts return _rot_pts([(x+a*s, y+b*s) for a, b in q], x, y, ang) lw = max(2, int(cam.sc(size*0.028))) OUTL = (94, 60, 34, 255) T = place(HAND_THUMB) d.polygon(cam.pl(T), fill=SKIN) d.line(cam.pl(T+[T[0]]), fill=OUTL, width=lw, joint="curve") P = place(HAND_SIL) d.polygon(cam.pl(P), fill=SKIN) d.line(cam.pl(P+[P[0]]), fill=OUTL, width=lw, joint="curve") for u in (-0.37, -0.13, 0.13, 0.39): # knuckle creases d.line(cam.pl(place([(u-0.07, -0.04), (u+0.07, -0.04)])), fill=SKIN2, width=lw) # a little modelling so it does not read as a paper cut-out d.polygon(cam.pl(place([(-0.49, 0.30), (0.50, 0.30), (0.51, 0.98), (-0.50, 0.98)])), fill=(SKIN2[0], SKIN2[1], SKIN2[2], 90)) def baren(d, cam, x, y, r, t=0.0): """The baren: a coiled cord disc in a bamboo sheath, with the twisted handle knot on the back.""" for i in range(9): rr = r*(1.0 - i*0.098) d.ellipse(_bb(cam.box(x-rr, y-rr, x+rr, y+rr)), outline=(150, 120, 76, 255), width=max(2, int(cam.sc(r*0.055)))) d.ellipse(_bb(cam.box(x-r, y-r, x+r, y+r)), outline=(96, 70, 40, 255), width=max(2, int(cam.sc(r*0.10)))) kr = r*0.30 d.ellipse(_bb(cam.box(x-kr, y-kr, x+kr, y+kr)), fill=BAMBOO) d.arc(_bb(cam.box(x-kr*1.5, y-kr*1.5, x+kr*1.5, y+kr*1.5)), 20, 300, fill=(150, 118, 62, 255), width=max(2, int(cam.sc(r*0.07)))) def brush(d, cam, x, y, ang, wmm, wet=(0.1, 0.1, 0.1), t=0.0): """Maru-bake — the wide flat brush: a block of wood, a bound ferrule and a fan of horsehair, loaded and splayed where it meets the block.""" hw = wmm*0.5 lw = max(2, int(cam.sc(wmm*0.016))) OUTL = (74, 46, 24, 255) def R(pts): return _rot_pts([(x+a, y+b) for a, b in pts], x, y, ang) Hd = R([(-hw, -wmm*1.05), (hw, -wmm*1.05), (hw, -wmm*0.30), (-hw, -wmm*0.30)]) d.polygon(cam.pl(Hd), fill=HANDLE) d.line(cam.pl(Hd+[Hd[0]]), fill=OUTL, width=lw, joint="curve") for u in (-0.55, 0.0, 0.55): # the wood's own grain d.line(cam.pl(R([(u*hw, -wmm*1.02), (u*hw, -wmm*0.34)])), fill=(112, 74, 38, 255), width=lw) Fe = R([(-hw*1.02, -wmm*0.34), (hw*1.02, -wmm*0.34), (hw*1.02, -wmm*0.18), (-hw*1.02, -wmm*0.18)]) d.polygon(cam.pl(Fe), fill=(168, 148, 106, 255)) d.line(cam.pl(Fe+[Fe[0]]), fill=OUTL, width=lw, joint="curve") col = tuple(int(c*255) for c in wet)+(255,) n = 26 for i in range(n): u = -0.99 + 1.98*i/(n-1) sp = 1.0 + 0.14*abs(u) wob = math.sin(u*9.0 + t*3.0)*wmm*0.012 d.line(cam.pl(R([(u*hw, -wmm*0.20), (u*hw*sp + wob, wmm*(0.40+0.05*math.cos(u*7.0)))])), fill=col, width=max(2, int(cam.sc(wmm*0.045)))) for i in range(9): # a few brighter bristles u = -0.9 + 1.8*i/8 d.line(cam.pl(R([(u*hw, -wmm*0.20), (u*hw*1.10, wmm*0.36)])), fill=(255, 255, 255, 54), width=lw) def knife(d, cam, x, y, ang, L=52.0): """The to — the Japanese carving knife, held like a dagger, tip at (x, y).""" lw = max(2, int(cam.sc(L*0.018))) OUTL = (52, 44, 40, 255) def R(pts): return _rot_pts([(x+a, y+b) for a, b in pts], x, y, ang) Bl = R([(0, 0), (-L*0.11, -L*0.22), (-L*0.11, -L*0.66), (L*0.07, -L*0.66), (L*0.07, -L*0.18)]) d.polygon(cam.pl(Bl), fill=(206, 210, 218, 255)) d.line(cam.pl(Bl+[Bl[0]]), fill=OUTL, width=lw, joint="curve") d.line(cam.pl(R([(0, 0), (L*0.07, -L*0.18), (L*0.07, -L*0.62)])), fill=(250, 252, 255, 255), width=lw) Hd = R([(-L*0.13, -L*0.62), (L*0.10, -L*0.62), (L*0.10, -L*1.30), (-L*0.13, -L*1.30)]) d.polygon(cam.pl(Hd), fill=HANDLE) d.line(cam.pl(Hd+[Hd[0]]), fill=OUTL, width=lw, joint="curve") for u in (-0.86, -1.02, -1.18): d.line(cam.pl(R([(-L*0.13, L*u), (L*0.10, L*u)])), fill=(104, 68, 34, 255), width=lw) def kento_mask(cam): im = Image.new("L", (SW, SH), 0); d = ImageDraw.Draw(im) kx, ky = KAGI d.polygon(cam.pl([(kx-2.0, ky-18), (kx+10, ky-18), (kx+10, ky+8), (kx-2.0, ky+8)]), fill=255) d.rectangle(_bb(cam.box(HIKI[0], HIKI[1]-2.0, HIKI[2], HIKI[3])), fill=255) return np.asarray(im.resize((W, H), Image.BOX), np.float32)/255.0 def cut_kento(rgb, cam): m = kento_mask(cam) if m.max() < 0.01: return rgb sm = blurpx(m, max(PF(0.6), cam.ppm*0.35)) gy, gx = np.gradient(sm) lit = np.clip((-gx*0.9 - gy*1.0)*min(90.0, cam.ppm*12.0), -1, 1) out = rgb*(1.0 - 0.42*m[..., None]) out = out*(1.0 + 0.60*np.clip(lit, 0, 1)[..., None]) return np.clip(out, 0, 1) # ═══════════════════════════════════════════════════════════════════════════ # SHEET GEOMETRY IN THE FRAME — peel, back-of-sheet, burnish # ═══════════════════════════════════════════════════════════════════════════ def blurpx_rgb(a, r): return np.stack([blurpx(a[..., c], r) for c in range(3)], -1) def back_of_sheet(cam, pulls, rubprog, e): """You rub the BACK. Kozo is thin, so the ink reads through it, softened and greyed — and the burnish rings the baren leaves stay on the surface.""" front = print_sheet(cam, pulls, rubprog, e) show = blurpx_rgb(front, max(1.0, cam.ppm*0.9)) g = show.mean(2, keepdims=True) show = show*0.45 + g*0.55 # the fibre kills the colour base = paper_base(cam) out = base*(0.40 + 0.60*show) p = pressure(cam, rubprog) burn = p - blurpx(p, max(PF(1.2), cam.ppm*1.7)) out = out*(1.0 + 0.42*burn[..., None]) out = out*(1.0 - 0.10*np.clip(1.0-p, 0, 1)[..., None]) return np.clip(out, 0, 1) def peel_warp(sheet_rgb, block_rgb, smask, u, cam, seed=0): """The sheet hinged at its top edge, swinging up toward the camera. Rows compress toward the hinge; the near edge widens; a bright cylindrical fold runs along the lift.""" ytop = cam.p(0, 0)[1]/SS ybot = cam.p(0, SHEET_H)[1]/SS ytop = float(np.clip(ytop, -4*H, 5*H)); ybot = float(np.clip(ybot, -4*H, 5*H)) if ybot <= ytop+2: return block_rgb span = ybot-ytop ybot2 = ytop + span*(1.0 - 0.86*ease(u)) y = _YY; x = _XX v = np.clip((y-ytop)/max(1.0, ybot2-ytop), 0, 1) inb = ((y >= ytop-1) & (y <= ybot2)).astype(np.float32) gamma = 1.0 + 2.4*ease(u) vs = v**gamma sy = np.clip(ytop + vs*span, 0, H-1).astype(np.int32) sxs = 1.0 + 0.22*ease(u)*v sx = np.clip(W/2 + (x-W/2)/sxs, 0, W-1).astype(np.int32) warp = sheet_rgb[sy, sx] wm = smask[sy, sx]*inb shade = 1.0 + 0.34*np.cos(np.clip(v, 0, 1)*math.pi*0.9)*ease(u) \ - 0.30*(v**3)*ease(u) warp = warp*shade[..., None] # the near edge catches the lamp edge = np.exp(-((y-ybot2)/max(2.0, H*0.012))**2)*ease(u) warp = warp + (edge*wm)[..., None]*0.32 a = np.clip(wm, 0, 1)[..., None] sh = blurpx(np.clip(wm, 0, 1), max(PF(1.5), cam.ppm*1.4)) sh = np.clip(np.roll(sh, int(max(2, cam.ppm*2.2)), 0) - wm, 0, 1) out = block_rgb*(1.0 - 0.40*ease(u)*sh[..., None]) return np.clip(out*(1-a) + warp*a, 0, 1) # ═══════════════════════════════════════════════════════════════════════════ # ENGINES # ═══════════════════════════════════════════════════════════════════════════ class Eng: def __init__(self, shot, rng): self.s, self.rng, self.p = shot, rng, shot.params self.t0 = shot.i0/FPS self.setup() def setup(self): pass def cams(self, u): raise NotImplementedError def draw(self, cam, t, u, k, e): raise NotImplementedError def frame(self, k, u, e): t = self.t0 + k/FPS return np.clip(self.draw(self.cams(u), t, u, k, e), 0, 1) SHEET_C = (SHEET_W/2, SHEET_H/2) def _pl(p): """Shot params -> the pulls already standing on the sheet.""" return pulls_for(p.get("done", 0), p.get("sheet", 0), BAD if p.get("bad") else None) class Plank(Eng): """The cherry block itself. raw / carve / detail / kento.""" def setup(self): m = self.p.get("mode", "raw") self.m = m self.a, self.b = { "raw": (Cam(190, 128, 470), Cam(196, 130, 415)), "carve": (Cam(150, 120, 300), Cam(118, 108, 210)), "detail": (Cam(112, 62, 96), Cam(138, 52, 58)), "kento": (Cam(388, 258, 78), Cam(384, 259, 40)), "cut": (Cam(238, 150, 180), Cam(210, 168, 128)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.1, seed=self.s.idx) def draw(self, cam, t, u, k, e): carved = {"raw": 0.0, "carve": 0.10+0.86*u, "detail": 0.62+0.34*u, "kento": 1.0, "cut": 0.30+0.62*u}[self.m] img = bench_bg(cam) blk = render_block(cam, 0, inked=0.0, carved=carved) bm = np.zeros((H, W), np.float32) bi = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi).polygon( cam.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi.resize((W, H), Image.BOX), np.float32)/255.0 img = lay_sheet(img, blk, bm, cam, shadow=1.0) img = cut_kento(img, cam) ov, d = _ov() if self.m in ("carve", "detail", "cut"): kx = lerp(60, 300, u) if self.m == "carve" else lerp(104, 152, u) ky = lerp(150, 92, u) if self.m == "carve" else lerp(74, 46, u) ky += math.sin(t*23.0)*cam.wmm*0.006 kl = float(np.clip(cam.wmm*0.34, 62, 150)) ka = 148 + math.sin(t*7.0)*5 hx, hy = _rot_pts([(kx, ky-kl*1.34)], kx, ky, ka)[0] knife(d, cam, kx, ky, ka, L=kl) hand(d, cam, hx, hy, kl*0.80, ang=ka) return comp_ov(img, ov) class Ink(Eng): """Loading the land. load / sweep / bokashi / close.""" def setup(self): m = self.p.get("mode", "sweep"); self.m = m self.bi = self.p.get("bi", 0) self.a, self.b = { "load": (Cam(190, 128, 460), Cam(176, 122, 372)), "sweep": (Cam(150, 132, 330), Cam(214, 128, 300)), "bokashi":(Cam(160, 138, 270), Cam(206, 148, 196)), "close": (Cam(96, 70, 78), Cam(126, 58, 46)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.2, seed=self.s.idx) def draw(self, cam, t, u, k, e): sw = np.clip(0.04 + u*1.06, 0, 1) bok = 1.0 if self.m == "bokashi" else 1.0 img = bench_bg(cam) blk = render_block(cam, self.bi, inked=1.0, sweep=sw, load=0.86+0.22*e.get("mid", .4), bok=bok) bi_ = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi_).polygon( cam.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi_.resize((W, H), Image.BOX), np.float32)/255.0 img = lay_sheet(img, blk, bm, cam, shadow=1.0) img = cut_kento(img, cam) ov, d = _ov() bx = lerp(-40, 420, sw) by = 128 + math.sin(t*9.0)*14.0 wet = tuple(float(c) for c in WET_RGB[BLOCKS[self.bi][3]]) bw = float(np.clip(cam.wmm*0.26, 34, 104)) ba = 6+math.sin(t*5)*4 hand(d, cam, bx+bw*0.06, by-bw*1.30, bw*0.92, ang=ba-6) brush(d, cam, bx, by, ba, wmm=bw, wet=wet, t=t) return comp_ov(img, ov) class Lay(Eng): """The damp sheet coming down, corner into the kagi. register / drop.""" def setup(self): m = self.p.get("mode", "register"); self.m = m self.bi = self.p.get("bi", 0) self.a, self.b = { "register": (Cam(340, 236, 190), Cam(378, 256, 84)), "drop": (Cam(190, 128, 470), Cam(190, 128, 402)), "bad": (Cam(346, 240, 170), Cam(380, 258, 70)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.0, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) blk = render_block(cam, self.bi, inked=1.0, sweep=1.0) bi_ = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi_).polygon( cam.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi_.resize((W, H), Image.BOX), np.float32)/255.0 img = lay_sheet(img, blk, bm, cam, shadow=1.0) img = cut_kento(img, cam) # the sheet descending, still slightly off until it seats miss = (1.0-ease(u)) ox = lerp(-BAD_OFF[0], 0.0, ease(u)) if self.m == "bad" else -miss*22.0 oy = -miss*14.0 - (0.0 if self.m != "bad" else 0.0) if self.m == "bad": ox = -BAD_OFF[0]; oy = -BAD_OFF[1] sm = sheet_mask(cam, ox, oy, rot=miss*1.6) paper = paper_base(cam)*(1.0 - 0.10*miss) img = lay_sheet(img, paper, sm, cam, shadow=1.0+2.2*miss) ov, d = _ov() hand(d, cam, 300+ox, SHEET_H+96+oy, 104.0, ang=-26) return comp_ov(img, ov) class Rub(Eng): """The baren on the back of the sheet. start / spiral / edge / hard.""" def setup(self): m = self.p.get("mode", "spiral"); self.m = m self.a, self.b = { "start": (Cam(190, 128, 440), Cam(150, 110, 330)), "spiral": (Cam(150, 120, 300), Cam(240, 150, 250)), "edge": (Cam(300, 200, 190), Cam(340, 226, 128)), "hard": (Cam(160, 110, 150), Cam(120, 92, 96)), }[m] self.p0 = self.p.get("p0", 0.05); self.p1 = self.p.get("p1", 1.0) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.6, seed=self.s.idx) def draw(self, cam, t, u, k, e): prog = lerp(self.p0, self.p1, u) pulls = _pl(self.p) + [Pull(self.p.get("bi", 0), reg_off(self.p.get("sheet", 0), self.p.get("bi", 0), BAD if self.p.get("bad") else None), prog=prog)] img = bench_bg(cam) back = back_of_sheet(cam, pulls, prog, e) sm = sheet_mask(cam) img = lay_sheet(img, back, sm, cam, shadow=0.8) ov, d = _ov() bx, by, br = arc_near(cam, prog) bx += math.cos(t*17.0)*br*0.30; by += math.sin(t*17.0)*br*0.30 rr = 56.0*(1.0+0.05*e.get("hit", 0)) hand(d, cam, bx+rr*0.10, by+rr*0.52, 88.0, ang=8) baren(d, cam, bx, by, rr, t) return comp_ov(img, ov) class Peel(Eng): """Lifting the sheet off the block between pulls.""" def setup(self): m = self.p.get("mode", "off"); self.m = m self.bi = self.p.get("bi", 0) self.a, self.b = { "off": (Cam(190, 128, 430), Cam(190, 122, 372)), "half": (Cam(160, 140, 340), Cam(190, 128, 400)), "corner": (Cam(300, 210, 210), Cam(250, 180, 300)), }[m] self.u0 = self.p.get("u0", 0.02); self.u1 = self.p.get("u1", 0.95) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.0, seed=self.s.idx) def draw(self, cam, t, u, k, e): lift = ease(lerp(self.u0, self.u1, u)) done = self.p.get("done", 0) pulls = pulls_for(done+1, self.p.get("sheet", 0), BAD if self.p.get("bad") else None) sheet = print_sheet(cam, pulls, 1.0, e) blk = render_block(cam, self.bi, inked=0.30, sweep=1.0) bi_ = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi_).polygon( cam.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi_.resize((W, H), Image.BOX), np.float32)/255.0 base = lay_sheet(bench_bg(cam), blk, bm, cam, shadow=1.0) base = cut_kento(base, cam) sm = sheet_mask(cam) img = peel_warp(sheet, base, sm, lift, cam, seed=self.s.idx) ov, d = _ov() ey = SHEET_H*(1.0-0.86*lift) + 122.0 hand(d, cam, 22, ey, 88.0, ang=-22) hand(d, cam, 356, ey+16, 88.0, ang=22, flip=True) return comp_ov(img, ov) class Sheet(Eng): """The print, flat. flat / tilt / hold / bad / fresh.""" def setup(self): m = self.p.get("mode", "flat"); self.m = m self.a, self.b = { "flat": (Cam(190, 128, 452), Cam(190, 128, 408)), "tilt": (Cam(190, 128, 430, rot=0.05), Cam(186, 126, 386, rot=-0.03)), "hold": (Cam(150, 110, 320), Cam(210, 140, 264)), "bad": (Cam(180, 118, 400), Cam(132, 82, 196)), "fresh": (Cam(190, 128, 460), Cam(190, 128, 430)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=0.9, seed=self.s.idx) def draw(self, cam, t, u, k, e): pulls = _pl(self.p) sheet = print_sheet(cam, pulls, 1.0, e) if pulls else paper_base(cam) sm = sheet_mask(cam) img = lay_sheet(bench_bg(cam), sheet, sm, cam, shadow=1.0) return img class Macro(Eng): """Right into the paper. grain / fibre / bokashi / wet / kento / crest.""" def setup(self): m = self.p.get("mode", "grain"); self.m = m self.a, self.b = { "grain": (Cam(168, 34, 108), Cam(150, 28, 46)), "fibre": (Cam(78, 224, 100), Cam(96, 228, 44)), "bokashi":(Cam(280, 146, 130),Cam(268, 158, 60)), "wet": (Cam(214, 104, 120),Cam(202, 112, 52)), "crest": (Cam(150, 52, 200), Cam(112, 62, 104)), "seal": (Cam(362, 238, 54), Cam(360, 238, 24)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=0.7, seed=self.s.idx) def draw(self, cam, t, u, k, e): pulls = _pl(self.p) return print_sheet(cam, pulls, 1.0, e) if pulls else paper_base(cam) class Blocks(Eng): """The set of blocks laid out on the bench. row / fan / last.""" def setup(self): m = self.p.get("mode", "row"); self.m = m self.n = self.p.get("n", 6) self.a, self.b = { "row": (Cam(190, 128, 1180), Cam(190, 128, 1010)), "fan": (Cam(190, 128, 1080), Cam(120, 150, 760)), "last": (Cam(146, 66, 330), Cam(118, 76, 236)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=0.8, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) if self.m == "last": # one block, alone, inked bi = self.p.get("bi", 5) c2, bm = _blockmask(cam) img = lay_sheet(img, render_block(cam, bi, inked=self.p.get("inked", 0.95)), bm, cam, shadow=1.0) return cut_kento(img, cam) cols = 3 for i in range(self.n): cx = (i % cols)*(SHEET_W+40) - (SHEET_W+40) cy = (i//cols)*(SHEET_H+34) - (SHEET_H+34)*0.5 c2 = Cam(cam.cx-cx, cam.cy-cy, cam.wmm, cam.rot) blk = render_block(c2, i, inked=self.p.get("inked", 0.85), sweep=1.0) bi_ = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi_).polygon( c2.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi_.resize((W, H), Image.BOX), np.float32)/255.0 if bm.max() < 0.004: continue img = lay_sheet(img, blk, bm, cam, shadow=1.0) return img class Stack(Eng): """The pile of proofs, or the reject going onto it. pile / fan / aside.""" def setup(self): m = self.p.get("mode", "pile"); self.m = m self.a, self.b = { "pile": (Cam(190, 128, 600), Cam(214, 142, 490)), "fan": (Cam(210, 150, 700), Cam(150, 120, 550)), "aside": (Cam(230, 138, 780), Cam(346, 150, 720)), }[m] self.n = self.p.get("n", 5) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.0, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) R = rs(SEED0+self.s.idx) for q in range(self.n): done = min(len(BLOCKS), 1 + (q*5)//max(1, self.n-1)) if self.n > 1 else 6 ox = R.uniform(-9, 9) + (q*26 if self.m == "fan" else 0) oy = R.uniform(-7, 7) + (q*13 if self.m == "fan" else 0) rot = R.uniform(-2.4, 2.4) + (q*1.4 if self.m == "fan" else 0) c2 = Cam(cam.cx-ox, cam.cy-oy, cam.wmm, cam.rot) sheet = print_sheet(c2, pulls_for(done, sheet=q), 1.0, e) sm = sheet_mask(c2, 0, 0, rot) img = lay_sheet(img, sheet, sm, c2, shadow=0.8) if self.m == "aside": ox = lerp(0, 340, ease(u)); oy = lerp(0, 40, ease(u)) c2 = Cam(cam.cx-ox, cam.cy-oy, cam.wmm, cam.rot) bad = print_sheet(c2, pulls_for(6, 0, BAD), 1.0, e) sm = sheet_mask(c2, 0, 0, lerp(0, 9, ease(u))) img = lay_sheet(img, bad, sm, c2, shadow=1.4) return img class Slip(Eng): """The moment the sheet is not in the kento. seat / gap / land.""" def setup(self): m = self.p.get("mode", "seat"); self.m = m self.a, self.b = { "seat": (Cam(366, 242, 150), Cam(378, 256, 74)), "gap": (Cam(378, 254, 78), Cam(383, 259, 40)), "land": (Cam(190, 128, 440), Cam(150, 104, 260)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.4, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) blk = render_block(cam, BAD, inked=1.0, sweep=1.0) bi_ = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi_).polygon( cam.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi_.resize((W, H), Image.BOX), np.float32)/255.0 img = lay_sheet(img, blk, bm, cam, shadow=1.0) img = cut_kento(img, cam) if self.m == "land": pulls = pulls_for(6, 0, BAD) sheet = print_sheet(cam, pulls, 1.0, e) img = lay_sheet(img, sheet, sheet_mask(cam), cam, shadow=1.0) else: sm = sheet_mask(cam, -BAD_OFF[0], -BAD_OFF[1]) paper = paper_base(cam) img = lay_sheet(img, paper, sm, cam, shadow=1.1) return img class Reject(Eng): """The ruined sheet. hold / lift.""" def setup(self): m = self.p.get("mode", "hold"); self.m = m self.a, self.b = { "hold": (Cam(190, 128, 448), Cam(130, 96, 230)), "lift": (Cam(190, 128, 420), Cam(230, 150, 330)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.3, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) sheet = print_sheet(cam, pulls_for(6, 0, BAD), 1.0, e) rot = 0.0 if self.m == "hold" else lerp(0, 7, ease(u)) sm = sheet_mask(cam, 0, 0, rot) c2 = cam img = lay_sheet(img, sheet, sm, c2, shadow=1.0+1.4*ease(u)) return img BLOCK_PILE = (-470.0, 26.0) # where the cut blocks live on the bench PAPER_PILE = (452.0, 26.0) # the damp sheets under their board REJECT_PIN = (86.0, -352.0) # on the wall, where he pins the bad one def _blockmask(cam, cx=0.0, cy=0.0): c2 = Cam(cam.cx-cx, cam.cy-cy, cam.wmm, cam.rot) im = Image.new("L", (SW, SH), 0) ImageDraw.Draw(im).polygon( c2.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) return c2, np.asarray(im.resize((W, H), Image.BOX), np.float32)/255.0 def draw_pinned(img, cam, e, cx=None, cy=None, rot=-1.8): """The misregistered sheet, pinned to the wall.""" px, py = REJECT_PIN if cx is None else (cx, cy) c4 = Cam(cam.cx-px, cam.cy-py, cam.wmm, cam.rot) sm = sheet_mask(c4, 0, 0, rot) if sm.max() < 0.004: return img bad = print_sheet(c4, pulls_for(6, 0, BAD), 1.0, e) img = lay_sheet(img, bad, sm, c4, shadow=1.7) ov, d = _ov() for (ax, ay) in ((18, 9), (SHEET_W-16, 6)): r = 3.4 d.ellipse(_bb(c4.box(ax-r, ay-r, ax+r, ay+r)), fill=(216, 202, 170, 255)) d.ellipse(_bb(c4.box(ax-r*.42, ay-r*.42, ax+r*.42, ay+r*.42)), fill=(112, 96, 68, 255)) return comp_ov(img, ov) class Bench(Eng): """The whole workshop. wide / final.""" def setup(self): m = self.p.get("mode", "wide"); self.m = m self.a, self.b = { "wide": (Cam(170, 70, 1020), Cam(196, 100, 860)), "final": (Cam(196, 16, 1040), Cam(180, -46, 760)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.0, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) # the six cut blocks, piled at the left of the bench for i in range(6): cx = BLOCK_PILE[0] + i*9.0 cy = BLOCK_PILE[1] - i*11.0 c2, bm = _blockmask(cam, cx, cy) if bm.max() < 0.004: continue img = lay_sheet(img, render_block(c2, i, inked=0.7 if i < 5 else 0.9), bm, cam, shadow=1.0) # the damp paper, under its board for q in range(7): c3 = Cam(cam.cx-PAPER_PILE[0]-q*2.0, cam.cy-PAPER_PILE[1]+q*3.0, cam.wmm, cam.rot) sm = sheet_mask(c3, 0, 0, (q-3)*0.7) if sm.max() < 0.004: continue img = lay_sheet(img, paper_base(c3)*0.99, sm, c3, shadow=0.7) # the sheet in work done = self.p.get("done", 0) sheet = print_sheet(cam, _pl(self.p), 1.0, e) if done else paper_base(cam) img = lay_sheet(img, sheet, sheet_mask(cam), cam, shadow=1.0) if self.p.get("wall"): img = draw_pinned(img, cam, e) ov, d = _ov() hand(d, cam, 118, 372, 108, ang=-16) hand(d, cam, 306, 384, 108, ang=14, flip=True) return comp_ov(img, ov) class Quick(Eng): """One whole pull inside a single shot — ink, rub, peel. The pulls come faster, so the film cuts less and compresses more.""" def setup(self): self.bi = self.p.get("bi", 1) self.done = self.p.get("done", 1) w = self.p.get("w", 400) self.a = Cam(190, 128, w*1.06); self.b = Cam(190, 128, w*0.90) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.5, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = bench_bg(cam) bi_ = Image.new("L", (SW, SH), 0); ImageDraw.Draw(bi_).polygon( cam.pl([(BLK[0], BLK[1]), (BLK[2], BLK[1]), (BLK[2], BLK[3]), (BLK[0], BLK[3])]), fill=255) bm = np.asarray(bi_.resize((W, H), Image.BOX), np.float32)/255.0 ov, d = _ov() if u < 0.34: # ink sw = np.clip(u/0.32, 0, 1) blk = render_block(cam, self.bi, inked=1.0, sweep=sw) img = lay_sheet(img, blk, bm, cam, shadow=1.0) img = cut_kento(img, cam) wet = tuple(float(c) for c in WET_RGB[BLOCKS[self.bi][3]]) qbx = lerp(-40, 420, sw); qby = 128+math.sin(t*11)*16 hand(d, cam, qbx+6, qby-124, 88.0, ang=0) brush(d, cam, qbx, qby, 6, wmm=96.0, wet=wet, t=t) elif u < 0.64: # rub prog = np.clip((u-0.34)/0.30, 0.05, 1.0) pulls = pulls_for(self.done, self.p.get("sheet", 1)) + \ [Pull(self.bi, reg_off(self.p.get("sheet", 1), self.bi), prog=prog)] back = back_of_sheet(cam, pulls, prog, e) img = lay_sheet(img, back, sheet_mask(cam), cam, shadow=0.8) bx, by, br = arc_near(cam, prog) hand(d, cam, bx+6, by+30, 86.0, ang=8) baren(d, cam, bx, by, 56.0, t) else: # peel lift = ease((u-0.64)/0.36) pulls = pulls_for(self.done+1, self.p.get("sheet", 1)) sheet = print_sheet(cam, pulls, 1.0, e) blk = render_block(cam, self.bi, inked=0.30) base = cut_kento(lay_sheet(img, blk, bm, cam, shadow=1.0), cam) img = peel_warp(sheet, base, sheet_mask(cam), lift, cam) return comp_ov(img, ov) class Wave(Eng): """The finished print. reveal / push / hold / crest / boats.""" def setup(self): m = self.p.get("mode", "hold"); self.m = m self.a, self.b = { "reveal": (Cam(120, 60, 90), Cam(190, 128, 452)), "push": (Cam(190, 128, 440), Cam(126, 66, 190)), "hold": (Cam(190, 128, 470), Cam(190, 128, 420)), "crest": (Cam(150, 50, 210), Cam(104, 62, 120)), "boats": (Cam(110, 200, 190), Cam(160, 196, 130)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=0.8, seed=self.s.idx) def draw(self, cam, t, u, k, e): pulls = pulls_for(6, self.p.get("sheet", 1)) sheet = print_sheet(cam, pulls, 1.0, e) img = lay_sheet(bench_bg(cam), sheet, sheet_mask(cam), cam, shadow=1.0) return img class Wall(Eng): """The reject, pinned up behind him. pinned / both.""" def setup(self): m = self.p.get("mode", "pinned"); self.m = m rx, ry = REJECT_PIN self.a, self.b = { "pinned": (Cam(rx+SHEET_W/2, ry+SHEET_H/2, 540), Cam(rx+126, ry+92, 286)), "both": (Cam(rx+SHEET_W/2, ry+SHEET_H/2+250, 980), Cam(rx+SHEET_W/2, ry+SHEET_H/2+180, 800)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=0.9, seed=self.s.idx) def draw(self, cam, t, u, k, e): img = draw_pinned(bench_bg(cam), cam, e) if self.m == "both": good = print_sheet(cam, pulls_for(6, 1), 1.0, e) sm2 = sheet_mask(cam, 0, 0, 1.8) if sm2.max() > 0.004: img = lay_sheet(img, good, sm2, cam, shadow=1.9) return img ENGINES = dict(plank=Plank, ink=Ink, lay=Lay, rub=Rub, peel=Peel, sheet=Sheet, macro=Macro, blocks=Blocks, stack=Stack, slip=Slip, reject=Reject, bench=Bench, quick=Quick, wave=Wave, wall=Wall) # ═══════════════════════════════════════════════════════════════════════════ # THE SCORE — beats, checked against the bars of each section # ═══════════════════════════════════════════════════════════════════════════ S0 = dict(sheet=0) S1 = dict(sheet=1) def D(done, **kw): d = dict(done=done); d.update(kw); return d SCORE = [ # ── carve · 16 beats · clave alone ───────────────────────────────────── ("carve", "plank", 5, dict(mode="raw")), ("carve", "plank", 4, dict(mode="carve")), ("carve", "plank", 4, dict(mode="detail")), ("carve", "plank", 3, dict(mode="kento")), # ── p1 · KEY BLOCK · sumi · + congas + bass ──────────────────────────── ("p1", "ink", 4, dict(mode="load", bi=0)), ("p1", "lay", 3, dict(mode="register", bi=0)), ("p1", "rub", 4, D(0, mode="spiral", bi=0, sheet=0, p0=.05, p1=1.0)), ("p1", "peel", 3, D(0, mode="off", bi=0, sheet=0)), ("p1", "sheet", 2, D(1, mode="flat", sheet=0)), # ── p2 · GREY · + timbales ───────────────────────────────────────────── ("p2", "blocks", 3, dict(mode="row", n=6, inked=0.0)), ("p2", "ink", 3, dict(mode="sweep", bi=1)), ("p2", "rub", 3, D(1, mode="start", bi=1, sheet=0, p0=.05, p1=.92)), ("p2", "peel", 3, D(1, mode="off", bi=1, sheet=0)), ("p2", "sheet", 2, D(2, mode="tilt", sheet=0)), ("p2", "macro", 2, D(2, mode="grain", sheet=0)), # ── p3 · YELLOW · + piano montuno ────────────────────────────────────── ("p3", "ink", 3, dict(mode="sweep", bi=2)), ("p3", "lay", 3, dict(mode="drop", bi=2)), ("p3", "rub", 3, D(2, mode="spiral", bi=2, sheet=0, p0=.10, p1=1.0)), ("p3", "peel", 3, D(2, mode="off", bi=2, sheet=0)), ("p3", "sheet", 2, D(3, mode="flat", sheet=0)), ("p3", "macro", 2, D(3, mode="fibre", sheet=0)), # ── p4 · PINK · bokashi · + campana ──────────────────────────────────── ("p4", "ink", 4, dict(mode="bokashi", bi=3)), ("p4", "rub", 3, D(3, mode="edge", bi=3, sheet=0, p0=.05, p1=.95)), ("p4", "peel", 3, D(3, mode="off", bi=3, sheet=0)), ("p4", "sheet", 3, D(4, mode="tilt", sheet=0)), ("p4", "macro", 3, D(4, mode="bokashi", sheet=0)), # ── p5 · BLUE · + trombones ──────────────────────────────────────────── ("p5", "ink", 3, dict(mode="sweep", bi=4)), ("p5", "lay", 2, dict(mode="register", bi=4)), ("p5", "rub", 3, D(4, mode="hard", bi=4, sheet=0, p0=.06, p1=1.0)), ("p5", "peel", 3, D(4, mode="off", bi=4, sheet=0)), ("p5", "sheet", 3, D(5, mode="hold", sheet=0)), ("p5", "macro", 2, D(5, mode="crest", sheet=0)), # ── slip · PRUSSIAN goes down 4 mm out · the band falls over ─────────── ("slip", "ink", 3, dict(mode="bokashi", bi=5)), ("slip", "slip", 3, dict(mode="seat")), ("slip", "rub", 2, D(5, mode="spiral", bi=5, sheet=0, bad=True, p0=.20, p1=1.0)), ("slip", "peel", 3, D(5, mode="off", bi=5, sheet=0, bad=True)), ("slip", "sheet", 3, D(6, mode="bad", sheet=0, bad=True)), ("slip", "reject", 2, dict(mode="lift")), # ── fresh · he does not stop ─────────────────────────────────────────── ("fresh", "stack", 4, dict(mode="aside", n=3)), ("fresh", "bench", 3, dict(mode="wide", done=0, sheet=1)), ("fresh", "ink", 3, dict(mode="close", bi=0)), ("fresh", "rub", 3, D(0, mode="spiral", bi=0, sheet=1, p0=.05, p1=1.0)), ("fresh", "peel", 3, D(0, mode="off", bi=0, sheet=1)), # ── fast · the pulls come faster · 24 beats ──────────────────────────── ("fast", "quick", 3, dict(bi=1, done=1, sheet=1, w=430)), ("fast", "quick", 3, dict(bi=2, done=2, sheet=1, w=400)), ("fast", "macro", 3, D(3, mode="wet", sheet=1)), ("fast", "quick", 3, dict(bi=3, done=3, sheet=1, w=380)), ("fast", "quick", 3, dict(bi=4, done=4, sheet=1, w=360)), ("fast", "sheet", 3, D(5, mode="hold", sheet=1)), ("fast", "blocks",3, dict(mode="last", bi=5, inked=0.95)), ("fast", "stack", 3, dict(mode="fan", n=4)), # ── mambo · the last pull · trumpets · 28 beats ──────────────────────── ("mambo", "ink", 4, dict(mode="bokashi", bi=5)), ("mambo", "lay", 3, dict(mode="register", bi=5)), ("mambo", "rub", 4, D(5, mode="spiral", bi=5, sheet=1, p0=.05, p1=.78)), ("mambo", "rub", 3, D(5, mode="edge", bi=5, sheet=1, p0=.78, p1=1.0)), ("mambo", "peel", 3, D(5, mode="corner", bi=5, sheet=1, u0=.02, u1=.34)), ("mambo", "peel", 4, D(5, mode="off", bi=5, sheet=1, u0=.30, u1=.98)), ("mambo", "wave", 4, dict(mode="reveal", sheet=1)), ("mambo", "wave", 3, dict(mode="crest", sheet=1)), # ── coda · 12 beats ──────────────────────────────────────────────────── ("coda", "wave", 4, dict(mode="hold", sheet=1)), ("coda", "wall", 4, dict(mode="pinned")), ("coda", "bench", 4, dict(mode="final", done=6, sheet=1, wall=True)), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "params") def __init__(self, idx, i0, i1, engine, section, params): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.section, self.params = engine, section, params self.seed = SEED0 + idx*7919 def build_shots(): tally = {} for (sec, _, beats, _p) in SCORE: tally[sec] = tally.get(sec, 0) + beats for nm, b0, b1 in SECTIONS: want = (b1-b0)*4 if tally.get(nm, 0) != want: raise SystemExit(f"score error: section {nm} has {tally.get(nm,0)} " f"beats, wants {want}") shots = []; beat = 0.0 for idx, (sec, eng, beats, params) in enumerate(SCORE): i0 = int(round(beat*BEAT*FPS)); beat += beats i1 = int(round(beat*BEAT*FPS)) shots.append(Shot(idx, i0, i1, eng, sec, params)) shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ═══════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> [crisp text] -> letterbox # ═══════════════════════════════════════════════════════════════════════════ CARD = {} for _i, (_k, _rom, _en, _pig, _g, _ins) in enumerate(BLOCKS): CARD[_i] = (_rom + " · " + _en, "+ " + _ins) # (bar, bars, line1, line2, style) CAPTIONS = [ (0.4, 2.2, "YAMAZAKURA", "cherry plank, planed", "small"), (2.6, 1.2, "KENTO", "the registration notch", "small"), (4.0, 2.6, CARD[0][0], CARD[0][1], "pull"), (8.0, 2.6, CARD[1][0], CARD[1][1], "pull"), (12.0, 2.6, CARD[2][0], CARD[2][1], "pull"), (16.0, 2.6, CARD[3][0], CARD[3][1], "pull"), (18.6, 1.2, "BOKASHI", "the gradient is wiped by hand", "small"), (20.0, 2.6, CARD[4][0], CARD[4][1], "pull"), (24.0, 1.8, CARD[5][0], CARD[5][1], "pull"), (25.6, 2.0, "OUT OF REGISTER", "6.6 mm", "bad"), (27.6, 2.2, "he does not stop", "", "small"), (30.0, 1.8, "A FRESH SHEET", "", "pull"), (32.0, 1.6, "FASTER", "", "big"), (38.0, 2.0, "THE LAST PULL", CARD[5][1], "pull"), (41.0, 1.6, "IN REGISTER", "", "big"), (43.0, 2.0, "KANAGAWA OKI NAMI URA", "under the wave off Kanagawa", "title"), (46.4, 1.8, "and the reject is beautiful too", "", "small"), ] _VIG = None def vignette(): global _VIG if _VIG is None: nx = (_XX-W/2)/(W/2); ny = (_YY-H/2)/(H/2) r = np.sqrt(nx*nx+ny*ny)/1.42 _VIG = np.clip(1.0-0.30*r**2.1, 0, 1)[..., None].astype(np.float32) return _VIG def caption_at(t): for (b0, db, l1, l2, style) in CAPTIONS: t0 = b0*BAR; t1 = t0+db*BAR if t0 <= t < t1: a = min(1.0, (t-t0)/0.16)*min(1.0, (t1-t)/0.22) return l1, l2, style, a return None, None, None, 0.0 TITLE_T = 2.6 # seconds the opening title slab is up INK_K = (30, 26, 24) INK_R = (176, 44, 44) CREAM = (247, 241, 224) def post(arr01, i, e, shot): a = np.clip(arr01, 0, 1)*255.0 lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.float32([10, 4, -8]) # tint: warm workshop lamp a *= vignette() # vignette rng = rs(9000+i) # grain if S == 1.0: a += rng.normal(0, 2.2, a.shape) else: # grain is a look, not a resolution: drawn at 1280x720 and blown up gn = rng.normal(0, 2.2, (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 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) t = i/FPS # ── THE TITLE MOMENT ─────────────────────────────────────────────────── # Struck as one more block in the caption system's own language: an ink # slab with the vermilion edge bar, up out of the way of the shop notes # that run along the bottom of the frame. if t < TITLE_T: tal = min(1.0, t/0.30)*min(1.0, (TITLE_T-t)/0.55) def tb(c, bg): return tuple(int(cc*tal+bg*(1-tal)) for cc in c) ft = fit_text(TITLE, W*0.44, "Georgia Bold.ttf", start=PX(64)) fu = fit_text(SUBT, W*0.30, "Menlo.ttc", start=PX(19)) tw = max(ft.getlength(TITLE), fu.getlength(SUBT)) x0, y0 = W*0.06, H*0.135 hh = ft.size*1.20 + fu.size*1.85 d.rectangle([x0-PF(18), y0-PF(14), x0+tw+PF(24), y0+hh], fill=tb(INK_K, 246)) d.rectangle([x0-PF(18), y0-PF(14), x0-PF(18)+max(6, int(W*0.006)), y0+hh], fill=tb(INK_R, 246)) d.text((x0, y0), TITLE, font=ft, fill=tb(CREAM, 246)) d.text((x0, y0+ft.size*1.30), SUBT, font=fu, fill=tb((214, 152, 128), 246)) l1, l2, style, al = caption_at(t) if l1 and al > 0.01: def blend(c, bg): return tuple(int(cc*al+bg*(1-al)) for cc in c) if style == "title": f = fit_text(l1, W*0.66, "Georgia.ttf", start=PX(76)) g = fit_text(l2, W*0.50, "Georgia Italic.ttf", start=PX(30)) tw = f.getlength(l1); th = f.size x0, y0 = W*0.06, H*0.655 d.rectangle([x0-PF(16), y0-PF(12), x0+tw+PF(20), y0+th*1.62], fill=blend(INK_K, 246)) d.rectangle([x0-PF(16), y0-PF(12), x0-PF(16)+max(6, int(W*0.006)), y0+th*1.62], fill=blend(INK_R, 246)) d.text((x0, y0), l1, font=f, fill=blend(CREAM, 246)) if l2: d.text((x0+PF(2), y0+th*1.10), l2, font=g, fill=blend((210, 198, 172), 246)) elif style == "big": f = fit_text(l1, W*0.46, "Impact.ttf", start=PX(104)) tw = f.getlength(l1); th = f.size x0, y0 = W*0.06, H*0.68 d.rectangle([x0-PF(14), y0-PF(8), x0+tw+PF(18), y0+th*1.22], fill=blend(INK_R, 246)) d.text((x0, y0), l1, font=f, fill=blend(CREAM, 246)) elif style == "bad": f = fit_text(l1, W*0.50, "Impact.ttf", start=PX(78)) g = fit_text(l2, W*0.24, "Menlo.ttc", start=PX(36)) tw = max(f.getlength(l1), g.getlength(l2)); th = f.size x0, y0 = W*0.06, H*0.645 d.rectangle([x0-PF(14), y0-PF(8), x0+tw+PF(18), y0+th*1.24+g.size*1.5], fill=blend((28, 26, 26), 246)) d.text((x0, y0), l1, font=f, fill=blend((236, 92, 82), 246)) d.text((x0, y0+th*1.30), l2, font=g, fill=blend((242, 214, 208), 246)) elif style == "pull": f = fit_text(l1, W*0.42, "Georgia Bold.ttf", start=PX(46)) g = fit_text(l2, W*0.34, "Menlo.ttc", start=PX(28)) if l2 else None tw = max(f.getlength(l1), g.getlength(l2) if g else 0) x0, y0 = W*0.06, H*0.705 hh = f.size*1.16 + (g.size*1.5 if g else 0) d.rectangle([x0-PF(14), y0-PF(10), x0+tw+PF(20), y0+hh], fill=blend((250, 245, 230), 246)) d.rectangle([x0-PF(14), y0-PF(10), x0-PF(14)+max(5, int(W*0.005)), y0+hh], fill=blend(INK_R, 246)) d.text((x0, y0), l1, font=f, fill=blend(INK_K, 246)) if g: d.text((x0, y0+f.size*1.24), l2, font=g, fill=blend((132, 66, 52), 246)) else: f = fit_text(l1, W*0.36, "Georgia Italic.ttf", start=PX(34)) g = fit_text(l2, W*0.32, "Menlo.ttc", start=PX(20)) if l2 else None tw = max(f.getlength(l1), g.getlength(l2) if g else 0) x0, y0 = W*0.06, H*0.755 hh = f.size*1.26 + (g.size*1.55 if g else 0) d.rectangle([x0-PF(10), y0-PF(7), x0+tw+PF(14), y0+hh], fill=blend((246, 240, 224), 246)) d.text((x0, y0), l1, font=f, fill=blend((62, 52, 46), 246)) if g: d.text((x0, y0+f.size*1.34), l2, font=g, fill=blend((116, 98, 82), 246)) bh = int(H*0.045) # letterbox d.rectangle([0, 0, W, bh], fill=(16, 14, 13)) d.rectangle([0, H-bh, W, H], fill=(16, 14, 13)) return out # ═══════════════════════════════════════════════════════════════════════════ # RENDER # ═══════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() eng = ENGINES[shot.engine](shot, np.random.default_rng(shot.seed)) made = 0 for k in range(shot.n): i = shot.i0 + k p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} u = k/max(1, shot.n-1) post(eng.frame(k, u, e), i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:7s} {shot.section:6s} {made}/{shot.n}" def contact_sheet(shots, only=None): import time cols = 7; rows = (len(shots)+cols-1)//cols tw, th = PX(320), PX(180) # 16:9 thumbs sheet = Image.new("RGB", (cols*tw, rows*(th+PX(26))), (12, 12, 16)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): cx, cy = (n % cols)*tw, (n//cols)*(th+PX(26)) if only is not None and sh.idx not in only: continue t0 = time.time() try: eng = ENGINES[sh.engine](sh, np.random.default_rng(sh.seed)) mid = int(sh.n*0.55) i = sh.i0+mid e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} im = post(eng.frame(mid, mid/max(1, sh.n-1), e), i, e, sh) note = (f"{sh.idx:02d} {sh.engine} {sh.params.get('mode','')} " f"{sh.section} {sh.i0/FPS:.1f}s {(time.time()-t0)*1000:.0f}ms") except Exception as ex: import traceback; traceback.print_exc() im = Image.new("RGB", (W, H), (120, 0, 0)) note = f"{sh.idx:02d} {sh.engine} FAIL {type(ex).__name__}" print(note, flush=True) sheet.paste(im.resize((tw, th), Image.LANCZOS), (cx, cy)) sd.text((cx+PX(5), cy+th+PX(5)), note, font=font(PX(12), "Menlo.ttc"), fill=(198, 202, 212)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(12, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists(): print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") wav, mix = build_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() want = None if a.shots.strip(): want = set(int(x) for x in a.shots.split(",") if x.strip()) if a.sheet: contact_sheet(shots, want); return if not a.mux_only: jobs = [(s, a.force) for s in shots if want is None or s.idx in want] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames " f"on {a.jobs} workers") import multiprocessing as mp with mp.get_context("fork").Pool(a.jobs) as pool: for r in pool.imap_unordered(render_shot, jobs): print(" ", r, flush=True) if want is not None: print("partial render — rerun with --mux-only to reassemble"); return missing = [i for i in range(N_FRAMES) if not (FRAMES/f"f{i:05d}.png").exists()] if missing: raise SystemExit(f"{len(missing)} frames missing, first={missing[0]}") print("[3/3] mux…") try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" stamp = (f"generator=renders/{SETDIR}/{NAME}/render.py git={sha} " f"branch={br} at={datetime.datetime.now().astimezone().isoformat()} " f"music={MUSIC_DESC}") 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", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SUBT} — {TITLE}", "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", 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: S={S} — native re-rasterisation, WREF={WREF}, block raster {SW}x{SH}\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"shots: {len(shots)} engines: {ENGINE_DESC}\n" f"press: relief woodblock — carved height masks, maru-bake ink field,\n" f" baren pressure field ({len(_ARCS)} rub strokes, {NB} buckets),\n" f" damp-fibre bleed, Beer-Lambert ink stacking, per-block\n" f" registration offsets (misregistration = {BAD_OFF} mm on " f"block {BAD})\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()