#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Quilt (11/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/quilt # # A quilt pieced from the clothes of the dead, one block per person. # # 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/quilt.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/quilt.mp4 # cover: https://genekogan.com/player_computer/media/quilt.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 quilt.py (writes frames/, audio/, and the final mp4 # next to the script; writes ~8 GB of frames, # takes 5-15 min on a modern machine) # ═════════════════════════════════════════════════════════════════════════════ """ night_watch 19 — "QUILT" Old-time / bluegrass, 132 bpm, G major. 36 bars. Intro(2) V1(4) V2(4) Chorus(4) V3(4) V4(4) Break(4) Chorus2(4) Landing(6) Round 2 (player_computer_2): the woman is on screen now. Round 1 had her hands and nothing else; this cut gives her a chair, a lamp, a shawl and a face, and she bookends the film — she starts it by lifting the first scrap to the light and ends it by carrying the finished quilt to the bed and laying it over the child. In between she is cut into every section, always stitching on the beat: the needle rises and falls on the same `stitch_phase` the sound uses, so her hands and the banjo are the same clock. The quilt on her lap is the real quilt — the cloth engine renders it and it is masked into her lap, so it grows across the film exactly as the block shots say. Delivered at 1280x720 (16:9) for this set: the quilt camera works in quilt units scaled off W, so the wider frame simply shows more cloth. Nothing is stretched and the letterbox bars are gone. A woman pieces a quilt out of dead relatives' clothes. Each block is a person, cut from the actual garment — her father's chambray work shirt, her sister Ada's calico Sunday dress, her son's olive uniform, her mother's gingham apron — and as each block goes in, the block resolves for a moment into a pieced-fabric portrait of who they were, then settles back into geometry. Twelve blocks. Then she quilts it, and throws it over a sleeping child, and the child is warm. Look: PIECED FABRIC. A cloth engine with a real warp/weft interlace (visible at macro), band-dyed warps and wefts so gingham/plaid/ticking emerge from the weaving rather than being painted on, per-thread slub and irregularity, sun-fade and wear, seam allowances with running stitches, and a batting height field that puffs between the quilting lines so the quilted areas read as relief. Composition: engine : audio-first x shot-parallel (tier 4-P) content: audio-groove (clawhammer banjo, shuffle-bow fiddle, boom-chuck guitar, upright bass) x tts-voices (mountain harmony via a one-modulator vocoder stack) x effects-post Run from repo root: python3 renders/player_computer_2/quilt/render.py --sheet python3 renders/player_computer_2/quilt/render.py """ 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 = "quilt" TITLE = "QUILT" SETDIR = "player_computer_final" SETNUM = "19" # Final cut: native 1080p. Most of this film is already resolution-free — the # quilt camera works in quilt units (Cam.ppu = W/span) and the cloth engine is # a numpy field over W x H — so raising W and H scales it on its own. SC exists # for the parts that were authored in literal 720p screen pixels: the parlour, # the bedroom, the woman herself (one `sc` multiplier reaches all of her), the # two warp amplitudes in the drape shots, and the post chain. W, H, FPS = 1920, 1080, 30 SC = H / 720.0 # 1.5 def sci(v): return int(round(v*SC)) def scf(v): return v*SC BPM = 132.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 OUT = Path(__file__).resolve().parent FRAMES = OUT / "frames"; FRAMES.mkdir(exist_ok=True) AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = Path(__file__).resolve().parent # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" SECTIONS = [ ("intro", 0, 2), ("v1", 2, 6), ("v2", 6, 10), ("chorus1", 10, 14), ("v3", 14, 18), ("v4", 18, 22), ("break", 22, 26), ("chorus2", 26, 30), ("landing", 30, 36), ] N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 2.7 N_FRAMES = int(DUR * FPS) MUSIC_DESC = (f"old-time / bluegrass, {BPM:.0f}bpm, G major, {N_BARS} bars, " "clawhammer banjo + shuffle-bow fiddle + boom-chuck guitar + " "upright bass + mountain harmony") ENGINE_DESC = ("granny / basket / garment / needle / piece / block / portrait / " "quilt / hands / quilting / throw / child / label (pieced fabric)") # ════════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0 * 2.0 ** ((m - 69) / 12.0) _PC = {"C":0,"C#":1,"Db":1,"D":2,"D#":3,"Eb":3,"E":4,"F":5,"F#":6,"Gb":6, "G":7,"G#":8,"Ab":8,"A":9,"A#":10,"Bb":10,"B":11} def nf(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return mtof(12 * (int(name[i:]) + 1) + _PC[name[:i]]) # G major, for diatonic harmony GMAJ = ["G", "A", "B", "C", "D", "E", "F#"] def deg_shift(name, degrees): """Move a note name by N scale degrees inside G major.""" i = 2 if (len(name) > 2 and name[1] in "#b") else 1 pit, octv = name[:i], int(name[i:]) if pit not in GMAJ: # chromatic passing tone: semitones return mtof(12*(octv+1) + _PC[pit] + degrees*2) k = GMAJ.index(pit) + degrees o = octv + (0 if k >= 0 else -1) + (k // 7 if k >= 0 else (k+1)//7) o = octv + (k // 7) return nf(f"{GMAJ[k % 7]}{o}") 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 goes through this so nothing in the kit 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 resonate(x, modes): """Add resonant body modes (freq, q, gain) in the FFT domain. Used for the banjo head, the fiddle box and the bass body — a plain lowpass makes any plucked string sound like a synth; the box is what makes it an instrument.""" n = len(x) if n < 32: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1/SR), 1e-6) g = np.ones_like(fq) for f0, q, gn in modes: g += gn*np.exp(-((np.log(fq/f0))**2)/(2*(1.0/q)**2)) return np.fft.irfft(X*g, n) def voice(freq, dur, kind="saw", nh=24, c0=4200, c1=800, ck=7.0, res=0.0, detune=(0.0,), a=.005, d=.09, s=.7, r=.10, vib=(0.0, 0.0), seed=0): """Additive voice through a *moving* emulated filter.""" n = int(dur*SR) if n <= 0: return np.zeros(0) t = np.arange(n)/SR co = c1 + (c0-c1)*np.exp(-t*ck) rng = np.random.RandomState(seed) out = np.zeros(n); vd, vr = vib for det in detune: f0 = freq*(1 + det*0.006) for k in range(1, nh+1): if kind == "saw": base = 1.0/k elif kind == "square": base = (1.0/k) if k % 2 else 0.0 elif kind == "tri": base = (1.0/(k*k)) if k % 2 else 0.0 elif kind == "sine": base = 1.0 if k == 1 else 0.0 else: base = 1.0/k if base == 0.0: continue fk = f0*k if fk > SR*0.45: break g = base/np.sqrt(1.0 + (fk/co)**4) if res: g = g + res*base*np.exp(-((fk-co)/(0.3*co+1))**2) ph = rng.uniform(0, 2*np.pi) phase = 2*np.pi*fk*t + ph if vd: phase = phase + vd*np.sin(2*np.pi*vr*t) out += g*np.sin(phase) return (out/len(detune))*adsr(n, a, d, s, r) def ks(freq, dur, damp=0.994, bright=1.0, seed=0, pluck=0.0): """Karplus-Strong, vectorised **per lap of the delay line**. The textbook loop runs one Python iteration per sample; here a whole lap of L samples is one numpy op, because in true KS every element of the buffer is filtered exactly once per lap. (The only inexactness is the wrap element, which reads the pre-lap value of buf[0] instead of the post-lap one — one sample in L, inaudible.) 40x faster, which is what makes a banjo playing 600 notes affordable. """ n = int(dur*SR) if n <= 0: return np.zeros(0) L = max(4, int(round(SR/max(20.0, freq)))) rng = np.random.RandomState(seed) buf = rng.uniform(-1, 1, L) if bright < 1.0: # a softer pick = a duller excitation k = max(1, int((1.0-bright)*L*0.35)) buf = np.convolve(buf, np.ones(k)/k, "same") if pluck > 0.0: # pick position notch m = int(L*pluck) buf = buf - np.roll(buf, m)*0.6 buf = buf/(np.max(np.abs(buf))+1e-9) laps = n//L + 2 out = np.empty(laps*L) for q in range(laps): out[q*L:(q+1)*L] = buf buf = damp*0.5*(buf + np.roll(buf, -1)) return out[:n] def reverb(x, rt=1.5, mix=.26, seed=29, pre=0.018): n = int(rt*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) ir = rng.randn(n)*np.exp(-t*(5.0/rt)) ir[:int(pre*SR)] = 0 ir /= np.abs(ir).sum()/40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x)+n))) wet = irfft(rfft(x, L)*rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet))+1e-9 return x*(1-mix) + wet*mix*(np.max(np.abs(x))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # THE STRING BAND — every instrument built for this piece # ════════════════════════════════════════════════════════════════════════════ BANJO_HEAD = ((286, 5.0, 2.4), (441, 6.0, 1.5), (712, 7.0, .9), (1580, 4.0, .6)) def banjo(freq, dur=0.42, brush=False, seed=0, gain=1.0): """A five-string banjo note. Steel string on a mylar head: a bright, fast-decaying KS string, the head's resonant modes, and the bridge tick that makes it *bark* on the attack.""" x = ks(freq, dur, damp=0.9895, bright=0.98, seed=seed, pluck=0.16) n = len(x) if n == 0: return x t = np.arange(n)/SR x = x*np.exp(-t*(4.2 if not brush else 8.0)) tick = bandshape(np.random.RandomState(seed+91).randn(n), lo=1800, hi=8000) x = x + tick*np.exp(-t*260)*0.20 x = resonate(x, BANJO_HEAD) x = bandshape(x, lo=110, hi=9500) return x/(np.max(np.abs(x))+1e-9)*gain def banjo_brush(chord, dur=0.26, seed=0, gain=1.0): """The 'dit' of bum-ditty: the back of the index nail dragged across the first three strings. Strings arrive 3-6 ms apart and die fast.""" n = int(dur*SR); out = np.zeros(n) for j, f in enumerate(chord): s = banjo(f, dur*0.9, brush=True, seed=seed+j*13) o = int((0.004 + j*0.0035)*SR) m = min(n, o+len(s)) if m > o: out[o:m] += s[:m-o]*(0.9 - 0.12*j) return out/(np.max(np.abs(out))+1e-9)*gain FIDDLE_BODY = ((278, 8.0, 3.2), (460, 7.0, 2.6), (830, 5.0, 1.4), (1290, 4.0, 1.1), (2400, 2.6, .8), (3400, 3.0, .5)) def fiddle(freq, dur, drone=None, bow=1.0, slur=False, seed=0): """Old-time fiddle: the melody note sounded *against an open drone string* on the same bow — the double stop is the whole sound of the genre. `bow` scales the attack scratch; `slur` removes the re-attack so two notes ride one bow stroke.""" a = 0.004 if not slur else 0.030 x = voice(freq, dur, kind="saw", nh=22, c0=3000, c1=1350, ck=3.2, detune=(0.0, -0.9, 1.0), vib=(0.016 if dur > 0.30 else 0.0, 5.6), a=a, d=0.10, s=0.86, r=0.06, seed=seed) if drone: dr = voice(drone, dur, kind="saw", nh=16, c0=2200, c1=950, ck=2.0, detune=(0.0, 0.8), vib=(0.008, 4.7), a=a*1.6, d=0.12, s=0.9, r=0.06, seed=seed+7) x = x + dr[:len(x)]*0.62 n = len(x) if n == 0: return x t = np.arange(n)/SR if not slur: scr = bandshape(np.random.RandomState(seed+55).randn(n), lo=900, hi=5200) x = x + scr*np.exp(-t*95)*0.16*bow x = resonate(x, FIDDLE_BODY) return x/(np.max(np.abs(x))+1e-9) def guitar_boom(freq, dur=0.44, seed=0): x = ks(freq, dur, damp=0.9965, bright=0.72, seed=seed, pluck=0.22) n = len(x); t = np.arange(n)/SR x = x*np.exp(-t*3.0) x = resonate(x, ((104, 6.0, 2.6), (198, 5.0, 1.6), (410, 4.0, .8))) return bandshape(x, lo=64, hi=5200)/(np.max(np.abs(x))+1e-9) def guitar_chuck(chord, dur=0.20, seed=0): """The 'chuck': an upstroke on the treble strings, palm-damped so it is mostly attack and almost no note.""" n = int(dur*SR); out = np.zeros(n) for j, f in enumerate(chord): s = ks(f, dur*0.95, damp=0.982, bright=0.55, seed=seed+j*7, pluck=0.3) o = int((0.0035 + j*0.0042)*SR) m = min(n, o+len(s)) if m > o: out[o:m] += s[:m-o]*(0.75 + 0.08*j) t = np.arange(n)/SR out = out*np.exp(-t*17.0) out = resonate(out, ((196, 5.0, 1.4), (620, 4.0, .8))) return bandshape(out, lo=150, hi=6200)/(np.max(np.abs(out))+1e-9) def upright(freq, dur=0.55, seed=0): """Pizzicato doghouse bass: gut string, big box, and the thump of a finger letting go.""" x = ks(freq, dur, damp=0.9988, bright=0.30, seed=seed, pluck=0.11) n = len(x); t = np.arange(n)/SR sub = np.sin(2*np.pi*freq*t)*np.exp(-t*3.4)*0.55 thump = bandshape(np.random.RandomState(seed+31).randn(n), lo=40, hi=260) x = x*np.exp(-t*4.6) + sub + thump*np.exp(-t*46)*0.5 x = resonate(x, ((62, 5.0, 3.0), (118, 5.0, 1.8), (230, 4.0, .7))) return bandshape(x, lo=32, hi=1800)/(np.max(np.abs(x))+1e-9) def foot(dur=0.16, seed=3): """A boot on a porch board.""" n = int(dur*SR); t = np.arange(n)/SR f = 58 + 130*np.exp(-t*70) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*24) wood = bandshape(np.random.RandomState(seed).randn(n), lo=140, hi=1500) return (body + wood*np.exp(-t*70)*0.7)*0.9 def thimble(dur=0.05, seed=5): """Thimble against the needle's eye — the stitch clock.""" n = int(dur*SR); t = np.arange(n)/SR x = (np.sin(2*np.pi*3120*t) + .6*np.sin(2*np.pi*4830*t) + .3*np.sin(2*np.pi*7100*t)) return x*np.exp(-t*150)*0.22 def scissors(dur=0.22, seed=8): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=2400, hi=9000) ring = np.sin(2*np.pi*2650*t)*np.exp(-t*40)*0.25 return nz*(np.exp(-t*34)*np.clip(t*180, 0, 1))*0.55 + ring def cloth_rustle(dur=0.5, seed=11): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=900, hi=5200) am = 0.4 + 0.6*np.abs(np.sin(2*np.pi*7.0*t + seed)) return nz*am*np.sin(np.pi*np.clip(t/dur, 0, 1))**0.7*0.5 def crickets(n, seed=13): """A porch, not a stadium. Narrow-band chirps at 4.2 kHz, pulsed — a *texture* under the band, never a hiss bed.""" rng = np.random.RandomState(seed) t = np.arange(n)/SR nz = bandshape(rng.randn(n), lo=3700, hi=4900) pulse = (np.sin(2*np.pi*11.0*t) > 0.55).astype(float) pulse = np.convolve(pulse, np.hanning(300)/150, "same") slow = 0.5 + 0.5*np.sin(2*np.pi*0.09*t + 1.2) return nz*pulse*slow*0.22 def chair_creak(dur=0.9, seed=17): n = int(dur*SR); t = np.arange(n)/SR f = 140 + 60*np.sin(2*np.pi*1.1*t) x = np.tanh(np.sin(2*np.pi*np.cumsum(f)/SR)*2.4) return bandshape(x, lo=110, hi=900)*np.sin(np.pi*np.clip(t/dur, 0, 1))*0.28 # ════════════════════════════════════════════════════════════════════════════ # SONG CANVAS # ════════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0, swing=0.0): sw = swing*(BEAT/4) if (step % 2) else 0.0 return bar*BAR + step*(BEAT/4) + sw def put(self, track, sig, at, g=1.0, pan=0.0): b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR); j = min(self.n, i+len(sig)) if i >= self.n or j <= i: return if i < 0: sig = sig[-i:]; i = 0; j = min(self.n, i+len(sig)) if j <= i: return th = (pan*.5+.5)*(np.pi/2) b[i:j] += np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1)*g def bus(self, track, fn): if track in self.tr: b = self.tr[track] self.tr[track] = np.stack([fn(b[:, 0]), fn(b[:, 1])], 1) def sec_env(self, levels, glide=0.30): env = np.ones(self.n) for nm, b0, b1 in SECTIONS: i0, i1 = int(b0*BAR*SR), min(self.n, int(b1*BAR*SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) env[int(SECTIONS[-1][2]*BAR*SR):] = levels.get("tail", 0.5) k = max(1, int(glide*SR)) return np.convolve(env, np.ones(k)/k, "same") def mixdown(self, gains, pump_depth=.10, pump_rel=.13, levels=None): mix = np.zeros((self.n, 2)) for k, b in self.tr.items(): mix += b*gains.get(k, 1.0) if levels: mix *= self.sec_env(levels)[:, None] if self.kick_t: env = np.ones(self.n); rl = int(pump_rel*SR) shape = 1 - pump_depth*np.exp(-np.arange(rl)/(pump_rel*SR/4)) for at in self.kick_t: i = int(at*SR); j = min(self.n, i+rl) if i < self.n: env[i:j] = np.minimum(env[i:j], shape[:j-i]) env = np.convolve(env, np.ones(280)/280, "same") mix *= env[:, None] a = math.exp(-2*math.pi*32.0/SR) # DC / rumble trim 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.22)/np.tanh(1.22) return mix/(np.max(np.abs(mix))+1e-9)*.94 def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1)*32767).astype(" macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, vc, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, vc, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def carrier(fps_arr, nh=28, detune=(0.0, -0.5, 0.55), vib=(0.0, 0.0)): n = len(fps_arr); t = np.arange(n)/SR; out = np.zeros(n) for d in detune: f = fps_arr*(1 + d*0.005) if vib[0]: f = f*(1 + vib[0]*np.sin(2*np.pi*vib[1]*t)) ph = 2*np.pi*np.cumsum(f)/SR for k in range(1, nh+1): live = (f*k) < SR*0.45 if not live.any(): break out += np.sin(ph*k)/k*live return out/len(detune) def vocode(mod, car, nfft=1024, hop=256, bands=26, lo=110, hi=6400, gmax=12.0, rel=0.55, sib=0.05, tilt=4000.0, warp=1.0): n = max(len(mod), len(car)) mod = np.pad(mod, (0, n-len(mod))); car = np.pad(car, (0, n-len(car))) win = np.hanning(nfft); nfr = 1 + max(0, (n-nfft))//hop fr = np.fft.rfftfreq(nfft, 1/SR) edges = np.geomspace(lo, hi, bands+1) idx = [np.where((fr >= edges[b]) & (fr < edges[b+1]))[0] for b in range(bands)] keep = np.zeros(len(fr), bool) for ii in idx: keep[ii] = True src = [int(np.clip(round(b/warp), 0, bands-1)) for b in range(bands)] out = np.zeros(n); wsum = np.zeros(n)+1e-9; prev = np.zeros(bands) for f in range(nfr): s = f*hop M = np.fft.rfft(mod[s:s+nfft]*win); C = np.fft.rfft(car[s:s+nfft]*win) am = np.abs(M); ac = np.abs(C) em = np.array([np.sqrt((am[ii]**2).mean()) if len(ii) else 0.0 for ii in idx]) g = np.zeros(len(fr)) for b, ii in enumerate(idx): if not len(ii): continue ec = np.sqrt((ac[ii]**2).mean()) gb = np.clip(em[src[b]]/(ec+1e-4), 0, gmax) gb = prev[b]*rel + gb*(1-rel); prev[b] = gb; g[ii] = gb out[s:s+nfft] += np.fft.irfft(C*g*keep)*win wsum[s:s+nfft] += win**2 ws = np.maximum(wsum, 0.35*np.median(wsum[nfft:max(nfft+1, n-nfft)])) y = out/ws; y[:hop] = 0.0; y[-hop:] = 0.0 y /= (np.max(np.abs(y))+1e-9) hp = np.zeros_like(mod); hp[1:] = mod[1:]-mod[:-1] for _ in range(2): hp = np.convolve(hp, [1, -0.93], "same") hp = np.clip(hp/(np.percentile(np.abs(hp), 99.5)+1e-9), -1, 1) a = math.exp(-2*math.pi*tilt/SR); z = 0.0; lp = np.empty_like(y) for i in range(len(y)): z = (1-a)*y[i] + a*z; lp[i] = z y = 0.55*y + 0.85*lp + sib*hp return y/(np.max(np.abs(y))+1e-9) def note_curve(notes, n, gliss=0.010): tot = sum(w for _, w in notes) or 1.0 f = np.zeros(n); at = 0 for i, (fq, w) in enumerate(notes): ln = int(n*w/tot) if i < len(notes)-1 else n-at f[at:at+ln] = fq; at += ln if gliss: k = max(3, int(gliss*SR)); f = np.convolve(f, np.ones(k)/k, "same") f[:k] = f[k]; f[-k:] = f[-k-1] return f # lead / tenor a third above / baritone a third below / low octave HARMONY = [ ( 0, .54, 0.00, 1.00, 0.000, (.013, 5.0), 28), ( 2, .27, 0.24, 1.07, 0.013, (.011, 5.6), 26), (-2, .23, -0.22, 0.92, 0.019, (.010, 4.4), 30), (-7, .13, 0.05, 0.85, 0.026, (.007, 4.0), 22), ] def sing_stack(song, track, text, mel, at, dur, parts, vc="Moira", rate=138, g=1.0): """`mel` = [(note_name, weight), …]. Harmony is computed diatonically.""" n = int(dur*SR) mod = fit(say_wav(text, vc, rate, AUD/("say_"+_h(text, vc, rate)+".wav")), n) for (deg, gain, pan, warp, late, vib, nh) in parts: if deg == -7: notes = [(nf(nm)/2, w) for nm, w in mel] else: notes = [(deg_shift(nm, deg), w) for nm, w in mel] car = carrier(note_curve(notes, n), nh=nh, vib=vib) y = vocode(mod, car, warp=warp) song.put(track, y, at+late, g=gain*g, pan=pan) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ SW = 0.10 # a light old-time lilt, not swing CHORDS = { "G": dict(root="G2", fifth="D3", banjo=("G3", "B3", "D4"), gtr=("G3", "B3", "D4", "G4")), "C": dict(root="C3", fifth="G2", banjo=("C4", "E4", "G4"), gtr=("C4", "E4", "G4", "C5")), "D": dict(root="D2", fifth="A2", banjo=("D4", "F#4", "A4"), gtr=("D4", "F#4", "A4", "D5")), "Em": dict(root="E2", fifth="B2", banjo=("E4", "G4", "B4"), gtr=("E4", "G4", "B4", "E5")), } PROG = (["G"]*2 + # intro ["G", "G", "C", "G"] + # v1 ["G", "G", "D", "G"] + # v2 ["C", "G", "D", "G"] + # chorus1 ["G", "Em", "C", "D"] + # v3 ["G", "G", "C", "G"] + # v4 ["C", "G", "D", "G"] + # break ["C", "G", "D", "G"] + # chorus2 ["G", "C", "G", "Em", "C", "G"]) # landing assert len(PROG) == N_BARS # the tune — one melody note per beat, four beats a bar, in G TUNE_A = ["B3","D4","G4","A4", "B4","A4","G4","E4", "D4","E4","G4","D4", "E4","D4","B3","G3"] TUNE_B = ["D5","B4","G4","A4", "B4","D5","B4","A4", "G4","E4","D4","E4", "G4","A4","G4","D4"] DRONE5 = "G4" # the banjo's 5th string, always VERSES = { 2: [("This blue was my father's shirt, he wore it thin at the seam,", [("D4",1),("D4",1),("E4",1),("G4",1.4),("G4",1),("E4",1),("D4",1.7)]), ("Twelve years of Tuesday mornings, and he never told a dream.", [("E4",1),("G4",1),("A4",1),("B4",1.3),("A4",1),("G4",1),("E4",1),("D4",1.9)])], 6: [("This calico was Ada's; she was married in the spring,", [("D4",1),("D4",1),("E4",1),("G4",1.4),("G4",1),("E4",1),("D4",1.7)]), ("She sang the alto over me on every song we'd sing.", [("E4",1),("G4",1),("A4",1),("B4",1.3),("A4",1),("G4",1),("E4",1),("D4",1.9)])], 14: [("This olive was my boy's own coat; they sent it home alone,", [("D4",1),("D4",1),("E4",1),("G4",1.4),("F#4",1),("E4",1),("D4",1.7)]), ("I cut around the pocket where he kept a river stone.", [("E4",1),("G4",1),("A4",1),("B4",1.3),("A4",1),("G4",1),("E4",1),("D4",1.9)])], 18: [("This gingham was my mother's, flour worn into the blue,", [("D4",1),("D4",1),("E4",1),("G4",1.4),("G4",1),("E4",1),("D4",1.7)]), ("Her hands are in the batting and her stitches hold it true.", [("E4",1),("G4",1),("A4",1),("B4",1.3),("A4",1),("G4",1),("E4",1),("D4",1.9)])], } CHORUS = [ ("Piece it, piece it, run the needle through,", [("B4",1.2),("B4",1),("A4",1),("G4",1.2),("A4",1),("B4",1.7)]), ("Everyone I lost, I'm keeping warm for you.", [("D5",1),("B4",1),("A4",1.2),("G4",1),("E4",1),("D4",1.2),("G4",2.1)]), ] LANDING = [ ("Sleep now, little one, the whole of them are here,", [("G4",1.2),("G4",1),("F#4",1),("E4",1.2),("D4",1),("E4",1.7)]), ("Everyone who's gone from me is lying on you, dear.", [("E4",1),("G4",1),("A4",1),("B4",1.2),("A4",1),("G4",1),("D4",1),("G4",2.3)]), ] FINAL_LINE = ("And she is warm.", [("D4",1),("E4",1),("G4",1.4),("G4",2.4)]) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "landing" def build_song(): s = Song(DUR) SUBS = [] stitches = [] # every visual stitch/patch event, in seconds for bar in range(N_BARS): sec = sec_of(bar) ch = CHORDS[PROG[bar]] quiet = sec in ("intro",) land = sec == "landing" lu = 0.0 if not land else (bar-30)/5.0 # the landing fades out full = sec in ("chorus1", "chorus2", "break", "v3", "v4") tune = TUNE_B if sec in ("chorus1", "chorus2", "break") else TUNE_A # ── clawhammer banjo: bum (beat) - dit (brush, &) - ty (5th drone, a) for b in range(4): i16 = b*4 mel = nf(tune[(bar*4+b) % 16]) gm = (0.55 if quiet else 0.72)*(1.0-0.55*lu) s.put("banjo", banjo(mel, 0.40, seed=bar*97+b*11), s.t(bar, i16, SW), g=gm, pan=-0.16) s.kick_t.append(s.t(bar, i16, SW)) if not land or lu < 0.55: s.put("banjo", banjo_brush([nf(x) for x in ch["banjo"]], 0.24, seed=bar*53+b*7), s.t(bar, i16+2, SW), g=0.30*(1.0-0.8*lu), pan=-0.22) s.put("banjo", banjo(nf(DRONE5), 0.30, seed=bar*31+b), s.t(bar, i16+3, SW), g=0.34*(1.0-0.7*lu), pan=-0.10) stitches.append(s.t(bar, i16, SW)) if full and not land: stitches.append(s.t(bar, i16+2, SW)) # ── guitar boom-chuck if not quiet: for b, kind in ((0, "boom"), (1, "chuck"), (2, "boom5"), (3, "chuck")): at = s.t(bar, b*4, SW) if kind == "chuck": s.put("gtr", guitar_chuck([nf(x) for x in ch["gtr"]], seed=bar*17+b), at, g=0.20*(1.0-0.75*lu), pan=0.26) else: note = ch["root"] if kind == "boom" else ch["fifth"] s.put("gtr", guitar_boom(nf(note)*2, 0.42, seed=bar*23+b), at, g=0.17*(1.0-0.7*lu), pan=0.22) # ── upright bass: root on 1, fifth on 3 if not quiet: for b, note in ((0, ch["root"]), (2, ch["fifth"])): s.put("bass", upright(nf(note), 0.52, seed=bar*29+b), s.t(bar, b*4, SW), g=0.50*(1.0-0.6*lu), pan=0.0) # ── fiddle: shuffle bow (long-short-short) with an open drone string if sec in ("chorus1", "chorus2", "break", "v3", "v4") or (land and lu < 0.5): dr = nf("D4") if PROG[bar] in ("G", "Em") else nf("A3") SHUF = ((0, 2.0, False), (4, 1.0, False), (6, 1.0, True), (8, 2.0, False), (12, 1.0, False), (14, 1.0, True)) for st, ln, sl in SHUF: fq = nf(tune[(bar*4 + st//4) % 16]) s.put("fid", fiddle(fq, BEAT*ln*0.52*0.9, drone=dr, slur=sl, seed=bar*41+st), s.t(bar, st, SW), g=(0.155 if sec != "break" else 0.21)*(1.0-0.8*lu), pan=0.30) # ── foot on the porch boards, thimble on every beat if not quiet: for b in (1, 3): s.put("perc", foot(seed=bar*3+b), s.t(bar, b*4, SW), g=0.24*(1.0-0.8*lu), pan=-0.05) for b in range(4): s.put("perc", thimble(seed=bar+b), s.t(bar, b*4, SW), g=0.13*(1.0-0.5*lu), pan=0.12) # ── the vocals ─────────────────────────────────────────────────────────── def line(text, mel, at, dur, parts, g=1.0, upper=False): sing_stack(s, "vox", text, mel, at, dur, parts, g=g) SUBS.append((at-0.10, at+dur+0.35, text)) for b0, lines in VERSES.items(): for i, (text, mel) in enumerate(lines): line(text, mel, (b0+i*2)*BAR + BEAT*0.35, BAR*2*0.86, HARMONY[:1] + [HARMONY[2]], g=1.0) for b0 in (10, 26): for i, (text, mel) in enumerate(CHORUS): line(text, mel, (b0+i*2)*BAR + BEAT*0.30, BAR*2*0.88, HARMONY, g=1.0) for i, (text, mel) in enumerate(LANDING): line(text, mel, (30+i*2)*BAR + BEAT*0.35, BAR*2*0.86, HARMONY[:1] + [HARMONY[1], HARMONY[2]], g=0.95) line(FINAL_LINE[0], FINAL_LINE[1], 34*BAR + BEAT*0.6, BAR*1.5*0.9, [HARMONY[0]], g=0.85) # ── the room ───────────────────────────────────────────────────────────── n2 = int(DUR*SR) s.put("room", crickets(n2), 0.0, g=0.18) for bar, sd in ((0, 17), (9, 19), (21, 23), (33, 27)): s.put("room", chair_creak(seed=sd), bar*BAR + BEAT*1.4, g=0.28, pan=-0.3) for bar, sd in ((3, 8), (7, 9), (15, 10), (19, 11)): s.put("room", scissors(seed=sd), bar*BAR + BEAT*2.1, g=0.34, pan=0.18) for bar, sd in ((22, 11), (24, 12), (30, 13), (31, 14)): s.put("room", cloth_rustle(0.7, seed=sd), bar*BAR, g=0.24, pan=-0.12) # the throw: one big sweep of cloth s.put("room", cloth_rustle(1.5, seed=41), 30*BAR - BEAT*0.6, g=0.42) s.bus("banjo", lambda x: reverb(x, rt=1.1, mix=.16, seed=2001)) s.bus("fid", lambda x: reverb(x, rt=1.5, mix=.24, seed=2003)) s.bus("vox", lambda x: reverb(x, rt=1.7, mix=.26, seed=2007)) s.bus("gtr", lambda x: reverb(x, rt=1.0, mix=.13, seed=2011)) s.bus("room", lambda x: reverb(x, rt=2.0, mix=.30, seed=2013)) mix = s.mixdown(dict(banjo=1.0, gtr=1.0, bass=1.0, fid=1.0, perc=1.0, vox=1.0, room=1.0), pump_depth=.07, pump_rel=.12, levels=dict(intro=.55, v1=.80, v2=.88, chorus1=1.00, v3=.92, v4=.96, break_=1.0, chorus2=1.08, landing=.72, tail=.42)) wav = AUD/"final.wav" s.write(wav, mix) ev = dict(stitch=sorted(set(round(x, 4) for x in stitches)), subs=[[a, b, c] for a, b, c in SUBS]) (AUD/"events.json").write_text(json.dumps(ev)) return wav, mix def analyze(mix): x = mix.mean(1) hop = SR/FPS; win = int(hop*1.7) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high")} for f in range(N_FRAMES): i = int(f*hop); seg = x[i:i+win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg**2).mean()) sp = np.abs(np.fft.rfft(seg*np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1/SR) E["low"][f] = sp[fr < 180].sum() E["mid"][f] = sp[(fr >= 180) & (fr < 2600)].sum() E["high"][f] = sp[fr >= 2600].sum() for k in E: p = np.percentile(E[k], 96) + 1e-9 E[k] = np.clip(E[k]/p, 0, 1.25) lo = E["low"] flux = np.maximum(0, lo - np.concatenate([[0], lo[:-1]])) E["hit"] = np.clip(np.convolve(flux, [.25, .5, .25], "same") / (np.percentile(flux, 97)+1e-9), 0, 1) np.savez(AUD/"env.npz", **E) return E _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV _EV = {} def events(): if not _EV: p = AUD/"events.json" d = json.loads(p.read_text()) if p.exists() else dict(stitch=[], subs=[]) _EV["stitch"] = np.array(d["stitch"], np.float64) _EV["subs"] = d["subs"] return _EV # ════════════════════════════════════════════════════════════════════════════ # THE CLOTH ENGINE — a real woven fabric # # A fabric is not a colour, it is a *warp* and a *weft*. Every band-dyed # pattern in the piece (gingham, ticking, plaid, chambray) falls out of # colouring the two thread sets independently and then interlacing them; only # calico's print is painted on top, because calico really is printed. # # iw, jw integer warp / weft thread indices # fx, fy position across the thread (0..1) -> its round section # top which thread is on top at this crossing (weave structure) # ppt pixels per thread; below ~2 the interlace is blended out # toward the mean colour, which is exactly what a real # fabric does to the eye at a distance # ════════════════════════════════════════════════════════════════════════════ TPU = 6.0 # threads per quilt unit NJIT = 1024 # per-thread irregularity table length def _bandlut(bands): """bands = [((r,g,b), n_threads), …] -> (N,3) float32 lut""" cols = [] for c, n in bands: cols.extend([c]*int(n)) return np.asarray(cols, np.float32) class Fabric: __slots__ = ("key", "wl", "el", "nw", "ne", "weave", "print_", "ink", "fade", "wear", "nap", "wj", "ej", "slub", "seed", "avg") def __init__(self, key, warp, weft, weave="plain", print_=None, ink=None, fade=0.0, wear=0.0, nap=0.0, seed=0): self.key = key self.wl = _bandlut(warp); self.el = _bandlut(weft) self.nw, self.ne = len(self.wl), len(self.el) self.weave, self.print_, self.ink = weave, ink and print_ or print_, ink self.fade, self.wear, self.nap, self.seed = fade, wear, nap, seed r = np.random.RandomState(7000+seed) self.wj = (0.93 + 0.14*r.rand(NJIT)).astype(np.float32) self.ej = (0.93 + 0.14*r.rand(NJIT)).astype(np.float32) self.slub = (0.80 + 0.40*r.rand(NJIT)).astype(np.float32) self.avg = (self.wl.mean(0) + self.el.mean(0))*0.5 FABRICS = {} def F(*a, **k): f = Fabric(*a, **k); FABRICS[f.key] = f; return f CREAM = (226, 216, 194) MUSL = (214, 202, 176) F("chambray", [((58, 92, 132), 1)], [((206, 200, 182), 1)], weave="plain", fade=0.30, wear=0.34, seed=1) F("denim", [((36, 58, 96), 1)], [((198, 192, 174), 1)], weave="twill", fade=0.22, wear=0.30, seed=2) F("calico", [(MUSL, 1)], [(MUSL, 1)], weave="plain", print_="floral", ink=(158, 46, 60), fade=0.34, wear=0.20, seed=3) F("calico2", [((196, 176, 152), 1)], [((196, 176, 152), 1)], weave="plain", print_="floral", ink=(72, 96, 74), fade=0.30, wear=0.18, seed=4) F("serge", [((84, 88, 60), 1)], [((72, 78, 52), 1)], weave="twill", fade=0.26, wear=0.42, seed=5) F("gingham", [((222, 214, 196), 8), ((70, 104, 142), 8)], [((222, 214, 196), 8), ((70, 104, 142), 8)], weave="plain", fade=0.30, wear=0.24, seed=6) F("gingham_r", [((226, 218, 200), 7), ((162, 62, 60), 7)], [((226, 218, 200), 7), ((162, 62, 60), 7)], weave="plain", fade=0.28, wear=0.22, seed=7) F("ticking", [((220, 210, 188), 14), ((58, 66, 96), 4), ((220, 210, 188), 4), ((58, 66, 96), 4)], [((220, 210, 188), 1)], weave="twill", fade=0.24, wear=0.26, seed=8) F("plaid", [((132, 44, 40), 10), ((30, 34, 42), 4), ((178, 148, 96), 2), ((132, 44, 40), 6), ((30, 34, 42), 4)], [((132, 44, 40), 10), ((30, 34, 42), 4), ((178, 148, 96), 2), ((132, 44, 40), 6), ((30, 34, 42), 4)], weave="twill", nap=0.5, fade=0.28, wear=0.30, seed=9) F("muslin", [(MUSL, 1)], [(CREAM, 1)], weave="plain", fade=0.16, wear=0.14, seed=10) F("shirting", [((208, 202, 186), 9), ((96, 118, 150), 2)], [((214, 208, 192), 1)], weave="plain", fade=0.22, wear=0.20, seed=11) F("wool", [((92, 64, 58), 1)], [((78, 54, 50), 1)], weave="twill", nap=0.8, fade=0.20, wear=0.36, seed=12) F("turkey", [((150, 40, 44), 1)], [((150, 40, 44), 1)], weave="plain", fade=0.30, wear=0.22, seed=13) # ── value noise on a hashed integer lattice (deterministic, no hash()) ─────── _NT = {} def _ntab(seed): if seed not in _NT: _NT[seed] = np.random.RandomState(4000+seed).rand(4099).astype(np.float32) return _NT[seed] def vnoise(U, V, seed=0): tab = _ntab(seed); n = len(tab) i0 = np.floor(U).astype(np.int64); j0 = np.floor(V).astype(np.int64) fx = (U-i0).astype(np.float32); fy = (V-j0).astype(np.float32) sx = fx*fx*(3-2*fx); sy = fy*fy*(3-2*fy) def g(i, j): return tab[(i*198491 + j*6151) % n] a = g(i0, j0); b = g(i0+1, j0); c = g(i0, j0+1); d = g(i0+1, j0+1) return (a*(1-sx)+b*sx)*(1-sy) + (c*(1-sx)+d*sx)*sy def fbm2(U, V, seed=0, oct=3): out = np.zeros(U.shape, np.float32); amp = 1.0; nrm = 0.0 for o in range(oct): out += amp*vnoise(U*(2**o), V*(2**o), seed+o); nrm += amp; amp *= 0.55 return out/nrm def floral(U, V, seed=0): """Tiny calico rosettes on a jittered lattice + a scatter of dots. Returns a 0..1 ink coverage.""" P = 22.0 # threads per repeat cu = np.floor(U/P); cv = np.floor(V/P) jx = vnoise(cu*3.1+11.7, cv*3.1+5.3, seed) jy = vnoise(cu*3.1+41.1, cv*3.1+29.9, seed+1) du = (U/P - cu - 0.15 - 0.7*jx)*P dv = (V/P - cv - 0.15 - 0.7*jy)*P r = np.sqrt(du*du + dv*dv) + 1e-6 th = np.arctan2(dv, du) petal = 3.0 + 2.1*np.cos(5*th + 6.0*jx) ink = np.clip((petal - r)*1.4, 0, 1) ink += np.clip((1.1 - np.abs(r-0.9))*0.8, 0, 1)*0.5 # the centre dots = (vnoise(U*0.55+7.0, V*0.55+3.0, seed+2) > 0.86).astype(np.float32) return np.clip(ink + dots*0.35, 0, 1) def cloth_rgb(fab, U, V, ppt, tone=1.0, worn=1.0, seed=0, flat=0.0): """Sample a woven fabric. U,V in thread units. Returns float32 (...,3). `flat` pulls the dyed pattern toward the cloth's own average colour while leaving the interlace alone — a gingham check is a strong image in its own right and will fight any tonal picture pieced out of it.""" iw = np.floor(U).astype(np.int64); jw = np.floor(V).astype(np.int64) fx = (U-iw).astype(np.float32); fy = (V-jw).astype(np.float32) wc = fab.wl[iw % fab.nw] ec = fab.el[jw % fab.ne] if fab.weave == "twill": top = ((iw + jw) % 4) < 2 elif fab.weave == "basket": top = ((iw//2 + jw//2) % 2) == 0 else: top = ((iw + jw) % 2) == 0 topf = top[..., None] base = np.where(topf, wc, ec) lod = float(np.clip(ppt/2.1, 0.0, 1.0)) if lod < 0.999: base = base*lod + (0.5*(wc+ec))*(1.0-lod) if flat > 0.0: base = base*(1.0-flat) + fab.avg*flat # round thread section + light from the upper left pw = 1.0 - (2*fx-1)**2 pe = 1.0 - (2*fy-1)**2 prof = np.where(top, pw, pe) hil = np.where(top, (0.5-fx), (0.5-fy)) sh = np.where(top, 0.80 + 0.30*prof + 0.26*hil, 0.46 + 0.20*prof + 0.10*hil) gap = np.clip(1.0 - 1.7*(1.0 - np.maximum(pw, pe)), 0.35, 1.0) sh = sh*gap ji = fab.wj[iw % NJIT]*np.where(top, 1.0, 0.0) + \ fab.ej[jw % NJIT]*np.where(top, 0.0, 1.0) slub = fab.slub[((iw*7 + (jw//9)*13) % NJIT)] sh = sh*ji*(0.90 + 0.16*slub) sh = 1.0 + (sh-1.0)*lod # blend the interlace out at range out = base*sh[..., None] if fab.print_ == "floral" and fab.ink is not None: ink = floral(U, V, fab.seed) if lod < 0.35: ink = ink*0.86 + 0.14 out = out*(1-ink[..., None]*0.88) + \ np.asarray(fab.ink, np.float32)*(ink[..., None]*0.88)*(sh[..., None]*0.9+0.1) if fab.nap > 0.0: # brushed flannel / wool fz = fbm2(U*0.8, V*0.8, fab.seed+40) out = out*(1.0 - fab.nap*0.30) + out.mean(-1, keepdims=True)*fab.nap*0.30 out = out*(0.88 + 0.24*fz[..., None]) # sun-fade and wear, both blotchy, both permanent if fab.fade > 0.0 or fab.wear > 0.0: f = fbm2(U*0.020, V*0.020, fab.seed+70) fade = (fab.fade*worn*(0.35 + 0.65*f))[..., None] out = out*(1.0-fade) + np.asarray((200, 190, 170), np.float32)*fade wf = fbm2(U*0.055+13.0, V*0.055+7.0, fab.seed+90) thin = np.clip((wf - 0.62)*3.0, 0, 1)*fab.wear*worn out = out*(1.0 - 0.30*thin[..., None]) + 26.0*thin[..., None] return out*tone # ════════════════════════════════════════════════════════════════════════════ # QUILT GEOMETRY — blocks, patches, people # ════════════════════════════════════════════════════════════════════════════ BU = 16.0 # block size in quilt units COLS, ROWS = 4, 3 QW, QH = COLS*BU, ROWS*BU # 64 x 48 SASH = 0.0 class Patch: __slots__ = ("poly", "fab", "tone", "grain", "off", "worn") def __init__(self, poly, fab, tone=1.0, grain=0.0, off=(0.0, 0.0), worn=1.0): self.poly = poly; self.fab = fab; self.tone = tone self.grain = grain; self.off = off; self.worn = worn def _rect(x0, y0, x1, y1): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def block_logcabin(rng, hearth, light, dark): """Concentric strips around a hearth square — the strips go on one at a time, which is why it is the block she starts with.""" ps = [] s = BU/9.0 ps.append(Patch(_rect(4*s, 4*s, 5*s, 5*s), hearth, 1.0)) x0, y0, x1, y1 = 4*s, 4*s, 5*s, 5*s for r in range(4): f1 = light if r % 2 == 0 else dark f2 = dark if r % 2 == 0 else light ps.append(Patch(_rect(x0, y0-s, x1, y0), f1, 1.0)) # top ps.append(Patch(_rect(x1, y0-s, x1+s, y1), f1, 0.94)) # right ps.append(Patch(_rect(x0-s, y1, x1+s, y1+s), f2, 1.0)) # bottom ps.append(Patch(_rect(x0-s, y0-s, x0, y1+s), f2, 0.94)) # left x0 -= s; y0 -= s; x1 += s; y1 += s return ps def block_geese(rng, goose, sky): """Flying geese: four rows, each a big triangle with two sky corners.""" ps = []; hgt = BU/4.0 for r in range(4): y0 = r*hgt; y1 = y0+hgt ps.append(Patch([(0, y1), (BU/2, y0), (BU, y1)], goose, 1.0)) ps.append(Patch([(0, y0), (BU/2, y0), (0, y1)], sky, 1.0)) ps.append(Patch([(BU/2, y0), (BU, y0), (BU, y1)], sky, 0.95)) return ps def block_ninepatch(rng, a, b): ps = []; s = BU/3.0 for r in range(3): for c in range(3): f = a if (r+c) % 2 == 0 else b ps.append(Patch(_rect(c*s, r*s, (c+1)*s, (r+1)*s), f, 1.0 - 0.04*((r*3+c) % 3))) return ps def block_pinwheel(rng, a, b): """Half-square triangles spun into four blades — a block that is going somewhere.""" ps = []; s = BU/2.0 quads = [(0, 0), (1, 0), (1, 1), (0, 1)] for q, (cx, cy) in enumerate(quads): x0, y0 = cx*s, cy*s; x1, y1 = x0+s, y0+s if q % 2 == 0: ps.append(Patch([(x0, y0), (x1, y0), (x0, y1)], a, 1.0)) ps.append(Patch([(x1, y0), (x1, y1), (x0, y1)], b, 0.97)) else: ps.append(Patch([(x0, y0), (x1, y0), (x1, y1)], b, 1.0)) ps.append(Patch([(x0, y0), (x1, y1), (x0, y1)], a, 0.97)) return ps def block_bars(rng, fabs): ps = []; n = len(fabs); s = BU/n for i, f in enumerate(fabs): ps.append(Patch(_rect(i*s, 0, (i+1)*s, BU), f, 1.0 - 0.03*i)) return ps # ── who the blocks are ────────────────────────────────────────────────────── PEOPLE = { "father": dict(name="FATHER", garm="work shirt", fab="chambray", card="FATHER · WORK SHIRT", figure="man"), "ada": dict(name="ADA", garm="sunday dress", fab="calico", card="ADA · SUNDAY DRESS", figure="woman"), "son": dict(name="MY BOY", garm="uniform", fab="serge", card="MY BOY · UNIFORM", figure="soldier"), "mama": dict(name="MAMA", garm="apron", fab="gingham", card="MAMA · APRON", figure="granny"), } # (bar placed, col, row, pattern, fabrics, person) BLOCKS = [ ( 2.0, 1, 1, "logcabin", ("turkey", "chambray", "muslin"), "father"), ( 4.0, 0, 1, "bars", ("plaid", "muslin", "ticking", "shirting"), None), ( 6.0, 2, 1, "geese", ("calico", "muslin"), "ada"), ( 8.0, 1, 0, "ninepatch",("shirting", "denim"), None), (10.0, 2, 0, "pinwheel", ("ticking", "muslin"), None), (12.0, 3, 1, "logcabin", ("turkey", "plaid", "muslin"), None), (14.0, 1, 2, "pinwheel", ("serge", "muslin"), "son"), (16.0, 0, 0, "ninepatch",("calico2", "muslin"), None), (18.0, 2, 2, "ninepatch",("gingham", "muslin"), "mama"), (20.0, 3, 0, "bars", ("denim", "muslin", "wool", "calico2"), None), (22.0, 0, 2, "geese", ("plaid", "muslin"), None), (24.0, 3, 2, "logcabin", ("turkey", "gingham_r", "muslin"), None), ] def build_blocks(): out = [] for bi, (bar, c, r, pat, fk, per) in enumerate(BLOCKS): rng = np.random.RandomState(1200 + bi*37) fs = [FABRICS[k] for k in fk] if pat == "logcabin": ps = block_logcabin(rng, fs[0], fs[1], fs[2]) elif pat == "geese": ps = block_geese(rng, fs[0], fs[1]) elif pat == "ninepatch":ps = block_ninepatch(rng, fs[0], fs[1]) elif pat == "pinwheel": ps = block_pinwheel(rng, fs[0], fs[1]) else: ps = block_bars(rng, fs) ox, oy = c*BU, r*BU for p in ps: # place in quilt space, jitter grain p.poly = [(x+ox, y+oy) for x, y in p.poly] p.grain = float(rng.choice([0.0, math.pi/2])) + rng.uniform(-.05, .05) p.off = (float(rng.uniform(0, 40)), float(rng.uniform(0, 40))) p.worn = float(rng.uniform(0.7, 1.25)) out.append(dict(i=bi, bar=bar, col=c, row=r, pat=pat, patches=ps, person=per, fabs=fk)) return out QBLOCKS = build_blocks() def blocks_at(t): return [b for b in QBLOCKS if t >= b["bar"]*BAR - 1e-6] def block_of_person(key): for b in QBLOCKS: if b["person"] == key: return b return QBLOCKS[0] # ── the pieced portraits ──────────────────────────────────────────────────── PCH, PCW = 30, 24 # portrait chart rows / cols _PORTRAITS = {} def portrait_chart(kind): """Draw a figure at chart resolution; the value picks which tonal cut of the garment's own cloth goes in that cell. The portrait is *pieced*, so it can only ever be as detailed as a quilt made of squares.""" if kind in _PORTRAITS: return _PORTRAITS[kind] # the figures are laid out in an 18 x 22 design space and scaled to # whatever the chart resolution is, so the chart can get finer without # every coordinate being rewritten S = 8 SX, SY = PCW*S/18.0, PCH*S/22.0 im = Image.new("L", (PCW*S, PCH*S), 62) d = ImageDraw.Draw(im) def E(x0, y0, x1, y1, v): d.ellipse([x0*SX, y0*SY, x1*SX, y1*SY], fill=v) def R(x0, y0, x1, y1, v): d.rectangle([x0*SX, y0*SY, x1*SX, y1*SY], fill=v) def P(pts, v): d.polygon([(x*SX, y*SY) for x, y in pts], fill=v) if kind == "man": P([(2, 22), (3.4, 13.6), (7, 12), (11, 12), (14.6, 13.6), (16, 22)], 168) R(7.4, 11.0, 10.6, 13.2, 216) # neck E(6.0, 3.4, 12.0, 12.0, 216) # head P([(4.6, 4.6), (13.4, 4.6), (12.6, 2.2), (5.4, 2.2)], 40) # cap P([(4.0, 5.0), (14.2, 5.0), (14.2, 4.0), (4.0, 4.0)], 28) # brim R(6.6, 6.9, 8.2, 7.9, 44); R(9.8, 6.9, 11.4, 7.9, 44) # eyes R(6.3, 6.1, 8.4, 6.6, 60); R(9.6, 6.1, 11.7, 6.6, 60) # brows P([(7.0, 9.9), (11.0, 9.9), (9.0, 11.4)], 86) # mouth R(8.4, 13.0, 9.6, 22, 150) # placket R(4.2, 15.2, 6.4, 17.6, 168); R(11.6, 15.2, 13.8, 17.6, 168) # pockets elif kind == "woman": P([(1.6, 22), (3.2, 14.4), (6.6, 12.2), (11.4, 12.2), (14.8, 14.4), (16.4, 22)], 172) R(7.6, 11.0, 10.4, 12.6, 218) E(6.2, 3.6, 11.8, 11.8, 218) P([(5.2, 8.0), (5.0, 3.6), (7.0, 1.9), (11.0, 1.9), (13.0, 3.6), (12.8, 8.0), (11.6, 4.6), (6.4, 4.6)], 66) # hair, parted R(6.7, 6.8, 8.2, 7.9, 42); R(9.8, 6.8, 11.3, 7.9, 42) P([(7.2, 9.9), (10.8, 9.9), (9.0, 11.4)], 92) P([(6.6, 12.2), (11.4, 12.2), (9.0, 15.4)], 246) # white collar E(8.4, 13.0, 9.6, 14.2, 40) # brooch elif kind == "soldier": P([(2.2, 22), (3.6, 13.8), (7, 12.2), (11, 12.2), (14.4, 13.8), (15.8, 22)], 164) R(7.5, 11.0, 10.5, 13.0, 214) E(6.2, 3.8, 11.8, 12.0, 216) P([(5.0, 4.6), (13.0, 4.6), (12.4, 2.0), (5.6, 2.0)], 52) # garrison cap P([(4.4, 5.0), (13.6, 5.0), (13.6, 4.2), (4.4, 4.2)], 34) R(6.7, 6.9, 8.2, 8.0, 40); R(9.8, 6.9, 11.3, 8.0, 40) R(6.4, 6.1, 8.4, 6.6, 58); R(9.6, 6.1, 11.6, 6.6, 58) P([(7.2, 10.1), (10.8, 10.1), (9.0, 11.2)], 88) P([(6.4, 13.0), (9.0, 16.6), (11.6, 13.0)], 176) # lapels R(4.0, 14.0, 6.2, 14.9, 246); R(4.0, 15.4, 5.6, 16.2, 246) # ribbons E(12.2, 14.0, 13.6, 15.4, 250) # button else: # granny P([(1.8, 22), (3.4, 14.6), (6.8, 12.4), (11.2, 12.4), (14.6, 14.6), (16.2, 22)], 176) R(7.6, 11.2, 10.4, 12.8, 216) E(6.2, 4.0, 11.8, 12.0, 218) P([(5.4, 8.4), (5.2, 4.4), (7.2, 2.6), (10.8, 2.6), (12.8, 4.4), (12.6, 8.4)], 172) # white hair E(7.6, 0.9, 10.4, 3.4, 200) # bun R(6.8, 7.2, 8.2, 8.2, 46); R(9.8, 7.2, 11.2, 8.2, 46) d.arc([6.4*SX, 6.4*SY, 8.6*SX, 8.8*SY], 0, 360, fill=150, width=max(2, int(0.5*S))) d.arc([9.4*SX, 6.4*SY, 11.6*SX, 8.8*SY], 0, 360, fill=150, width=max(2, int(0.5*S))) d.line([8.6*SX, 7.6*SY, 9.4*SX, 7.6*SY], fill=150, width=max(2, int(0.4*S))) P([(7.3, 9.9), (10.7, 9.9), (9.0, 11.5)], 90) P([(6.8, 12.4), (11.2, 12.4), (12.0, 22), (6.0, 22)], 250) # apron bib R(6.6, 13.0, 11.4, 13.5, 176) a = np.asarray(im.resize((PCW, PCH), Image.BOX), np.float32)/255.0 _PORTRAITS[kind] = a return a # ── the garments, before they were cut up ─────────────────────────────────── def garment_polys(kind): """Flat-lay garment in a 0..1 x 0..1 box. (polygon, tone) list, plus the square she is about to cut out of it.""" if kind == "work shirt": body = [(.20, .30), (.80, .30), (.84, .90), (.16, .90)] parts = [(body, 1.0), ([(.02, .34), (.22, .30), (.26, .46), (.06, .54)], .92), ([(.98, .34), (.78, .30), (.74, .46), (.94, .54)], .92), ([(.36, .30), (.50, .40), (.64, .30), (.58, .22), (.42, .22)], 1.06)] cut = (.40, .52, .70, .82) det = [("btn", .50, .40), ("btn", .50, .52), ("btn", .50, .64), ("btn", .50, .76), ("pkt", .60, .40, .76, .56)] elif kind == "sunday dress": parts = [([(.30, .26), (.70, .26), (.86, .92), (.14, .92)], 1.0), ([(.08, .30), (.31, .26), (.34, .42), (.12, .48)], .92), ([(.92, .30), (.69, .26), (.66, .42), (.88, .48)], .92), ([(.40, .26), (.50, .36), (.60, .26)], 1.10)] cut = (.34, .58, .64, .88) det = [("btn", .50, .34), ("btn", .50, .44), ("belt", .18, .54, .82, .60)] elif kind == "uniform": parts = [([(.20, .26), (.80, .26), (.84, .88), (.16, .88)], 1.0), ([(.02, .30), (.22, .26), (.26, .44), (.06, .52)], .92), ([(.98, .30), (.78, .26), (.74, .44), (.94, .52)], .92), ([(.34, .26), (.50, .46), (.66, .26)], 1.08)] cut = (.24, .52, .54, .82) det = [("btn", .50, .50), ("btn", .50, .62), ("btn", .50, .74), ("pkt", .58, .52, .78, .68), ("pkt", .22, .52, .42, .68)] else: # apron parts = [([(.24, .34), (.76, .34), (.84, .94), (.16, .94)], 1.0), ([(.37, .05), (.63, .05), (.65, .34), (.35, .34)], 1.03), ([(.03, .38), (.26, .335), (.26, .365), (.035, .415)], .88), ([(.97, .38), (.74, .335), (.74, .365), (.965, .415)], .88), ([(.40, .02), (.44, .02), (.42, .06), (.38, .06)], .95), ([(.56, .02), (.60, .02), (.62, .06), (.58, .06)], .95)] cut = (.30, .56, .60, .86) det = [("pkt", .40, .58, .62, .74)] return parts, cut, det # ════════════════════════════════════════════════════════════════════════════ # THE CAMERA + THE COMPOSITOR # ════════════════════════════════════════════════════════════════════════════ class Cam: """A window on quilt space. span = quilt units across the frame.""" __slots__ = ("cx", "cy", "span") def __init__(self, cx, cy, span): self.cx, self.cy, self.span = cx, cy, span @property def ppu(self): return W/self.span def q2p(self, qx, qy): p = self.ppu return ((qx-self.cx)*p + W/2, (qy-self.cy)*p + H/2) def grid(self): p = self.ppu xs = (np.arange(W, dtype=np.float32)+0.5-W/2)/p + self.cx ys = (np.arange(H, dtype=np.float32)+0.5-H/2)/p + self.cy return xs, ys LAMP = np.array([255, 236, 206], np.float32) _BG = {} def room_bg(cam, t, kind="table"): """Whatever is behind / under the quilt. A lamp-lit table for the piecing shots, a dark bedroom for the landing.""" key = (round(cam.cx, 2), round(cam.cy, 2), round(cam.span, 2), kind) if key in _BG: return _BG[key].copy() xs, ys = cam.grid() X, Y = np.meshgrid(xs, ys) if kind == "bed": base = np.array([34, 29, 33], np.float32) g = np.clip(1.35 - 0.9*np.abs((Y-QH*0.4)/(QH*1.4)), 0.25, 1.4) a = base[None, None, :]*g[..., None] else: wood = 0.72 + 0.28*np.sin(Y*0.9 + 2.0*fbm2(X*0.05, Y*0.05, 300)) grainn = fbm2(X*0.35, Y*0.045, 301) a = (np.array([78, 54, 36], np.float32)[None, None, :] * (wood*0.55 + 0.52)[..., None] * (0.78+0.36*grainn)[..., None]) # lamplight falloff, upper left r = np.sqrt(((X-QW*0.18)/(QW*1.05))**2 + ((Y-QH*0.05)/(QH*1.25))**2) a = a*np.clip(1.35-0.75*r, 0.25, 1.4)[..., None] _BG[key] = a.astype(np.float32) if len(_BG) > 24: _BG.pop(next(iter(_BG))) return _BG[key].copy() def poly_px(cam, poly): return [cam.q2p(x, y) for x, y in poly] def fill_patch(img, cam, patch, X, Y, sunk=None, alpha_extra=None, tone=1.0): """Composite one patch of cloth into `img` (float32 HxWx3), antialiased, and stamp its outline into the sunken-line mask.""" pp = poly_px(cam, patch.poly) xs = [p[0] for p in pp]; ys = [p[1] for p in pp] x0 = int(max(0, math.floor(min(xs))-2)); x1 = int(min(W, math.ceil(max(xs))+2)) y0 = int(max(0, math.floor(min(ys))-2)); y1 = int(min(H, math.ceil(max(ys))+2)) if x1 <= x0 or y1 <= y0: return px = np.arange(x0, x1, dtype=np.float32)[None, :] py = np.arange(y0, y1, dtype=np.float32)[:, None] # signed distance to each edge of the convex polygon, in pixels dmin = None n = len(pp) area = 0.0 for i in range(n): ax, ay = pp[i]; bx, by = pp[(i+1) % n] area += ax*by - bx*ay sgn = 1.0 if area > 0 else -1.0 for i in range(n): ax, ay = pp[i]; bx, by = pp[(i+1) % n] ex, ey = bx-ax, by-ay L = math.hypot(ex, ey) + 1e-6 d = (( (px-ax)*ey - (py-ay)*ex )/L)*(-sgn) dmin = d if dmin is None else np.minimum(dmin, d) a = np.clip(dmin + 0.5, 0.0, 1.0) if not a.any(): return g = patch.grain ca, sa = math.cos(g), math.sin(g) qx = (px-W/2)/cam.ppu + cam.cx qy = (py-H/2)/cam.ppu + cam.cy ox, oy = patch.off U = ((qx-patch.poly[0][0])*ca + (qy-patch.poly[0][1])*sa)*TPU + ox V = (-(qx-patch.poly[0][0])*sa + (qy-patch.poly[0][1])*ca)*TPU + oy U = np.broadcast_to(U, a.shape); V = np.broadcast_to(V, a.shape) ppt = cam.ppu/TPU rgb = cloth_rgb(patch.fab, U, V, ppt, tone=patch.tone*tone, worn=patch.worn) if alpha_extra is not None: a = a*alpha_extra img[y0:y1, x0:x1] = img[y0:y1, x0:x1]*(1-a[..., None]) + rgb*a[..., None] def sink_lines(dmask, cam, segs, wpx): """Rasterise seam / quilting lines into the sunken-line mask.""" if not segs: return d = ImageDraw.Draw(dmask) w = max(1, int(round(wpx))) for (x0, y0), (x1, y1) in segs: a = cam.q2p(x0, y0); b = cam.q2p(x1, y1) d.line([a, b], fill=255, width=w) def puff(sunkimg, cam, strength=1.0, loft=1.0): """Batting. The height of the quilt at a pixel is how far it is from the nearest line of stitching; the light rakes across the gradient of that height, which is the whole reason a quilt looks like a quilt and not like a printed picture.""" r = max(2.0, min(90.0*SC, cam.ppu*0.55*loft)) blur = sunkimg.filter(ImageFilter.GaussianBlur(r)) Hf = 1.0 - np.asarray(blur, np.float32)/255.0 Hf = np.clip(Hf, 0, 1)**0.75 gy, gx = np.gradient(Hf) k = 2.6*strength*max(1.0, r/8.0) lam = 1.0 - k*(gx*0.72 + gy*0.62) lam = np.clip(lam, 0.42, 1.75) ao = 0.70 + 0.30*Hf return (lam*ao).astype(np.float32) def running_stitch(draw, cam, p0, p1, phase=0.0, frac=1.0, thread=(238, 230, 168), sc=1.0, seed=0): """A hand running stitch: on the cloth for a stitch, under it for a gap, never quite straight, and lit on its upper-left shoulder.""" ax, ay = cam.q2p(*p0); bx, by = cam.q2p(*p1) L = math.hypot(bx-ax, by-ay) if L < 3: return step = max(4.0, cam.ppu*0.50*sc) n = int(L/step) if n <= 0: return ux, uy = (bx-ax)/L, (by-ay)/L nx, ny = -uy, ux wpx = max(1.0, cam.ppu*0.055*sc) rng = np.random.RandomState(3000+seed) lim = frac*n for i in range(n): if i > lim: break s0 = (i + 0.12 + phase*0.0)*step s1 = s0 + step*0.56 j0 = rng.uniform(-0.30, 0.30)*wpx*2.2 j1 = rng.uniform(-0.30, 0.30)*wpx*2.2 q0 = (ax+ux*s0+nx*j0, ay+uy*s0+ny*j0) q1 = (ax+ux*s1+nx*j1, ay+uy*s1+ny*j1) fade = 1.0 if i < lim-1 else max(0.0, lim-i) if fade <= 0: continue c = tuple(int(v*fade + 34*(1-fade)) for v in thread) dk = tuple(int(v*0.30) for v in thread) # the thread runs *under* the cloth across the gap, so the gap gets a # faint dark trace rather than nothing if i and wpx > 1.6: g0 = (ax+ux*(s1+step*0.06), ay+uy*(s1+step*0.06)) g1 = (ax+ux*(s0+step*0.94), ay+uy*(s0+step*0.94)) draw.line([g0, g1], fill=dk, width=max(1, int(wpx*0.55))) draw.line([(q0[0]+wpx*0.30, q0[1]+wpx*0.40), (q1[0]+wpx*0.30, q1[1]+wpx*0.40)], fill=dk, width=max(1, int(wpx*1.25))) draw.line([q0, q1], fill=c, width=max(1, int(wpx))) if wpx > 2.0: # rounded ends: the strand is round for qq in (q0, q1): draw.ellipse([qq[0]-wpx*0.5, qq[1]-wpx*0.5, qq[0]+wpx*0.5, qq[1]+wpx*0.5], fill=c) hl = tuple(min(255, int(v*1.16)) for v in thread) draw.line([(q0[0]-wpx*0.22, q0[1]-wpx*0.26), (q1[0]-wpx*0.22, q1[1]-wpx*0.26)], fill=hl, width=max(1, int(wpx*0.34))) _SEAMC = {} def block_seams(blk): """Every patch edge, but *deduped* — two patches sewn together share one seam, and drawing it twice doubles the thread and turns a close-up into a lattice.""" k = blk["i"] if k in _SEAMC: return _SEAMC[k] seen = set(); segs = [] for p in blk["patches"]: n = len(p.poly) for i in range(n): a, b = p.poly[i], p.poly[(i+1) % n] ka = (round(a[0], 3), round(a[1], 3)); kb = (round(b[0], 3), round(b[1], 3)) key = (ka, kb) if ka <= kb else (kb, ka) if key in seen: continue seen.add(key); segs.append((a, b)) _SEAMC[k] = segs return segs def quilt_lines(t): """Diagonal crosshatch, laid down during chorus2 and after.""" t0 = 25*BAR; t1 = 29.5*BAR if t < t0: return [], 0.0 u = float(np.clip((t-t0)/(t1-t0), 0, 1)) sp = BU/2.0 def clip(x0, y0, x1, y1): """A quilting line stops at the edge of the quilt; the needle has nowhere else to go.""" s0, s1 = 0.0, 1.0 dx = x1-x0 if abs(dx) > 1e-9: a0, a1 = (0.0-x0)/dx, (QW-x0)/dx s0 = max(s0, min(a0, a1)); s1 = min(s1, max(a0, a1)) elif not (0.0 <= x0 <= QW): return None if s1 - s0 < 1e-3: return None return ((x0+dx*s0, y0+(y1-y0)*s0), (x0+dx*s1, y0+(y1-y0)*s1)) segs = [] for i in range(-int(QH/sp)-1, int(QW/sp)+2): x0 = i*sp for c in (clip(x0, 0.0, x0+QH, QH), clip(x0+QH, 0.0, x0, QH)): if c: segs.append(c) # sweep in the order the needle would actually travel: left to right # across the quilt, not in the order the two line families were generated segs = [sg for sg in segs if -QH*0.5 < (sg[0][0]+sg[1][0])*0.5 < QW + QH*0.5] segs.sort(key=lambda sg: (sg[0][0]+sg[1][0])*0.5) return segs[:int(round(len(segs)*u))], u # ── frayed / bound edge ───────────────────────────────────────────────────── def edge_fray(img, cam, t, alive=True): """The raw outer edge: a binding strip that stops short, and loose warp threads escaping where it hasn't been bound yet.""" im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) rng = np.random.RandomState(777) bw = 0.55 bound = np.clip((t - 24*BAR)/(6*BAR), 0, 1) corners = [(0.0, 0.0), (QW, 0.0), (QW, QH), (0.0, QH)] per = [(corners[i], corners[(i+1) % 4]) for i in range(4)] total = 0.0 for (a, b) in per: L = math.hypot(b[0]-a[0], b[1]-a[1]); total += L run = bound*total; acc = 0.0 for (a, b) in per: L = math.hypot(b[0]-a[0], b[1]-a[1]) f = np.clip((run-acc)/L, 0, 1); acc += L # frayed raw edge everywhere n = int(L*2) for i in range(n): u = (i+0.5)/n px, py = a[0]+(b[0]-a[0])*u, a[1]+(b[1]-a[1])*u nx, ny = (b[1]-a[1])/L, -(b[0]-a[0])/L ln = 0.10 + 0.42*rng.rand() p0 = cam.q2p(px, py) p1 = cam.q2p(px - nx*ln + (rng.rand()-.5)*0.3, py - ny*ln + (rng.rand()-.5)*0.3) if -60 < p0[0] < W+60 and -60 < p0[1] < H+60: d.line([p0, p1], fill=(196, 184, 160), width=max(1, sci(1))) if f > 0.01: wq = bw q0 = cam.q2p(a[0], a[1]); q1 = cam.q2p(a[0]+(b[0]-a[0])*f, a[1]+(b[1]-a[1])*f) d.line([q0, q1], fill=(126, 42, 44), width=max(2, int(cam.ppu*wq))) return np.asarray(im, np.float32) # ════════════════════════════════════════════════════════════════════════════ # HANDS, NEEDLE, SCISSORS — the only things in the film that aren't cloth # ════════════════════════════════════════════════════════════════════════════ SKIN = (168, 128, 100) SKIN2 = (128, 94, 74) STEEL = (226, 228, 232) def rot_ellipse(d, P, u0, v0, u1, v1, n=22, **kw): """PIL can only draw axis-aligned ellipses. Everything in this film that is a rounded body sits on a rotated frame, so it gets drawn as a polygon sampled off the ellipse instead.""" cu, cv = (u0+u1)/2, (v0+v1)/2 ru, rv = abs(u1-u0)/2, abs(v1-v0)/2 pts = [P(cu+ru*math.cos(i*math.tau/n), cv+rv*math.sin(i*math.tau/n)) for i in range(n)] d.polygon(pts, **kw) def draw_hand(d, x, y, sc, ang=0.0, mirror=False, grip=0.0): """A working hand: forearm off the frame edge, knuckles, three folded fingers and a thumb. Lit from the same lamp as everything else.""" ca, sa = math.cos(ang), math.sin(ang) m = -1.0 if mirror else 1.0 def P(u, v): u = u*m return (x + (u*ca - v*sa)*sc, y + (u*sa + v*ca)*sc) d.polygon([P(-30, -46), P(30, -40), P(120, -150), P(30, -170)], fill=SKIN2) d.polygon([P(-34, -6), P(34, -18), P(40, -60), P(-30, -50)], fill=SKIN) rot_ellipse(d, P, -38, -34, 38, 26, fill=SKIN) for i in range(3): fx = -22 + i*20; fy = 20 + i*3 rot_ellipse(d, P, fx-13, fy-16-grip*8, fx+13, fy+16, fill=SKIN) rot_ellipse(d, P, fx-11, fy+2, fx+11, fy+22, fill=SKIN2) rot_ellipse(d, P, 24, -8, 52, 22, fill=SKIN) # thumb rot_ellipse(d, P, -30, -26, 30, 18, n=26, outline=SKIN2, width=max(1, int(2*sc))) # knuckle crease def draw_thimble(d, x, y, sc): d.ellipse([x-9*sc, y-11*sc, x+9*sc, y+9*sc], fill=(196, 198, 204), outline=(120, 122, 130), width=max(1, int(1.4*sc))) r = np.random.RandomState(19) for i in range(16): a = r.uniform(0, 6.28); rr = r.uniform(0, 7*sc) d.ellipse([x+math.cos(a)*rr-1, y+math.sin(a)*rr-1, x+math.cos(a)*rr+1, y+math.sin(a)*rr+1], fill=(150, 152, 158)) def draw_needle(d, x, y, ang, L, eye=True): ca, sa = math.cos(ang), math.sin(ang) x1, y1 = x+ca*L, y+sa*L w = max(2, int(L*0.022)) d.line([x-ca*L*0.06, y-sa*L*0.06, x1, y1], fill=(96, 100, 108), width=w+2) d.line([x, y, x1, y1], fill=STEEL, width=w) d.line([x, y, x1, y1], fill=(255, 255, 255), width=max(1, w//3)) if eye: ex, ey = x-ca*L*0.02, y-sa*L*0.02 d.ellipse([ex-w, ey-w*2.2, ex+w, ey+w*2.2], outline=(80, 84, 92), width=max(1, w//2)) def draw_scissors(d, x, y, ang, sc, open_a=0.22): ca, sa = math.cos(ang), math.sin(ang) def P(u, v): return (x + (u*ca - v*sa)*sc, y + (u*sa + v*ca)*sc) for s in (1, -1): a = open_a*s c2, s2 = math.cos(a), math.sin(a) def Q(u, v): uu, vv = u*c2 - v*s2, u*s2 + v*c2 return P(uu, vv) d.polygon([Q(0, -3), Q(66, -2.2), Q(72, 0), Q(66, 2.2), Q(0, 3)], fill=(214, 216, 222), outline=(120, 124, 132)) d.line([Q(0, 0), Q(-40, 5*s)], fill=(40, 42, 48), width=max(3, int(5*sc))) rot_ellipse(d, Q, -62, -14*s, -34, 12*s, outline=(40, 42, 48), width=max(3, int(5*sc))) d.ellipse([x-4*sc, y-4*sc, x+4*sc, y+4*sc], fill=(90, 94, 100)) # ════════════════════════════════════════════════════════════════════════════ # THREAD / SPOOL / BASKET # ════════════════════════════════════════════════════════════════════════════ def folded_cloth(img, cam, fab, cx, cy, w, h, ang, seed): """A folded scrap in the basket: three stacked leaves of the same cloth, each a shade different, with a soft fold shadow.""" rng = np.random.RandomState(seed) for k in range(3): dx = (k-1)*w*0.06 + rng.uniform(-0.2, 0.2) dy = k*h*0.16 + rng.uniform(-0.15, 0.15) a = ang + rng.uniform(-0.09, 0.09) ca, sa = math.cos(a), math.sin(a) hw, hh = w*0.5, h*0.5*(1.0-0.16*k) poly = [] for u, v in ((-hw, -hh), (hw, -hh*0.94), (hw*0.97, hh), (-hw, hh*0.95)): poly.append((cx+dx + u*ca - v*sa, cy+dy + u*sa + v*ca)) p = Patch(poly, fab, tone=1.0 - 0.10*k) p.grain = a; p.off = (rng.uniform(0, 30), rng.uniform(0, 30)) p.worn = rng.uniform(0.7, 1.3) fill_patch(img, cam, p, None, None) # ════════════════════════════════════════════════════════════════════════════ # ENGINES # ════════════════════════════════════════════════════════════════════════════ def stitch_phase(t): """How far into the current stitch we are, 0..1, and the index.""" ev = events()["stitch"] if len(ev) == 0: return 0, 0.0 i = int(np.searchsorted(ev, t, "right")) - 1 if i < 0: return 0, 0.0 nxt = ev[i+1] if i+1 < len(ev) else ev[i] + BEAT return i, float(np.clip((t-ev[i])/max(1e-6, nxt-ev[i]), 0, 1)) def ease_io(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def lerp(a, b, u): return a + (b-a)*u class Base: bg = "table" def __init__(self, shot, rng): self.s = shot; self.rng = rng; self.p = shot.params; self.i0 = shot.i0 def frame(self, k, u, e): raise NotImplementedError class Quilt(Base): """The quilt top as it stands. Modes: wide / drift / corner.""" def frame(self, k, u, e): t = (self.i0+k)/FPS mode = self.p.get("mode", "wide") if mode == "wide": span = lerp(86, 78, ease_io(u)) cam = Cam(QW/2, QH/2, span) elif mode == "corner": c, r = self.p.get("cell", (1, 1)) cam = Cam(c*BU+BU/2 + 4*math.sin(t*0.3), r*BU+BU/2, lerp(40, 34, ease_io(u))) else: cam = Cam(QW*0.5 + 10*math.sin(t*0.22 + 1.0), QH*0.5 + 5*math.cos(t*0.17), lerp(56, 50, ease_io(u))) return compose_quilt(cam, t, e, bg=self.bg, hl=self.p.get("hl"), raking=self.p.get("rake", 0.0)) def compose_quilt(cam, t, e, bg="table", hl=None, raking=0.0, extra_lines=None, fray=True, backing=True): img = room_bg(cam, t, bg) xs, ys = cam.grid() X, Y = np.meshgrid(xs, ys) inq = (X >= 0) & (X <= QW) & (Y >= 0) & (Y <= QH) if backing: # muslin backing + batting showing wherever no block has landed yet U = X*TPU; V = Y*TPU back = cloth_rgb(FABRICS["muslin"], U, V, cam.ppu/TPU, tone=0.50) loft = 0.84 + 0.22*fbm2(X*0.28, Y*0.28, 511) back = back*loft[..., None]*np.array([0.93, 0.94, 1.00], np.float32) a = inq.astype(np.float32)[..., None] img = img*(1-a) + back*a blks = blocks_at(t) have = {(b["col"], b["row"]) for b in blks} if backing and len(have) < COLS*ROWS: # the layout she chalked onto the foundation before she started: every # block has a place waiting for it im0 = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d0 = ImageDraw.Draw(im0) for c in range(COLS): for r in range(ROWS): if (c, r) in have: continue q0 = cam.q2p(c*BU+0.5, r*BU+0.5) q1 = cam.q2p((c+1)*BU-0.5, (r+1)*BU-0.5) for (ax, ay, bx, by) in ((q0[0], q0[1], q1[0], q0[1]), (q1[0], q0[1], q1[0], q1[1]), (q1[0], q1[1], q0[0], q1[1]), (q0[0], q1[1], q0[0], q0[1])): L3 = math.hypot(bx-ax, by-ay); ns = max(2, int(L3/scf(16))) for q in range(ns): f3, g3 = q/ns, (q+0.5)/ns d0.line([(ax+(bx-ax)*f3, ay+(by-ay)*f3), (ax+(bx-ax)*g3, ay+(by-ay)*g3)], fill=(196, 190, 176), width=max(1, int(cam.ppu*0.05))) img = np.asarray(im0, np.float32) sunk = Image.new("L", (W, H), 0) segs = [] for b in blks: age = t - b["bar"]*BAR placed = np.clip(age/(BAR*0.9), 0, 1) # a block drops in patch by patch, one per beat npat = len(b["patches"]) show = npat if age > BAR*0.95 else int(np.ceil(placed*npat)) for pi, p in enumerate(b["patches"][:show]): fr = 1.0 if pi == show-1 and age < BAR*0.95: fr = float(np.clip(placed*npat - (show-1), 0.15, 1.0)) fill_patch(img, cam, p, None, None, alpha_extra=fr) if show >= npat: segs.extend(block_seams(b)) ql, qu = quilt_lines(t) segs.extend(ql) if extra_lines: segs.extend(extra_lines) sink_lines(sunk, cam, segs, max(1.0, cam.ppu*0.10)) # the quilt's own outer edge is a sunken line too sink_lines(sunk, cam, [((0, 0), (QW, 0)), ((QW, 0), (QW, QH)), ((QW, QH), (0, QH)), ((0, QH), (0, 0))], max(1.0, cam.ppu*0.16)) lam = puff(sunk, cam, strength=1.25 + raking*1.9) inqf = inq.astype(np.float32) lam = 1.0 + (lam-1.0)*inqf img = img*lam[..., None] # running stitches along every seam + every quilting line im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) si = 0 for b in blks: if t - b["bar"]*BAR < BAR*0.95: continue for (a2, b2) in block_seams(b): running_stitch(d, cam, a2, b2, sc=1.0, seed=si); si += 1 for (a2, b2) in ql: running_stitch(d, cam, a2, b2, thread=(246, 240, 224), sc=1.1, seed=si) si += 1 img = np.asarray(im, np.float32) if fray: img = edge_fray(img, cam, t) if hl is not None: c, r = hl im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) p0 = cam.q2p(c*BU, r*BU); p1 = cam.q2p((c+1)*BU, (r+1)*BU) gl = 0.5 + 0.5*math.sin(t*4.0) d.rectangle([p0, p1], outline=(int(210+40*gl), int(180+40*gl), 130), width=max(2, int(cam.ppu*0.06))) img = np.asarray(im, np.float32) return img class Block(Base): """One block filling the frame, assembling patch by patch on the beat.""" def frame(self, k, u, e): t = (self.i0+k)/FPS b = QBLOCKS[self.p["block"]] c, r = b["col"], b["row"] z = lerp(BU*1.5, BU*1.22, ease_io(u)) cam = Cam(c*BU+BU/2, r*BU+BU/2, z) img = room_bg(cam, t, "table") xs, ys = cam.grid(); X, Y = np.meshgrid(xs, ys) inb = ((X >= c*BU) & (X <= (c+1)*BU) & (Y >= r*BU) & (Y <= (r+1)*BU)) back = cloth_rgb(FABRICS["muslin"], X*TPU, Y*TPU, cam.ppu/TPU, tone=0.7) a = inb.astype(np.float32)[..., None] img = img*(1-a) + back*a age = t - b["bar"]*BAR npat = len(b["patches"]) si, sph = stitch_phase(t) nshow = int(np.clip(np.floor((t - b["bar"]*BAR)/(BEAT*0.28)), 0, npat)) if age > BAR*1.2: nshow = npat segs = [] for pi, p in enumerate(b["patches"][:nshow]): drop = 1.0 if pi == nshow-1: dt = (t - b["bar"]*BAR) - pi*BEAT*0.28 drop = float(np.clip(dt/(BEAT*0.24), 0, 1)) if drop < 1.0: # the patch comes down onto the block off = (1.0-drop)*BU*0.30 q = Patch([(x+off*0.6, y-off) for x, y in p.poly], p.fab, p.tone, p.grain, p.off, p.worn) fill_patch(img, cam, q, None, None, alpha_extra=0.35+0.65*drop) else: fill_patch(img, cam, p, None, None) n = len(p.poly) for i in range(n): segs.append((p.poly[i], p.poly[(i+1) % n])) sunk = Image.new("L", (W, H), 0) sink_lines(sunk, cam, segs, max(1.0, cam.ppu*0.09)) sink_lines(sunk, cam, [((c*BU, r*BU), ((c+1)*BU, r*BU)), (((c+1)*BU, r*BU), ((c+1)*BU, (r+1)*BU)), (((c+1)*BU, (r+1)*BU), (c*BU, (r+1)*BU)), ((c*BU, (r+1)*BU), (c*BU, r*BU))], max(1.0, cam.ppu*0.14)) lam = puff(sunk, cam, strength=1.1) img = img*(1.0 + (lam-1.0)*inb.astype(np.float32))[..., None] im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) for j, (a2, b2) in enumerate(segs): running_stitch(d, cam, a2, b2, sc=1.0, seed=j) return np.asarray(im, np.float32) class Portrait(Base): """The block resolves, for four bars, into who it was made of. The portrait is *pieced*: a grid of squares cut from that person's own garment, each square a different tonal cut of the same cloth, each turned on its own grain so the weave catches the light differently. It is drawn in a single vectorised pass over the block — one cloth evaluation with a per-cell tone, grain and offset — because 720 individual patch fills is a quarter-second a frame and this shot has to breathe. """ def frame(self, k, u, e): t = (self.i0+k)/FPS key = self.p["who"]; per = PEOPLE[key] b = block_of_person(key) c, r = b["col"], b["row"] cam = Cam(c*BU+BU/2, r*BU+BU/2, lerp(29.0, 25.5, ease_io(u))) img = room_bg(cam, t, "table") for p in b["patches"]: # the geometric block, under fill_patch(img, cam, p, None, None) ch = portrait_chart(per["figure"]) fab = FABRICS[per["fab"]] cw, chh = BU/PCW, BU/PCH x0, y0 = c*BU, r*BU mixv = float(np.clip(math.sin(u*math.pi)**0.5, 0, 1)) xs, ys = cam.grid(); X, Y = np.meshgrid(xs, ys) CI = np.floor((X-x0)/cw).astype(np.int64) RI = np.floor((Y-y0)/chh).astype(np.int64) inside = (CI >= 0) & (CI < PCW) & (RI >= 0) & (RI < PCH) if not inside.any() or mixv <= 0.01: return img Cc = np.clip(CI, 0, PCW-1); Rr = np.clip(RI, 0, PCH-1) # dissolve order: a fixed shuffle of the cells, revealed with mixv rng = np.random.RandomState(6100 + sum(ord(z) for z in key)) rank = np.empty(PCH*PCW, np.int32) rank[np.argsort(rng.rand(PCH*PCW))] = np.arange(PCH*PCW) RANK = rank.reshape(PCH, PCW)[Rr, Cc] live = inside & (RANK < int(mixv*PCH*PCW)) if not live.any(): return img # per-cell grain: alternate cells are turned 90 degrees, and each has # its own offset into the cloth, so no two squares repeat swap = ((Rr+Cc) % 2) == 1 ou = ((Cc*13 + Rr*29) % 37).astype(np.float32) ov = ((Cc*7 + Rr*19) % 41).astype(np.float32) U = np.where(swap, Y, X)*TPU + ou V = np.where(swap, X, Y)*TPU + ov tone = (0.34 + 1.42*ch[Rr, Cc]).astype(np.float32) worn = 0.75 + 0.55*(((Rr*7 + Cc*3) % 5)/5.0) flat = 0.62 if max(fab.nw, fab.ne) > 1 else 0.0 rgb = cloth_rgb(fab, U, V, cam.ppu/TPU, tone=tone[..., None], worn=worn.astype(np.float32), flat=flat) a2 = live.astype(np.float32)[..., None] img = img*(1-a2) + rgb*a2 # the cell seams, so it reads as pieced rather than printed segs = [] for ci in range(PCW+1): segs.append(((x0+ci*cw, y0), (x0+ci*cw, y0+BU))) for ri in range(PCH+1): segs.append(((x0, y0+ri*chh), (x0+BU, y0+ri*chh))) sunk = Image.new("L", (W, H), 0) sink_lines(sunk, cam, segs, max(1.0, cam.ppu*0.055)) lam = puff(sunk, cam, strength=0.75) lam = 1.0 + (lam-1.0)*live.astype(np.float32) return img*lam[..., None] class Needle(Base): """MACRO. The needle goes in on the beat and the thread pulls through, dimpling the cloth. At this range the weave is the picture.""" def frame(self, k, u, e): t = (self.i0+k)/FPS fab = FABRICS[self.p.get("fab", "chambray")] span = self.p.get("span", 4.2) cx = self.p.get("cx", 20.0) + 0.9*math.sin(t*0.5) cy = self.p.get("cy", 20.0) + 0.5*math.cos(t*0.4) cam = Cam(cx, cy, span) xs, ys = cam.grid(); X, Y = np.meshgrid(xs, ys) img = cloth_rgb(fab, X*TPU, Y*TPU, cam.ppu/TPU, tone=1.0) si, sph = stitch_phase(t) rng = np.random.RandomState(4400 + si) # the stitch line marches left to right across the macro field sy = cy + 0.10*math.sin(si*1.7) step = span/7.0 hx = cx - span*0.26 + ((si % 5)/4.0)*span*0.52 # cloth dimples where the thread pulls sunk = Image.new("L", (W, H), 0) segs = [((hx-step*6.5, sy), (hx+step*0.5, sy))] sink_lines(sunk, cam, segs, max(2.0, cam.ppu*0.06)) lam = puff(sunk, cam, strength=1.5, loft=0.55) img = img*lam[..., None] im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) # the finished stitches behind the needle running_stitch(d, cam, (hx-step*6.5, sy), (hx-step*0.3, sy), thread=(244, 236, 176), sc=1.0, seed=si) # the needle: down on the first half of the beat, up on the second pierce = math.sin(sph*math.pi) px, py = cam.q2p(hx, sy) L = cam.ppu*span*0.60 ang = -0.86 + 0.07*math.sin(t*2.0) nx = px + math.cos(ang)*L*(1.0-0.35*pierce) ny = py + math.sin(ang)*L*(1.0-0.35*pierce) # thread trailing from the eye, slack tr = [] for i in range(16): uu = i/15.0 tx = nx + math.cos(ang)*L*0.75*uu + scf(34)*math.sin(uu*3.2+t*2.2) ty = ny + math.sin(ang)*L*0.75*uu + scf(60)*uu*uu tr.append((tx, ty)) tw = max(2, int(cam.ppu*0.062)) d.line([(x+tw*0.5, y+tw*0.7) for x, y in tr], fill=(96, 82, 66), width=int(tw*1.3)) d.line(tr, fill=(238, 230, 164), width=tw) # the hole the needle is in hr = cam.ppu*0.11 d.ellipse([px-hr, py-hr*0.66, px+hr, py+hr*0.66], fill=(38, 31, 26)) draw_needle(d, px, py, ang, L*(1.0-0.32*pierce)) return np.asarray(im, np.float32) class Piece(Base): """MID. Two patches brought together and joined, seam allowance flipped open, one stitch per beat marching down the seam.""" def frame(self, k, u, e): t = (self.i0+k)/FPS f1 = FABRICS[self.p.get("a", "chambray")] f2 = FABRICS[self.p.get("b", "muslin")] cam = Cam(0.0, 1.5, 34.0) img = room_bg(cam, t, "table") close = ease_io(float(np.clip(u*2.2, 0, 1))) gap = (1.0-close)*5.0 pa = Patch(_rect(-11-gap, -8, -0.2-gap, 8), f1, 1.0) pb = Patch(_rect(0.2+gap, -8, 11+gap, 8), f2, 1.0) pa.grain = 0.0; pa.off = (5, 11); pa.worn = 1.0 pb.grain = math.pi/2; pb.off = (17, 3); pb.worn = 1.0 fill_patch(img, cam, pa, None, None) fill_patch(img, cam, pb, None, None) sunk = Image.new("L", (W, H), 0) segs = [((-11-gap, -8), (-0.2-gap, -8)), ((-11-gap, 8), (-0.2-gap, 8)), ((0.2+gap, -8), (11+gap, -8)), ((0.2+gap, 8), (11+gap, 8))] if close > 0.98: segs.append(((0.0, -8), (0.0, 8))) sink_lines(sunk, cam, segs, max(1.0, cam.ppu*0.09)) lam = puff(sunk, cam, strength=1.2) img = img*lam[..., None] im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) if close > 0.98: sew = float(np.clip((u-0.42)/0.5, 0, 1)) running_stitch(d, cam, (0.0, -8), (0.0, 8), frac=sew, thread=(240, 232, 212), sc=1.0, seed=7) if sew < 1.0: py = -8 + 16*sew px, py2 = cam.q2p(0.0, py) draw_needle(d, px, py2, -1.05, cam.ppu*3.2) if self.p.get("hands", True): draw_hand(d, W*0.07, H*0.90, 1.7*SC, ang=-0.52, grip=0.40) draw_hand(d, W*0.96, H*0.94, 1.7*SC, ang=0.52, mirror=True, grip=0.55) return np.asarray(im, np.float32) class Garment(Base): """The clothes themselves, laid out flat, and the square coming out.""" bg = "table" def frame(self, k, u, e): t = (self.i0+k)/FPS who = self.p["who"]; per = PEOPLE[who] fab = FABRICS[per["fab"]] cam = Cam(0.0, 0.0, lerp(42, 37, ease_io(u))) img = room_bg(cam, t, "table") parts, cut, det = garment_polys(per["garm"]) S = 36.0 def M(x, y): return ((x-0.5)*S, (y-0.5)*S*1.05) segs = [] for poly, tone in parts: qp = [M(x, y) for x, y in poly] p = Patch(qp, fab, tone=tone) p.grain = 0.0; p.off = (3, 9); p.worn = 1.15 fill_patch(img, cam, p, None, None) for i in range(len(qp)): segs.append((qp[i], qp[(i+1) % len(qp)])) cutp = [M(cut[0], cut[1]), M(cut[2], cut[1]), M(cut[2], cut[3]), M(cut[0], cut[3])] lift = float(np.clip((u-0.58)/0.38, 0, 1)) if lift > 0.0: segs.extend([(cutp[i], cutp[(i+1) % 4]) for i in range(4)]) sunk = Image.new("L", (W, H), 0) sink_lines(sunk, cam, segs, max(1.0, cam.ppu*0.08)) lam = puff(sunk, cam, strength=0.85) img = img*lam[..., None] im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) for it in det: # buttons, pockets if it[0] == "btn": px, py = cam.q2p(*M(it[1], it[2])) rr = cam.ppu*0.45 d.ellipse([px-rr, py-rr, px+rr, py+rr], fill=(226, 222, 164), outline=(140, 132, 116), width=sci(2)) d.ellipse([px-rr*0.24, py-rr*0.24, px+rr*0.24, py+rr*0.24], fill=(150, 142, 126)) elif it[0] in ("pkt", "belt"): a2 = cam.q2p(*M(it[1], it[2])); b2 = cam.q2p(*M(it[3], it[4])) d.rectangle([a2, b2], outline=(60, 52, 44), width=sci(2)) running_stitch(d, cam, M(it[1], it[2]), M(it[3], it[2]), thread=(226, 216, 194), sc=0.8, seed=3) # the tailor's chalk square she is going to cut on, and the pins for i in range(4): a3 = cam.q2p(*cutp[i]); b3 = cam.q2p(*cutp[(i+1) % 4]) L3 = math.hypot(b3[0]-a3[0], b3[1]-a3[1]); nseg = max(3, int(L3/14)) for q in range(nseg): f3, g3 = q/nseg, (q+0.55)/nseg d.line([(a3[0]+(b3[0]-a3[0])*f3, a3[1]+(b3[1]-a3[1])*f3), (a3[0]+(b3[0]-a3[0])*g3, a3[1]+(b3[1]-a3[1])*g3)], fill=(238, 232, 176), width=sci(2)) for i, (fx3, fy3) in enumerate(((0.15, 0.5), (0.5, 0.12), (0.85, 0.5), (0.5, 0.88))): qx3 = cutp[0][0] + (cutp[2][0]-cutp[0][0])*fx3 qy3 = cutp[0][1] + (cutp[2][1]-cutp[0][1])*fy3 ppx, ppy = cam.q2p(qx3, qy3) d.line([ppx-cam.ppu*0.9, ppy-cam.ppu*0.35, ppx+cam.ppu*0.9, ppy+cam.ppu*0.35], fill=(206, 208, 172), width=sci(2)) d.ellipse([ppx+cam.ppu*0.75, ppy+cam.ppu*0.20, ppx+cam.ppu*1.15, ppy+cam.ppu*0.58], fill=(178, 46, 52)) # the scissors, mid-cut cu = float(np.clip((u-0.12)/0.46, 0, 1)) if 0.0 < cu < 1.0: per_pts = [cutp[0], cutp[1], cutp[2], cutp[3], cutp[0]] tot = sum(math.hypot(per_pts[i+1][0]-per_pts[i][0], per_pts[i+1][1]-per_pts[i][1]) for i in range(4)) run = cu*tot; acc = 0.0; pos = cutp[0]; ang = 0.0 for i in range(4): L = math.hypot(per_pts[i+1][0]-per_pts[i][0], per_pts[i+1][1]-per_pts[i][1]) if acc+L >= run: f = (run-acc)/L pos = (per_pts[i][0]+(per_pts[i+1][0]-per_pts[i][0])*f, per_pts[i][1]+(per_pts[i+1][1]-per_pts[i][1])*f) ang = math.atan2(per_pts[i+1][1]-per_pts[i][1], per_pts[i+1][0]-per_pts[i][0]) break acc += L # the cut line already made d.line([cam.q2p(*p2) for p2 in per_pts[:1]] + [cam.q2p(*pos)], fill=(70, 60, 50), width=sci(2)) sx, sy = cam.q2p(*pos) draw_scissors(d, sx, sy, ang+math.pi, cam.ppu*0.115, open_a=0.16+0.10*math.sin(t*14)) if lift > 0.0: img2 = np.asarray(im, np.float32) off = lift*8.0 q = Patch([(x+off*0.5, y-off*1.2) for x, y in cutp], fab, tone=1.06) q.grain = 0.0; q.off = (3, 9); q.worn = 1.15 fill_patch(img2, cam, q, None, None) im = Image.fromarray(np.clip(img2, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) if lift > 0.25: draw_hand(d, *cam.q2p(cutp[1][0]+off*0.5, cutp[1][1]-off*1.2), 1.05, ang=0.7, mirror=True, grip=0.9) return np.asarray(im, np.float32) class Hands(Base): """Her hands over the work — thimble, needle, the block under them.""" def frame(self, k, u, e): t = (self.i0+k)/FPS b = QBLOCKS[self.p.get("block", 0)] c, r = b["col"], b["row"] cam = Cam(c*BU+BU/2, r*BU+BU/2 + 2.0, lerp(30, 27, ease_io(u))) img = compose_quilt(cam, t, e, fray=False) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) si, sph = stitch_phase(t) bobx = 12*math.sin(si*1.1); boby = 9*math.sin(sph*math.pi) draw_hand(d, W*0.20+bobx, H*0.90 - boby*0.4, 2.1*SC, ang=-0.42, grip=0.35) draw_hand(d, W*0.82-bobx*0.6, H*0.93, 2.1*SC, ang=0.40, mirror=True, grip=0.55) draw_thimble(d, W*0.795-bobx*0.6, H*0.71, 2.6*SC) nx, ny = W*0.44+bobx*0.6, H*0.62 - boby draw_needle(d, nx, ny, -0.9, scf(120)) tr = [(nx + 8*i + 20*math.sin(i*0.7+t*2.0), ny - 6*i - i*i*1.6) for i in range(10)] d.line(tr, fill=(140, 124, 104), width=sci(4)) d.line([(x, y-SC) for x, y in tr], fill=(238, 230, 168), width=sci(2)) return np.asarray(im, np.float32) class Quilting(Base): """The crosshatch going on over the finished top, raking light, and the batting rising between the lines.""" def frame(self, k, u, e): t = (self.i0+k)/FPS mode = self.p.get("mode", "mid") if mode == "macro": cam = Cam(QW*0.40 + 7*u, QH*0.34, lerp(24, 19, ease_io(u))) elif mode == "mid2": cam = Cam(QW*0.5, QH*0.5, lerp(56, 80, ease_io(u))) # pull out else: cam = Cam(QW*0.30 + 10*u, QH*0.62, lerp(46, 40, ease_io(u))) img = compose_quilt(cam, t, e, raking=1.0) if mode == "macro": im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) ql, qu = quilt_lines(t) if ql: a2, b2 = ql[min(len(ql)-1, int(qu*len(ql)))] si, sph = stitch_phase(t) fx = float(np.clip((t-26*BAR)/(4*BAR)*1.6 % 1.0, 0, 1)) px, py = cam.q2p(lerp(a2[0], b2[0], fx), lerp(a2[1], b2[1], fx)) if -200 < px < W+200 and -200 < py < H+200: draw_needle(d, px, py, -0.9, cam.ppu*1.6) img = np.asarray(im, np.float32) return img class Basket(Base): """The scrap basket. Everyone who is left, folded.""" def frame(self, k, u, e): t = (self.i0+k)/FPS cam = Cam(0.0, 0.0, lerp(44, 39, ease_io(u))) img = room_bg(cam, t, "table") rng = np.random.RandomState(8800) keys = ["plaid", "gingham", "chambray", "calico", "ticking", "serge", "denim", "shirting", "calico2", "gingham_r", "wool", "muslin", "turkey"] n = len(keys) take = self.p.get("take", n) for i in range(min(n, take)): fab = FABRICS[keys[i]] a = i/max(1, n-1) cx = -14 + (i % 4)*9.3 + rng.uniform(-1.4, 1.4) cy = -11 + (i // 4)*7.6 + rng.uniform(-1.0, 1.0) folded_cloth(img, cam, fab, cx, cy, 11.0, 7.2, rng.uniform(-0.16, 0.16), 900+i*13) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) # the basket rim p0 = cam.q2p(-19.5, -15.0); p1 = cam.q2p(19.5, 16.0) d.arc([p0[0], p0[1], p1[0], p1[1]], 0, 360, fill=(122, 86, 48), width=max(3, int(cam.ppu*0.55))) for q in range(48): a2 = q*math.tau/48 xx = (p0[0]+p1[0])/2 + math.cos(a2)*(p1[0]-p0[0])/2 yy = (p0[1]+p1[1])/2 + math.sin(a2)*(p1[1]-p0[1])/2 d.ellipse([xx-cam.ppu*0.24, yy-cam.ppu*0.18, xx+cam.ppu*0.24, yy+cam.ppu*0.18], fill=(146, 104, 58)) if self.p.get("hand"): draw_hand(d, W*0.74, H*0.92, 2.0*SC, ang=0.3, mirror=True, grip=0.7) return np.asarray(im, np.float32) def warp_image(img, MX, MY): """Bilinear resample of a float32 image at (MX,MY) pixel coordinates.""" h, w = img.shape[:2] x = np.clip(MX, 0, w-1.001); y = np.clip(MY, 0, h-1.001) x0 = x.astype(np.int32); y0 = y.astype(np.int32) x1 = x0+1; y1 = y0+1 fx = (x-x0)[..., None]; fy = (y-y0)[..., None] a = img[y0, x0]; b = img[y0, x1]; c = img[y1, x0]; dd = img[y1, x1] return (a*(1-fx)+b*fx)*(1-fy) + (c*(1-fx)+dd*fx)*fy class Throw(Base): """The quilt goes up and comes down. Rendered flat, then draped: the finished top is warped by a travelling fold and lit by the fold's own gradient, so the cloth actually billows instead of sliding.""" bg = "bed" def frame(self, k, u, e): t = (self.i0+k)/FPS cam = Cam(QW/2, QH*0.48, lerp(104, 74, ease_io(u))) flat = compose_quilt(cam, t, e, bg="bed", raking=0.6) yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) ph = self.p.get("ph", 0.0) au = 0.35 + 0.65*math.sin(min(1.0, u*1.05)*math.pi) amp = 30.0*au*SC kx = 2.0*math.pi/ (W*0.55) wave = np.sin(xx*kx + t*4.2 + ph) * np.cos(yy*kx*0.55 - t*2.1) fall = (1.0-ease_out(u))*H*0.12 MX = xx + wave*amp*0.5 MY = yy + wave*amp*1.3 - fall*(1.0-yy/H*0.4) out = warp_image(flat, MX, MY) # the fold's own shading gy, gx = np.gradient(wave) lam = np.clip(1.0 - (gx*1.5 + gy*1.1)*amp*0.55, 0.50, 1.62) out = out*lam[..., None] # the room the quilt is falling into vign = np.clip(1.16 - 0.34*np.abs((yy-H*0.52)/(H*0.72)), 0.55, 1.20) return out*vign[..., None]*np.array([1.03, 0.98, 0.94], np.float32) class Child(Base): """The landing. The quilt over a sleeping child: the surface breathes, the batting stands up in raking light, one small hand at the binding.""" bg = "bed" def frame(self, k, u, e): t = (self.i0+k)/FPS mode = self.p.get("mode", "wide") breath = math.sin(t*0.72) if mode == "wide": cam = Cam(QW*0.5, QH*0.33, lerp(84, 77, ease_io(u))) elif mode == "hand": cam = Cam(QW*0.13, QH*0.66, lerp(20, 17, ease_io(u))) else: cam = Cam(QW*0.42, QH*0.16, lerp(48, 42, ease_io(u))) flat = compose_quilt(cam, t, e, bg="bed", raking=1.2) yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) # the body under the cloth: two broad gaussian rises, one breathing def gauss(cx, cy, sx, sy): return np.exp(-(((xx-cx)/sx)**2 + ((yy-cy)/sy)**2)) body = (gauss(W*0.52, H*0.72, W*0.30, H*0.34)*1.0 + gauss(W*0.40, H*0.44, W*0.13, H*0.16)*0.55) body = body*(1.0 + 0.06*breath) gy, gx = np.gradient(body) MX = xx - gx*2400.0*SC*SC; MY = yy - gy*2400.0*SC*SC out = warp_image(flat, MX, MY) lam = np.clip(1.0 - (gx*0.55 + gy*0.95)*95.0*SC, 0.62, 1.42) out = out*(lam*(0.90 + 0.22*body))[..., None] # lamplight from the door, low and warm r = np.sqrt(((xx-W*0.18)/(W*1.05))**2 + ((yy-H*0.10)/(H*1.30))**2) out = out*np.clip(1.34-0.62*r, 0.40, 1.34)[..., None] out = out*np.array([1.02, 0.97, 0.94], np.float32) im = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) if mode in ("wide", "mid"): # a sheet, a pillow, and a child asleep in it sy0 = cam.q2p(0, -1.0)[1] d.rectangle([0, sy0-cam.ppu*14.0, W, sy0+scf(2)], fill=(150, 132, 110)) d.rectangle([0, sy0-cam.ppu*1.2, W, sy0+scf(2)], fill=(190, 172, 144)) hx, hy = cam.q2p(QW*0.40, -3.6) rr = cam.ppu*5.0 if -400 < hx < W+400: d.ellipse([hx-rr*2.0, hy-rr*1.15, hx+rr*2.0, hy+rr*1.05], fill=(206, 198, 180)) # pillow d.ellipse([hx-rr*1.95, hy+rr*0.45, hx+rr*1.95, hy+rr*1.05], fill=(178, 170, 152)) # its shadow d.ellipse([hx-rr*0.80, hy-rr*0.74, hx+rr*0.80, hy+rr*0.80], fill=(176, 136, 106)) # head d.chord([hx-rr*0.92, hy-rr*0.98, hx+rr*0.86, hy+rr*0.42], 188, 356, fill=(58, 42, 34)) # hair for sgn in (-1, 1): # shut eyes d.arc([hx+sgn*rr*0.10-rr*0.24, hy+rr*0.02, hx+sgn*rr*0.10+rr*0.24, hy+rr*0.30], 200, 340, fill=(96, 68, 54), width=max(2, int(rr*0.06))) d.arc([hx-rr*0.20, hy+rr*0.30, hx+rr*0.20, hy+rr*0.56], 20, 160, fill=(150, 104, 88), width=max(2, int(rr*0.05))) d.ellipse([hx-rr*0.62, hy+rr*0.16, hx-rr*0.30, hy+rr*0.44], fill=(196, 150, 120)) # cheeks d.ellipse([hx+rr*0.30, hy+rr*0.16, hx+rr*0.62, hy+rr*0.44], fill=(196, 150, 120)) if mode == "hand": # a child's hand come out from under the quilt and gone slack: # wrist off the left of frame, palm down, fingers just parted hx, hy = W*0.52, H*0.46 sc2 = cam.ppu*0.50*(1.0 + 0.012*breath) SK, SK2 = (206, 166, 134), (176, 134, 106) d.ellipse([hx-2.4*sc2, hy-1.6*sc2, hx+2.8*sc2, hy+3.0*sc2], fill=(96, 78, 66)) # its shadow d.polygon([(hx-6.2*sc2, hy-1.5*sc2), (hx-1.4*sc2, hy-2.1*sc2), (hx-1.0*sc2, hy+1.9*sc2), (hx-6.2*sc2, hy+1.5*sc2)], fill=SK2) # wrist d.ellipse([hx-2.6*sc2, hy-2.3*sc2, hx+1.9*sc2, hy+2.2*sc2], fill=SK) # palm for i, (ang2, ln) in enumerate(((-0.62, 2.5), (-0.22, 2.9), (0.14, 2.8), (0.48, 2.3))): bx = hx + 1.3*sc2 + 0.15*sc2*i by = hy - 1.5*sc2 + 1.0*sc2*i ex = bx + math.cos(ang2)*ln*sc2 ey = by + math.sin(ang2)*ln*sc2 d.line([(bx, by), (ex, ey)], fill=SK, width=max(3, int(0.80*sc2))) d.ellipse([ex-0.42*sc2, ey-0.42*sc2, ex+0.42*sc2, ey+0.42*sc2], fill=SK) d.ellipse([ex-0.26*sc2, ey-0.30*sc2, ex+0.20*sc2, ey+0.06*sc2], fill=(226, 198, 176)) # nail tx = hx - 1.0*sc2; ty = hy + 1.7*sc2 d.line([(tx, ty), (tx+1.6*sc2, ty+1.5*sc2)], fill=SK, width=max(3, int(0.92*sc2))) d.ellipse([tx+1.2*sc2, ty+1.1*sc2, tx+2.0*sc2, ty+1.9*sc2], fill=SK) d.arc([hx-2.4*sc2, hy-2.1*sc2, hx+1.7*sc2, hy+2.0*sc2], 210, 330, fill=(228, 196, 172), width=max(2, int(0.20*sc2))) return np.asarray(im, np.float32) class Label(Base): """The quilt label: muslin, embroidered, sewn into the corner.""" def frame(self, k, u, e): t = (self.i0+k)/FPS cam = Cam(QW*0.76, QH*0.80, lerp(38, 33, ease_io(u))) img = compose_quilt(cam, t, e, bg="bed", raking=0.9) # the muslin label patch lp = _rect(QW*0.80-7.5, QH*0.86-4.6, QW*0.80+7.5, QH*0.86+4.6) p = Patch(lp, FABRICS["muslin"], tone=1.10) p.grain = 0.0; p.off = (11, 5); p.worn = 0.5 fill_patch(img, cam, p, None, None) sunk = Image.new("L", (W, H), 0) sink_lines(sunk, cam, [(lp[i], lp[(i+1) % 4]) for i in range(4)], max(1.0, cam.ppu*0.09)) lam = puff(sunk, cam, strength=1.0) img = img*lam[..., None] im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) for i in range(4): running_stitch(d, cam, lp[i], lp[(i+1) % 4], thread=(210, 180, 150), sc=0.9, seed=40+i) f = font(max(11, int(cam.ppu*1.7)), "Georgia Italic.ttf") f2 = font(max(9, int(cam.ppu*1.25)), "Georgia.ttf") lines = [("for June", f), ("from all of us", f2), ("1974", f2)] y = cam.q2p(0, QH*0.86-3.3)[1] for txt, ff in lines: tw = d.textlength(txt, font=ff) cxp = cam.q2p(QW*0.80, 0)[0] # embroidered: a dark backstitch, then the floss on top d.text((cxp-tw/2+2, y+2), txt, font=ff, fill=(120, 96, 78)) d.text((cxp-tw/2, y), txt, font=ff, fill=(78, 58, 48)) y += ff.size*1.25 return np.asarray(im, np.float32) # ════════════════════════════════════════════════════════════════════════════ # THE WOMAN # # Round 1 shot only her hands. She is the subject of the film, so she gets a # body: a chair, a lamp, a shawl, spectacles, and a needle that rises and # falls on the same stitch clock the banjo does. The quilt on her lap is the # actual quilt, rendered by the cloth engine and masked into the lap polygon, # so it fills in across the film in step with the block shots. # ════════════════════════════════════════════════════════════════════════════ GSKIN, GSKIN2 = (206, 168, 138), (172, 134, 108) GHAIR, GHAIR2 = (234, 232, 226), (196, 194, 188) GCARD, GCARD2 = (108, 116, 132), (78, 86, 102) GSHAWL, GSHAWL2 = (150, 86, 70), (114, 60, 50) GSKIRT, GSKIRT2 = (60, 54, 66), (42, 38, 48) GCHAIR, GCHAIR2 = (104, 68, 40), (74, 46, 26) def _mask_poly(pts, blur=2.0): m = Image.new("L", (W, H), 0) ImageDraw.Draw(m).polygon(pts, fill=255) if blur: m = m.filter(ImageFilter.GaussianBlur(blur*SC)) return np.asarray(m, np.float32)[..., None]/255.0 def parlor_bg(t, e, night=False): """The room she works in: striped paper, a lamp, one framed photograph.""" im = Image.new("RGB", (W, H), (46, 34, 30) if not night else (26, 22, 28)) d = ImageDraw.Draw(im) fl = int(H*0.78) for x in range(sci(-20), W+sci(40), sci(46)): # wallpaper stripes c = (56, 41, 36) if not night else (32, 27, 34) d.rectangle([x, 0, x+sci(22), fl], fill=c) d.rectangle([0, fl-sci(16), W, fl], fill=(70, 48, 34)) # skirting d.rectangle([0, fl, W, H], fill=(84, 56, 36)) # floorboards for y in range(fl, H+sci(40), sci(34)): d.line([(0, y), (W, y)], fill=(66, 43, 27), width=sci(3)) for x in range(0, W+sci(60), sci(190)): d.line([(x, fl), (x-scf(70), H)], fill=(66, 43, 27), width=sci(2)) # a framed photograph, because everyone in the quilt used to be in a frame fx, fy = W*0.72, H*0.20 d.rectangle([fx-scf(72), fy-scf(92), fx+scf(72), fy+scf(92)], fill=(96, 66, 40)) d.rectangle([fx-scf(58), fy-scf(78), fx+scf(58), fy+scf(78)], fill=(150, 138, 116)) d.ellipse([fx-scf(26), fy-scf(44), fx+scf(26), fy+scf(16)], fill=(112, 100, 86)) d.polygon([(fx-scf(44), fy+scf(78)), (fx-scf(30), fy+scf(6)), (fx+scf(30), fy+scf(6)), (fx+scf(44), fy+scf(78))], fill=(112, 100, 86)) # the lamp, upper left, and its pool lx, ly = W*0.13, H*0.30 flick = 0.90 + 0.10*math.sin(t*5.1) + 0.05*e["rms"] for q in range(9, 0, -1): r = q*scf(54) v = (q*0.11) d.ellipse([lx-r, ly-r, lx+r, ly+r], fill=(int(46+30*(10-q)*0.10*flick + (0 if not night else -18)), int(34+24*(10-q)*0.10*flick), int(28+13*(10-q)*0.10*flick))) d.polygon([(lx-scf(40), ly+scf(66)), (lx+scf(40), ly+scf(66)), (lx+scf(26), ly-scf(6)), (lx-scf(26), ly-scf(6))], fill=(214, 178, 116)) # shade d.rectangle([lx-scf(9), ly+scf(66), lx+scf(9), ly+scf(150)], fill=(158, 122, 74)) d.ellipse([lx-scf(40), ly+scf(142), lx+scf(40), ly+scf(166)], fill=(158, 122, 74)) d.ellipse([lx-scf(20), ly+scf(12), lx+scf(20), ly+scf(62)], fill=(int(252*flick), int(232*flick), int(180*flick))) a = np.asarray(im, np.float32) yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) r = np.sqrt(((xx-W*0.16)/(W*1.02))**2 + ((yy-H*0.26)/(H*1.20))**2) a = a*np.clip(1.42-0.86*r, 0.22, 1.42)[..., None] return a def bedroom_bg(t, e): im = Image.new("RGB", (W, H), (24, 21, 28)) d = ImageDraw.Draw(im) d.rectangle([0, 0, W*0.20, H], fill=(16, 14, 20)) # door jamb for q in range(8, 0, -1): # light spill d.polygon([(W*0.14, 0), (W*0.20+q*scf(46), 0), (W*0.20+q*scf(62), H), (W*0.10, H)], fill=(int(30+7*(9-q)), int(25+6*(9-q)), int(22+4*(9-q)))) d.rectangle([0, H*0.62, W, H], fill=(38, 33, 40)) # the bed d.rectangle([0, H*0.60, W, H*0.64], fill=(58, 50, 58)) a = np.asarray(im, np.float32) yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) r = np.sqrt(((xx-W*0.12)/(W*1.05))**2 + ((yy-H*0.10)/(H*1.35))**2) return a*np.clip(1.40-0.78*r, 0.26, 1.40)[..., None] def draw_chair(d, x, y, sc): def Q(u, v): return (x+u*sc, y+v*sc) d.polygon([Q(-150, 6), Q(-30, 6), Q(-24, 30), Q(-150, 30)], fill=GCHAIR2) for u in range(-142, -50, 18): # spindles d.line([Q(u, 6), Q(u-14, -190)], fill=GCHAIR, width=max(2, int(7*sc))) d.line([Q(-152, -186), Q(-56, -196)], fill=GCHAIR, width=max(3, int(13*sc))) d.line([Q(-158, -120), Q(-52, -128)], fill=GCHAIR2, width=max(2, int(8*sc))) d.line([Q(-146, 30), Q(-134, 206)], fill=GCHAIR2, width=max(2, int(9*sc))) d.line([Q(-40, 30), Q(-30, 206)], fill=GCHAIR2, width=max(2, int(9*sc))) d.line([Q(-186, 206), Q(24, 214)], fill=GCHAIR, width=max(3, int(11*sc))) def draw_granny(d, x, y, sc, t, e, pose="sit", rot=0.0, sph=0.0): """Seated, facing her work. `sph` is the stitch phase 0..1: the needle hand rises through it and comes down on the stitch.""" ca, sa2 = math.cos(rot), math.sin(rot) def P(u, v): return (x + (u*ca - v*sa2)*sc, y + (u*sa2 + v*ca)*sc) stand = pose == "stand" # ── skirt / legs ── if stand: d.polygon([P(-58, -18), P(34, -22), P(66, 96), P(76, 214), P(-56, 216), P(-72, 96)], fill=GSKIRT) d.polygon([P(-56, 216), P(76, 214), P(80, 232), P(-60, 234)], fill=(30, 24, 22)) else: d.polygon([P(-80, -16), P(16, -24), P(122, 16), P(156, 60), P(160, 140), P(96, 148), P(72, 100), P(-70, 120)], fill=GSKIRT) d.polygon([P(96, 140), P(158, 138), P(168, 208), P(112, 210)], fill=GSKIRT2) d.polygon([P(104, 202), P(184, 200), P(186, 220), P(100, 222)], fill=(30, 24, 22)) d.line([P(-30, -10), P(60, 88)], fill=GSKIRT2, width=max(2, int(5*sc))) # ── torso, shawl ── d.polygon([P(-50, -16), P(28, -24), P(40, -130), P(-34, -126)], fill=GCARD) d.polygon([P(-58, -120), P(44, -128), P(60, -52), P(-6, -22), P(-56, -54)], fill=GSHAWL) d.line([P(-58, -120), P(-56, -54)], fill=GSHAWL2, width=max(2, int(4*sc))) for q in range(9): # shawl fringe u0 = -54 + q*13 v0 = -54 + abs(q-4)*3.5 d.line([P(u0, v0), P(u0-2, v0+16 + 3*math.sin(t*2 + q))], fill=GSHAWL2, width=max(1, int(2.6*sc))) d.polygon([P(-6, -142), P(24, -146), P(26, -118), P(-4, -114)], fill=GSKIN2) # ── head ── hu, hv = 16, -172 rot_ellipse(d, P, hu-42, hv-30, hu+18, hv+42, n=26, fill=GHAIR2) # bun rot_ellipse(d, P, hu-34, hv-40, hu+36, hv+42, n=28, fill=GSKIN) # hair: a cap over the crown, swept back rot_ellipse(d, P, hu-38, hv-44, hu+30, hv-2, n=26, fill=GHAIR) rot_ellipse(d, P, hu-52, hv-16, hu-8, hv+30, n=22, fill=GHAIR) for q in range(5): d.line([P(hu-40+q*8, hv-30), P(hu-52+q*6, hv+6)], fill=GHAIR2, width=max(1, int(2*sc))) d.polygon([P(hu+34, hv+2), P(hu+52, hv+14), P(hu+34, hv+18)], fill=GSKIN) # nose rot_ellipse(d, P, hu-14, hv+4, hu-2, hv+18, n=14, fill=GSKIN2) # ear # spectacles for du, rr in ((30, 13), (6, 15)): rot_ellipse(d, P, hu+du-rr, hv-rr+2, hu+du+rr, hv+rr+2, n=20, outline=(72, 66, 60), width=max(1, int(2.4*sc))) d.line([P(hu+15, hv+2), P(hu+19, hv+2)], fill=(72, 66, 60), width=max(1, int(2*sc))) d.line([P(hu-9, hv+2), P(hu-30, hv+6)], fill=(72, 66, 60), width=max(1, int(2*sc))) d.line([P(hu+22, hv-6), P(hu+34, hv-3)], fill=(238, 240, 246), width=max(1, int(2.4*sc))) d.arc([P(hu+16, hv+22)[0]-9*sc, P(hu+16, hv+22)[1]-5*sc, P(hu+16, hv+22)[0]+9*sc, P(hu+16, hv+22)[1]+9*sc], 10, 170, fill=(142, 96, 84), width=max(1, int(2.2*sc))) for q in range(3): # a few lines d.line([P(hu+26+q*3, hv+16+q*5), P(hu+38+q*2, hv+15+q*5)], fill=GSKIN2, width=max(1, int(1.6*sc))) # ── arms ── lift = math.sin(min(1.0, sph)*math.pi) if pose == "reach": ha = (-96, 150); hb = (48, -30) elif pose == "tie": ha = (52, -142); hb = (86, -122) elif pose == "stand": ha = (66, -34); hb = (-18, -30) elif pose == "lean": ha = (150, 30); hb = (128, -6) else: ha = (124, -22 - 34*lift); hb = (86, -6) for (tu, tv), sh_u, sh_v, col in ((ha, 8, -122, GCARD), (hb, -26, -116, GCARD2)): eu = (sh_u + tu)/2 + 18; ev = (sh_v + tv)/2 + 18 d.line([P(sh_u, sh_v), P(eu, ev), P(tu, tv)], fill=col, width=max(3, int(24*sc)), joint="curve") rot_ellipse(d, P, tu-15, tv-13, tu+15, tv+13, n=18, fill=GSKIN) for q in range(3): rot_ellipse(d, P, tu+2+q*7, tv-9+q*5, tu+13+q*7, tv+3+q*5, n=12, fill=GSKIN2) if pose in ("sit", "tie", "thread"): nx, ny = P(ha[0]+16, ha[1]-4) draw_needle(d, nx, ny, -0.85, 34*sc) bx2, by2 = P(76, 6) d.line([(nx, ny), ((nx+bx2)/2 + 14*sc, (ny+by2)/2), (bx2, by2)], fill=(238, 230, 168), width=max(1, int(2.4*sc)), joint="curve") class Granny(Base): """Her, working. The lap cloth is the real quilt, masked in.""" # hip x hip y scale pose room POSE = { "begin": (0.46, 0.62, 0.86, "sit", "parlor"), "work": (0.44, 0.64, 1.05, "sit", "parlor"), "thread": (0.38, 0.74, 1.45, "sit", "parlor"), "cu": (0.34, 0.92, 2.10, "sit", "parlor"), "reach": (0.50, 0.58, 0.90, "reach", "parlor"), "tie": (0.38, 0.76, 1.45, "tie", "parlor"), "rise": (0.50, 0.72, 0.94, "stand", "parlor"), "tuck": (0.24, 0.60, 1.00, "lean", "bedroom"), } def frame(self, k, u, e): t = (self.i0+k)/FPS mode = self.p.get("mode", "work") fx, fy, fs, pose, room = self.POSE[mode] sc = fs*SC*(1.0 + 0.035*ease_io(u)) # a slow push on every shot x, y = W*fx, H*fy rock = math.sin(t*0.55)*0.012 if pose == "sit" else 0.0 si, sph = stitch_phase(t) img = parlor_bg(t, e) if room == "parlor" else bedroom_bg(t, e) im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) if room == "bedroom": # the bed, the pillow, the child, then the quilt over all of it d.ellipse([W*0.62, H*0.50, W*0.92, H*0.66], fill=(196, 186, 166)) hx, hy = W*0.74, H*0.545 d.ellipse([hx-scf(42), hy-scf(40), hx+scf(42), hy+scf(44)], fill=(176, 136, 106)) d.chord([hx-scf(48), hy-scf(52), hx+scf(40), hy+scf(10)], 190, 356, fill=(58, 42, 34)) for sgn in (-1, 1): d.arc([hx+sgn*scf(6)-scf(13), hy+scf(2), hx+sgn*scf(6)+scf(13), hy+scf(18)], 200, 340, fill=(96, 68, 54), width=sci(3)) qimg = compose_quilt(Cam(QW*0.5, QH*0.5, 96), t, e, bg="bed", raking=1.0) drop = (1.0-ease_out(min(1.0, u*1.25)))*H*0.26 lap = [(W*0.16, H*0.60-drop), (W*1.05, H*0.55-drop), (W*1.05, H*1.02), (W*0.10, H*1.02)] m = _mask_poly(lap, blur=3) img = np.asarray(im, np.float32)*(1-m) + qimg*m im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) draw_granny(d, x, y, sc, t, e, pose="lean", rot=0.16, sph=sph) return np.asarray(im, np.float32) if pose == "sit": draw_chair(d, x, y, sc) draw_granny(d, x, y, sc, t, e, pose=pose, rot=rock, sph=sph) img = np.asarray(im, np.float32) if pose in ("sit", "tie", "reach"): # the quilt across her knees — the real one, growing as it grows qimg = compose_quilt(Cam(QW*0.5, QH*0.56, 74), t, e, bg="table", raking=0.55) g2 = 0.44 + 0.56*float(np.clip(t/(N_BARS*BAR), 0, 1)) def Pp(uu, vv): return (x + uu*sc*(g2 if uu < 0 else g2), y + vv*sc*g2 + 26*sc*(1-g2)) lap = [Pp(-92, -4), Pp(30, -20), Pp(176, 34), Pp(210, 128), Pp(120, 176), Pp(-96, 150)] m = _mask_poly(lap, blur=3) # drape shading: darker where the cloth falls away over the knees yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) lam = np.clip(1.16 - 0.55*np.abs((xx - x)/(W*0.62)) - 0.30*np.clip((yy - y)/(H*0.5), 0, 2), 0.42, 1.20) img = img*(1-m) + (qimg*lam[..., None])*m im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) # the working hand rides ON TOP of the cloth lift = math.sin(min(1.0, sph)*math.pi) hu, hv = (52, -142) if pose == "tie" else \ ((-96, 150) if pose == "reach" else (124, -22-34*lift)) hx2, hy2 = Pp(hu, hv) rot_ellipse(d, lambda uu, vv: (hx2+uu*sc, hy2+vv*sc), -15, -13, 15, 13, n=18, fill=GSKIN) for q in range(3): rot_ellipse(d, lambda uu, vv: (hx2+uu*sc, hy2+vv*sc), 2+q*7, -9+q*5, 13+q*7, 3+q*5, n=12, fill=GSKIN2) if pose != "reach": draw_needle(d, hx2+16*sc, hy2-4*sc, -0.85, 34*sc) img = np.asarray(im, np.float32) elif pose == "stand": # the quilt gathered in both arms qimg = compose_quilt(Cam(QW*0.5, QH*0.5, 62), t, e, bg="table", raking=0.8) def Pp(uu, vv): return (x+uu*sc, y+vv*sc) bun = [Pp(-104, -62), Pp(30, -104), Pp(146, -46), Pp(150, 62), Pp(24, 118), Pp(-116, 56)] m = _mask_poly(bun, blur=4) img = img*(1-m) + qimg*0.94*m im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) for (tu, tv) in ((66, -34), (-18, -30)): hx2, hy2 = Pp(tu, tv) rot_ellipse(d, lambda uu, vv: (hx2+uu*sc, hy2+vv*sc), -15, -13, 15, 13, n=18, fill=GSKIN) img = np.asarray(im, np.float32) if pose == "reach": # the scrap basket at her feet bx2, by2 = x - 150*sc, y + 176*sc im = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im) d.ellipse([bx2-84*sc, by2-38*sc, bx2+84*sc, by2+38*sc], fill=(120, 84, 46)) d.ellipse([bx2-76*sc, by2-30*sc, bx2+76*sc, by2+26*sc], fill=(64, 44, 26)) for q, cc in enumerate(((156, 92, 84), (104, 128, 150), (188, 172, 132), (128, 108, 84))): d.ellipse([bx2-60*sc+q*34*sc, by2-40*sc, bx2-16*sc+q*34*sc, by2-4*sc], fill=cc) for q in range(30): a2 = q*math.tau/30 d.ellipse([bx2+math.cos(a2)*84*sc-6*sc, by2+math.sin(a2)*38*sc-5*sc, bx2+math.cos(a2)*84*sc+6*sc, by2+math.sin(a2)*38*sc+5*sc], fill=(146, 104, 58)) img = np.asarray(im, np.float32) return img ENGINES = {"quilt": Quilt, "block": Block, "portrait": Portrait, "needle": Needle, "piece": Piece, "garment": Garment, "hands": Hands, "quilting": Quilting, "basket": Basket, "throw": Throw, "child": Child, "label": Label, "granny": Granny} # ════════════════════════════════════════════════════════════════════════════ # THE SHOT PLAN # ════════════════════════════════════════════════════════════════════════════ def P(eng, **kw): return (eng, kw) # The score. Every cut is written down: this piece has to build (a woman # alone with a basket) into a reveal (the quilt exists) into a landing (it is # on a child), and a shuffled pool cannot be trusted to put the throw before # the sleeping. Lengths are in BEATS; each section's beats sum to bars x 4. SCORE = { "intro": [ (3, P("granny", mode="begin")), (3, P("basket", take=13)), (2, P("needle", fab="muslin", cx=18, cy=19)), ], "v1": [ # her father (4, P("garment", who="father")), (3, P("needle", fab="chambray", cx=22, cy=21)), (3, P("granny", mode="work")), (3, P("block", block=0)), (3, P("portrait", who="father")), ], "v2": [ # her sister Ada (4, P("garment", who="ada")), (3, P("piece", a="calico", b="muslin")), (3, P("block", block=2)), (3, P("portrait", who="ada")), (3, P("granny", mode="thread")), ], "chorus1": [ # the reveal (5, P("quilt", mode="wide")), (3, P("hands", block=3)), (3, P("needle", fab="ticking", cx=36, cy=6)), (3, P("block", block=4)), (2, P("quilt", mode="corner", cell=(2, 0))), ], "v3": [ # her son (4, P("garment", who="son")), (3, P("needle", fab="serge", cx=22, cy=38)), (4, P("block", block=6)), (3, P("portrait", who="son")), (2, P("granny", mode="work")), ], "v4": [ # her mother (4, P("garment", who="mama")), (4, P("block", block=8)), (4, P("portrait", who="mama")), (4, P("granny", mode="cu")), ], "break": [ # everybody else, fast (3, P("granny", mode="reach")), (3, P("block", block=10)), (2, P("needle", fab="plaid", cx=6, cy=38)), (3, P("block", block=11)), (2, P("piece", a="plaid", b="muslin")), (3, P("quilt", mode="wide")), ], "chorus2": [ # quilting it (4, P("quilting", mode="mid")), (4, P("quilting", mode="macro")), (3, P("granny", mode="tie")), (5, P("quilting", mode="mid2")), ], "landing": [ # she carries it to the bed (3, P("granny", mode="rise")), (4, P("throw", ph=0.0)), (3, P("throw", ph=2.1)), (3, P("granny", mode="tuck")), (3, P("child", mode="wide")), (3, P("label")), (2, P("child", mode="hand")), (3, P("child", mode="wide")), ], } # section title cards / block labels CARDS = { "intro": "QUILT", "v1": None, "v2": None, "chorus1": None, "v3": None, "v4": None, "break": None, "chorus2": None, "landing": None, } PERSON_CARD = {"father": "FATHER · WORK SHIRT · WORN THIN AT THE SEAM", "ada": "ADA · SUNDAY DRESS · MARRIED IN THE SPRING", "son": "MY BOY · UNIFORM · SENT HOME ALONE", "mama": "MAMA · APRON · FLOUR WORN INTO THE BLUE"} class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "params", "section", "seed", "card") def __init__(self, idx, i0, i1, engine, params, section, card=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.params, self.section = engine, params, section self.seed = 60219 + idx*7717 self.card = card def build_shots(): shots = []; idx = 0 for nm, b0, b1 in SECTIONS: score = SCORE[nm] beats = sum(n for n, _ in score); want = (b1-b0)*4 if beats != want: raise SystemExit(f"section {nm}: score is {beats} beats, " f"section is {want} beats") t = b0*BAR for j, (nb, (eng, params)) in enumerate(score): t2 = t + nb*BEAT i0, i1 = int(round(t*FPS)), int(round(t2*FPS)) card = CARDS[nm] if j == 0 else None if eng in ("garment", "portrait") and "who" in params: card = PERSON_CARD[params["who"]] shots.append(Shot(idx, i0, i1, eng, params, nm, card)) idx += 1; t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> letterbox, then text (never shifted) # ════════════════════════════════════════════════════════════════════════════ # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) _FC = {} def font(size, name="Georgia.ttf"): key = (int(size), name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, int(size)) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.42*r**2.1, 0, 1)[..., None].astype(np.float32) return _VIG["v"] def wrap(d, text, f, maxw): words = text.split(); lines = []; cur = "" for w2 in words: trial = (cur+" "+w2).strip() if d.textlength(trial, font=f) > maxw and cur: lines.append(cur); cur = w2 else: cur = trial if cur: lines.append(cur) return lines def post(arr, i, e, shot): a = np.asarray(arr, np.float32) # 1 tint — kerosene lamp on cotton lum = a.mean(2, keepdims=True)/255.0 a = a*np.array([1.045, 0.995, 0.930], np.float32) a = a + (1.0-lum)*np.array([9, 3, -5], np.float32) # 2 vignette a = a*(vignette()*0.34 + 0.66) # 3 grain — rolled at 720p and blown up NEAREST so the grain SIZE scales rng = np.random.RandomState(51900 + i) if SC == 1.0: a = a + rng.normal(0, 1.9, a.shape) else: g = rng.normal(0, 1.9, (int(H/SC), int(W/SC), 3)).astype(np.float32) a = a + np.stack([np.asarray(Image.fromarray(g[..., c], "F") .resize((W, H), Image.NEAREST), np.float32) for c in range(3)], -1) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) bh = sci(10) # --- text, composited crisply after everything --- t = i/FPS if shot.card: age = i - shot.i0 if age < FPS*2.9: al = min(1.0, age/7.0)*min(1.0, (FPS*2.9-age)/11.0) big = shot.card == TITLE f = font(sci(58) if big else sci(25), "Georgia Bold.ttf" if big else "Georgia.ttf") lw = d.textlength(shot.card, font=f) x = W/2-lw/2; y = H*0.115 if big else bh+sci(16) d.text((x+scf(2), y+scf(2)), shot.card, font=f, fill=(int(18*al), int(14*al), int(11*al))) d.text((x, y), shot.card, font=f, fill=(int(238*al), int(226*al), int(198*al))) if big: # the show, embroidered small under the title f9 = font(sci(19), "Georgia.ttf") s9 = "P L A Y E R C O M P U T E R" lw9 = d.textlength(s9, font=f9) d.text((W/2-lw9/2+scf(2), y+scf(80)+scf(2)), s9, font=f9, fill=(int(18*al), int(14*al), int(11*al))) d.text((W/2-lw9/2, y+scf(80)), s9, font=f9, fill=(int(226*al), int(206*al), int(172*al))) SUBS = events()["subs"] cur = None for (t0, t1, tx) in SUBS: if t0 <= t < t1: cur = tx if cur: f = font(sci(25), "Georgia Italic.ttf") lines = wrap(d, cur, f, W*0.82) y0 = H - bh - sci(30) - sci(30)*(len(lines)-1) for li, ln in enumerate(lines): lw = d.textlength(ln, font=f) x = W/2-lw/2; y = y0 + li*sci(30) d.rectangle([x-scf(14), y-scf(5), x+lw+scf(14), y+scf(29)], fill=(222, 212, 190)) d.rectangle([x-scf(14), y-scf(5), x+lw+scf(14), y+scf(29)], outline=(120, 96, 78), width=sci(2)) d.text((x, y), ln, font=f, fill=(58, 44, 36)) return out # ════════════════════════════════════════════════════════════════════════════ # RENDER # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) eng = ENGINES[shot.engine](shot, rng) made = 0 for k in range(shot.n): i = shot.i0+k p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue # every engine is a pure f(t): no state e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(k, k/max(1, shot.n-1), e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:9s} {shot.section:8s} {made}/{shot.n}" def contact_sheet(shots): cols = 7; rows = (len(shots)+cols-1)//cols tw, th = 280, 180 sheet = Image.new("RGB", (cols*tw, rows*(th+26)), (12, 11, 14)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) eng = ENGINES[sh.engine](sh, rng) mid = sh.n//2 i = sh.i0+mid e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(mid, mid/max(1, sh.n-1), e) im = post(arr, i, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+26) sheet.paste(im, (cx, cy)) lab = f"{sh.idx:02d} {sh.engine}" p2 = sh.params for kk in ("mode", "who", "block", "fab", "a"): if kk in p2: lab += f":{p2[kk]}" sd.text((cx+5, cy+th+5), f"{lab} · {sh.section} · {sh.i0/FPS:.1f}s · {sh.n/FPS:.1f}s", font=font(13, "Menlo.ttc"), fill=(196, 200, 168)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots, {rows}x{cols})") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(12, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() or not (AUD/"events.json").exists(): print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") wav, mix = build_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames " f"on {a.jobs} workers") import multiprocessing as mp with mp.get_context("fork").Pool(a.jobs) as pool: for r in pool.imap_unordered(render_shot, jobs): print(" ", r) if sel: print("partial render — rerun with --mux-only to reassemble"); return missing = [i for i in range(N_FRAMES) if not (FRAMES/f"f{i:05d}.png").exists()] if missing: raise SystemExit(f"{len(missing)} frames missing, first={missing[0]}") print("[3/3] mux…") out = OUT/f"{NAME}.mp4" try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" stamp = (f"generator=renders/{SETDIR}/{NAME}/render.py git={sha} branch={br} " f"built={datetime.datetime.now().astimezone().isoformat()} " f"{W}x{H}@{FPS} {DUR:.2f}s | {MUSIC_DESC}") subprocess.run( ["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", # the mp4 muxer silently drops unknown keys; comment/description survive "-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"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engines: {ENGINE_DESC} (shot-parallel tier 4-P, pure f(t))\n" f"substrate: pieced fabric — warp/weft interlace at {TPU} threads/unit, " f"band-dyed warps, batting height field between quilting lines\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()