#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Panini (10/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/panini # # A kid is one sticker short of a full album, and the whole street trades to fix it. # # 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/panini.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/panini.mp4 # cover: https://genekogan.com/player_computer/media/panini.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 panini.py (writes frames/, audio/, and the final mp4 # next to the script; writes ~8 GB of frames, # takes 5-15 min on a modern machine) # ═════════════════════════════════════════════════════════════════════════════ """ player_computer_2 — "PANINI" (round-2 curation) Baile funk / funk carioca, 130 bpm, D minor. 36 bars. intro(4) v1(7) coro1(6) v2(6) break(3) coro2(6) landing(4) A kid is one sticker short of a complete album. The whole street trades to help — packets torn open on a stoop, the same double coming up over and over (ZÉ CARLOS · 4, forever) — until the last packet of the day is opened and the missing sticker is HIM. His own face. His own name. Number 20. Look: THE FILM NEVER LEAVES THE ALBUM. Everything is printed matter — a sticker-album spread photographed by a camera that can go from a full spread down to a 12 mm macro, and the cheap CMYK screen resolves into rosettes as it goes, because the halftone cell is measured in *millimetres of paper*, not pixels of screen. Stickers sit proud of the page with drop shadows, land crooked, and carry a moving varnish specular; the special ones carry a foil hologram whose hue shifts with angle. The packets are waxed paper and tear with a fibrous edge. Composition: engine : audio-first x shot-parallel (tier 4-P) x mm-space camera content: audio-groove (tamborzão kit, apito, air horn, crunchy sampler) x tts-voices (pt_BR shouted call-and-response) x effects-post New substrate built here (nothing in this repo printed before): press() real CMYK separation + UCR, four screens at their classic angles (C15 M75 Y0 K45), spot-function dots whose hardness is a function of the cell size in *output pixels*, so the same code is contone at a wide and a rosette at a macro. Plus misregistration, dot gain and Beer-Lambert ink stacking. surface() the physical pass that happens after the ink: sticker drop shadows, die-cut edge light, moving gloss varnish, foil holography (phase grating + spectral ramp + glints), wax sheen. tear_path() fibrous waxed-paper tear with loose fibres along the edge. Run from repo root: python3 renders/player_computer_2/panini/render.py --sheet python3 renders/player_computer_2/panini/render.py """ import argparse, colorsys, datetime, hashlib, json, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "panini" TITLE = "PANINI" SETDIR = "player_computer_final" SETNUM = "09" # Final cut: native 1080p. Everything in this film is authored in MILLIMETRES # of paper and projected through `Cam` (ppu = SW/wmm), so the whole picture — # including the halftone cell, which is CELL_MM of real paper — scales on its # own the moment W changes. Only the post chain, which worked in output pixels, # is scaled by hand. W, H, FPS = 1920, 1080, 30 S = H / 720.0 # 1.5 def si(v): return int(round(v*S)) def sf(v): return v*S SS = 2 # plate supersample SW, SH = W*SS, H*SS BPM = 130.0 BEAT = 60.0/BPM BAR = 4*BEAT STEP = BEAT/4 # a sixteenth SR = 44100 TAU = math.tau 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, 4), ("v1", 4, 11), ("coro1", 11, 17), ("v2", 17, 23), ("brk", 23, 26), ("coro2", 26, 32), ("land", 32, 36), ] N_BARS = SECTIONS[-1][2] DUR = N_BARS*BAR + 1.5 N_FRAMES = int(DUR*FPS) MUSIC_DESC = f"baile funk / funk carioca, {BPM:.0f}bpm, D minor, {N_BARS} bars, tamborzão" ENGINE_DESC = ("cover/page/slap/packet/fan/doubles/holo/stoop/trade/checklist/" "macro/flip/reveal/complete — printed sticker album (CMYK press)") # ═══════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ═══════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0*2.0**((m-69)/12.0) _PC = {"C":0,"C#":1,"Db":1,"D":2,"D#":3,"Eb":3,"E":4,"F":5,"F#":6,"Gb":6, "G":7,"G#":8,"Ab":8,"A":9,"A#":10,"Bb":10,"B":11} def nf(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return mtof(12*(int(name[i:])+1) + _PC[name[:i]]) def adsr(n, a, d, s, r): e = np.zeros(n) ai, di, ri = max(1, int(a*SR)), max(1, int(d*SR)), max(1, int(r*SR)) ai = min(ai, n); e[:ai] = np.linspace(0, 1, ai) if ai < n: dd = min(di, n-ai) e[ai:ai+dd] = np.linspace(1, s, dd); e[ai+dd:] = s if ri < n: e[-ri:] *= np.linspace(1, 0, ri) return e def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Every noise source goes through this — 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 bitcrush(x, bits=9, srdiv=2): """The whole point of funk carioca's sound: a beat that has been ripped, re-encoded and played off a cheap sampler at half rate.""" if srdiv > 1: n = len(x); m = n//srdiv x = np.repeat(x[:m*srdiv].reshape(m, srdiv)[:, 0], srdiv) x = np.pad(x, (0, n-len(x))) q = 2.0**(bits-1) return np.round(np.clip(x, -1, 1)*q)/q def drive(x, amt=2.4, asym=0.12): return np.tanh(x*amt + asym*x*x)/np.tanh(amt) def sat_fold(x, amt=1.4): """Soft wavefolder — the sampler's input stage giving up.""" y = x*amt return np.where(np.abs(y) <= 1, y, np.sign(y)*(2-np.abs(np.clip(y, -2, 2)))) def saw(freq, dur, nh=24, seed=0): n = int(dur*SR); t = np.arange(n)/SR out = np.zeros(n) rng = np.random.RandomState(seed) for k in range(1, nh+1): f = freq*k if f > SR*0.45: break out += np.sin(TAU*f*t + rng.uniform(0, TAU))/k return out def voice(freq, dur, kind="saw", nh=20, c0=4200, c1=600, ck=7.0, res=0.0, detune=(0.0,), a=.004, d=.09, s=.7, r=.09, 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, TAU) phase = TAU*fk*t + ph if vd: phase = phase + vd*np.sin(TAU*vr*t) out += g*np.sin(phase) out /= len(detune) return out*adsr(n, a, d, s, r) def reverb(x, rt=1.2, mix=.24, seed=29, pre=0.015): n = int(rt*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) ir = rng.randn(n)*np.exp(-t*(5.0/rt)) ir[:int(pre*SR)] = 0 ir /= np.abs(ir).sum()/40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x)+n))) wet = irfft(rfft(x, L)*rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet)) + 1e-9 return x*(1-mix) + wet*mix*(np.max(np.abs(x))+1e-9) def delay(x, time=.25, fb=.36, mix=.22, taps=6): d = int(time*SR); out = x.copy() for i in range(1, taps+1): g = mix*(fb**i); s = d*i if s >= len(x): break out[s:] += x[:len(x)-s]*g return out # ═══════════════════════════════════════════════════════════════════════════ # THE TAMBORZÃO KIT # # The genre is the beat. The tamborzão is a two-bar cell on a sixteenth grid: # the bumbo (deep surdo kick) lands 3-3-4-3-3 — steps 0,3,6,10,13 — and a pair # of atabaques answers in the holes. Bar B varies the tail (0,3,6,10,12,14) and # every fourth bar takes a tom roll. Everything then goes through a 9-bit / # half-rate sampler and a clipping input stage, because that is what it sounds # like when a beat has been through four DJs and a CD-R. # ═══════════════════════════════════════════════════════════════════════════ def bumbo(dur=.36, f0=178, f1=45, punch=24, click=.42, seed=1): n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*punch) body = np.sin(TAU*np.cumsum(f)/SR)*np.exp(-t*9.0) sub = np.sin(TAU*f1*0.82*t)*np.exp(-t*6.5)*0.55 ck = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=7000) ck *= np.exp(-t*220)*click return drive(body + sub + ck, 2.1)*0.98 def atabaque(dur=.20, f0=210, decay=17.0, skin=0.8, slap=0.5, seed=2): """Hand drum: two membrane modes with a small downward sweep, a skin-noise body, and a slap transient.""" n = int(dur*SR); t = np.arange(n)/SR f = f0*(1 + 0.22*np.exp(-t*38)) ph = TAU*np.cumsum(f)/SR body = (np.sin(ph) + 0.42*np.sin(ph*1.59) + 0.18*np.sin(ph*2.14)) body *= np.exp(-t*decay) rng = np.random.RandomState(seed) sk = bandshape(rng.randn(n), lo=f0*1.6, hi=4200)*np.exp(-t*decay*2.2)*skin sl = bandshape(rng.randn(n), lo=2200, hi=8000)*np.exp(-t*160)*slap return drive(body*0.8 + sk*0.5 + sl*0.6, 1.8)*0.9 def caixa(dur=.16, tone=232, bright=1.0, seed=3): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=900, hi=8200) body = np.sin(TAU*tone*t) + .55*np.sin(TAU*tone*1.62*t) return drive(nz*np.exp(-t*24)*.9*bright + body*np.exp(-t*30)*.45, 1.6) def aro(dur=.07, seed=4): """Rim / woodblock click.""" n = int(dur*SR); t = np.arange(n)/SR return (np.sin(TAU*1620*t) + .55*np.sin(TAU*2480*t))*np.exp(-t*95)*.55 def chocalho(dur=.07, seed=5, bright=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=4200*bright, hi=11000) return nz*(np.exp(-t*60)*np.clip(t*320, 0, 1))*.42 def prato(dur=1.2, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1600, hi=9200) return nz*(np.exp(-t*3.2) + .25*np.exp(-t*.8))*.5 def apito(dur=.26, f=2420, chirp=0.0, seed=7): """The samba whistle. Two chambers a whole tone apart beating against each other, with the pea rattling at ~27 Hz.""" n = int(dur*SR); t = np.arange(n)/SR fr = f*(1 + chirp*(t/max(dur, 1e-6))) ph = TAU*np.cumsum(fr)/SR tone = np.sin(ph) + 0.72*np.sin(ph*1.128) + 0.22*np.sin(ph*2.02) rng = np.random.RandomState(seed) pea = bandshape(rng.randn(n), lo=1800, hi=7000) pea *= (0.55 + 0.45*np.sign(np.sin(TAU*27.0*t))) env = np.clip(t*260, 0, 1)*np.minimum(1.0, (dur-t)*40) return drive((tone*0.5 + pea*0.30)*env, 2.0)*0.85 def airhorn(dur=1.05, f0=196, seed=11, stabs=None): """The MC air horn. Detuned saw stack, a fast pitch bend in, a wah that opens, and far too much gain.""" n = int(dur*SR); t = np.arange(n)/SR bend = 1.0 - 0.22*np.exp(-t*26) vibr = 1.0 + 0.010*np.sin(TAU*5.6*t)*np.clip((t-0.10)*6, 0, 1) out = np.zeros(n) for det, g in ((0.0, 1.0), (-0.011, .8), (0.013, .8), (0.006, .5)): f = f0*(1+det)*bend*vibr ph = TAU*np.cumsum(f)/SR for k in range(1, 22): if f0*k > SR*0.45: break out += g*np.sin(ph*k)/k co = 700 + 3400*np.clip(t*3.0, 0, 1) out = bandshape(out, lo=150, hi=float(np.mean(co))) env = np.clip(t*90, 0, 1)*np.minimum(1.0, (dur-t)*7) y = drive(out*env*0.5, 3.2) if stabs: gate = np.zeros(n) for (s0, s1) in stabs: i0, i1 = int(s0*SR), int(min(dur, s1)*SR) gate[i0:i1] = 1.0 k = 220 gate = np.convolve(gate, np.ones(k)/k, "same") y = y*gate return y*0.9 def sub_bass(freq, dur, seed=0): n = int(dur*SR); t = np.arange(n)/SR x = np.sin(TAU*freq*t) + 0.28*np.sin(TAU*freq*2*t) return x*adsr(n, .004, .10, .78, .12) # ═══════════════════════════════════════════════════════════════════════════ # VOICE — shouted pt_BR call-and-response through the same cheap sampler # ═══════════════════════════════════════════════════════════════════════════ def _h(*parts): """Stable cache key — Python's str hash is salted per process.""" return hashlib.md5("|".join(str(p) for p in parts).encode()).hexdigest()[:16] def read_wav(p): with wave.open(str(p)) as w: ch = w.getnchannels() x = np.frombuffer(w.readframes(w.getnframes()), " macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, vc, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, vc, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def pitch(x, ratio): """Resample — pitch and duration move together, which is exactly the artefact a sampler gives you when you play a vocal off a key.""" return fit(x, max(2, int(len(x)/ratio))) def trim_speech(x, thr=0.02): a = np.abs(x) idx = np.where(a > thr*np.max(a+1e-9))[0] if len(idx) < 2: return x i0 = max(0, idx[0]-int(0.01*SR)); i1 = min(len(x), idx[-1]+int(0.03*SR)) return x[i0:i1] # Voices verified with `say -v '?'` — Brazilian Portuguese is available. # # ROUND 2 RECAST. Gene: "maybe try different voice for the vocals." All ten # pt_BR/pt_PT voices on the machine were auditioned through the exact shout() # chain (pitch -> 170/6200 bandshape -> bitcrush(8,2) -> drive) and measured # for the two things that decide whether an MC survives a tamborzão: energy # in 1.5-5 kHz (cut) and energy in 80-400 Hz (mud). # # voice cut mud voice cut mud # Reed .024 .640 Rocko .009 .739 <- the old lead # Eddy .020 .710 Luciana .009 .624 # Grandpa .013 .501 Flo .008 .841 # Joana .013 .847 Shelley .006 .754 # Grandma .012 .693 Sandy .004 .772 # # Rocko was the single dullest voice available (f0 102 Hz, spectral centroid # 591 Hz, almost nothing above 4 kHz) — which is exactly why the hook sat # under the drums instead of on top of them. REED cuts 2.7x better with 13% # less mud, and pitched +3 semitones (see SEMIS) lands at f0 ~149 Hz: a boy # shouting, not a man. That matters here, because the last sticker is HIM — # the lead voice and the face on card 20 should be the same kid. VOX = {"lead": ("Reed (Portuguese (Brazil))", 228), "kid": ("Eddy (Portuguese (Brazil))", 230), "girl": ("Luciana", 218), "gran": ("Grandpa (Portuguese (Brazil))", 190)} # how far each role is pitched up in shout(): the lead is a kid, the gran is not SEMIS = {"lead": 3.0, "kid": 2.5, "girl": 1.5, "gran": -1.5} def shout(text, who="lead", cache=None, semis=0.0, cr=(8, 2), dr=3.0, gain=1.0): """One shouted line: `say`, trimmed, pitched, crushed, clipped.""" vc, rate = VOX[who] key = cache/("say_"+_h(text, vc, rate)+".wav") x = trim_speech(say_wav(text, vc, rate, key)) x = x/(np.max(np.abs(x))+1e-9) if semis: x = pitch(x, 2.0**(semis/12.0)) x = bandshape(x, lo=170, hi=6200) x = drive(bitcrush(x, cr[0], cr[1]), dr) return x/(np.max(np.abs(x))+1e-9)*gain def crowd(text, cache=None, n=4, spread=1.6, seed=1234, base="kid", lift=2.0): """The response. One `say` render layered against itself at several pitches and a few milliseconds of human lateness = a street shouting. Round 2: `lift` raises the whole crowd — it is a street of children, and the old spread sat it around the adult register of the old lead voice.""" R = np.random.RandomState(seed) who = [base, "girl", "lead", "kid"] parts = [] for i in range(n): s = lift + R.uniform(-spread, spread) + (i-1.5)*1.3 p = shout(text, who[i % len(who)], cache, semis=s, cr=(7, 2), dr=3.6, gain=1.0) lag = int(R.uniform(0.004, 0.030)*SR) parts.append(np.pad(p, (lag, 0))) m = max(len(p) for p in parts) out = np.zeros(m) for p in parts: out[:len(p)] += p out = drive(out/n, 2.6) return out/(np.max(np.abs(out))+1e-9) # ═══════════════════════════════════════════════════════════════════════════ # SONG # ═══════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0, sw=0.0): s = sw*STEP if (step % 2) else 0.0 return bar*BAR + step*STEP + s def put(self, track, sig, at, g=1.0, pan=0.0): b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR); j = min(self.n, i+len(sig)) if i >= self.n or j <= i: return th = (pan*.5+.5)*(np.pi/2) b[i:j] += np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1)*g def bus(self, track, fn): if track in self.tr: b = self.tr[track] self.tr[track] = np.stack([fn(b[:, 0]), fn(b[:, 1])], 1) def sec_env(self, levels, glide=0.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(SECTIONS[-1][0], 1.0) k = max(1, int(glide*SR)) return np.convolve(env, np.ones(k)/k, "same") def mixdown(self, gains, pump=.34, prel=.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(prel*SR) shape = 1 - pump*np.exp(-np.arange(rl)/(prel*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(256)/256, "same") mix *= env[:, None] a = math.exp(-2*math.pi*32.0/SR) for c in range(2): # DC / rumble trim 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.35)/np.tanh(1.35) # master tilt: the crushed sampler throws a lot of aliasing above 9k. # Keep the crunch, take the ice off it. for c in range(2): mix[:, c] = mix[:, c] - 0.42*(mix[:, c] - bandshape(mix[:, c], hi=9000)) 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(" F (i i VII III) RIFF = [[0, 3, 5, 7, 5, 3, 0, 0], [7, 5, 3, 0, 3, 5, 7, 10], [0, 0, 3, 5, 3, 0, -2, 0], [10, 7, 5, 3, 5, 7, 10, 12]] for bar in range(N_BARS): sec = sec_of(bar) quiet = sec in ("intro", "brk") big = sec in ("coro1", "coro2", "land") cell = cell_for(bar) rootf = ROOT*2**(PROG[bar % 4]/12.0) dg = 0.55 if quiet else (1.0 if not big else 1.08) if sec == "intro" and bar < 2: dg *= 0.55 for st in cell["bumbo"]: at = s.t(bar, st, SW_) v = 1.0 if st == 0 else R.uniform(.86, 1.0) s.put("dr", bumbo(f0=178, f1=45+2*(st % 3), seed=bar*17+st), at, g=.92*dg*v) if st in (0, 6): s.kick_t.append(at) if not quiet: s.put("bs", sub_bass(rootf, BEAT*0.62, seed=bar*7+st), at, g=.40*dg) for st in cell["low"]: s.put("dr", atabaque(.22, 168, 15.0, .7, .45, seed=bar*23+st), s.t(bar, st, SW_), g=.62*dg, pan=-.22) for st in cell["mid"]: s.put("dr", atabaque(.17, 262, 20.0, .85, .60, seed=bar*29+st), s.t(bar, st, SW_), g=.58*dg, pan=.20) for st in cell["hi"]: s.put("dr", atabaque(.11, 392, 30.0, .95, .75, seed=bar*31+st), s.t(bar, st, SW_), g=.44*dg, pan=.30) for st in cell["aro"]: s.put("dr", aro(seed=bar*37+st), s.t(bar, st, SW_), g=.40*dg, pan=-.30) s.put("dr", caixa(.15, 232, .8 if not big else 1.0, seed=bar*41+st), s.t(bar, st, SW_), g=.34*dg, pan=.06) if not quiet: for st in range(0, 16, 2): s.put("perc", chocalho(seed=bar*43+st, bright=1.0 if st % 4 == 0 else .8), s.t(bar, st, SW_), g=.20*dg*(1.0 if st % 4 == 0 else .62), pan=.34) # sampler organ riff — the blown-out hook if not quiet: ph = RIFF[(bar//2) % 4] for j in range(8): if sec == "v2" and j % 4 == 3: continue iv = ph[j] dur2 = BEAT*0.44 if j % 4 != 3 else BEAT*0.85 sig = voice(nf("D4")*2**(iv/12.0), dur2, kind="square", nh=14, c0=3600, c1=1500, ck=6.0, res=.35, detune=(-1.0, 0.0, 1.1), a=.006, d=.08, s=.6, r=.08, seed=bar*53+j) sig = drive(bitcrush(sig, 8, 3), 2.2) s.put("org", sig, s.t(bar, j*2, SW_), g=(.16 if not big else .21), pan=-.14 + .06*(j % 3)) if sec == "brk": # a held organ pad, filtered down — the street holding its breath for k2, iv in enumerate((0, 7, 12)): s.put("org", voice(nf("D3")*2**(iv/12.0), BAR*1.1, kind="saw", nh=12, c0=1100, c1=420, ck=1.0, detune=(-.8, .9), a=.5, d=.6, s=.6, r=.6, seed=bar*61+k2), s.t(bar, 0), g=.11, pan=-.2+.2*k2) # ---- apito (whistle) ---------------------------------------------------- for bar in (0, 4, 11, 17, 26, 31, 35): base = bar*BAR s.put("fx", apito(.13, 2380, seed=71+bar), base, g=.30, pan=-.24) s.put("fx", apito(.13, 2380, seed=72+bar), base+STEP*2, g=.30, pan=-.24) s.put("fx", apito(.42, 2380, chirp=.05, seed=73+bar), base+STEP*4, g=.34, pan=-.24) # ---- air horns ---------------------------------------------------------- for bar in (11, 26, 32): s.put("fx", airhorn(1.15, 196, seed=81+bar), bar*BAR - .08, g=.34, pan=.1) for bar in (14, 29): s.put("fx", airhorn(.95, 262, seed=91+bar, stabs=[(0, .18), (.26, .44), (.52, .95)]), bar*BAR, g=.30, pan=-.1) for bar in (12, 20, 28, 34): s.put("fx", prato(1.0, seed=101+bar), bar*BAR, g=.16, pan=.12) # ---- vocals ------------------------------------------------------------- for (bar, st, kind, text, g, pan) in VOCALS: at = bar*BAR + st*STEP if kind == "crowd": sig = crowd(text, cache=AUD, seed=1000+int(bar*10)) else: sig = shout(text, kind, cache=AUD, semis=SEMIS.get(kind, 1.5)) s.put("vox", sig, at, g=g*.82, pan=pan) # the running gag gets a stutter-chop: "ZÉ ZÉ ZÉ CARLOS" for bar in (15.5, 19.5): base = crowd("ZÉ!", cache=AUD, n=3, seed=1700+int(bar*10)) for j in range(4): s.put("vox", base*(0.55+0.15*j), bar*BAR + j*STEP*1.5, g=.42, pan=-.2+.13*j) s.bus("vox", lambda x: delay(reverb(x, rt=.9, mix=.16, seed=303), BEAT*.5, .25, .16)) s.bus("org", lambda x: reverb(delay(x, BEAT*.75, .28, .18), rt=1.0, mix=.18, seed=307)) s.bus("fx", lambda x: reverb(x, rt=1.5, mix=.30, seed=311)) s.bus("perc", lambda x: reverb(x, rt=.7, mix=.12, seed=313)) # the drum bus IS the sampler s.bus("dr", lambda x: drive(bitcrush(x, 9, 2), 1.9)) mix = s.mixdown(dict(dr=1.0, perc=1.0, bs=1.0, org=1.0, fx=1.0, vox=1.0), pump=.26, prel=.12, levels=dict(intro=.60, v1=.92, coro1=1.06, v2=.94, brk=.44, coro2=1.10, land=1.02)) wav = AUD/"final.wav" s.write(wav, mix) # events: every bumbo hit, for the sticker slaps ev = dict(kicks=sorted(round(float(t), 5) for t in _all_bumbo()), bpm=BPM, bars=N_BARS) (AUD/"events.json").write_text(json.dumps(ev)) return wav, mix def _all_bumbo(): out = [] for bar in range(N_BARS): for st in cell_for(bar)["bumbo"]: sw = SW_*STEP if (st % 2) else 0.0 out.append(bar*BAR + st*STEP + sw) return out 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["kick"] = np.clip(np.convolve(flux, [.25, .5, .25], "same") / (np.percentile(flux, 97)+1e-9), 0, 1) np.savez(AUD/"env.npz", **E) return E _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV _EVT = {} def events(): if not _EVT: _EVT.update(json.loads((AUD/"events.json").read_text())) return _EVT # ═══════════════════════════════════════════════════════════════════════════ # THE PRESS — CMYK separation, four screens, ink stacking # # This is the new substrate. Everything is drawn as continuous-tone artwork on # a supersampled plate; press() then *prints* it. The halftone cell is a fixed # size in millimetres of paper (55 lines per inch — a cheap sticker album), so # the cell's size in output pixels falls out of the camera. A wide of the # spread has a 1.2 px cell and reads as flat colour; a 14 mm macro has a 25 px # cell and reads as a rosette, with no per-shot decisions at all. # ═══════════════════════════════════════════════════════════════════════════ LPI = 55.0 CELL_MM = 25.4/LPI ANGLES = dict(c=15.0, m=75.0, y=0.0, k=45.0) INK = dict(c=np.float32([0.02, 0.66, 0.92]), m=np.float32([0.92, 0.09, 0.52]), y=np.float32([0.99, 0.92, 0.07]), k=np.float32([0.09, 0.08, 0.09])) PAPER = np.float32([0.965, 0.947, 0.898]) MISREG = dict(c=(-1.0, 0.35), m=(0.55, -0.85), y=(0.30, 0.75), k=(0.0, 0.0)) _XX = np.arange(W, dtype=np.float32)[None, :].repeat(H, 0) _YY = np.arange(H, dtype=np.float32)[:, None].repeat(W, 1) _ROT = {} def _rot(angle): if angle not in _ROT: a = math.radians(angle); ca, sa = math.cos(a), math.sin(a) _ROT[angle] = ((_XX*ca + _YY*sa).astype(np.float32), (-_XX*sa + _YY*ca).astype(np.float32)) return _ROT[angle] def _shift(a, dx, dy): if abs(dx) < .5 and abs(dy) < .5: return a return np.roll(np.roll(a, int(round(dy)), 0), int(round(dx)), 1) _PAPER_TILE = None def paper_tile(): global _PAPER_TILE if _PAPER_TILE is None: R = np.random.RandomState(4242) t = np.zeros((512, 512), np.float32) amp = 1.0; nrm = 0.0 for o, sc in enumerate((64, 24, 9, 3)): g = R.rand(512//sc+2, 512//sc+2).astype(np.float32) im = Image.fromarray((g*255).astype(np.uint8)).resize((512, 512), Image.BICUBIC) t += amp*(np.asarray(im, np.float32)/255); nrm += amp; amp *= .55 t /= nrm fib = R.rand(512, 512).astype(np.float32) t = t*0.72 + 0.28*(fib > 0.986) # visible fibres in the stock _PAPER_TILE = (t - t.mean()) return _PAPER_TILE def paper_field(cam): """Paper fibre sampled in *paper* coordinates so it scales with the zoom.""" tile = paper_tile() ppm = cam.ppu/SS # output px per mm per_mm = 5.6 # tile texels per mm sx = per_mm/max(ppm, 1e-4) ix = ((_XX*sx + cam.cx*per_mm + 900.0) % 512).astype(np.int32) iy = ((_YY*sx + cam.cy*per_mm + 300.0) % 512).astype(np.int32) return tile[iy, ix] def press(rgb, cell, fnum, ucr=0.74, gain=0.075, pfield=None, dirty=1.0): """Continuous-tone RGB (0..1) -> printed sheet.""" cell = float(np.clip(cell, 0.7, 70.0)) r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] c = 1.0-r; m = 1.0-g; y = 1.0-b k = np.minimum(np.minimum(c, m), y)*ucr den = np.maximum(1e-3, 1.0-k) plane = dict(c=np.clip((c-k)/den, 0, 1), m=np.clip((m-k)/den, 0, 1), y=np.clip((y-k)/den, 0, 1), k=np.clip(k, 0, 1)) lod = float(np.clip((cell-1.15)/1.6, 0, 1)) # 0 = contone, 1 = full screen hard = max(2.0, cell*0.62) wob = math.sin(fnum*0.19)*0.5 + math.cos(fnum*0.11)*0.4 out = np.empty((H, W, 3), np.float32) out[...] = PAPER if pfield is not None: out *= (1.0 + pfield[..., None]*0.085) for ch in ("y", "m", "c", "k"): v = np.clip(plane[ch]*(1.0+gain), 0, 1) dx, dy = MISREG[ch] s = 0.16*cell*dirty v = _shift(v, dx*s + wob*0.4*dirty, dy*s - wob*0.3*dirty) if lod > 0.001: ru, rv = _rot(ANGLES[ch]) sp = 0.5 + 0.5*np.cos(TAU*ru/cell)*np.cos(TAU*rv/cell) ink = np.clip((v - sp)*hard + 0.5, 0, 1) ink = v*(1.0-lod) + ink*lod else: ink = v out *= (1.0 - ink[..., None]*(1.0 - INK[ch])[None, None, :]) return out # ═══════════════════════════════════════════════════════════════════════════ # THE SURFACE — everything that is light rather than ink # ═══════════════════════════════════════════════════════════════════════════ _SPEC = None def spectral(): """Hue ramp for the foil. Not a hue rotation — a real rainbow with the green stretched, the way a diffraction grating actually looks.""" global _SPEC if _SPEC is None: lut = np.zeros((256, 3), np.float32) for i in range(256): hh = (i/256.0) hh = hh + 0.06*math.sin(hh*TAU) lut[i] = colorsys.hsv_to_rgb(hh % 1.0, 0.78, 1.0) _SPEC = lut return _SPEC def blurL(mask, radius): im = Image.fromarray((np.clip(mask, 0, 1)*255).astype(np.uint8)) im = im.filter(ImageFilter.GaussianBlur(max(0.4, radius))) return np.asarray(im, np.float32)/255.0 def surface(printed, m, cam, t, e, u, light=-0.55, gloss_g=1.0, foil_g=1.0, shadow_g=1.0, fnum=0): """Sticker shadows, die-cut edge light, moving varnish, foil holography.""" out = printed stick = m["stick"]; foil = m["foil"]; wax = m["wax"] ppm = cam.ppu/SS if shadow_g > 0.01 and stick.max() > 0.01: off = max(1, int(0.9*ppm)) # ~0.9 mm of lift sh = blurL(stick, max(1.2, 1.1*ppm)) sh = _shift(sh, off*1.0, off*1.25) sh = np.clip(sh - stick, 0, 1) out = out*(1.0 - 0.52*shadow_g*sh[..., None]*np.float32([1.0, .97, .92])) # die-cut edge: the top-left rim of the sticker catches the lamp rim = np.clip(_shift(stick, -off*0.5, -off*0.6) - stick, 0, 1) out = out + rim[..., None]*0.30*np.float32([1, 1, .96]) if gloss_g > 0.01 and stick.max() > 0.01: a = light + 0.16*math.sin(t*0.7) ca, sa = math.cos(a), math.sin(a) d = ((_XX-W/2)*ca + (_YY-H/2)*sa)/(W*0.5) pos = -1.25 + 2.5*((u*0.7 + t*0.11) % 1.0) band = np.exp(-((d-pos)/0.16)**2) + 0.42*np.exp(-((d-pos-0.30)/0.06)**2) sheen = 0.10 + 0.55*e.get("high", .3) out = out + (band*stick)[..., None]*(0.34*gloss_g*sheen)*np.float32([1, 1, .98]) if foil_g > 0.01 and foil.max() > 0.01: a = light*0.8 + 0.9 ca, sa = math.cos(a), math.sin(a) mx = (_XX-W/2)/ppm + cam.cx; my = (_YY-H/2)/ppm + cam.cy proj = mx*ca + my*sa ph = proj*0.058 + t*0.42 + u*1.1 idx = np.clip(((ph % 1.0)*255), 0, 255).astype(np.int32) hol = spectral()[idx] grate = 0.60 + 0.40*np.sin(proj*TAU*1.15 + t*2.0) sh = (0.45 + 0.55*e.get("high", .3))*foil_g fa = (foil*grate*sh)[..., None] out = out*(1.0-fa*0.72) + hol*fa*0.95 # glints R = np.random.RandomState(777) if not hasattr(surface, "_gl"): surface._gl = (R.rand(H, W) > 0.9993).astype(np.float32) gl = surface._gl*foil*max(0.0, math.sin(t*3.1 + u*5.0)) out = out + blurL(gl, 1.6)[..., None]*1.4 if wax.max() > 0.01: a = light + 1.5 ca, sa = math.cos(a), math.sin(a) d = ((_XX-W/2)*ca + (_YY-H/2)*sa)/(W*0.5) pos = -0.9 + 1.8*((u*0.5 + 0.2) % 1.0) band = np.exp(-((d-pos)/0.30)**2) out = out + (band*wax)[..., None]*0.20 return out # ═══════════════════════════════════════════════════════════════════════════ # CAMERA — the frame is a rectangle of paper, measured in millimetres # ═══════════════════════════════════════════════════════════════════════════ # Round 2 delivers 16:9. Every framing below is authored as a WIDTH IN MM # against the old 14:9 frame; scaling that width by 8/7 keeps the *vertical* # millimetres — and therefore the composition and the halftone cell size in # output pixels — exactly where they were, and spends the extra 160px on more # album. Only cam_move passes _raw, because its widths are already scaled. WMMK = (W/H) / (1120/720) class Cam: __slots__ = ("cx", "cy", "wmm", "rot", "ppu", "cell", "_c", "_s") def __init__(self, cx, cy, wmm, rot=0.0, _raw=False): wmm = max(3.0, wmm) if _raw else max(3.0, wmm)*WMMK self.cx, self.cy, self.wmm, self.rot = cx, cy, wmm, rot self.ppu = SW/self.wmm # supersampled px per mm self.cell = CELL_MM*(self.ppu/SS) # output px per screen cell self._c, self._s = math.cos(rot), math.sin(rot) def p(self, x, y): dx, dy = (x-self.cx)*self.ppu, (y-self.cy)*self.ppu return (SW/2 + dx*self._c - dy*self._s, SH/2 + dx*self._s + dy*self._c) def pl(self, pts): return [self.p(x, y) for x, y in pts] def s(self, mm): return mm*self.ppu def box(self, x0, y0, x1, y1): return self.pl([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) def lerp(a, b, u): return a + (b-a)*u def ease(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def ease_in(u): return u**3 def sstep(a, b, x): t = np.clip((x-a)/max(1e-9, b-a), 0, 1); return t*t*(3-2*t) def spring(u, freq=2.6, damp=6.0): if u <= 0: return 0.0 return 1 - math.exp(-damp*u)*math.cos(freq*TAU*u) def cam_move(a, b, u, shake=0.0, seed=0): """Interpolate two Cams; add a small deterministic handheld drift.""" e = ease(min(max(u, 0.0), 1.0)) cx = lerp(a.cx, b.cx, e); cy = lerp(a.cy, b.cy, e) wm = math.exp(lerp(math.log(a.wmm), math.log(b.wmm), e)) rt = lerp(a.rot, b.rot, e) if shake: j = wm*0.004*shake cx += math.sin(u*23.0+seed)*j; cy += math.cos(u*19.0+seed*1.7)*j return Cam(cx, cy, wm, rt, _raw=True) # ═══════════════════════════════════════════════════════════════════════════ # THE PLATE # ═══════════════════════════════════════════════════════════════════════════ class Plate: def __init__(self, bg=None): col = tuple(int(c*255) for c in (bg if bg is not None else PAPER)) self.im = Image.new("RGB", (SW, SH), col) self.d = ImageDraw.Draw(self.im) self.stick = Image.new("L", (SW, SH), 0); self.ds = ImageDraw.Draw(self.stick) self.foil = Image.new("L", (SW, SH), 0); self.df = ImageDraw.Draw(self.foil) self.wax = Image.new("L", (SW, SH), 0); self.dw = ImageDraw.Draw(self.wax) self.pen = Image.new("RGBA", (SW, SH), (0, 0, 0, 0)) self.dp = ImageDraw.Draw(self.pen) def out(self): rgb = np.asarray(self.im.resize((W, H), Image.BOX), np.float32)/255.0 m = {k: np.asarray(getattr(self, k).resize((W, H), Image.BOX), np.float32)/255.0 for k in ("stick", "foil", "wax")} pen = np.asarray(self.pen.resize((W, H), Image.BOX), np.float32)/255.0 return rgb, m, pen def compose(plate, cam, t, e, u, fnum, **sk): rgb, m, pen = plate.out() pf = paper_field(cam) printed = press(rgb, cam.cell, fnum, pfield=pf) if pen[..., 3].max() > 0.01: # handwriting is not printed a = pen[..., 3:4] printed = printed*(1-a) + pen[..., :3]*a out = surface(printed, m, cam, t, e, u, fnum=fnum, **sk) return np.clip(out, 0, 1) # ═══════════════════════════════════════════════════════════════════════════ # THE ALBUM — geometry in millimetres # ═══════════════════════════════════════════════════════════════════════════ PAGE_H = 270.0 LP0, LP1 = 0.0, 200.0 # left page RP0, RP1 = 210.0, 410.0 # right page SPREAD_C = 205.0 SLOT_W, SLOT_H = 32.0, 44.0 def slot_xy(i): col, row = i % 5, i//5 return (222.0 + col*36.0, 63.0 + row*49.0) def slot_c(i): x, y = slot_xy(i); return (x+SLOT_W/2, y+SLOT_H/2) INK_K = (26, 22, 22) INK_R = (206, 40, 48) INK_B = (28, 74, 158) INK_G = (22, 128, 76) INK_Y = (244, 196, 30) PAPER_C = tuple(int(c*255) for c in PAPER) SLOTBG = (232, 224, 206) PEN_B = (34, 46, 110, 235) PEN_R = (176, 34, 40, 230) TEAMS = [ ("VILA", (26, 118, 70), (240, 232, 210)), ("MARÉ", (30, 78, 156), (222, 60, 56)), ("MORRO", (238, 186, 26), (34, 30, 30)), ("LADEIRA", (194, 42, 48), (240, 234, 216)), ("BECO", (92, 52, 140), (240, 148, 40)), ] NAMES = ["NEGUINHO", "BIGODE", "TOTÓ", "ZÉ CARLOS", "PELEZINHO", "CACÁ", "MARQUINHOS", "JUNINHO", "GORDÃO", "CANHOTO", "PIPOCA", "SERGINHO", "TIÃO", "BABALU", "MAGRÃO", "FUMAÇA", "CARECA", "BOLINHA", "VOVÔ", "DUDU"] GAG = 3 # ZÉ CARLOS · 4, forever HERO = 19 # DUDU · 20 class Player: __slots__ = ("i", "num", "name", "team", "seed", "foil") def __init__(self, i): self.i = i; self.num = i+1; self.name = NAMES[i] self.team = TEAMS[i % 5] self.seed = 7000 + i*137 self.foil = i in (0, 9, 19) # capitão, craque, and him PLAYERS = [Player(i) for i in range(20)] SKINS = [(232, 190, 152), (206, 158, 116), (168, 116, 78), (128, 82, 52), (92, 58, 38), (240, 206, 174)] HAIRC = [(28, 22, 20), (52, 34, 22), (94, 62, 34), (18, 16, 16), (140, 104, 58)] def _R(seed): return np.random.RandomState(seed % (2**31-1)) def face(d, cx, cy, r, seed, mood="calm", tilt=0.0): """A printed head. r = head half-height in pixels.""" R = _R(seed) skin = SKINS[R.randint(len(SKINS))] hair = HAIRC[R.randint(len(HAIRC))] style = R.randint(6) shade = tuple(int(c*0.80) for c in skin) ex = r*0.78 # ears for s in (-1, 1): d.ellipse([cx+s*ex-r*0.16, cy-r*0.08, cx+s*ex+r*0.16, cy+r*0.26], fill=skin, outline=shade, width=max(1, int(r*0.035))) # head d.ellipse([cx-ex, cy-r, cx+ex, cy+r], fill=skin) # hair if style == 0: # buzz d.chord([cx-ex*1.03, cy-r*1.06, cx+ex*1.03, cy+r*0.35], 180, 360, fill=hair) elif style == 1: # afro d.ellipse([cx-ex*1.32, cy-r*1.42, cx+ex*1.32, cy+r*0.12], fill=hair) d.ellipse([cx-ex*0.92, cy-r*0.72, cx+ex*0.92, cy+r*1.0], fill=skin) elif style == 2: # side part d.chord([cx-ex*1.06, cy-r*1.12, cx+ex*1.06, cy+r*0.22], 180, 360, fill=hair) d.polygon([(cx-ex*0.9, cy-r*0.52), (cx+ex*0.4, cy-r*0.98), (cx+ex*1.0, cy-r*0.42), (cx-ex*0.2, cy-r*0.30)], fill=hair) elif style == 3: # curls for q in range(9): a = math.pi + q*math.pi/8 d.ellipse([cx+math.cos(a)*ex*0.98-r*0.30, cy+math.sin(a)*r*0.98-r*0.30, cx+math.cos(a)*ex*0.98+r*0.30, cy+math.sin(a)*r*0.98+r*0.30], fill=hair) elif style == 4: # bald / careca pass else: # flat top d.rectangle([cx-ex*0.94, cy-r*1.16, cx+ex*0.94, cy-r*0.42], fill=hair) d.chord([cx-ex*1.0, cy-r*1.0, cx+ex*1.0, cy+r*0.2], 180, 360, fill=hair) # brows bw = r*0.30 for s in (-1, 1): y0 = cy - r*0.28 + (r*0.05 if mood == "sad" else 0)*(1 if s < 0 else -1) d.line([(cx+s*ex*0.52-bw*0.5, y0), (cx+s*ex*0.52+bw*0.5, y0 - (r*0.07 if mood != "sad" else -r*0.06)*s)], fill=hair, width=max(2, int(r*0.10))) # eyes for s in (-1, 1): exx = cx + s*ex*0.50; eyy = cy - r*0.05 d.ellipse([exx-r*0.20, eyy-r*0.15, exx+r*0.20, eyy+r*0.15], fill=(248, 246, 240)) d.ellipse([exx-r*0.09, eyy-r*0.10, exx+r*0.09, eyy+r*0.10], fill=(30, 24, 20)) d.ellipse([exx-r*0.03, eyy-r*0.08, exx+r*0.01, eyy-r*0.03], fill=(255, 255, 255)) # nose d.line([(cx, cy-r*0.02), (cx-r*0.10, cy+r*0.30), (cx+r*0.06, cy+r*0.32)], fill=shade, width=max(2, int(r*0.075)), joint="curve") # mouth my = cy + r*0.56 if mood == "grin": d.chord([cx-r*0.42, my-r*0.34, cx+r*0.42, my+r*0.30], 10, 170, fill=(96, 40, 40)) d.chord([cx-r*0.42, my-r*0.34, cx+r*0.42, my+r*0.05], 10, 170, fill=(250, 248, 240)) d.line([(cx-r*0.06, my-r*0.05), (cx-r*0.06, my+r*0.10)], fill=(96, 40, 40), width=max(2, int(r*0.06))) # gap tooth elif mood == "sad": d.arc([cx-r*0.36, my-r*0.06, cx+r*0.36, my+r*0.38], 190, 350, fill=(120, 58, 52), width=max(2, int(r*0.09))) elif mood == "shout": d.ellipse([cx-r*0.28, my-r*0.24, cx+r*0.28, my+r*0.34], fill=(92, 36, 36)) d.chord([cx-r*0.28, my-r*0.30, cx+r*0.28, my+r*0.06], 0, 180, fill=(250, 248, 240)) else: d.arc([cx-r*0.34, my-r*0.30, cx+r*0.34, my+r*0.22], 10, 170, fill=(120, 58, 52), width=max(2, int(r*0.085))) return skin def crest(d, cx, cy, r, team, seed=0, style=None): """A generative club crest — shield or roundel with a band and a star.""" prim, sec = team[1], team[2] R = _R(seed + 31) style = R.randint(3) if style is None else style if style == 0: pts = [(cx-r, cy-r*1.05), (cx+r, cy-r*1.05), (cx+r, cy+r*0.25), (cx, cy+r*1.15), (cx-r, cy+r*0.25)] d.polygon(pts, fill=prim, outline=INK_K, width=max(1, int(r*0.07))) d.polygon([(cx-r, cy-r*0.22), (cx+r, cy-r*0.55), (cx+r, cy-r*0.05), (cx-r, cy+r*0.28)], fill=sec) elif style == 1: d.ellipse([cx-r, cy-r, cx+r, cy+r], fill=prim, outline=INK_K, width=max(1, int(r*0.09))) d.ellipse([cx-r*0.62, cy-r*0.62, cx+r*0.62, cy+r*0.62], fill=sec) d.ellipse([cx-r*0.40, cy-r*0.40, cx+r*0.40, cy+r*0.40], fill=prim) else: pts = [(cx-r, cy-r), (cx+r, cy-r), (cx+r, cy+r*0.3), (cx, cy+r*1.1), (cx-r, cy+r*0.3)] d.polygon(pts, fill=sec, outline=INK_K, width=max(1, int(r*0.07))) for q in range(3): d.rectangle([cx-r+q*r*0.66, cy-r, cx-r+q*r*0.66+r*0.33, cy+r*1.1], fill=prim) # star sp = [] for q in range(10): rr = r*(0.42 if q % 2 == 0 else 0.18) a = -math.pi/2 + q*math.pi/5 sp.append((cx+math.cos(a)*rr, cy+math.sin(a)*rr - r*0.12)) d.polygon(sp, fill=(248, 244, 228)) _FC = {} MINPT = 9 # bundled Helvetica.ttc raises below 8pt; 2200 trips PIL's cap # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) def font(size, name="Helvetica.ttc"): size = int(max(MINPT, min(2200, size))) key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] def ctext(d, x, y, s, f, fill, anchor="mm"): if f is None: return d.text((x, y), s, font=f, fill=fill, anchor=anchor) def fit_text(s, px, name="Helvetica.ttc", start=None, minsz=MINPT, skip=False): """Largest bundled-font size at which `s` fits in `px` pixels. `skip` returns None when even the smallest face would overflow — at that size the type is illegible anyway and drawing it just makes mud.""" sz = int(start or px*0.6) while sz > minsz: f = font(sz, name) if f.getlength(s) <= px: return f sz = int(sz*0.88) if sz > 14 else sz-1 f = font(minsz, name) if skip and f.getlength(s) > px*1.2: return None return f # ---- the sticker ---------------------------------------------------------- _CARD_CACHE = {} def card_art(pw, ph, pl, mood="calm", back=False): """One sticker at pw x ph pixels: RGBA art + an L foil mask.""" pw = max(14, min(4200, int(pw))); ph = max(18, min(4200, int(ph))) qw, qh = (pw+5)//6*6, (ph+5)//6*6 key = (qw, qh, pl.i, mood, back) if key not in _CARD_CACHE: if len(_CARD_CACHE) > 160: _CARD_CACHE.clear() _CARD_CACHE[key] = _card_art(qw, qh, pl, mood, back) art, fm = _CARD_CACHE[key] if (art.width, art.height) != (pw, ph): art = art.resize((pw, ph), Image.BICUBIC) fm = fm.resize((pw, ph), Image.BICUBIC) return art, fm def _card_art(pw, ph, pl, mood, back): prim, sec = pl.team[1], pl.team[2] art = Image.new("RGBA", (pw, ph), (0, 0, 0, 0)) d = ImageDraw.Draw(art) fm = Image.new("L", (pw, ph), 0); df = ImageDraw.Draw(fm) bd = max(1, int(pw*0.045)) # white die-cut border d.rounded_rectangle([0, 0, pw-1, ph-1], radius=max(2, int(pw*0.06)), fill=(250, 248, 242, 255)) x0, y0, x1, y1 = bd, bd, pw-1-bd, ph-1-bd rr = max(1, int(pw*0.03)) if back: d.rounded_rectangle([x0, y0, x1, y1], radius=rr, fill=(200, 46, 40, 255)) st = max(3, int(pw*0.13)) for yy in range(int(y0), int(y1), st): for xx in range(int(x0), int(x1), st): if ((xx//st)+(yy//st)) % 2: continue d.polygon([(xx+st/2, yy), (xx+st, yy+st/2), (xx+st/2, yy+st), (xx, yy+st/2)], fill=(232, 190, 54, 255)) d.rectangle([x0, y0+(y1-y0)*0.40, x1, y0+(y1-y0)*0.60], fill=(244, 240, 226, 255)) ctext(d, pw/2, y0+(y1-y0)*0.50, "COPA DA VILA", fit_text("COPA DA VILA", (x1-x0)*0.86, start=int((y1-y0)*0.13), skip=True), (176, 34, 30, 255)) return art, fm d.rounded_rectangle([x0, y0, x1, y1], radius=rr, fill=prim+(255,)) # diagonal band d.polygon([(x0, y1-(y1-y0)*0.55), (x1, y1-(y1-y0)*0.86), (x1, y1-(y1-y0)*0.52), (x0, y1-(y1-y0)*0.21)], fill=sec+(255,)) if pl.foil: cx, cy = pw/2, y0+(y1-y0)*0.36 for q in range(24): a = q*TAU/24; rl = (y1-y0)*(0.62 if q % 2 == 0 else 0.40) df.polygon([(cx, cy), (cx+math.cos(a)*rl, cy+math.sin(a)*rl*0.9), (cx+math.cos(a+0.13)*rl, cy+math.sin(a+0.13)*rl*0.9)], fill=255) if q % 2 == 0: d.polygon([(cx, cy), (cx+math.cos(a)*rl, cy+math.sin(a)*rl*0.9), (cx+math.cos(a+0.13)*rl, cy+math.sin(a+0.13)*rl*0.9)], fill=(246, 240, 214, 255)) # portrait hy = y0 + (y1-y0)*0.40 hr = (y1-y0)*0.23 # shoulders / kit sw2 = (x1-x0)*0.44 d.polygon([(pw/2-sw2, y1-(y1-y0)*0.20), (pw/2+sw2, y1-(y1-y0)*0.20), (pw/2+sw2*0.66, hy+hr*0.62), (pw/2-sw2*0.66, hy+hr*0.62)], fill=sec+(255,)) d.polygon([(pw/2-sw2*0.20, hy+hr*0.55), (pw/2+sw2*0.20, hy+hr*0.55), (pw/2, hy+hr*1.25)], fill=prim+(255,)) # collar V if pl.foil: # knock the portrait out of the foil mask — df.ellipse([pw/2-hr*1.30, hy-hr*1.42, pw/2+hr*1.30, hy+hr*1.30], fill=0) df.polygon([(pw/2-sw2, y1), (pw/2+sw2, y1), (pw/2+sw2*0.66, hy+hr*0.5), (pw/2-sw2*0.66, hy+hr*0.5)], fill=0) face(d, pw/2, hy, hr, pl.seed, mood=mood) # name banner bh = (y1-y0)*0.16 d.rectangle([x0, y1-bh, x1, y1], fill=(248, 246, 238, 255)) d.rectangle([x0, y1-bh, x1, y1-bh+max(1, int(ph*0.008))], fill=INK_K+(255,)) f = fit_text(pl.name, (x1-x0)*0.90, start=int(bh*0.72), skip=True) ctext(d, pw/2, y1-bh*0.48, pl.name, f, INK_K+(255,)) # number badge nb = (y1-y0)*0.14 d.ellipse([x0+nb*0.22, y0+nb*0.22, x0+nb*1.72, y0+nb*1.72], fill=(250, 248, 240, 255), outline=INK_K+(255,), width=max(1, int(pw*0.012))) ctext(d, x0+nb*0.97, y0+nb*0.97, str(pl.num), fit_text(str(pl.num), nb*1.05, start=int(nb*0.95), skip=True), INK_K+(255,)) if pl.foil: ctext(d, x1-(x1-x0)*0.16, y0+nb*0.9, "★", fit_text("★", nb*1.2, start=int(nb)), (250, 246, 220, 255)) return art, fm AREA_CAP = 8.0e6 # a macro card can be 30 000 px wide; never materialise it def put_card(plate, cam, cx, cy, wmm, pl, ang=0.0, mood="calm", back=False, scale=1.0, foil_on=True, shadow=True): """Place a sticker on the page. At a 7 mm macro the sticker's true footprint is ~30 000 px across, which is both un-allocatable and pointless: the halftone cell there is 70 output pixels, so any softness in the source is far below the screen. So the art is rendered at a bounded area, and only the *visible* rectangle of it is cropped and scaled onto the plate.""" hmm = wmm*SLOT_H/SLOT_W pw = cam.s(wmm)*scale; ph = cam.s(hmm)*scale if pw < 6 or ph < 8: return None down = min(1.0, math.sqrt(AREA_CAP/max(1.0, pw*ph))) art, fm = card_art(pw*down, ph*down, pl, mood, back) a2 = ang + math.degrees(cam.rot) if abs(a2) > 0.05: art = art.rotate(-a2, resample=Image.BICUBIC, expand=True) fm = fm.rotate(-a2, resample=Image.BICUBIC, expand=True) fw, fh = art.width/down, art.height/down px, py = cam.p(cx, cy) x0, y0 = px-fw/2, py-fh/2 vx0 = max(0, int(math.floor(x0))); vy0 = max(0, int(math.floor(y0))) vx1 = min(SW, int(math.ceil(x0+fw))); vy1 = min(SH, int(math.ceil(y0+fh))) if vx1-vx0 < 1 or vy1-vy0 < 1: return None if (vx0, vy0, vx1, vy1) != (int(x0), int(y0), int(x0+fw), int(y0+fh)) or down < 1.0: sx0 = int(max(0, (vx0-x0)*down)); sy0 = int(max(0, (vy0-y0)*down)) sx1 = int(min(art.width, math.ceil((vx1-x0)*down))) sy1 = int(min(art.height, math.ceil((vy1-y0)*down))) if sx1-sx0 < 1 or sy1-sy0 < 1: return None tgt = (vx1-vx0, vy1-vy0) art = art.crop((sx0, sy0, sx1, sy1)).resize(tgt, Image.BICUBIC) fm = fm.crop((sx0, sy0, sx1, sy1)).resize(tgt, Image.BICUBIC) box = (vx0, vy0) al = art.getchannel("A") plate.im.paste(art.convert("RGB"), box, al) if shadow: plate.stick.paste(al, box, al) if foil_on and pl.foil: fmm = Image.composite(fm, Image.new("L", fm.size, 0), al) plate.foil.paste(fmm, box, fmm) return box # ---- the page ------------------------------------------------------------- def draw_slot(plate, cam, i, label=True, hi=False): x, y = slot_xy(i) d = plate.d d.polygon(cam.box(x, y, x+SLOT_W, y+SLOT_H), fill=SLOTBG) lw = max(1, int(cam.s(0.35))) for q in range(4): # keyline (dashed) pass d.line(cam.box(x, y, x+SLOT_W, y+SLOT_H) + [cam.p(x, y)], fill=(178, 166, 142), width=lw) if hi: d.line(cam.box(x-1.2, y-1.2, x+SLOT_W+1.2, y+SLOT_H+1.2) + [cam.p(x-1.2, y-1.2)], fill=INK_R, width=max(2, int(cam.s(0.7)))) if label and cam.s(SLOT_W) > 26: pl = PLAYERS[i] f = fit_text(str(pl.num), cam.s(SLOT_W)*0.34, start=int(cam.s(SLOT_H)*0.18), skip=True) px, py = cam.p(x+SLOT_W/2, y+SLOT_H*0.30) ctext(d, px, py, str(pl.num), f, (168, 154, 128)) f2 = fit_text(pl.name, cam.s(SLOT_W)*0.86, start=int(cam.s(SLOT_H)*0.10), skip=True) px, py = cam.p(x+SLOT_W/2, y+SLOT_H*0.72) ctext(d, px, py, pl.name, f2, (170, 158, 132)) def page_furniture(plate, cam, t, complete=False): """The RIGHT sheet only. left_page() owns the left one — they must not overlap, or one silently erases the other.""" d = plate.d d.polygon(cam.box(RP0, 0, RP1, PAGE_H), fill=PAPER_C) # gutter shadow, falling off the spine into the right page for q in range(16): u = q/15.0 g = int(255 - 84*math.exp(-((u-0.42)*3.4)**2)) d.polygon(cam.box(LP1+u*22, 0, LP1+(u+0.075)*22, PAGE_H), fill=(g, int(g*0.97), int(g*0.92))) # right page header d.polygon(cam.box(RP0+12, 14, RP1-12, 52), fill=INK_G) crest(plate.d, *cam.p(RP0+30, 33), cam.s(13), TEAMS[0], seed=3, style=0) px, py = cam.p((RP0+RP1)/2 + 12, 33) ctext(d, px, py, "ESTRELA DA VILA", fit_text("ESTRELA DA VILA", cam.s(112), start=int(cam.s(19)), skip=True), (248, 244, 228)) px, py = cam.p(RP1-26, 33) f = fit_text("1—20", cam.s(24), start=int(cam.s(11)), skip=True) ctext(d, px, py, "1—20", f, (248, 244, 228)) for i in range(20): draw_slot(plate, cam, i, hi=False) # page number px, py = cam.p((RP0+RP1)/2, PAGE_H-8) ctext(d, px, py, "20", fit_text("20", cam.s(12), start=int(cam.s(8)), skip=True), (160, 148, 124)) def left_page(plate, cam, t, struck=0, panel="stoop", e=None, mood="calm"): d = plate.d d.polygon(cam.box(LP0, 0, LP1, PAGE_H), fill=PAPER_C) d.polygon(cam.box(14, 14, LP1-14, 52), fill=INK_R) px, py = cam.p(LP1/2, 33) ctext(d, px, py, "COPA DA VILA", fit_text("COPA DA VILA", cam.s(150), start=int(cam.s(22))), (250, 246, 232)) # checklist d.polygon(cam.box(14, 60, LP1-14, 172), fill=(238, 231, 212)) px, py = cam.p(LP1/2, 70) ctext(d, px, py, "MINHA LISTA", fit_text("MINHA LISTA", cam.s(80), start=int(cam.s(9)), skip=True), (120, 108, 88)) for i in range(20): col, row = i % 4, i//4 x = 24 + col*42; y = 80 + row*18 f = fit_text(f"{i+1:2d} {NAMES[i][:8]}", cam.s(38), start=int(cam.s(6.4)), skip=True) if f is not None: ctext(d, *cam.p(x, y), f"{i+1:2d} {NAMES[i][:8]}", f, INK_K, anchor="lm") if i < struck and i != HERO: R = _R(9000+i) pts = [] for q in range(6): u = q/5.0 pts.append(cam.p(x-1 + u*39 + R.uniform(-.6, .6), y + R.uniform(-1.2, 1.2))) plate.dp.line(pts, fill=PEN_B, width=max(1, int(cam.s(0.55))), joint="curve") if i == HERO and struck >= 19: R = _R(9100) pts = [] for q in range(26): a = q/25.0*TAU pts.append(cam.p(x+18 + math.cos(a)*22 + R.uniform(-.7, .7), y + math.sin(a)*6.4 + R.uniform(-.5, .5))) plate.dp.line(pts+[pts[0]], fill=PEN_R, width=max(1, int(cam.s(0.7))), joint="curve") if panel == "stoop": stoop_panel(plate, cam, 12, 176, LP1-12, PAGE_H-10, t, e, mood) # ---- the printed illustration on the left page --------------------------- def kid_figure(plate, cam, x, ybase, hmm, seed, t, pose="sit", mood="calm", shirt=None, holding=None): """A neighbourhood kid, drawn as flat printed illustration.""" d = plate.d R = _R(seed) sh = shirt or [(214, 66, 52), (40, 96, 176), (232, 178, 40), (44, 132, 88), (150, 88, 176)][R.randint(5)] hr = hmm*0.205 hy = ybase - hmm*0.80 bob = math.sin(t*3.0 + seed*0.7)*hmm*0.012 hy += bob # legs lg = (52, 42, 36) if pose == "sit": d.polygon(cam.pl([(x-hmm*0.13, ybase-hmm*0.34), (x+hmm*0.16, ybase-hmm*0.34), (x+hmm*0.30, ybase), (x-hmm*0.02, ybase)]), fill=lg) d.polygon(cam.pl([(x-hmm*0.20, ybase-hmm*0.30), (x+hmm*0.05, ybase-hmm*0.30), (x+hmm*0.18, ybase+hmm*0.02), (x-hmm*0.12, ybase+hmm*0.02)]), fill=(40, 32, 28)) else: sp = math.sin(t*4.2+seed)*hmm*0.05 for s in (-1, 1): d.polygon(cam.pl([(x+s*hmm*0.07-hmm*0.05, ybase-hmm*0.42), (x+s*hmm*0.07+hmm*0.05, ybase-hmm*0.42), (x+s*hmm*0.07+hmm*0.06+s*sp, ybase), (x+s*hmm*0.07-hmm*0.05+s*sp, ybase)]), fill=lg) # torso d.polygon(cam.pl([(x-hmm*0.17, hy+hr*1.0), (x+hmm*0.17, hy+hr*1.0), (x+hmm*0.20, ybase-hmm*0.30), (x-hmm*0.20, ybase-hmm*0.30)]), fill=sh) # arms aa = math.sin(t*2.4+seed*1.3)*0.25 for s in (-1, 1): ex_, ey_ = (x+s*hmm*0.34, hy+hr*1.9 - (hmm*0.34 if (holding and s > 0) else 0)) d.line([cam.p(x+s*hmm*0.17, hy+hr*1.3), cam.p(ex_, ey_ + math.sin(aa)*hmm*0.05)], fill=sh, width=max(2, int(cam.s(hmm*0.075)))) d.ellipse([*cam.p(ex_-hmm*0.045, ey_-hmm*0.045), *cam.p(ex_+hmm*0.045, ey_+hmm*0.045)], fill=SKINS[_R(seed).randint(len(SKINS))]) face(d, *cam.p(x, hy), cam.s(hr), seed, mood=mood) if holding: pl, wmm = holding put_card(plate, cam, x+hmm*0.36, hy+hr*1.6, wmm, pl, ang=-12, shadow=False) def stoop_panel(plate, cam, x0, y0, x1, y1, t, e=None, mood="calm", wide=False): """The printed illustration: kids on a stoop. Drawn in ink, so it screens like everything else.""" d = plate.d d.polygon(cam.box(x0, y0, x1, y1), fill=(226, 214, 186)) d.line(cam.box(x0, y0, x1, y1)+[cam.p(x0, y0)], fill=INK_K, width=max(1, int(cam.s(0.5)))) hh = y1-y0; ww = x1-x0 # sky wash + a wall d.polygon(cam.box(x0, y0, x1, y0+hh*0.42), fill=(198, 214, 226)) d.polygon(cam.box(x0, y0+hh*0.10, x0+ww*0.42, y1), fill=(206, 178, 146)) for q in range(7): # bricks d.line([cam.p(x0, y0+hh*0.14+q*hh*0.12), cam.p(x0+ww*0.42, y0+hh*0.14+q*hh*0.12)], fill=(184, 154, 122), width=max(1, int(cam.s(0.4)))) d.polygon(cam.box(x0+ww*0.08, y0+hh*0.16, x0+ww*0.30, y0+hh*0.58), fill=(120, 92, 70)) # doorway # steps for q in range(4): d.polygon(cam.box(x0, y1-hh*(0.10+q*0.10), x1, y1-hh*(0.04+q*0.10)), fill=(198, 190, 172) if q % 2 == 0 else (186, 178, 160)) # kids base = y1-hh*0.10 kids = [(x0+ww*0.36, base, 4600, "sit", hh*0.52), (x0+ww*0.58, base-hh*0.10, 4711, "sit", hh*0.50), (x0+ww*0.82, base, 4822, "stand", hh*0.58)] for (kx, ky, sd, ps, hm) in kids: kid_figure(plate, cam, kx, ky, hm, sd, t, pose=ps, mood="grin" if sd == 4711 else "calm") # DUDU — our kid, always on the left, always the same face kid_figure(plate, cam, x0+ww*0.14, base, hh*0.58, PLAYERS[HERO].seed, t, pose="sit", mood=mood, shirt=(238, 216, 60), holding=(PLAYERS[GAG], ww*0.085)) # scattered stickers on the step R = _R(5150) for q in range(6): put_card(plate, cam, x0+ww*(0.22+0.12*q) + R.uniform(-1, 1), y1-hh*0.045, ww*0.055, PLAYERS[(q*7+2) % 20], ang=R.uniform(-40, 40), shadow=False, foil_on=False) # ---- the packet ----------------------------------------------------------- def tear_path(x0, x1, y, seed, n=110, amp=1.6): R = _R(seed) xs = np.linspace(x0, x1, n) w = np.cumsum(R.normal(0, 1, n))*0.5 w -= np.linspace(w[0], w[-1], n) ys = y + w*amp*0.5 + R.normal(0, 1, n)*amp*0.32 return xs, ys def packet(plate, cam, cx, cy, wmm, hmm, t, torn=0.0, seed=1, ang=0.0, flap_fly=0.0, label=True): """A waxed-paper packet. `torn` 0..1 opens the tear across the top.""" d = plate.d x0, x1 = cx-wmm/2, cx+wmm/2 y0, y1 = cy-hmm/2, cy+hmm/2 ty = y0 + hmm*0.26 xs, ys = tear_path(x0-1, x1+1, ty, seed, amp=hmm*0.055) cut = int(np.clip(torn, 0, 1)*(len(xs)-1)) body_top = [(xs[i], ys[i]) for i in range(len(xs))] if cut < len(xs)-1: for i in range(cut, len(xs)): body_top[i] = (xs[i], ty) PKT = (222, 68, 46) PKT2 = (244, 206, 60) # body poly = [(x0, y1), (x1, y1)] + [(x, y) for (x, y) in reversed(body_top)] d.polygon(cam.pl(poly), fill=PKT) plate.dw.polygon(cam.pl(poly), fill=210) # crinkles R = _R(seed+77) for q in range(9): u = (q+0.5)/9 xx = x0 + u*wmm d.line(cam.pl([(xx+R.uniform(-1, 1), y1), (xx+R.uniform(-3, 3), ty+hmm*0.10)]), fill=(196, 52, 34) if q % 2 else (238, 96, 72), width=max(1, int(cam.s(wmm*0.012)))) # print on the packet if label: d.polygon(cam.pl([(x0+wmm*0.08, cy-hmm*0.03), (x1-wmm*0.08, cy-hmm*0.09), (x1-wmm*0.08, cy+hmm*0.14), (x0+wmm*0.08, cy+hmm*0.20)]), fill=PKT2) f = fit_text("COPA DA VILA", cam.s(wmm*0.72), start=int(cam.s(hmm*0.16))) ctext(d, *cam.p(cx, cy+hmm*0.055), "COPA DA VILA", f, (30, 26, 24)) f2 = fit_text("5 FIGURINHAS", cam.s(wmm*0.5), start=int(cam.s(hmm*0.10)), skip=True) ctext(d, *cam.p(cx, cy+hmm*0.30), "5 FIGURINHAS", f2, (250, 236, 200)) crest(d, *cam.p(cx, cy-hmm*0.22), cam.s(hmm*0.14), TEAMS[0], seed=5, style=1) # the fibres along the torn edge if torn > 0.001: for i in range(0, cut): fx, fy = xs[i], ys[i] L = R.uniform(0.3, 2.4)*hmm*0.030 d.line(cam.pl([(fx, fy+hmm*0.006), (fx+R.uniform(-.9, .9)*hmm*0.022, fy-L)]), fill=(214, 74, 52), width=max(1, int(cam.s(hmm*0.005)))) if i % 2 == 0: d.line(cam.pl([(fx, fy), (fx+R.uniform(-.5, .5)*hmm*0.02, fy+L*0.55)]), fill=(250, 228, 208), width=max(1, int(cam.s(hmm*0.004)))) # the flap if torn > 0.02: fy0 = y0 - flap_fly*hmm*0.55 rot = flap_fly*22 fl = [(x0, fy0)] + [(x, y - (ty-y0)*0 - flap_fly*hmm*0.55) for (x, y) in body_top[:max(2, cut)]] + \ [(xs[max(1, cut-1)], fy0)] cxm = (x0+x1)/2 if rot: cc, ssn = math.cos(math.radians(rot)), math.sin(math.radians(rot)) fl = [(cxm + (px-cxm)*cc - (py-fy0)*ssn, fy0 + (px-cxm)*ssn + (py-fy0)*cc) for (px, py) in fl] d.polygon(cam.pl(fl), fill=(200, 56, 38)) plate.dw.polygon(cam.pl(fl), fill=180) # top edge before tearing if torn < 0.98: d.polygon(cam.pl([(x0, y0), (x1, y0), (x1, ty), (x0, ty)]), fill=(206, 58, 40)) plate.dw.polygon(cam.pl([(x0, y0), (x1, y0), (x1, ty), (x0, ty)]), fill=200) for q in range(20): # serrated seal xx = x0 + (q+0.5)*wmm/20 d.polygon(cam.pl([(xx-wmm/40, y0+hmm*0.03), (xx+wmm/40, y0+hmm*0.03), (xx, y0+hmm*0.07)]), fill=PAPER_C) def hand(plate, cam, cx, cy, wmm, seed=1, ang=0.0, skin=None, back=True): """A hand, from the wrist, holding whatever is at (cx,cy).""" d = plate.d R = _R(seed) sk = skin or SKINS[R.randint(len(SKINS))] dk = tuple(int(c*0.82) for c in sk) ca, sa = math.cos(math.radians(ang)), math.sin(math.radians(ang)) def P(dx, dy): return (cx + dx*ca - dy*sa, cy + dx*sa + dy*ca) # wrist + palm d.polygon(cam.pl([P(-wmm*0.46, wmm*0.16), P(wmm*0.46, wmm*0.16), P(wmm*0.52, wmm*1.40), P(-wmm*0.52, wmm*1.40)]), fill=sk) d.ellipse([*cam.pl([P(-wmm*0.46, -wmm*0.06)])[0], *cam.pl([P(wmm*0.46, wmm*0.40)])[0]], fill=sk) for q in range(4): fx = -wmm*0.33 + q*wmm*0.22 ln = wmm*(0.30 + 0.06*math.sin(q*1.4)) d.line([cam.p(*P(fx, wmm*0.18)), cam.p(*P(fx+wmm*0.015, -ln))], fill=sk, width=max(2, int(cam.s(wmm*0.155)))) d.ellipse([*cam.pl([P(fx-wmm*0.078, -ln-wmm*0.078)])[0], *cam.pl([P(fx+wmm*0.093, -ln+wmm*0.078)])[0]], fill=sk) d.arc([*cam.pl([P(fx-wmm*0.078, -ln-wmm*0.05)])[0], *cam.pl([P(fx+wmm*0.093, -ln+wmm*0.10)])[0]], 200, 340, fill=dk, width=max(1, int(cam.s(wmm*0.02)))) # thumb d.line([cam.p(*P(-wmm*0.44, wmm*0.62)), cam.p(*P(-wmm*0.86, wmm*0.06))], fill=sk, width=max(2, int(cam.s(wmm*0.21)))) d.ellipse([*cam.pl([P(-wmm*0.96, -wmm*0.04)])[0], *cam.pl([P(-wmm*0.76, wmm*0.16)])[0]], fill=sk) def rubber_band(plate, cam, cx, cy, wmm, hmm, ang=0.0, col=(186, 132, 84)): """Two straps wrapping the pile — one across, one down. They stop at the edge of the pile plus a couple of millimetres, like actual elastic.""" d = plate.d for (aa, L) in ((ang, wmm*0.58), (ang+92, hmm*0.56)): a = math.radians(aa) ca, sa = math.cos(a), math.sin(a) d.line(cam.pl([(cx-ca*L, cy-sa*L), (cx+ca*L, cy+sa*L)]), fill=col, width=max(2, int(cam.s(wmm*0.048)))) d.line(cam.pl([(cx-ca*L, cy-sa*L-wmm*0.014), (cx+ca*L, cy+sa*L-wmm*0.014)]), fill=tuple(min(255, int(c*1.28)) for c in col), width=max(1, int(cam.s(wmm*0.014)))) # ═══════════════════════════════════════════════════════════════════════════ # THE FILL SCHEDULE — slots land on bumbo hits # ═══════════════════════════════════════════════════════════════════════════ _FILL = None def fill_schedule(): global _FILL if _FILL is None: ks = [t for t in events()["kicks"] if 2.0*BAR <= t <= 31.4*BAR] R = _R(31337) order = list(range(19)) # everything except HERO R.shuffle(order) pick = [ks[int(q*(len(ks)-1)/18)] for q in range(19)] sched = sorted(zip(pick, order)) sched.append((33.35*BAR, HERO)) _FILL = sched return _FILL def filled_at(t): return [i for (tt, i) in fill_schedule() if tt <= t] def slap_u(t, i): """0..1 landing progress of slot i (1 = seated).""" for (tt, j) in fill_schedule(): if j == i: return float(np.clip((t-tt)/0.16 + 1.0, 0.0, 1.0)) if t >= tt-0.16 else 0.0 return 0.0 _CROOK = None def crook(i): global _CROOK if _CROOK is None: R = _R(4242) _CROOK = [(R.uniform(-6.5, 6.5), R.uniform(-1.0, 1.0), R.uniform(-1.0, 1.0)) for _ in range(20)] return _CROOK[i] def draw_filled(plate, cam, t, upto=None, hero_ok=True): got = filled_at(t) if upto is None else upto for i in got: if i == HERO and not hero_ok: continue a, jx, jy = crook(i) su = slap_u(t, i) if su <= 0.0: continue sc = 1.0 + (1.0-su)*1.45 # slams in from above cx, cy = slot_c(i) cy -= (1.0-su)**2*36.0 put_card(plate, cam, cx+jx*su, cy+jy*su, SLOT_W*1.02, PLAYERS[i], ang=a*su, scale=sc) # ═══════════════════════════════════════════════════════════════════════════ # ENGINES # ═══════════════════════════════════════════════════════════════════════════ class Eng: def __init__(self, shot, rng): self.s, self.rng, self.p = shot, rng, shot.params self.t0 = shot.i0/FPS self.setup() def setup(self): pass def cams(self, u): raise NotImplementedError def draw(self, plate, cam, t, u, k, e): raise NotImplementedError def sk(self, t, u, e): return {} def frame(self, k, u, e): t = self.t0 + k/FPS cam = self.cams(u) plate = Plate() self.draw(plate, cam, t, u, k, e) return compose(plate, cam, t, e, u, self.s.i0+k, **self.sk(t, u, e)) WIDE = Cam(SPREAD_C, 136, 432) RIGHTP = Cam(310, 140, 216) GRID = Cam(310, 155, 190) class Page(Eng): """The spread. Modes: wide / right / grid / hole / complete.""" def setup(self): m = self.p.get("mode", "wide") self.a, self.b = { "wide": (Cam(SPREAD_C, 136, 460), Cam(SPREAD_C, 136, 424)), "right": (Cam(312, 150, 236), Cam(308, 146, 208)), "grid": (Cam(300, 150, 180), Cam(320, 160, 156)), "pan": (Cam(250, 90, 200), Cam(360, 200, 200)), "hole": (Cam(360, 210, 120), Cam(383, 232, 62)), "done": (Cam(310, 150, 200), Cam(SPREAD_C, 136, 430)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=.5, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0), e=e, mood=self.p.get("mood", "calm")) page_furniture(plate, cam, t) if self.p.get("hi") is not None: draw_slot(plate, cam, self.p["hi"], hi=True) draw_filled(plate, cam, t) if self.p.get("confetti"): R = _R(6060) for q in range(34): x0 = R.uniform(20, 400); sp = R.uniform(0.7, 1.5) uu = np.clip(u*1.5 - R.uniform(0, .35), 0, 1) put_card(plate, cam, x0 + math.sin(uu*5.0+q)*22, -40 + uu*sp*430, 26, PLAYERS[GAG], ang=R.uniform(0, 360)+uu*520, shadow=False, foil_on=False) class Slap(Eng): """Close on a run of slots as stickers land on the bumbo.""" def setup(self): sl = self.p.get("slots", [0, 1, 2, 3, 4]) xs = [slot_c(i) for i in sl] cx = sum(x for x, _ in xs)/len(xs); cy = sum(y for _, y in xs)/len(xs) w = self.p.get("w", 118) self.a = Cam(cx-6, cy-3, w*1.08, rot=self.p.get("rot", 0.0)) self.b = Cam(cx+6, cy+3, w*0.92, rot=self.p.get("rot", 0.0)*0.5) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.4, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): page_furniture(plate, cam, t) draw_filled(plate, cam, t) def sk(self, t, u, e): return dict(gloss_g=1.0+1.2*e.get("kick", 0), shadow_g=1.0+0.5*e.get("kick", 0)) class Packet(Eng): """The waxed packet. Modes: hold / shake / tear / spill.""" def setup(self): m = self.p.get("mode", "hold") self.m = m self.a, self.b = { "hold": (Cam(200, 150, 210), Cam(200, 148, 170)), "shake": (Cam(200, 150, 180), Cam(200, 150, 168)), "tear": (Cam(200, 132, 190), Cam(200, 124, 138)), "slow": (Cam(200, 136, 172), Cam(200, 126, 112)), "spill": (Cam(200, 160, 250), Cam(200, 168, 214)), }[m] self.seed = self.p.get("seed", 1) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.1, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0)) page_furniture(plate, cam, t) draw_filled(plate, cam, t) wob = math.sin(t*10.0)*(4.0 if self.m == "shake" else 1.1)*(1+e.get("low", 0)) torn = {"hold": 0.0, "shake": 0.0, "tear": np.clip(u*1.5, 0, 1), "slow": np.clip(u*1.15, 0, 1), "spill": 1.0}[self.m] fly = 0.0 if torn < 0.6 else np.clip((torn-0.6)/0.4, 0, 1) packet(plate, cam, 200+wob*0.6, 150+wob*0.25, 108, 78, t, torn=torn, seed=self.seed, flap_fly=fly) hand(plate, cam, 150, 214, 40, seed=self.seed+5, ang=-14) hand(plate, cam, 252, 214, 40, seed=self.seed+9, ang=16) if self.m == "spill": R = _R(self.seed*13+1) for q in range(5): pl = PLAYERS[self.p.get("cards", [GAG, 2, 7, GAG, 12])[q]] uu = np.clip(u*1.5 - q*0.10, 0, 1) a0 = -60 + q*30 put_card(plate, cam, 200 + math.cos(math.radians(a0))*uu*82, 120 + math.sin(math.radians(a0))*uu*38 + uu*uu*44, 34, pl, ang=a0*0.5 + uu*R.uniform(-30, 30)) class Fan(Eng): """Flicking through the five you just got. One per beat.""" def setup(self): self.cards = self.p.get("cards", [2, 7, GAG, 12, 5]) v = self.p.get("v", 0) self.a = Cam(205+v*5, 150-v*3, 176-v*9) self.b = Cam(205-v*4, 146+v*2, 140-v*7) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.0, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0)) page_furniture(plate, cam, t) draw_filled(plate, cam, t) n = len(self.cards) pos = u*n for q in range(n-1, -1, -1): pl = PLAYERS[self.cards[q]] gone = np.clip(pos - q, 0, 1.4) if gone >= 1.35: continue gx = ease_in(min(gone, 1.0)) put_card(plate, cam, 205 - q*3.0 - gx*150, 148 - q*2.4 - gx*46, 58, pl, ang=-7 + q*3.5 - gx*46, mood="grin" if q == 0 else "calm") hand(plate, cam, 214, 226, 46, seed=333, ang=-6) class Doubles(Eng): """The pile. Same face, every time.""" def setup(self): self.n = self.p.get("n", 3) self.a = Cam(196, 152, 172); self.b = Cam(200, 148, 140) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.2, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0)) page_furniture(plate, cam, t) draw_filled(plate, cam, t) R = _R(1234+self.n) show = self.n for q in range(show): uu = np.clip(u*2.0 - q*0.03, 0, 1) put_card(plate, cam, 190 + R.uniform(-9.0, 9.0), 152 + R.uniform(-7.5, 7.5) - q*0.9, 62, PLAYERS[GAG], ang=R.uniform(-19, 19)*uu, shadow=(q >= show-3)) if self.p.get("band", True): rubber_band(plate, cam, 190, 150, 62, 86, ang=9) lab = f"×{self.n}" f = fit_text(lab, cam.s(40), "Impact.ttf", start=int(cam.s(26))) px, py = cam.p(246, 112) tw = f.getlength(lab) plate.d.rectangle([px-tw*0.62, py-f.size*0.62, px+tw*0.62, py+f.size*0.66], fill=(250, 244, 224)) plate.d.text((px, py), lab, font=f, fill=INK_R, anchor="mm") class Holo(Eng): """One shiny sticker, filling the frame, turning in the light.""" def setup(self): self.pl = PLAYERS[self.p.get("who", 0)] w = self.p.get("w", 70) self.a = Cam(200, 150, w*1.25); self.b = Cam(200, 150, w*0.86) def cams(self, u): c = cam_move(self.a, self.b, u, shake=.8, seed=self.s.idx) c.rot = math.radians(math.sin(u*TAU*0.8)*7.0) c._c, c._s = math.cos(c.rot), math.sin(c.rot) return c def draw(self, plate, cam, t, u, k, e): plate.d.polygon(cam.box(-200, -200, 700, 600), fill=(206, 196, 176)) put_card(plate, cam, 200, 150, 62, self.pl, ang=math.sin(t*1.7)*4.0, mood="grin") def sk(self, t, u, e): return dict(foil_g=1.5, gloss_g=1.4) class Stoop(Eng): """The printed illustration, blown up until it is the whole frame.""" def setup(self): m = self.p.get("mode", "mid") self.a, self.b = { "wide": (Cam(100, 220, 205), Cam(100, 218, 176)), "mid": (Cam(76, 214, 132), Cam(96, 216, 112)), "close":(Cam(48, 208, 82), Cam(40, 206, 60)), "all": (Cam(100, 218, 182), Cam(100, 222, 238)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=1.5, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0), mood=self.p.get("mood", "calm")) page_furniture(plate, cam, t) draw_filled(plate, cam, t) class Trade(Eng): """Two hands, mid-swap.""" def setup(self): self.a = Cam(202, 150, 156); self.b = Cam(206, 152, 128) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.8, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0)) page_furniture(plate, cam, t) draw_filled(plate, cam, t) sw = ease(np.clip(u*1.5, 0, 1)) pa, pb = PLAYERS[self.p.get("a", 6)], PLAYERS[self.p.get("b", 11)] put_card(plate, cam, lerp(150, 254, sw), 148 - math.sin(sw*math.pi)*26, 44, pa, ang=lerp(-16, 14, sw)) put_card(plate, cam, lerp(254, 150, sw), 160 + math.sin(sw*math.pi)*22, 44, pb, ang=lerp(12, -18, sw)) hand(plate, cam, 120, 216, 44, seed=71, ang=-24) hand(plate, cam, 286, 216, 44, seed=77, ang=24) class Checklist(Eng): def setup(self): m = self.p.get("mode", "mid") self.a, self.b = { "mid": (Cam(100, 118, 200), Cam(100, 116, 168)), "close":(Cam(120, 152, 96), Cam(118, 150, 70)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=.9, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0)) page_furniture(plate, cam, t) draw_filled(plate, cam, t) class Macro(Eng): """Right down into the printing. The rosette is not a filter — the screen cell is 0.46 mm of paper, so it appears on its own once the camera is close enough. Modes: open / rosette / foil / fibre / corner.""" def setup(self): m = self.p.get("mode", "rosette") self.m = m self.who = PLAYERS[self.p.get("who", 6)] hx, hy = slot_c(HERO) self.a, self.b = { "open": (Cam(180, 172, 7.0), Cam(200, 150, 98)), "rosette": (Cam(200, 150, 44), Cam(178, 116, 8.5)), "foil": (Cam(198, 116, 34), Cam(208, 110, 14)), "fibre": (Cam(190, 132, 32), Cam(202, 133, 13)), "corner": (Cam(hx, hy+4, 54), Cam(hx+1, hy+15, 26)), }[m] def cams(self, u): return cam_move(self.a, self.b, u, shake=.7, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): if self.m in ("open", "rosette", "foil"): plate.d.polygon(cam.box(-200, -200, 700, 600), fill=(208, 198, 178)) put_card(plate, cam, 200, 150, 74, self.who, ang=math.sin(t*1.3)*2.5, mood="grin") elif self.m == "fibre": plate.d.polygon(cam.box(-200, -200, 700, 600), fill=(210, 200, 180)) packet(plate, cam, 200, 150, 108, 78, t, torn=0.66, seed=91, flap_fly=1.0, label=False) else: page_furniture(plate, cam, t) draw_filled(plate, cam, t) def sk(self, t, u, e): if self.m in ("foil",): return dict(foil_g=1.8, gloss_g=1.5) return {} class Flip(Eng): """The last card, face down, turning over.""" def setup(self): self.a = Cam(200, 150, 128); self.b = Cam(200, 150, 96) def cams(self, u): return cam_move(self.a, self.b, u, shake=1.0, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): plate.d.polygon(cam.box(-200, -200, 700, 600), fill=(202, 192, 172)) ph = self.p.get("phase", "down") if ph == "down": put_card(plate, cam, 200, 150, 64, PLAYERS[HERO], back=True, ang=math.sin(t*1.2)*3.0, foil_on=False) else: # the turn: horizontal squash through zero a = ease(np.clip(u*1.35, 0, 1)) sc = abs(math.cos(a*math.pi)) pl = PLAYERS[HERO] wmm = 64*max(0.03, sc) put_card(plate, cam, 200, 150, wmm, pl, back=(a < 0.5), ang=lerp(0, -6, a), mood="grin", foil_on=(a >= 0.5)) hand(plate, cam, 210, 224, 48, seed=404, ang=-8) def sk(self, t, u, e): return dict(foil_g=1.6, gloss_g=1.3) class Reveal(Eng): """His own face, held. The foil sweeps.""" def setup(self): self.a = Cam(200, 150, 104); self.b = Cam(200, 149, 62) def cams(self, u): c = cam_move(self.a, self.b, u, shake=1.3, seed=self.s.idx) c.rot = math.radians(math.sin(u*TAU*0.6+1.0)*5.0) c._c, c._s = math.cos(c.rot), math.sin(c.rot) return c def draw(self, plate, cam, t, u, k, e): plate.d.polygon(cam.box(-200, -200, 700, 600), fill=(200, 190, 170)) put_card(plate, cam, 200, 148, 66, PLAYERS[HERO], mood="grin", ang=math.sin(t*1.5)*3.5) hand(plate, cam, 208, 226, 48, seed=505, ang=-8) def sk(self, t, u, e): return dict(foil_g=1.8, gloss_g=1.6) class Cover(Eng): """The masthead, pulling back to show the whole left page.""" def setup(self): self.a = Cam(100, 33, 56); self.b = Cam(100, 120, 240) def cams(self, u): return cam_move(self.a, self.b, u, shake=.6, seed=self.s.idx) def draw(self, plate, cam, t, u, k, e): left_page(plate, cam, t, struck=self.p.get("struck", 0)) page_furniture(plate, cam, t) draw_filled(plate, cam, t) ENGINES = dict(page=Page, slap=Slap, packet=Packet, fan=Fan, doubles=Doubles, holo=Holo, stoop=Stoop, trade=Trade, checklist=Checklist, macro=Macro, flip=Flip, reveal=Reveal, cover=Cover) # ═══════════════════════════════════════════════════════════════════════════ # THE SCORE — beats, not frames. Each section is checked against its bars. # ═══════════════════════════════════════════════════════════════════════════ SCORE = [ # intro — 16 beats ("intro", "macro", 6, dict(mode="open", who=6)), ("intro", "cover", 5, dict()), ("intro", "page", 5, dict(mode="wide")), # v1 — 28 ("v1", "stoop", 5, dict(mode="wide")), ("v1", "packet", 3, dict(mode="hold", seed=11)), ("v1", "packet", 4, dict(mode="tear", seed=11)), ("v1", "packet", 3, dict(mode="spill", seed=11, cards=[2, 7, GAG, 12, 5])), ("v1", "slap", 4, dict(slots=[0, 1, 2, 3, 4], w=124)), ("v1", "page", 3, dict(mode="right")), ("v1", "macro", 2, dict(mode="rosette", who=6)), ("v1", "doubles", 2, dict(n=3)), ("v1", "checklist", 2, dict(mode="mid", struck=4)), # coro1 — 24 ("coro1", "holo", 4, dict(who=0, w=70)), ("coro1", "slap", 3, dict(slots=[5, 6, 7, 8, 9], w=124)), ("coro1", "trade", 3, dict(a=6, b=11)), ("coro1", "stoop", 3, dict(mode="mid")), ("coro1", "slap", 3, dict(slots=[2, 6, 7, 11, 12], w=132, rot=0.05)), ("coro1", "macro", 2, dict(mode="foil", who=9)), ("coro1", "page", 3, dict(mode="grid")), ("coro1", "doubles", 3, dict(n=8)), # v2 — 24 ("v2", "packet", 3, dict(mode="tear", seed=23, struck=9)), ("v2", "fan", 3, dict(cards=[4, 8, GAG, 13, 16], struck=9)), ("v2", "doubles", 2, dict(n=11, struck=9)), ("v2", "packet", 2, dict(mode="tear", seed=29, struck=12)), ("v2", "fan", 2, dict(cards=[GAG, GAG, 17, GAG, 1], struck=12)), ("v2", "doubles", 2, dict(n=14, struck=14)), ("v2", "stoop", 3, dict(mode="close", mood="sad", struck=16)), ("v2", "checklist", 3, dict(mode="close", struck=19)), ("v2", "page", 4, dict(mode="hole", hi=HERO, struck=19)), # brk — 12 ("brk", "packet", 4, dict(mode="hold", seed=41, struck=19)), ("brk", "macro", 3, dict(mode="fibre")), ("brk", "stoop", 3, dict(mode="all", struck=19, mood="calm")), ("brk", "page", 2, dict(mode="hole", hi=HERO, struck=19)), # coro2 — 24 ("coro2", "packet", 4, dict(mode="slow", seed=41, struck=19)), ("coro2", "fan", 2, dict(cards=[3, 15], struck=19, v=0)), ("coro2", "fan", 2, dict(cards=[8, 11], struck=19, v=1)), ("coro2", "fan", 2, dict(cards=[GAG, GAG], struck=19, v=2)), ("coro2", "fan", 2, dict(cards=[6, 17], struck=19, v=3)), ("coro2", "flip", 3, dict(phase="down")), ("coro2", "macro", 2, dict(mode="foil", who=19)), ("coro2", "flip", 3, dict(phase="turn")), ("coro2", "reveal", 4, dict()), # land — 16 ("land", "reveal", 4, dict()), ("land", "slap", 3, dict(slots=[HERO], w=96)), ("land", "page", 4, dict(mode="done")), ("land", "macro", 2, dict(mode="corner")), ("land", "page", 3, dict(mode="wide", confetti=True, struck=20)), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "params") def __init__(self, idx, i0, i1, engine, section, params): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.section, self.params = engine, section, params self.seed = 20260826 + idx*7919 def build_shots(): tally = {} for (sec, _, beats, _p) in SCORE: tally[sec] = tally.get(sec, 0) + beats for nm, b0, b1 in SECTIONS: want = (b1-b0)*4 if tally.get(nm, 0) != want: raise SystemExit(f"score error: section {nm} has {tally.get(nm,0)} " f"beats, wants {want}") shots = []; beat = 0.0 for idx, (sec, eng, beats, params) in enumerate(SCORE): i0 = int(round(beat*BEAT*FPS)); beat += beats i1 = int(round(beat*BEAT*FPS)) shots.append(Shot(idx, i0, i1, eng, sec, params)) shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ═══════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> [crisp text] -> letterbox # ═══════════════════════════════════════════════════════════════════════════ CAPTIONS = [ (0.05, 0.85, "PANINI", "title"), (1.0, 2.0, "ÁLBUM DA VILA", "card"), (3.0, 1.6, "TROCA! TROCA!", "shout"), (6.0, 2.0, "TENHO... TENHO... NÃO TENHO", "low"), (9.5, 1.4, "REPETIDA", "shout"), (11.0, 1.8, "TROCA! TROCA! TROCA!", "shout"), (13.0, 1.8, "ZÉ CARLOS DE NOVO", "low"), (15.0, 1.6, "REPETIDA", "shout"), (17.0, 1.6, "OUTRA VEZ", "low"), (19.0, 1.6, "ZÉ CARLOS", "shout"), (21.0, 2.0, "FALTA UMA", "shout"), (23.0, 2.4, "o último pacotinho", "low"), (26.0, 1.8, "ABRE! ABRE!", "shout"), (28.0, 2.2, "tenho, tenho, tenho", "low"), (30.5, 1.4, "NÃO TENHO", "shout"), (32.0, 1.3, "SOU EU!", "big"), (34.0, 1.8, "COMPLETOU!", "big"), (35.2, 1.6, "n.º 20 — DUDU", "card"), ] _VIG = None def vignette(): global _VIG if _VIG is None: nx = (_XX-W/2)/(W/2); ny = (_YY-H/2)/(H/2) r = np.sqrt(nx*nx+ny*ny)/1.42 _VIG = np.clip(1.0-0.34*r**2.1, 0, 1)[..., None].astype(np.float32) return _VIG def caption_at(t): for (b0, db, txt, style) in CAPTIONS: t0 = b0*BAR; t1 = t0+db*BAR if t0 <= t < t1: a = min(1.0, (t-t0)/0.14)*min(1.0, (t1-t)/0.22) return txt, style, a return None, None, 0.0 def post(arr01, i, e, shot): a = np.clip(arr01, 0, 1)*255.0 lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.float32([9, 3, -7]) # tint: warm newsprint shade a *= vignette() # vignette rng = np.random.RandomState(9000+i) # grain if S == 1.0: a += rng.normal(0, 2.3, a.shape) else: # rolled at 720p, NEAREST g = rng.normal(0, 2.3, (int(H/S), int(W/S), 3)).astype(np.float32) a += np.stack([np.asarray(Image.fromarray(g[..., c], "F") .resize((W, H), Image.NEAREST), np.float32) for c in range(3)], -1) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) t = i/FPS txt, style, al = caption_at(t) if txt and al > 0.01: if style in ("big", "title"): f = fit_text(txt, W*0.54, "Impact.ttf", start=si(112)) tw = f.getlength(txt); th = f.size # the masthead flag is centred on its own text (the shout style # hangs it off the left edge, which only reads for long lines) x0 = W*0.055 if style == "big" else (W/2 - tw/2 - sf(24)) y0 = H*0.655 if style == "big" else H*0.335 d.polygon([(x0-sf(6), y0+sf(6)), (x0+tw+sf(46), y0-sf(4)), (x0+tw+sf(38), y0+th*1.34), (x0-sf(14), y0+th*1.24)], fill=tuple(int(c*al+248*(1-al)) for c in INK_R)) d.text((W/2, y0+th*0.62), txt, font=f, anchor="mm", fill=tuple(int(c*al+206*(1-al)) for c in (252, 246, 228))) if style == "title": # the publisher's imprint, printed under the masthead f2 = fit_text("P L A Y E R C O M P U T E R", W*0.30, start=si(34)) d.text((W/2, y0+th*1.62), "P L A Y E R C O M P U T E R", font=f2, anchor="mm", fill=tuple(int(c*al+236*(1-al)) for c in (46, 40, 38))) elif style == "shout": f = fit_text(txt, W*0.56, "Impact.ttf", start=si(76)) tw = f.getlength(txt); th = f.size x0, y0 = W*0.05, H*0.72 d.rectangle([x0-sf(14), y0-sf(8), x0+tw+sf(16), y0+th*1.12], fill=tuple(int(c*al+250*(1-al)) for c in (250, 244, 222))) d.rectangle([x0-sf(14), y0+th*1.02, x0+tw+sf(16), y0+th*1.12], fill=tuple(int(c*al+250*(1-al)) for c in INK_K)) d.text((x0, y0), txt, font=f, fill=tuple(int(c*al+248*(1-al)) for c in INK_K)) elif style == "card": f = fit_text(txt, W*0.40, start=si(40)) tw = f.getlength(txt) x0, y0 = W*0.05, H*0.78 d.rectangle([x0-sf(12), y0-sf(8), x0+tw+sf(14), y0+f.size*1.3], fill=tuple(int(c*al+250*(1-al)) for c in INK_G)) d.text((x0, y0), txt, font=f, fill=tuple(int(c*al+250*(1-al)) for c in (250, 246, 230))) else: f = fit_text(txt, W*0.46, start=si(38)) tw = f.getlength(txt) x0, y0 = W*0.05, H*0.79 d.rectangle([x0-sf(10), y0-sf(6), x0+tw+sf(12), y0+f.size*1.28], fill=tuple(int(c*al+246*(1-al)) for c in (248, 242, 220))) d.text((x0, y0), txt, font=f, fill=tuple(int(c*al+246*(1-al)) for c in (58, 48, 44))) bh = int(H*0.045) # letterbox d.rectangle([0, 0, W, bh], fill=(18, 16, 16)) d.rectangle([0, H-bh, W, H], fill=(18, 16, 16)) return out # ═══════════════════════════════════════════════════════════════════════════ # RENDER # ═══════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env() eng = ENGINES[shot.engine](shot, np.random.default_rng(shot.seed)) made = 0 for k in range(shot.n): i = shot.i0 + k p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} u = k/max(1, shot.n-1) post(eng.frame(k, u, e), i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:9s} {shot.section:6s} {made}/{shot.n}" def contact_sheet(shots): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = 300, 193 sheet = Image.new("RGB", (cols*tw, rows*(th+26)), (12, 12, 16)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): eng = ENGINES[sh.engine](sh, np.random.default_rng(sh.seed)) mid = sh.n//2 i = sh.i0+mid e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} im = post(eng.frame(mid, mid/max(1, sh.n-1), e), i, e, sh) im = im.resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+26) sheet.paste(im, (cx, cy)) sd.text((cx+5, cy+th+5), f"{sh.idx:02d} {sh.engine} {sh.params.get('mode','')} · " f"{sh.section} · {sh.i0/FPS:.1f}s", font=font(13, "Menlo.ttc"), fill=(195, 200, 210)) p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(12, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() 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 want = None if a.shots.strip(): want = set(int(x) for x in a.shots.split(",") if x.strip()) if not a.mux_only: jobs = [(s, a.force) for s in shots if want is None or s.idx in want] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames 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 want is not None: print("partial render — rerun with --mux-only to reassemble"); return missing = [i for i in range(N_FRAMES) if not (FRAMES/f"f{i:05d}.png").exists()] if missing: raise SystemExit(f"{len(missing)} frames missing, first={missing[0]}") print("[3/3] mux…") try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" stamp = (f"generator=renders/{SETDIR}/{NAME}/render.py git={sha} " f"branch={br} at={datetime.datetime.now().astimezone().isoformat()} " f"music={MUSIC_DESC}") out = OUT/f"{NAME}.mp4" subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "22", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", str(out)], check=True, capture_output=True) (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"shots: {len(shots)} engines: {ENGINE_DESC}\n" f"press: CMYK {LPI:.0f} lpi, angles C15 M75 Y0 K45, UCR 0.74\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()