#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Four Up (26/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/four_up # # A dog gets out of a facility and four security cameras watch her go. # # 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/four_up.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/four_up.mp4 # cover: https://genekogan.com/player_computer/media/four_up.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 four_up.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 — "FOUR UP" (round 2 of night_watch_2 13: same multiplex, new score) Broken beat (West London), 96bpm, A aeolian. 25 bars. boot(2) drop(4) hall(4) bay(5) swarm(3) gate(4) out(3) A dog gets out. Four security cameras watch a facility at the same time, and the whole story has to be read across them simultaneously: she appears in one quadrant, leaves frame, and turns up in another — while the guards are still sweeping torches through the quadrant she just left. The timestamps stay locked to each other so the geography assembles itself in your head. She goes CAM 02 (kennel, top right) → CAM 01 (corridor, top left) → CAM 03 (loading bay, bottom left) → CAM 04 (gate, bottom right), a Z across the screen, and the guards trail her by exactly one camera the whole way. On CAM 04 the gate guard is already standing at the mechanism with it slid open. He looks up at the lens. Then he looks away. The DVR logs NO EVENT. THE SUBSTRATE — A FOUR-CHANNEL CCTV MULTIPLEX. New to this repo. One coherent 90-metre facility exists in world coordinates — kennel, corridor, loading bay, gate — built from ~380 shaded quads, chain-link mesh, hazard paint, crates, a truck and a sodium pole lamp. Four pinhole cameras sit in it at real mount heights with real yaw/pitch and 72–84° lenses, each with its own near-plane-clipped projection, its own barrel coefficient, its own IR emitter (so the near field blows out and the far field falls into the noise), its own sensor gain and its own tint. Each camera then runs its OWN imaging chain and its OWN frame rate: CAM 01 at 12fps, CAM 02 at 7, CAM 03 at 15, CAM 04 at 9, each with a phase offset, so motion stutters differently in every quadrant of the same frame. Interlace is real — every output frame takes its even scanlines from the camera's current field and its odd scanlines from the previous one, so anything moving combs and anything static doesn't. On top: radial barrel remap, highlight bloom off a separate additive torch/lamp layer, chroma starvation to ~15% saturation, shadow-weighted sensor noise, per-camera fixed-pattern column noise, block-shift signal dropouts and a whole-multiplex vertical sync roll. Burnt-in DVR text (labels, synchronised timestamps, OSD alerts, the active-channel box) is composited hard and crisp at the very end, after every distortion — the glitch goes around the words, never through them. "Cutting" in a multiplex means changing which channel OWNS the action: the DVR draws a white box round the active quadrant and inverts its label. So the cut language here is (a) the active box moving on the dembow and (b) occasional full-screen blowups of one camera. Composition: engine : audio-first x shot-parallel (tier 4-P) x a four-camera pinhole world renderer with per-channel imaging chains content: audio-groove (broken beat / displaced kick / congas in threes / Rhodes ninths / answering sub / bell hook) x tts-voices (Paulina, Rocko, Eddy, Mónica — chopped Spanish fragments and guard radio) x effects-post Run from repo root: python3 renders/player_computer_final/four_up/render.py --sheet python3 renders/player_computer_final/four_up/render.py python3 renders/player_computer_final/four_up/render.py --shots 21,22 --force python3 renders/player_computer_final/four_up/render.py --mux-only """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "four_up" TITLE = "FOUR UP" SETDIR = "player_computer_final" SETNUM = "13" W, H, FPS = 1920, 1080, 30 QW, QH = W//2, H//2 # 960 x 540 per channel # ── delivery scale ─────────────────────────────────────────────────────────── # FINAL CUT: native 1920x1080. The world renderer is already resolution-free — # the pinhole focal length is derived from the quadrant width, so geometry and # projected line widths scale on their own. What has to be scaled by hand is # everything that is a LOOK rather than a measurement: lens-blur radii, glitch # block heights, OSD type and padding — and every per-pixel noise field, which # is generated at 720p scale and NEAREST-upscaled so a speck of sensor noise # still covers the same fraction of a quadrant. WB, HB = 1280, 720 # the authoring frame SCL = H/720.0 def PXi(v): return max(1, int(round(v*SCL))) def PXf(v): return v*SCL def nn(shape, gen): """A noise field authored at 720p scale, NEAREST-upscaled to `shape`.""" h, w = int(shape[0]), int(shape[1]); rest = tuple(int(v) for v in shape[2:]) if SCL == 1.0: return gen((h, w) + rest) hb = max(1, int(round(h/SCL))); wb = max(1, int(round(w/SCL))) a = gen((hb, wb) + rest) ys = np.minimum((np.arange(h)/SCL).astype(np.int32), hb-1) xs = np.minimum((np.arange(w)/SCL).astype(np.int32), wb-1) return a[ys][:, xs] SR = 44100 BPM = 96.0 BEAT = 60.0/BPM # 0.625 BAR = 4*BEAT # 2.5 S16 = BEAT/4 # 0.15625 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 = [ ("boot", 0, 2), ("drop", 2, 6), ("hall", 6, 10), ("bay", 10, 15), ("swarm", 15, 18), ("gate", 18, 22), ("out", 22, 25), ] N_BARS = SECTIONS[-1][2] TAIL = 2.6 DUR = N_BARS*BAR + TAIL # 65.1 N_FRAMES = int(DUR*FPS) MUSIC_DESC = (f"broken beat (West London), {BPM:.0f}bpm, A aeolian, {N_BARS} bars, " "displaced kick / cross-stick / congas in threes / Rhodes ninths " "/ answering sub / bell hook / chopped Spanish vocal science") ENGINE_DESC = ("four-channel CCTV multiplex — one 90m facility, four pinhole " "cameras with independent fps, interlace fields, barrel lenses, " "IR falloff, shadow-weighted sensor noise, burnt-in DVR OSD") # ════════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0*2.0**((m-69)/12.0) _PC = {"C":0,"C#":1,"Db":1,"D":2,"D#":3,"Eb":3,"E":4,"F":5,"F#":6,"Gb":6, "G":7,"G#":8,"Ab":8,"A":9,"A#":10,"Bb":10,"B":11} def nf(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return mtof(12*(int(name[i:])+1) + _PC[name[:i]]) def adsr(n, a, d, s, r): e = np.zeros(n) ai, di, ri = max(1, int(a*SR)), max(1, int(d*SR)), max(1, int(r*SR)) ai = min(ai, n); e[:ai] = np.linspace(0, 1, ai) if ai < n: dd = min(di, n-ai) e[ai:ai+dd] = np.linspace(1, s, dd); e[ai+dd:] = s if ri < n: e[-ri:] *= np.linspace(1, 0, ri) return e def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Every noise source in the kit goes through this — nothing is ever 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 clip(x, drive): return np.tanh(x*drive)/np.tanh(drive) def kick808(dur=.42, f0=190, f1=44, punch=44, click=.42, drive=2.4, seed=1): """Reggaeton kick — long, round, more sub than snap.""" n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*punch) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*8.0) ck = bandshape(np.random.RandomState(seed).randn(n), lo=1200, hi=6000) \ * np.exp(-t*340)*click return clip(body + ck, drive)*.97 def dembow_snare(dur=.20, tone=228, seed=3, bright=1.0, snap=1.0): """The 'ch' of boom-ch-boom-chick. Tight, papery, slightly metallic — a rimshot crossed with a tiny clap.""" n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1500*bright, hi=9200) body = (np.sin(2*np.pi*tone*t) + .5*np.sin(2*np.pi*tone*2.7*t) + .3*np.sin(2*np.pi*tone*4.3*t)) return clip(nz*np.exp(-t*46)*.95*snap + body*np.exp(-t*58)*.60, 2.1)*.82 def hat(dur=.045, openh=False, seed=7, bright=1.0): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=7200*bright, hi=14500) return nz*np.exp(-t*(11 if openh else 110))*.40 def shaker(dur=.07, seed=11, bright=1.0): """Wooden, not metallic — the 16th-note bed under the dembow.""" 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*70) + .3*np.exp(-t*24))*.30 def guiro(dur=.26, seed=13, up=True): """A güiro scrape: a rasp made of ~16 discrete teeth, not a noise wash.""" n = int(dur*SR); rng = np.random.RandomState(seed) out = np.zeros(n); teeth = 17 for j in range(teeth): u = j/(teeth-1); u = u if up else 1-u i = int((0.02 + 0.94*(j/(teeth-1))**1.15)*dur*SR) if i >= n: break m = n-i tk = bandshape(rng.randn(m), lo=2400+3600*u, hi=9000+2500*u) out[i:] += tk*np.exp(-np.arange(m)/SR*230)*(0.5+0.5*u) return clip(out*0.55, 1.8)*.30 def rim(dur=.09, f=1180, seed=17): n = int(dur*SR); t = np.arange(n)/SR x = np.sin(2*np.pi*f*t) + .6*np.sin(2*np.pi*f*1.59*t) nz = bandshape(np.random.RandomState(seed).randn(n), lo=2200, hi=8000) return clip(x*np.exp(-t*90)*.7 + nz*np.exp(-t*150)*.5, 1.8)*.5 def sub808(f0, dur, slide_from=None, slide_t=0.09, drive=2.6, dec=1.9, seed=0): """The deep one. Portamento in, long tail, gently saturated.""" n = int(dur*SR) if n <= 2: return np.zeros(max(0, n)) t = np.arange(n)/SR f = np.full(n, float(f0)) if slide_from: u = np.clip(t/slide_t, 0, 1)**0.6 f = slide_from + (f0-slide_from)*u f = f*(1 + 1.3*np.exp(-t*80)) x = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*dec) x = x + 0.10*np.sin(4*np.pi*np.cumsum(f)/SR)*np.exp(-t*dec*2.2) return clip(x, drive)*adsr(n, .002, .03, .95, min(.14, dur*0.35))*0.96 def pluck(freq, dur, det=1.0, drive=2.6, hi=5200, a=.002, d=.10, s=.30, r=.10, nv=3, seed=0, sq=0.0): """The minor hook voice — a short detuned saw/square pluck with a moving lowpass, so each note has motion instead of sitting flat.""" n = int(dur*SR) if n <= 2: return np.zeros(max(0, n)) t = np.arange(n)/SR rng = np.random.RandomState(seed | 1) out = np.zeros(n) for i in range(nv): c = (i-(nv-1)/2)/max(1e-6, (nv-1)/2) if nv > 1 else 0.0 f = freq*(1 + c*det*0.01) ph = (np.cumsum(np.full(n, f))/SR + rng.rand()) % 1.0 w = 2*ph-1 if sq: w = (1-sq)*w + sq*np.sign(ph-0.5) out += w out /= nv env = adsr(n, a, d, s, r) # moving lowpass: block-wise band shaping that closes as the note decays y = np.zeros(n); blk = 2048 for i in range(0, n, blk): m = min(blk, n-i) u = i/max(1, n) fc = hi*(1.0 - 0.72*u**0.6) y[i:i+m] = bandshape(out[i:i+m+128], lo=110, hi=max(500, fc))[:m] return clip(y, drive)*env def horn(dur=1.35, f0=None, seed=19, scoop=8.0, gain=1.0): """AIR HORN. Two stacked saw voices a fifth apart that scoop up into the note, hard vibrato, band-limited, clipped. The reggaeton punctuation.""" f0 = f0 or nf("A4") n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed | 1) out = np.zeros(n) for k, (mul, g) in enumerate(((1.0, 1.0), (1.4983, .72), (2.0, .40))): for v in range(3): det = (v-1)*0.006 f = f0*mul*(1+det) * 2**(-(scoop/12.0)*np.exp(-t*13)) f = f*(1 + 0.017*np.sin(2*np.pi*6.4*t)*np.clip(t/0.10, 0, 1)) ph = (np.cumsum(f)/SR + rng.rand()) % 1.0 out += (2*ph-1)*g out /= 6.0 out = bandshape(out, lo=260, hi=7600) env = np.clip(t/0.012, 0, 1)*np.clip((dur-t)/0.16, 0, 1) env *= (0.72 + 0.28*np.exp(-t*1.2)) return clip(out*env, 5.0)*.60*gain def siren(dur=1.6, seed=23): n = int(dur*SR); t = np.arange(n)/SR f = 720 + 480*np.sin(2*np.pi*1.9*t) x = np.sin(2*np.pi*np.cumsum(f)/SR) return clip(bandshape(x, lo=400, hi=4200), 2.4) \ * np.clip(t/.05, 0, 1)*np.clip((dur-t)/.3, 0, 1)*.22 def riser(dur=1.4, seed=29, f0=200, f1=4600, gain=1.0): """Pitched + filtered climb. Never a static hiss.""" n = int(dur*SR); t = np.arange(n)/SR; u = t/max(dur, 1e-6) env = u**1.4 f = f0*(f1/f0)**(u**1.6) sweep = np.sin(2*np.pi*np.cumsum(f)/SR) rng = np.random.RandomState(seed) nz = np.zeros(n); blk = 4096 for i in range(0, n, blk): m = min(blk, n-i) uu = (i/max(1, n))**1.3 fc = 380 + 6200*uu nz[i:i+m] = bandshape(rng.randn(m+256), lo=fc*.72, hi=fc*1.8)[:m] return clip(nz*env*.50 + sweep*env*.26, 2.2)*.72*gain def dvr_hum(dur, seed=31): """The room the DVR lives in: mains hum, a fan, a faint line whine.""" n = int(dur*SR); t = np.arange(n)/SR x = (np.sin(2*np.pi*60*t)*0.50 + np.sin(2*np.pi*120*t)*0.20 + np.sin(2*np.pi*15734*t)*0.06) fan = bandshape(np.random.RandomState(seed).randn(n), lo=120, hi=900)*0.14 return (x + fan)*0.085 def latch(seed=37, big=1.0): """A steel latch letting go — the pen gate.""" n = int(.5*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) tick = bandshape(rng.randn(n), lo=2600, hi=11000)*np.exp(-t*180) ring = (np.sin(2*np.pi*1840*t)*.6 + np.sin(2*np.pi*2790*t)*.35) \ * np.exp(-t*14) return clip(tick*0.9 + ring*0.4, 2.0)*.55*big def fencerattle(dur=.8, seed=41): n = int(dur*SR); rng = np.random.RandomState(seed) out = np.zeros(n) for j in range(22): i = int(rng.rand()*dur*0.7*SR) m = n-i if m <= 2: continue out[i:] += bandshape(rng.randn(m), lo=2000, hi=9500) \ * np.exp(-np.arange(m)/SR*70)*(0.10+0.16*rng.rand()) return clip(out, 1.8)*.40 def gateslide(dur=2.0, seed=43): """A gate rolling on a track: a rumble with wheel-tooth ripple.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) rum = bandshape(rng.randn(n), lo=70, hi=520)*0.9 ripple = 0.5 + 0.5*np.sin(2*np.pi*24*t) sq = bandshape(rng.randn(n), lo=1400, hi=5200)*0.25*ripple env = np.clip(t/0.15, 0, 1)*np.clip((dur-t)/0.35, 0, 1) return clip((rum*ripple + sq)*env, 1.8)*.34 def pawsteps(dur, seed=47, rate=7.0): """Nails on concrete — the dog. Tiny, fast, panned.""" n = int(dur*SR); rng = np.random.RandomState(seed) out = np.zeros(n) k = 0 while True: at = (k/rate) + rng.rand()*0.02 if at >= dur: break i = int(at*SR); m = n-i if m > 2: out[i:] += bandshape(rng.randn(m), lo=3200, hi=12000) \ * np.exp(-np.arange(m)/SR*420)*(0.16+0.10*rng.rand()) k += 1 return out*.55 def reverb(x, rt=1.1, mix=.22, seed=53, pre=0.012): 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=.1875, fb=.40, mix=.20, taps=8): 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 # ════════════════════════════════════════════════════════════════════════════ # VOICE — shouted Spanish ad-libs + guard radio # ════════════════════════════════════════════════════════════════════════════ def _h(*parts): """Stable cache key. Python's str hash is salted per process — hash() here would resynthesise every line on every run.""" 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, voice, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, voice, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2 or n < 2: return np.zeros(max(0, n)) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def trimsil(x, thr=0.035): """`say` pads its output. An ad-lib has to hit exactly on the grid.""" if len(x) < 32: return x k = max(1, int(0.008*SR)) env = np.convolve(np.abs(x), np.ones(k)/k, "same") on = np.where(env > env.max()*thr)[0] if len(on) < 2: return x a = max(0, on[0]-int(0.006*SR)); b = min(len(x), on[-1]+int(0.03*SR)) return x[a:b] def adlib(text, voice="Paulina", rate=250, pitch=1.0, cache=None, drive=3.2, lo=190, hi=6200, slap=None, dur=None): """A SHOUT. Trim, pitch, clip hard, band-limit to a megaphone-ish window, then a 3/16 slap. This is what turns `say` into a hype ad-lib.""" x = trimsil(say_wav(text, voice, rate, cache/("say_"+_h(text, voice, rate)+".wav"))) if pitch != 1.0: x = fit(x, max(8, int(len(x)/pitch))) x = bandshape(x, lo=lo, hi=hi) x = clip(x/(np.max(np.abs(x))+1e-9), drive) n = len(x) x *= np.clip(np.arange(n)/max(1, int(0.004*SR)), 0, 1) x *= np.clip((n-np.arange(n))/max(1, int(0.02*SR)), 0, 1) if slap: x = delay(x, slap, fb=.34, mix=.26, taps=5) if dur: m = int(dur*SR) x = x[:m] if len(x) > m else np.pad(x, (0, m-len(x))) return x/(np.max(np.abs(x))+1e-9) def radio(text, voice="Eddy (Spanish (Mexico))", rate=210, cache=None, seed=61): """Handheld radio: squelch, a 400–2600 Hz window, grit, squelch.""" x = trimsil(say_wav(text, voice, rate, cache/("say_"+_h(text, voice, rate)+".wav"))) x = bandshape(x, lo=420, hi=2500) x = clip(x/(np.max(np.abs(x))+1e-9), 4.5) rng = np.random.RandomState(seed) sq = bandshape(rng.randn(int(.09*SR)), lo=1800, hi=6000) sq *= np.exp(-np.arange(len(sq))/SR*55)*0.55 out = np.concatenate([sq, x, sq[::-1]*0.7]) return out/(np.max(np.abs(out))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # SONG CANVAS # ════════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0): return bar*BAR + step*S16 def put(self, track, sig, at, g=1.0, pan=0.0): if len(sig) == 0: return b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR) if i < 0: sig = sig[-i:]; i = 0 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.12): 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_depth=.34, pump_rel=.16, levels=None, master_drive=1.35): 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(260)/260, "same") mix *= env[:, None] # DC / rumble trim a = math.exp(-2*math.pi*26.0/SR) for c in range(2): col = mix[:, c]; lp = np.empty(self.n); z = 0.0 for i in range(0, self.n, 8192): blk = col[i:i+8192] for j in range(len(blk)): z = (1-a)*blk[j] + a*z; lp[i+j] = z mix[:, c] = col - lp mix = clip(mix, master_drive) return mix/(np.max(np.abs(mix))+1e-9)*.95 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("= 170) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].sum() for k in E: p = np.percentile(E[k], 96) + 1e-9 E[k] = np.clip(E[k]/p, 0, 1.25) lo = E["low"] flux = np.maximum(0, lo - np.concatenate([[0], lo[:-1]])) E["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 # ════════════════════════════════════════════════════════════════════════════ # THE FACILITY — one world, four cameras # # World units are metres. +x runs the length of the compound (kennel at 0, # gate at 78), +y is across it, +z is up. Everything the four cameras see is # the SAME geometry — that is the entire premise of the piece, so the # geography has to actually exist. # ════════════════════════════════════════════════════════════════════════════ CONCRETE = (116, 114, 110) JOINT = (86, 85, 82) WALL = (100, 101, 104) WALL2 = (82, 83, 87) CEIL = (78, 79, 84) STEEL = (138, 142, 148) DARKSTEEL= (62, 66, 72) WOOD = (124, 100, 70) WOOD2 = (98, 78, 55) FENCE = (158, 162, 166) HAZARD = (188, 172, 66) PAINT = (196, 198, 196) TRUCKC = (76, 82, 92) LAMPC = (255, 255, 255) NIGHT = (16, 17, 20) UNIFORM = (54, 58, 66) SKIN = (172, 152, 132) VESTC = (218, 224, 232) FURC = (186, 180, 166) FURD = (126, 120, 110) SHADOWC = (14, 14, 16) def shadow(P, x, y, rx, ry=None): """A contact shadow. Without one, an actor at 20 m reads as a floating smudge; with one, the eye locks onto it instantly.""" ry = ry or rx*0.62 pts = [] for i in range(10): a = 2*math.pi*i/10 pts.append((x+math.cos(a)*rx, y+math.sin(a)*ry, 0.016)) P.append(mkpoly(pts, SHADOWC, flat=LY_FLOOR)) NEAR = 0.30 def _n3(a, b, c): ux, uy, uz = b[0]-a[0], b[1]-a[1], b[2]-a[2] vx, vy, vz = c[0]-a[0], c[1]-a[1], c[2]-a[2] nx, ny, nz = uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx m = math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0 return (nx/m, ny/m, nz/m) # Draw layers. A pure depth sort is not enough here: a 2.8 m ground tile can # have a nearer centroid than the dog standing on it, and then the FLOOR gets # painted over the dog. Ground first, then anything lying on the ground # (markings, contact shadows, expansion joints), then the world. LY_GROUND, LY_FLOOR, LY_WORLD = 0, 1, 2 def mkpoly(verts, col, glow=0.0, flat=LY_WORLD): n = _n3(verts[0], verts[1], verts[2]) cx = sum(v[0] for v in verts)/len(verts) cy = sum(v[1] for v in verts)/len(verts) cz = sum(v[2] for v in verts)/len(verts) return (verts, col, n, (cx, cy, cz), glow, flat) def mkline(p0, p1, col, w=0.03, glow=0.0, layer=LY_WORLD): return (p0, p1, col, w, glow, layer) def tessq(P, a, b, c, d, col, step=3.0, seed=0, jitter=0.09, flat=LY_WORLD): """Subdivide a planar quad into ~step-sized tiles. Big surfaces (the yard, the walls, the ceiling) are flat-shaded per poly, so as ONE quad they read as a uniform white sheet with no falloff. Tiled, each piece resolves its own distance to the lamps and to the camera's IR, which is what produces the pools of light and the dark corners the noise lives in. The seeded albedo jitter is what stops the tiling from reading as a checkerboard — it reads as stained concrete instead. """ lu = math.dist(a, b); lv = math.dist(a, d) nu = max(1, int(round(lu/step))); nv = max(1, int(round(lv/step))) R = np.random.RandomState(5000+seed) def lerp(p, q, u): return (p[0]+(q[0]-p[0])*u, p[1]+(q[1]-p[1])*u, p[2]+(q[2]-p[2])*u) for i in range(nu): u0, u1 = i/nu, (i+1)/nu for j in range(nv): v0, v1 = j/nv, (j+1)/nv e0 = lerp(lerp(a, b, u0), lerp(d, c, u0), v0) e1 = lerp(lerp(a, b, u1), lerp(d, c, u1), v0) e2 = lerp(lerp(a, b, u1), lerp(d, c, u1), v1) e3 = lerp(lerp(a, b, u0), lerp(d, c, u0), v1) f = 1.0 + (float(R.rand())*2-1)*jitter cc = tuple(max(0, min(255, int(v*f))) for v in col) P.append(mkpoly([e0, e1, e2, e3], cc, flat=flat)) def box(P, L, x0, y0, z0, x1, y1, z1, col, top=None, glow=0.0): top = top or tuple(min(255, int(c*1.16)) for c in col) sd = tuple(int(c*0.80) for c in col) P.append(mkpoly([(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)], top, glow)) P.append(mkpoly([(x0, y0, z0), (x1, y0, z0), (x1, y0, z1), (x0, y0, z1)], col, glow)) P.append(mkpoly([(x1, y1, z0), (x0, y1, z0), (x0, y1, z1), (x1, y1, z1)], sd, glow)) P.append(mkpoly([(x1, y0, z0), (x1, y1, z0), (x1, y1, z1), (x1, y0, z1)], sd, glow)) P.append(mkpoly([(x0, y1, z0), (x0, y0, z0), (x0, y0, z1), (x0, y1, z1)], col, glow)) def fence(P, L, x0, y0, x1, y1, h, post_every=3.0, col=FENCE, mesh=True, razor=False): dx, dy = x1-x0, y1-y0 ln = math.hypot(dx, dy) or 1.0 ux, uy = dx/ln, dy/ln L.append(mkline((x0, y0, h), (x1, y1, h), col, .055)) L.append(mkline((x0, y0, h*0.52), (x1, y1, h*0.52), tuple(int(c*.8) for c in col), .035)) L.append(mkline((x0, y0, 0.06), (x1, y1, 0.06), tuple(int(c*.7) for c in col), .035)) np_ = max(2, int(ln/post_every)+1) for i in range(np_): u = i*post_every if u > ln: u = ln px, py = x0+ux*u, y0+uy*u box(P, L, px-.055, py-.055, 0, px+.055, py+.055, h, tuple(int(c*.86) for c in col)) if mesh: step = 0.30 k = int(ln/step) for i in range(k+1): u = i*step L.append(mkline((x0+ux*u, y0+uy*u, 0.05), (x0+ux*u, y0+uy*u, h), tuple(int(c*.34) for c in col), .008)) for j in range(1, 7): z = h*j/7.0 L.append(mkline((x0, y0, z), (x1, y1, z), tuple(int(c*.30) for c in col), .007)) if razor: step = 0.55 k = int(ln/step) for i in range(k): u0, u1 = i*step, (i+1)*step zz = h+0.30 if i % 2 == 0 else h+0.06 L.append(mkline((x0+ux*u0, y0+uy*u0, h+0.18), (x0+ux*u1, y0+uy*u1, zz), (196, 200, 206), .022)) def build_facility(): P, L, LAMPS = [], [], [] # ---- ground + expansion joints ----------------------------------------- tessq(P, (-8, -10, 0), (94, -10, 0), (94, 20, 0), (-8, 20, 0), CONCRETE, step=2.8, seed=1, jitter=0.10, flat=LY_GROUND) for x in range(-8, 96, 4): L.append(mkline((x, -10, 0.012), (x, 20, 0.012), JOINT, .020, layer=LY_FLOOR)) for y in range(-10, 22, 4): L.append(mkline((-8, y, 0.012), (94, y, 0.012), JOINT, .020, layer=LY_FLOOR)) # ═══ ZONE A — KENNEL (CAM 02) : x 0..16, y 0..12 ═════════════════════════ tessq(P, (0, 12, 0), (16, 12, 0), (16, 12, 4.2), (0, 12, 4.2), WALL, step=2.4, seed=2) tessq(P, (0, 0, 0), (0, 12, 0), (0, 12, 4.2), (0, 0, 4.2), WALL2, step=2.4, seed=3) tessq(P, (16, 0, 0), (0, 0, 0), (0, 0, 3.4), (16, 0, 3.4), WALL2, step=2.4, seed=4) # divider wall x=16 with a doorway at y 5..7 tessq(P, (16, 0, 0), (16, 5, 0), (16, 5, 4.2), (16, 0, 4.2), WALL, step=2.2, seed=5) tessq(P, (16, 7, 0), (16, 12, 0), (16, 12, 4.2), (16, 7, 4.2), WALL, step=2.2, seed=6) P.append(mkpoly([(16, 5, 2.5), (16, 7, 2.5), (16, 7, 4.2), (16, 5, 4.2)], WALL)) P.append(mkpoly([(15.94, 5, 0), (15.94, 7, 0), (15.94, 7, 2.5), (15.94, 5, 2.5)], (26, 27, 30))) # the pen fence(P, L, 2, 2, 10, 2, 2.0) fence(P, L, 2, 2, 2, 9, 2.0) fence(P, L, 2, 9, 10, 9, 2.0) fence(P, L, 10, 2, 10, 4.0, 2.0) fence(P, L, 10, 7.2, 10, 9, 2.0) # kennel huts for (hx, hy) in ((3.2, 6.9), (5.6, 6.9), (8.0, 6.9)): box(P, L, hx, hy, 0, hx+1.7, hy+1.7, 1.15, (128, 122, 112)) P.append(mkpoly([(hx+.45, hy, .05), (hx+1.25, hy, .05), (hx+1.25, hy, .82), (hx+.45, hy, .82)], (22, 23, 26))) # bowls + a hose coil for (bx, by) in ((4.5, 3.4), (7.4, 3.9)): box(P, L, bx, by, 0, bx+.42, by+.42, .10, (150, 152, 156)) box(P, L, 12.4, 9.6, 0, 13.6, 10.8, .28, (70, 74, 80)) # floor paint P.append(mkpoly([(11.2, 1.6, .014), (11.5, 1.6, .014), (11.5, 10.4, .014), (11.2, 10.4, .014)], PAINT, flat=LY_FLOOR)) # flood lamp on the divider wall + a lamp over the pen box(P, L, 15.2, 10.5, 3.5, 15.9, 11.1, 3.9, (200, 202, 206), glow=1.0) LAMPS.append((15.3, 10.5, 3.6, 11.0, 0.95)) box(P, L, 6.2, 8.0, 0, 6.5, 8.3, 3.6, (92, 94, 98)) P.append(mkpoly([(5.7, 7.7, 3.6), (7.0, 7.7, 3.6), (7.0, 8.6, 3.6), (5.7, 8.6, 3.6)], LAMPC, glow=1.0)) LAMPS.append((6.35, 8.15, 3.5, 6.5, 0.72)) # ═══ ZONE B — CORRIDOR (CAM 01) : x 16..40, y 3..9 ═══════════════════════ tessq(P, (16, 3, 0), (40, 3, 0), (40, 3, 3.6), (16, 3, 3.6), WALL, step=2.6, seed=7) tessq(P, (40, 9, 0), (16, 9, 0), (16, 9, 3.6), (40, 9, 3.6), WALL, step=2.6, seed=8) tessq(P, (16, 3, 3.6), (40, 3, 3.6), (40, 9, 3.6), (16, 9, 3.6), CEIL, step=3.0, seed=9) # hazard stripes for yy in (4.15, 7.85): P.append(mkpoly([(16.2, yy, .014), (39.8, yy, .014), (39.8, yy+.22, .014), (16.2, yy+.22, .014)], HAZARD, flat=LY_FLOOR)) # pipes for zz, cc in ((3.15, (128, 130, 134)), (2.92, (96, 92, 84))): L.append(mkline((16.2, 3.35, zz), (39.8, 3.35, zz), cc, .13)) # recessed doors along the south wall for dx in (22.0, 30.0, 36.0): P.append(mkpoly([(dx, 3.02, 0), (dx+1.1, 3.02, 0), (dx+1.1, 3.02, 2.25), (dx, 3.02, 2.25)], (58, 60, 64)) ) L.append(mkline((dx+1.02, 3.0, 1.05), (dx+1.02, 3.0, 1.25), STEEL, .05)) # ceiling strip lamps for lx in (20.0, 26.0, 32.0, 38.0): P.append(mkpoly([(lx-.7, 5.4, 3.55), (lx+.7, 5.4, 3.55), (lx+.7, 6.6, 3.55), (lx-.7, 6.6, 3.55)], LAMPC, glow=1.0)) LAMPS.append((lx, 6.0, 3.5, 4.4, 0.88)) # a wall sign P.append(mkpoly([(27.0, 8.97, 2.0), (29.2, 8.97, 2.0), (29.2, 8.97, 2.55), (27.0, 8.97, 2.55)], (176, 178, 176))) # ═══ ZONE C — LOADING BAY (CAM 03) : x 40..62, y -4..16 ══════════════════ tessq(P, (40, 16, 0), (62, 16, 0), (62, 16, 7.0), (40, 16, 7.0), WALL, step=3.0, seed=10) tessq(P, (62, -4, 0), (62, 16, 0), (62, 16, 7.0), (62, -4, 7.0), WALL2, step=3.0, seed=11) tessq(P, (40, -4, 0), (40, 3, 0), (40, 3, 3.6), (40, -4, 3.6), WALL, step=2.6, seed=12) tessq(P, (40, 9, 0), (40, 16, 0), (40, 16, 7.0), (40, 9, 7.0), WALL, step=2.6, seed=13) tessq(P, (40, -4, 0), (62, -4, 0), (62, -4, 7.0), (40, -4, 7.0), WALL2, step=3.4, seed=14) # roller door P.append(mkpoly([(43, 15.96, 0), (49, 15.96, 0), (49, 15.96, 5.0), (43, 15.96, 5.0)], (146, 148, 152)) ) for z in np.arange(0.3, 5.0, 0.34): L.append(mkline((43, 15.93, float(z)), (49, 15.93, float(z)), (108, 110, 114), .045)) # bay floor markings for bx in (44.0, 51.0, 58.0): P.append(mkpoly([(bx, 10.0, .014), (bx+.20, 10.0, .014), (bx+.20, 15.4, .014), (bx, 15.4, .014)], PAINT, flat=LY_FLOOR)) P.append(mkpoly([(41.0, 2.0, .014), (61.0, 2.0, .014), (61.0, 2.22, .014), (41.0, 2.22, .014)], HAZARD, flat=LY_FLOOR)) # crates + pallets (seeded) CR = np.random.RandomState(4444) crates = [(44.0, 3.0, 1.6, 1.4, 1.5), (46.2, 3.2, 1.4, 1.4, 0.9), (48.8, 4.6, 2.0, 1.6, 2.1), (52.0, 3.4, 1.5, 1.5, 1.2), (54.4, 5.4, 1.8, 1.8, 1.8), (57.6, 4.2, 1.6, 1.4, 1.0), (59.4, 7.4, 1.5, 1.7, 2.3), (55.2, 10.6, 2.2, 1.9, 2.4), (57.8, 10.8, 1.7, 1.6, 1.5), (50.6, 8.8, 1.9, 1.5, 1.3), (46.8, 9.6, 1.6, 1.8, 1.9), (43.4, 7.8, 1.8, 1.5, 1.1), (60.2, 12.2, 1.4, 1.4, 0.8), (48.0, 12.8, 2.0, 1.7, 1.6)] for (cx, cy, cw, cd, ch) in crates: j = float(CR.rand()*0.22 - 0.11) cc = WOOD if CR.rand() > 0.34 else WOOD2 box(P, L, cx, cy, 0, cx+cw, cy+cd, ch, cc) L.append(mkline((cx, cy-.01, ch*0.55), (cx+cw, cy-.01, ch*0.55), tuple(int(c*.7) for c in cc), .05)) if CR.rand() > 0.55: box(P, L, cx+.15, cy+.15, ch, cx+cw-.15, cy+cd-.15, ch+0.55, (108, 110, 114)) _ = j for (px, py) in ((42.4, 5.4), (53.0, 8.0), (59.0, 3.0)): box(P, L, px, py, 0, px+1.2, py+1.0, .14, (112, 92, 64)) # the truck box(P, L, 50.0, 12.4, 0.55, 58.6, 15.4, 3.5, TRUCKC) box(P, L, 47.2, 12.6, 0.55, 50.0, 15.2, 2.6, (62, 68, 78)) P.append(mkpoly([(47.15, 12.8, 1.55), (47.15, 15.0, 1.55), (47.15, 15.0, 2.45), (47.15, 12.8, 2.45)], (150, 158, 170))) for wx in (48.4, 51.4, 56.4): box(P, L, wx, 12.35, 0, wx+0.8, 12.6, 0.55, (36, 38, 42)) # high bay lamps for (lx, ly) in ((46.0, 6.0), (57.0, 6.5)): P.append(mkpoly([(lx-.85, ly-.85, 5.5), (lx+.85, ly-.85, 5.5), (lx+.85, ly+.85, 5.5), (lx-.85, ly+.85, 5.5)], LAMPC, glow=1.0)) LAMPS.append((lx, ly, 5.4, 9.5, 0.95)) # ═══ ZONE D — GATE (CAM 04) : x 62..80 ═══════════════════════════════════ tessq(P, (62, 14, 0), (72, 14, 0), (72, 14, 3.0), (62, 14, 3.0), WALL2, step=2.6, seed=15) tessq(P, (62, 0, 0), (72, 0, 0), (72, 0, 3.0), (62, 0, 3.0), WALL2, step=2.6, seed=16) # perimeter fence at x=72, gap y 5..9 for the gate fence(P, L, 72, 0, 72, 5, 2.6, razor=True) fence(P, L, 72, 9, 72, 14, 2.6, razor=True) # guard hut — off to the far side so it never eats CAM 04's frame box(P, L, 64.6, 1.6, 0, 67.6, 4.6, 2.9, (104, 106, 110)) P.append(mkpoly([(67.55, 2.0, 1.05), (67.55, 4.2, 1.05), (67.55, 4.2, 2.15), (67.55, 2.0, 2.15)], (250, 246, 232), glow=1.0)) LAMPS.append((67.8, 3.1, 1.7, 3.0, 0.34)) # sodium pole lamp box(P, L, 70.5, 13.5, 0, 70.8, 13.8, 5.2, (92, 94, 98)) P.append(mkpoly([(70.0, 13.2, 5.2), (71.4, 13.2, 5.2), (71.4, 14.1, 5.2), (70.0, 14.1, 5.2)], LAMPC, glow=1.0)) LAMPS.append((70.7, 13.6, 5.1, 12.5, 1.15)) # a second head on the gate side, so the action at the gate has a pool box(P, L, 71.3, 3.4, 0, 71.6, 3.7, 3.5, (92, 94, 98)) P.append(mkpoly([(70.9, 3.1, 3.5), (72.0, 3.1, 3.5), (72.0, 4.0, 3.5), (70.9, 4.0, 3.5)], LAMPC, glow=1.0)) LAMPS.append((71.45, 3.55, 3.4, 8.0, 0.82)) # painted stop bar + bollards + tyre marks + a drain P.append(mkpoly([(70.4, 4.4, .014), (70.68, 4.4, .014), (70.68, 9.6, .014), (70.4, 9.6, .014)], PAINT, flat=LY_FLOOR)) for by in (3.6, 10.4): box(P, L, 70.9, by, 0, 71.2, by+.3, 0.95, (196, 186, 90)) TR = np.random.RandomState(6161) for j in range(7): yy = 5.4 + j*0.55 + float(TR.rand())*0.2 P.append(mkpoly([(64.0+float(TR.rand())*2, yy, .013), (71.2, yy-0.15, .013), (71.2, yy+0.02, .013), (64.0, yy+0.17, .013)], (78, 78, 80), flat=LY_FLOOR)) box(P, L, 66.4, 8.4, 0, 67.2, 9.2, .05, (74, 76, 80)) for j in range(4): L.append(mkline((66.4, 8.5+j*0.2, .07), (67.2, 8.5+j*0.2, .07), (44, 46, 50), .04, layer=LY_FLOOR)) # a barrier arm by the gate, permanently up box(P, L, 69.2, 10.0, 0, 69.5, 10.3, 1.05, (150, 152, 156)) L.append(mkline((69.35, 10.15, 1.02), (69.35, 10.15, 2.65), (216, 210, 198), .07)) # road beyond the fence tessq(P, (72.2, -2, .01), (92, -2, .01), (92, 16, .01), (72.2, 16, .01), (54, 54, 58), step=3.4, seed=17, flat=LY_GROUND) for x in range(74, 92, 5): P.append(mkpoly([(x, 6.8, .02), (x+2.4, 6.8, .02), (x+2.4, 7.1, .02), (x, 7.1, .02)], (170, 170, 168), flat=LY_FLOOR)) # far darkness P.append(mkpoly([(92, -12, 0), (92, 22, 0), (92, 22, 12), (92, -12, 12)], NIGHT)) return P, L, LAMPS FAC_P, FAC_L, FAC_LAMPS = build_facility() # ════════════════════════════════════════════════════════════════════════════ # STORY CLOCK # ════════════════════════════════════════════════════════════════════════════ T_PEN_OPEN = 2.4*BAR # 6.0 T_GATE_OPEN = 51.5 T_GATE_CLOSE = 61.3 T_DOG_THROUGH = 55.4 T_LOOK_UP = 58.6 T_LOOK_AWAY = 60.9 DOG = [ (0.0, 6.0, 5.5), (4.6, 6.4, 5.4), (5.6, 6.6, 5.5), (6.4, 8.2, 5.5), (7.4, 10.6, 5.6), (9.0, 13.4, 6.2), (11.2, 15.7, 6.0), (12.6, 18.0, 6.2), (15.0, 22.0, 5.7), (18.0, 28.0, 6.5), (21.0, 34.0, 5.8), (24.0, 39.6, 6.4), (26.2, 43.0, 5.0), (28.2, 46.6, 9.6), (30.6, 50.6, 4.4), (33.0, 54.0, 8.0), (35.2, 56.6, 11.4), (38.6, 56.9, 11.6), (40.4, 58.6, 8.6), (43.2, 61.0, 7.0), (46.0, 64.0, 7.2), (50.0, 68.4, 7.4), (53.6, 71.8, 7.0), (55.4, 74.6, 7.0), (58.0, 79.0, 6.4), (62.0, 86.0, 5.0), (68.0, 94.0, 4.0), ] GUARDS = [ # G1 — kennel, then the corridor she already left dict(seed=101, w=[(0.0, 17.4, 6.0), (11.4, 17.0, 6.0), (13.4, 14.6, 6.0), (16.4, 10.6, 5.2), (19.4, 6.6, 4.6), (23.0, 4.6, 4.0), (26.0, 8.2, 6.2), (29.0, 14.2, 6.0), (32.0, 18.4, 6.2), (36.0, 26.0, 6.0), (40.0, 34.0, 6.2), (43.4, 40.2, 6.0), (47.4, 45.0, 6.0), (52.0, 49.0, 8.2), (58.0, 52.4, 5.0), (64.0, 55.0, 6.0)], torch=True), # G2 — the corridor, then the bay dict(seed=202, w=[(0.0, 17.6, 7.4), (22.0, 17.6, 7.4), (25.0, 20.4, 6.4), (29.0, 26.0, 6.6), (33.0, 33.0, 5.8), (36.4, 39.0, 6.2), (39.6, 43.0, 5.0), (43.6, 47.0, 9.2), (47.6, 51.0, 6.0), (52.4, 55.0, 9.0), (57.6, 57.0, 11.2), (62.0, 58.2, 10.0), (68.0, 60.0, 9.0)], torch=True), # G3 — in through the roller door dict(seed=303, w=[(0.0, 45.5, 17.2), (33.6, 45.5, 17.2), (35.4, 45.0, 14.6), (38.0, 46.4, 11.0), (41.0, 49.0, 6.2), (45.0, 53.0, 4.2), (48.0, 55.2, 8.0), (52.0, 57.2, 11.4), (56.0, 59.0, 9.0), (62.0, 60.2, 8.0), (68.0, 61.0, 7.5)], torch=True), # G4 — the slow one dict(seed=404, w=[(0.0, 41.0, -3.2), (37.0, 41.0, -3.2), (40.0, 42.4, -0.6), (44.0, 45.4, 2.2), (49.0, 50.2, 3.0), (54.0, 55.0, 3.6), (60.0, 59.0, 4.0), (66.0, 61.4, 5.0)], torch=True), ] # the gate guard — the one who is not chasing anybody GG = dict(seed=505, w=[(0.0, 66.0, 4.9), (44.0, 66.0, 4.9), (47.4, 67.6, 6.6), (50.6, 70.0, 8.3), (56.2, 70.2, 8.4), (58.4, 67.2, 9.8), (61.2, 67.2, 9.8), (63.4, 66.4, 8.4), (68.0, 64.6, 5.6)], torch=False) def _heading_before(w, i): """Hold the last real heading through a stationary segment — otherwise a guard standing still snaps to facing +x, which is very visible when the thing he is standing still to do is look down the lens.""" for j in range(i, -1, -1): if j+1 >= len(w): continue d = math.hypot(w[j+1][1]-w[j][1], w[j+1][2]-w[j][2]) if d > 1e-4: return math.atan2(w[j+1][2]-w[j][2], w[j+1][1]-w[j][1]) return 0.0 def _path(w, t): """Position + heading + speed off a waypoint list.""" if t <= w[0][0]: x, y = w[0][1], w[0][2] return x, y, _heading_before(w, 0), 0.0 for i in range(len(w)-1): t0, x0, y0 = w[i]; t1, x1, y1 = w[i+1] if t0 <= t < t1: u = (t-t0)/max(1e-6, t1-t0) uu = u*u*(3-2*u)*0.35 + u*0.65 x = x0+(x1-x0)*uu; y = y0+(y1-y0)*uu d = math.hypot(x1-x0, y1-y0) sp = d/max(1e-6, t1-t0) hd = (math.atan2(y1-y0, x1-x0) if d > 1e-4 else _heading_before(w, i)) return x, y, hd, sp return w[-1][1], w[-1][2], _heading_before(w, len(w)-2), 0.0 def dog_state(t): x, y, hd, sp = _path(DOG, t) return x, y, hd, sp def gate_open(t): """0 = shut, 1 = fully slid back.""" if t < T_GATE_OPEN: return 0.0 if t < T_GATE_OPEN+2.0: u = (t-T_GATE_OPEN)/2.0 return u*u*(3-2*u) if t < T_GATE_CLOSE: return 1.0 u = min(1.0, (t-T_GATE_CLOSE)/1.7) return 1.0 - u*u*(3-2*u) # ════════════════════════════════════════════════════════════════════════════ # ACTORS — built as world polys/lines per call # ════════════════════════════════════════════════════════════════════════════ def _lp(x, y, hd, u, v, w): """Local (forward, left, up) -> world, about (x,y) with heading hd.""" c, s = math.cos(hd), math.sin(hd) return (x + u*c - v*s, y + u*s + v*c, w) def dog_polys(t, look_cam=None): P, L, G = [], [], [] x, y, hd, sp = dog_state(t) gait = 1.0 if sp > 0.4 else 0.0 ph = t*(3.2 + sp*1.5)*2*math.pi bob = 0.035*math.sin(ph*2)*gait zb = 0.30 + bob Pt = lambda u, v, w: _lp(x, y, hd, u, v, w) shadow(P, x, y, 0.42, 0.26) # body bl, bw, bh = 0.62, 0.20, 0.24 v000 = Pt(-bl/2, -bw, zb); v100 = Pt(bl/2, -bw, zb) v110 = Pt(bl/2, bw, zb); v010 = Pt(-bl/2, bw, zb) v001 = Pt(-bl/2, -bw, zb+bh); v101 = Pt(bl/2, -bw, zb+bh) v111 = Pt(bl/2, bw, zb+bh); v011 = Pt(-bl/2, bw, zb+bh) P.append(mkpoly([v001, v101, v111, v011], tuple(int(c*1.10) for c in FURC))) P.append(mkpoly([v000, v100, v101, v001], FURC)) P.append(mkpoly([v110, v010, v011, v111], tuple(int(c*.80) for c in FURC))) P.append(mkpoly([v100, v110, v111, v101], tuple(int(c*.92) for c in FURC))) # neck + head hbob = 0.03*math.sin(ph+0.8)*gait hz = zb+bh*0.9+0.13+hbob hd0 = Pt(bl/2-0.02, 0, zb+bh*0.75) hd1 = Pt(bl/2+0.20, 0, hz) L.append(mkline(hd0, hd1, tuple(int(c*1.02) for c in FURC), 0.16)) hx, hy, hzz = hd1 P.append(mkpoly([_lp(hx, hy, hd, -0.06, -0.09, hzz-0.07), _lp(hx, hy, hd, 0.20, -0.07, hzz-0.06), _lp(hx, hy, hd, 0.20, 0.07, hzz-0.06), _lp(hx, hy, hd, -0.06, 0.09, hzz-0.07)], tuple(int(c*1.06) for c in FURC))) P.append(mkpoly([_lp(hx, hy, hd, -0.06, -0.09, hzz-0.07), _lp(hx, hy, hd, 0.20, -0.07, hzz-0.06), _lp(hx, hy, hd, 0.20, -0.07, hzz+0.07), _lp(hx, hy, hd, -0.06, -0.09, hzz+0.10)], tuple(int(c*.92) for c in FURC))) P.append(mkpoly([_lp(hx, hy, hd, -0.06, 0.09, hzz-0.07), _lp(hx, hy, hd, 0.20, 0.07, hzz-0.06), _lp(hx, hy, hd, 0.20, 0.07, hzz+0.07), _lp(hx, hy, hd, -0.06, 0.09, hzz+0.10)], tuple(int(c*.78) for c in FURC))) P.append(mkpoly([_lp(hx, hy, hd, -0.06, -0.09, hzz+0.10), _lp(hx, hy, hd, 0.20, -0.07, hzz+0.07), _lp(hx, hy, hd, 0.20, 0.07, hzz+0.07), _lp(hx, hy, hd, -0.06, 0.09, hzz+0.10)], tuple(int(c*1.16) for c in FURC))) # ears for sgn in (-1, 1): P.append(mkpoly([_lp(hx, hy, hd, -0.03, sgn*0.08, hzz+0.09), _lp(hx, hy, hd, 0.06, sgn*0.07, hzz+0.09), _lp(hx, hy, hd, -0.02, sgn*0.11, hzz+0.30)], FURD)) # collar tag — the one thing that catches the IR G.append(mkpoly([_lp(hx, hy, hd, -0.09, -0.05, hzz-0.10), _lp(hx, hy, hd, -0.05, -0.05, hzz-0.10), _lp(hx, hy, hd, -0.05, 0.05, hzz-0.12), _lp(hx, hy, hd, -0.09, 0.05, hzz-0.12)], (255, 255, 255), glow=1.0)) # legs for j, (fu, fv) in enumerate(((0.22, -0.16), (0.22, 0.16), (-0.22, -0.16), (-0.22, 0.16))): pp = ph + [0.0, math.pi, math.pi, 0.0][j] swing = 0.20*math.sin(pp)*gait lift = max(0.0, 0.11*math.sin(pp))*gait hipz = zb+0.02 top = Pt(fu, fv, hipz) bot = Pt(fu+swing, fv*1.05, lift) L.append(mkline(top, bot, FURD, 0.075)) # tail wag = 0.30*math.sin(t*11.0) + 0.15*math.sin(t*5.0) ttop = Pt(-bl/2, 0, zb+bh*0.85) tmid = Pt(-bl/2-0.16, wag*0.5, zb+bh+0.16) ttip = Pt(-bl/2-0.24, wag, zb+bh+0.34) L.append(mkline(ttop, tmid, FURC, 0.055)) L.append(mkline(tmid, ttip, tuple(int(c*1.1) for c in FURC), 0.038)) return P, L, G def guard_polys(g, t, look_at_cam=False, cam=None, open_hand=False, head_yaw=0.0): P, L, G = [], [], [] x, y, hd, sp = _path(g["w"], t) R = np.random.RandomState(g["seed"]) scale = 0.94 + 0.10*float(R.rand()) gait = 1.0 if sp > 0.25 else 0.0 ph = t*(2.0 + sp*1.1)*2*math.pi idle = 0.014*math.sin(t*1.4 + g["seed"] % 7) Pt = lambda u, v, w: _lp(x, y, hd, u, v, w*scale) hipz = 0.86 + idle shz = 1.46 + idle shadow(P, x, y, 0.44, 0.32) # legs for j, sgn in enumerate((-1, 1)): pp = ph + (0 if j == 0 else math.pi) swing = 0.26*math.sin(pp)*gait lift = max(0.0, 0.10*math.sin(pp))*gait L.append(mkline(Pt(0, sgn*0.13, hipz), Pt(swing, sgn*0.15, lift), tuple(int(c*.86) for c in UNIFORM), 0.15)) # torso tw, td = 0.22, 0.15 a0 = Pt(-td, -tw, hipz); a1 = Pt(td, -tw, hipz) a2 = Pt(td, tw, hipz); a3 = Pt(-td, tw, hipz) b0 = Pt(-td, -tw, shz); b1 = Pt(td, -tw, shz) b2 = Pt(td, tw, shz); b3 = Pt(-td, tw, shz) P.append(mkpoly([a1, a2, b2, b1], UNIFORM)) P.append(mkpoly([a0, a1, b1, b0], tuple(int(c*.88) for c in UNIFORM))) P.append(mkpoly([a2, a3, b3, b2], tuple(int(c*.76) for c in UNIFORM))) P.append(mkpoly([a3, a0, b0, b3], tuple(int(c*.84) for c in UNIFORM))) P.append(mkpoly([b0, b1, b2, b3], tuple(int(c*1.1) for c in UNIFORM))) # hi-vis stripe — retroreflective, so it blows out zz = hipz + (shz-hipz)*0.62 G.append(mkpoly([Pt(td+.005, -tw, zz), Pt(td+.005, tw, zz), Pt(td+.005, tw, zz+0.10), Pt(td+.005, -tw, zz+0.10)], (255, 255, 255), glow=0.85)) G.append(mkpoly([Pt(-td-.005, -tw, zz), Pt(-td-.005, tw, zz), Pt(-td-.005, tw, zz+0.10), Pt(-td-.005, -tw, zz+0.10)], (255, 255, 255), glow=0.85)) # arms for j, sgn in enumerate((-1, 1)): pp = ph + (math.pi if j == 0 else 0) swing = 0.22*math.sin(pp)*gait eh = shz-0.52 if open_hand and sgn > 0: L.append(mkline(Pt(0, sgn*0.24, shz-0.04), Pt(0.36, sgn*0.40, shz+0.06), tuple(int(c*.92) for c in UNIFORM), 0.11)) else: L.append(mkline(Pt(0, sgn*0.24, shz-0.04), Pt(swing, sgn*0.28, eh), tuple(int(c*.92) for c in UNIFORM), 0.11)) # neck, then head look = look_at_cam tilt = 0.09 if look else 0.0 hcz = shz+0.06+tilt h = 0.115 L.append(mkline(Pt(0, 0, shz-0.06), Pt(0, 0, hcz+0.05), tuple(int(c*.80) for c in SKIN), 0.13)) Ph = lambda u, v, w: _lp(x, y, hd+head_yaw, u, v, w*scale) hv = [Ph(-h, -h, hcz), Ph(h, -h, hcz), Ph(h, h, hcz), Ph(-h, h, hcz), Ph(-h, -h, hcz+2*h), Ph(h, -h, hcz+2*h), Ph(h, h, hcz+2*h), Ph(-h, h, hcz+2*h)] HC = (48, 50, 56) P.append(mkpoly([hv[1], hv[2], hv[6], hv[5]], HC)) P.append(mkpoly([hv[0], hv[1], hv[5], hv[4]], tuple(int(c*.9) for c in HC))) P.append(mkpoly([hv[2], hv[3], hv[7], hv[6]], tuple(int(c*.8) for c in HC))) P.append(mkpoly([hv[3], hv[0], hv[4], hv[7]], tuple(int(c*.86) for c in HC))) P.append(mkpoly([hv[4], hv[5], hv[6], hv[7]], tuple(int(c*1.2) for c in HC))) # a face, but ONLY when he is looking down the lens. It billboards at # the camera, so the moment reads as eye contact rather than geometry. if look and cam is not None: cxv, cyv, czv = cam.pos dxv, dyv = cxv-x, cyv-y m = math.hypot(dxv, dyv) or 1.0 ux, uy = dxv/m, dyv/m px, py = -uy, ux fx0, fy0 = x+ux*0.155, y+uy*0.155 fz = hcz*scale+h*0.85 P.append(mkpoly([(fx0+px*0.115, fy0+py*0.115, fz-0.100), (fx0-px*0.115, fy0-py*0.115, fz-0.100), (fx0-px*0.115, fy0-py*0.115, fz+0.105), (fx0+px*0.115, fy0+py*0.115, fz+0.105)], SKIN)) for sgn in (-1, 1): ex, ey = fx0+px*0.048*sgn, fy0+py*0.048*sgn P.append(mkpoly([(ex+px*0.024+ux*0.01, ey+py*0.024+uy*0.01, fz+0.008), (ex-px*0.024+ux*0.01, ey-py*0.024+uy*0.01, fz+0.008), (ex-px*0.024+ux*0.01, ey-py*0.024+uy*0.01, fz+0.050), (ex+px*0.024+ux*0.01, ey+py*0.024+uy*0.01, fz+0.050)], (24, 22, 24))) # eyeshine. Retroreflective at IR wavelengths — the one instant in # the piece where somebody looks back down the lens. G.append(mkpoly([(ex+px*0.013+ux*0.02, ey+py*0.013+uy*0.02, fz+0.018), (ex-px*0.013+ux*0.02, ey-py*0.013+uy*0.02, fz+0.018), (ex-px*0.013+ux*0.02, ey-py*0.013+uy*0.02, fz+0.040), (ex+px*0.013+ux*0.02, ey+py*0.013+uy*0.02, fz+0.040)], (255, 255, 255), glow=0.95)) # torch: a bright cone poly on the ground + the lens itself if g.get("torch"): sweep = math.sin(t*0.9 + g["seed"] % 11)*0.55 th = hd + sweep c, s2 = math.cos(th), math.sin(th) n0 = (x+c*0.7, y+s2*0.7, 0.02) far = 7.5 wfar = 2.6 n1 = (x+c*far-s2*wfar, y+s2*far+c*wfar, 0.02) n2 = (x+c*far+s2*wfar, y+s2*far-c*wfar, 0.02) G.append(mkpoly([n0, n1, n2], (255, 255, 255), glow=0.55, flat=LY_FLOOR)) G.append(mkpoly([_lp(x, y, th, 0.30, -0.05, shz-0.44), _lp(x, y, th, 0.30, 0.05, shz-0.44), _lp(x, y, th, 0.30, 0.05, shz-0.34), _lp(x, y, th, 0.30, -0.05, shz-0.34)], (255, 255, 255), glow=1.0)) return P, L, G def gate_polys(t): """The sliding gate panel — CAM 04's whole story in one moving quad.""" P, L = [], [] o = gate_open(t) y0 = 5.0 - o*3.9 y1 = 9.0 - o*3.9 fence(P, L, 72, y0, 72, y1, 2.6, post_every=2.0, razor=False) for j in range(5): u = j/4.0 L.append(mkline((72, y0+(y1-y0)*u, 0.10), (72, y0+(y1-y0)*u, 2.6), (176, 180, 186), .045)) L.append(mkline((72, y0, 2.62), (72, y1, 2.62), (196, 200, 206), .07)) return P, L def scene_dynamic(t, cam): P = list(FAC_P); L = list(FAC_L); G = [] # pen gate swings open o = 0.0 if t < T_PEN_OPEN else min(1.0, (t-T_PEN_OPEN)/1.1) o = o*o*(3-2*o) ang = -o*1.25 px, py = 10.0, 4.0 ex, ey = px + math.cos(ang)*0.0 - math.sin(ang)*3.2, py + math.sin(ang)*0.0 + math.cos(ang)*3.2 fence(P, L, px, py, ex, ey, 2.0, post_every=3.4, mesh=True) gp, gl = gate_polys(t); P += gp; L += gl dp, dl, dg = dog_polys(t, cam); P += dp; L += dl; G += dg for g in GUARDS: gp2, gl2, gg2 = guard_polys(g, t) P += gp2; L += gl2; G += gg2 look = T_LOOK_UP <= t < T_LOOK_AWAY holding = T_GATE_OPEN <= t < T_GATE_CLOSE hy = 0.0 if t >= T_LOOK_AWAY: u = min(1.0, (t-T_LOOK_AWAY)/0.85) hy = -1.05*(u*u*(3-2*u)) # and then he looks away elif look: u = min(1.0, (t-T_LOOK_UP)/0.55) hy = 0.22*(1.0-u*u*(3-2*u)) # the small turn INTO the lens gp3, gl3, gg3 = guard_polys(GG, t, look_at_cam=look, cam=cam, open_hand=holding, head_yaw=hy) P += gp3; L += gl3; G += gg3 return P, L, G # ════════════════════════════════════════════════════════════════════════════ # THE CAMERAS # ════════════════════════════════════════════════════════════════════════════ class Chan: """One CCTV channel: a pinhole in the world plus a whole imaging chain.""" def __init__(self, label, zone, pos, target, fov, fps, phase, barrel, tint, gain, noise, ir, irrange, seed): self.label, self.zone = label, zone self.pos = pos; self.target = target; self.fov = fov self.fps = fps; self.phase = phase; self.barrel = barrel self.tint = np.array(tint, np.float32) self.gain = gain; self.noise = noise self.ir = ir; self.irrange = irrange; self.seed = seed fx = target[0]-pos[0]; fy = target[1]-pos[1]; fz = target[2]-pos[2] m = math.sqrt(fx*fx+fy*fy+fz*fz) or 1.0 self.f = (fx/m, fy/m, fz/m) rx, ry, rz = self.f[1]*1.0-self.f[2]*0.0, self.f[2]*0.0-self.f[0]*1.0, 0.0 rx, ry, rz = self.f[1], -self.f[0], 0.0 m = math.sqrt(rx*rx+ry*ry+rz*rz) or 1.0 self.r = (rx/m, ry/m, rz/m) ux = self.r[1]*self.f[2]-self.r[2]*self.f[1] uy = self.r[2]*self.f[0]-self.r[0]*self.f[2] uz = self.r[0]*self.f[1]-self.r[1]*self.f[0] m = math.sqrt(ux*ux+uy*uy+uz*uz) or 1.0 self.u = (ux/m, uy/m, uz/m) def toc(self, p): dx = p[0]-self.pos[0]; dy = p[1]-self.pos[1]; dz = p[2]-self.pos[2] return (dx*self.r[0]+dy*self.r[1]+dz*self.r[2], -(dx*self.u[0]+dy*self.u[1]+dz*self.u[2]), dx*self.f[0]+dy*self.f[1]+dz*self.f[2]) def field_index(self, t): return int(math.floor(t*self.fps + self.phase)) def field_time(self, fi): return (fi - self.phase)/self.fps CHANS = [ Chan("CAM 01", "CORRIDOR", (17.0, 7.00, 3.35), (40.0, 5.80, 0.90), 72.0, 12, 0.31, 0.20, (0.92, 0.97, 1.06), 0.86, 1.00, 0.58, 13.0, 11), Chan("CAM 02", "KENNEL", (14.30, 10.50, 3.25), (6.20, 5.30, 0.50), 78.0, 7, 0.63, 0.26, (0.95, 1.05, 0.94), 1.02, 1.55, 0.90, 9.0, 22), Chan("CAM 03", "LOADING BAY", (41.6, -3.20, 4.45), (54.0, 8.20, 0.70), 84.0, 15, 0.11, 0.28, (1.08, 1.00, 0.90), 1.00, 1.15, 0.62, 10.0, 33), Chan("CAM 04", "GATE", (62.6, 11.40, 4.15), (73.2, 7.00, 0.85), 76.0, 9, 0.47, 0.22, (0.94, 0.99, 1.09), 1.06, 1.30, 0.72, 11.0, 44), ] # quadrant order on screen: CAM01 CAM02 / CAM03 CAM04 QUAD_POS = [(0, 0), (QW, 0), (0, QH), (QW, QH)] def clip_near(pts): out = [] n = len(pts) for i in range(n): a = pts[i]; b = pts[(i+1) % n] ain = a[2] >= NEAR; bin_ = b[2] >= NEAR if ain: out.append(a) if ain != bin_: u = (NEAR-a[2])/(b[2]-a[2]) out.append((a[0]+(b[0]-a[0])*u, a[1]+(b[1]-a[1])*u, NEAR)) return out def shade_of(ch, normal, cen, lamps): """Lambert against the fixed lamps + the camera's own IR emitter. The IR term is what makes near objects blow out and far ones fall into noise.""" # Two-sided: flip the normal toward the camera before lighting. Painter's # algorithm never culls backfaces, so whichever side of a quad we can see # is the side that must be lit — this also makes the geometry immune to # winding-order mistakes in the facility builder. vx, vy, vz = ch.pos[0]-cen[0], ch.pos[1]-cen[1], ch.pos[2]-cen[2] if vx*normal[0]+vy*normal[1]+vz*normal[2] < 0: normal = (-normal[0], -normal[1], -normal[2]) sh = 0.10 for (lx, ly, lz, rad, inten) in lamps: dx, dy, dz = lx-cen[0], ly-cen[1], lz-cen[2] d2 = dx*dx+dy*dy+dz*dz d = math.sqrt(d2) or 1e-6 # a cheap omnidirectional bounce term — without it every ceiling and # every wall the lamps face away from goes to pure black, and the # picture reads as vector art rather than as a lit room sh += 0.26*inten/(1.0 + 2.4*d2/(rad*rad)) lam = (dx*normal[0]+dy*normal[1]+dz*normal[2])/d if lam <= 0: continue sh += inten*lam/(1.0 + d2/(rad*rad)) px, py, pz = ch.pos dx, dy, dz = px-cen[0], py-cen[1], pz-cen[2] d2 = dx*dx+dy*dy+dz*dz d = math.sqrt(d2) or 1e-6 lam = max(0.0, (dx*normal[0]+dy*normal[1]+dz*normal[2])/d) sh += ch.ir*(0.30+0.70*lam)/(1.0 + d2/(ch.irrange*ch.irrange)) return min(sh, 1.85) _BMAP = {} def barrel_map(w, h, k): key = (w, h, round(k, 4)) if key not in _BMAP: yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) hw, hh = (w-1)/2.0, (h-1)/2.0 nx = (xx-hw)/hw; ny = (yy-hh)/hw r2 = nx*nx + ny*ny f = (1.0 + k*r2)/(1.0 + k*0.62) sx = np.clip(hw + nx*f*hw, 0, w-1).astype(np.int32) sy = np.clip(hh + ny*f*hw, 0, h-1).astype(np.int32) _BMAP[key] = (sy, sx) return _BMAP[key] _FPN = {} def fixed_pattern(ch, w, h): key = (ch.seed, w, h) if key not in _FPN: R = np.random.RandomState(7000+ch.seed) cols = nn((1, w, 1), lambda sh: R.normal(0, 2.4, sh)).astype(np.float32) rows = nn((h, 1, 1), lambda sh: R.normal(0, 0.9, sh)).astype(np.float32) _FPN[key] = cols + rows return _FPN[key] def render_geo(ch, t, w, h, ss=1.5): """The 3D pass: project, near-clip, depth-sort, fill. Returns the base picture and the additive glow (torches, lamps, the collar tag).""" pw, ph = int(w*ss), int(h*ss) im = Image.new("RGB", (pw, ph), (7, 7, 9)) d = ImageDraw.Draw(im) gl = Image.new("L", (pw, ph), 0) gd = ImageDraw.Draw(gl) fx = (pw/2.0)/math.tan(math.radians(ch.fov)/2.0) cx, cy = pw/2.0, ph/2.0 P, L, G = scene_dynamic(t, ch) lamps = FAC_LAMPS items = [] for (verts, col, nrm, cen, glow, layer) in P + G: cpts = [ch.toc(v) for v in verts] if max(p[2] for p in cpts) < NEAR: continue cpts = clip_near(cpts) if len(cpts) < 3: continue pts = [(cx + fx*p[0]/p[2], cy + fx*p[1]/p[2]) for p in cpts] xs = [p[0] for p in pts]; ys = [p[1] for p in pts] if max(xs) < -40 or min(xs) > pw+40 or max(ys) < -40 or min(ys) > ph+40: continue z = sum(p[2] for p in cpts)/len(cpts) if glow > 0: items.append((layer, -z, 2, pts, col, glow)) else: sh = shade_of(ch, nrm, cen, lamps)*ch.gain c = (min(255, int(col[0]*sh)), min(255, int(col[1]*sh)), min(255, int(col[2]*sh))) items.append((layer, -z, 0, pts, c, 0.0)) for (p0, p1, col, lw, glow, layer) in L: a = ch.toc(p0); b = ch.toc(p1) if a[2] < NEAR and b[2] < NEAR: continue if a[2] < NEAR: u = (NEAR-a[2])/(b[2]-a[2]) a = (a[0]+(b[0]-a[0])*u, a[1]+(b[1]-a[1])*u, NEAR) elif b[2] < NEAR: u = (NEAR-b[2])/(a[2]-b[2]) b = (b[0]+(a[0]-b[0])*u, b[1]+(a[1]-b[1])*u, NEAR) pa = (cx + fx*a[0]/a[2], cy + fx*a[1]/a[2]) pb = (cx + fx*b[0]/b[2], cy + fx*b[1]/b[2]) if (max(pa[0], pb[0]) < -40 or min(pa[0], pb[0]) > pw+40 or max(pa[1], pb[1]) < -40 or min(pa[1], pb[1]) > ph+40): continue z = (a[2]+b[2])/2.0 cen = ((p0[0]+p1[0])/2, (p0[1]+p1[1])/2, (p0[2]+p1[2])/2) dxv = ch.pos[0]-cen[0]; dyv = ch.pos[1]-cen[1]; dzv = ch.pos[2]-cen[2] d2 = dxv*dxv+dyv*dyv+dzv*dzv sh = 0.16 + ch.ir*0.75/(1.0+d2/(ch.irrange*ch.irrange)) for (lx, ly, lz, rad, inten) in lamps: ddx, ddy, ddz = lx-cen[0], ly-cen[1], lz-cen[2] dd2 = ddx*ddx+ddy*ddy+ddz*ddz sh += inten*0.50/(1.0+dd2/(rad*rad)) sh = min(sh, 1.85)*ch.gain c = (min(255, int(col[0]*sh)), min(255, int(col[1]*sh)), min(255, int(col[2]*sh))) wpx = max(1, int(lw*fx/max(0.4, z))) items.append((layer, -z, 1, (pa, pb), c, wpx)) items.sort(key=lambda it: (it[0], it[1])) for (layer, nz, kind, payload, col, extra) in items: z = -nz if kind == 0: d.polygon(payload, fill=col) # The glow layer is additive and carries no depth buffer of its # own, so every opaque surface has to ERASE it as it paints — # otherwise torch beams and hi-vis stripes shine straight through # walls, and CAM 01 ends up showing you guards in the loading bay. gd.polygon(payload, fill=0) elif kind == 1: d.line([payload[0], payload[1]], fill=col, width=int(extra)) else: g = int(min(255, 255*extra*(1.0 + 1.2/(1.0+z/6.0)))) gd.polygon(payload, fill=g) if extra > 0.7: d.polygon(payload, fill=(min(255, int(col[0]*0.95)), min(255, int(col[1]*0.95)), min(255, int(col[2]*0.95)))) if ss != 1.0: im = im.resize((w, h), Image.LANCZOS) gl = gl.resize((w, h), Image.BILINEAR) return im, gl def image_chain(ch, im, gl, w, h, fi): """The sensor: barrel -> glow/bloom -> chroma starve + tint -> noise. Everything here is per-CHANNEL, which is why the four quadrants of one frame never look like the same camera.""" a = np.asarray(im, np.float32) sy, sx = barrel_map(w, h, ch.barrel) a = a[sy, sx] g = np.asarray(gl, np.float32)[sy, sx] # torch/lamp glow, twice-blurred so it has a tight core and a wide halo gi = Image.fromarray(np.clip(g, 0, 255).astype(np.uint8)) g1 = np.asarray(gi.filter(ImageFilter.GaussianBlur(PXf(1.8))), np.float32)/255.0 g2 = np.asarray(gi.filter(ImageFilter.GaussianBlur(PXf(10.0))), np.float32)/255.0 a = a + (g1[..., None]*118.0 + g2[..., None]*62.0) # highlight bloom off the picture itself — blown CCTV whites lum = a.mean(2) hot = np.clip((lum-198.0)/57.0, 0, 1)*255.0 hb = np.asarray(Image.fromarray(hot.astype(np.uint8)) .filter(ImageFilter.GaussianBlur(PXf(6.0))), np.float32)/255.0 a = a + hb[..., None]*np.array([74.0, 77.0, 82.0], np.float32) # chroma starvation + the channel's own colour cast grey = a.mean(2, keepdims=True) a = grey*(1.0-0.16) + a*0.16 a = a*ch.tint[None, None, :] # sensor noise — weighted into the shadows, plus fixed-pattern columns R = np.random.RandomState(9000 + ch.seed*131 + (fi % 4096)) l01 = np.clip(a.mean(2)/255.0, 0, 1) sig = ch.noise*(2.1 + 13.0*(1.0-l01)**2.2) a = a + nn((h, w, 1), lambda sh: R.normal(0, 1, sh)).astype(np.float32)*sig[..., None] a = a + nn((h, w, 3), lambda sh: R.normal(0, 1, sh)).astype(np.float32)*(sig[..., None]*0.22) a = a + fixed_pattern(ch, w, h)*ch.noise return np.clip(a, 0, 255) FIELD_GAP = 1.0/50.0 # the two fields of one recorded frame, 20 ms apart _FIELD = {} def field(ci, fi, par, w, h): """One interlace field, cached. A DVR at N fps records N whole frames a second, and each recorded frame is itself two fields exposed 20 ms apart. Judder therefore comes from `fi` (the camera's own frame rate) and combing from `par` (the field within the frame) — they are separate, and modelling them separately is what makes a 7 fps camera stutter without every moving object shredding. """ key = (ci, fi, par, w, h) hit = _FIELD.get(key) if hit is not None: return hit ch = CHANS[ci] t = max(0.0, ch.field_time(fi)) + par*FIELD_GAP ss = 1.5 if w <= PXi(640) else 1.35 im, gl = render_geo(ch, t, w, h, ss=ss) a = image_chain(ch, im, gl, w, h, fi*2+par) if len(_FIELD) > 40: for k in list(_FIELD.keys())[:14]: _FIELD.pop(k, None) _FIELD[key] = a return a def channel_frame(ci, t, w, h): """Interlaced output: even scanlines from field 0 of the camera's current recorded frame, odd scanlines from field 1. Motion combs; static content doesn't; and the whole frame is held until the camera's next tick.""" ch = CHANS[ci] fi = ch.field_index(t) out = field(ci, fi, 0, w, h).copy() out[1::2] = field(ci, fi, 1, w, h)[1::2] return out # ════════════════════════════════════════════════════════════════════════════ # THE DVR — multiplex, OSD, glitches # ════════════════════════════════════════════════════════════════════════════ BOOT = [0.0, 0.9, 1.7, 2.6] # when each channel comes up GLITCHES = [ # (t, dur, channel or -1 for the whole multiplex, strength) (7.55, 0.20, 1, 0.9), (12.65, 0.14, 0, 0.7), (17.35, 0.18, 3, 0.8), (22.05, 0.22, 2, 1.0), (26.60, 0.16, 1, 0.7), (31.25, 0.26, -1, 0.9), (35.15, 0.20, 2, 1.0), (39.80, 0.14, 0, 0.6), (43.10, 0.24, 3, 0.8), (46.85, 0.18, 2, 0.9), (50.10, 0.30, -1, 1.0), (54.00, 0.16, 1, 0.7), (57.75, 0.12, 0, 0.5), ] OSD = [ (0.45, 3.10, "PLAYER COMPUTER DVR 4CH REC", "sys"), (5.70, 1.70, "MOTION CH02", "alert"), (6.35, 1.50, "ZONE 2 PEN GATE OPEN", "alert"), (12.10, 1.40, "CH02 UNIT 1 ENTERING", "alert"), (15.70, 1.70, "MOTION CH01", "alert"), (23.30, 1.50, "CH01 2 TARGETS", "alert"), (26.55, 1.70, "MOTION CH03", "alert"), (33.60, 1.40, "CH03 UNIT 3 ENTERING", "alert"), (35.40, 1.90, "CH03 TRACK LOST", "alert"), (38.60, 2.10, "SEARCH CH03 4 UNITS", "alert"), (45.90, 1.70, "MOTION CH04", "alert"), (51.60, 2.00, "CH04 GATE OPEN", "alert"), (55.60, 1.70, "CH04 ZONE CLEAR", "alert"), (61.60, 3.50, "CH04 GATE CLOSED NO EVENT LOGGED", "sys"), ] CLOCK0 = (3, 14, 6) # 03:14:06 # ── 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="Menlo.ttc"): """Size is in authoring units; the loaded face is scaled once by SCL.""" key = (int(size), name, SCL) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, int(size) if SCL == 1.0 else max(2, PXi(size))) return _FC[key] def stamp(t): hh, mm, ss = CLOCK0 tot = hh*3600 + mm*60 + ss + t hh = int(tot//3600) % 24; mm = int(tot//60) % 60; ss = int(tot) % 60 return f"2026-08-26 {hh:02d}:{mm:02d}:{ss:02d}" def osd_text(d, x, y, s, size, fill=(246, 248, 250), anchor="la"): f = font(size) o = PXi(1) for ox, oy in ((-o, 0), (o, 0), (0, -o), (0, o), (o, o), (-o, -o)): d.text((x+ox, y+oy), s, font=f, fill=(6, 6, 8), anchor=anchor) d.text((x, y), s, font=f, fill=fill, anchor=anchor) def dog_channel(t): """Which channel currently has her. Drives the DVR's MOTION tag.""" x, _, _, _ = dog_state(t) if x < 15.9: return 1 if x < 40.0: return 0 if x < 62.0: return 2 return 3 def glitch_quad(a, t, ci, R): """Block tearing + a dropped-signal band inside ONE quadrant.""" for (gt, gd_, gc, gs) in GLITCHES: if gc != ci: continue if not (gt <= t < gt+gd_): continue h, w = a.shape[:2] nb = 3 + int(gs*5) for _ in range(nb): y0 = int(R.rand()*(h-PXi(8))); hh = PXi(3 + R.rand()*22*gs) y1 = min(h, y0+hh) sh = int((R.rand()*2-1)*w*0.20*gs) a[y0:y1] = np.roll(a[y0:y1], sh, axis=1) if R.rand() < 0.30: a[y0:y1] = (a[y0:y1]*0.35 + nn((y1-y0, w, 1), lambda s2: R.normal(120, 46, s2))*0.65) if R.rand() < 0.5: y0 = int(R.rand()*(h-PXi(20))) a[y0:y0+PXi(2 + R.rand()*4)] = 232.0 return a def multiplex(i, t, mode, active, E): """Assemble the frame: four channels, or one blown up to full screen.""" R = np.random.RandomState(300007 + i*17) canvas = np.zeros((H, W, 3), np.float32) if mode == "blow": a = channel_frame(active, t, W, H).astype(np.float32) a = glitch_quad(a, t, active, R) canvas[:] = a boxes = [(0, 0, W, H, active, True)] else: boxes = [] for ci in range(4): qx, qy = QUAD_POS[ci] if t < BOOT[ci]: # channel not up yet: pure static st = nn((QH, QW, 1), lambda sh: R.normal(96, 44, sh)).astype(np.float32) canvas[qy:qy+QH, qx:qx+QW] = np.repeat(st, 3, 2) boxes.append((qx, qy, QW, QH, ci, False)) continue a = channel_frame(ci, t, QW, QH).astype(np.float32) a = glitch_quad(a, t, ci, R) if t < BOOT[ci]+0.34: u = (t-BOOT[ci])/0.34 st = nn((QH, QW, 1), lambda sh: R.normal(96, 44, sh)).astype(np.float32) a = a*u + np.repeat(st, 3, 2)*(1-u) canvas[qy:qy+QH, qx:qx+QW] = a boxes.append((qx, qy, QW, QH, ci, ci == active)) # whole-multiplex vertical sync roll for (gt, gd_, gc, gs) in GLITCHES: if gc != -1: continue if gt <= t < gt+gd_: u = (t-gt)/gd_ canvas = np.roll(canvas, int(u*H*1.6) % H, axis=0) band = int(H*0.03) y0 = int(u*H*1.6) % H canvas[y0:min(H, y0+band)] *= 0.28 return canvas, boxes def draw_osd(img, t, boxes, mode, active, i): d = ImageDraw.Draw(img) dchan = dog_channel(t) big = (mode == "blow") bh = int(H*0.045) for (qx, qy, qw, qh, ci, act) in boxes: ch = CHANS[ci] s = 22 if big else 14 sp = PXf(s) # the type's real pixel height up = t >= BOOT[ci] lab = f"{ch.label} {ch.zone}" pad = PXf(12 if big else 8) # keep every burnt-in string clear of the letterbox ytop = max(qy+pad, bh+PXf(6)) ybot = min(qy+qh-pad-sp-PXf(2), H-bh-sp-PXf(8)) if act: # the DVR inverts the active channel's label and boxes the quadrant f = font(s) tw = d.textlength(lab, font=f) d.rectangle([qx+pad-PXf(4), ytop-PXf(3), qx+pad+tw+PXf(4), ytop+sp+PXf(4)], fill=(240, 242, 246)) d.text((qx+pad, ytop), lab, font=f, fill=(10, 10, 12)) else: osd_text(d, qx+pad, ytop, lab, s, fill=(232, 236, 240) if up else (120, 124, 130)) if not up: osd_text(d, qx+qw/2, qy+qh/2, "NO SIGNAL", s+4, fill=(200, 204, 210), anchor="mm") continue osd_text(d, qx+qw-pad, ytop, stamp(t), s, anchor="ra") # REC dot, blinking at 1Hz if int(t*2) % 2 == 0: r = PXf(4 if big else 3) cxr = qx+qw-pad-r; cyr = ybot+sp/2 d.ellipse([cxr-r, cyr-r, cxr+r, cyr+r], fill=(206, 84, 74)) osd_text(d, cxr-r-PXf(5), ybot, "REC", s, anchor="ra") if ci == dchan and int(t*6) % 2 == 0 and t > 5.4: osd_text(d, qx+pad, ybot, "MOTION", s, fill=(252, 252, 252)) if act and not big: d.rectangle([qx+PXf(3), max(qy+PXf(3), bh+PXf(2)), qx+qw-PXf(4), min(qy+qh-PXf(4), H-bh-PXf(3))], outline=(246, 248, 252), width=PXi(3)) # multiplex divider if mode != "blow": d.line([(QW, 0), (QW, H)], fill=(16, 17, 20), width=PXi(3)) d.line([(0, QH), (W, QH)], fill=(16, 17, 20), width=PXi(3)) # ── the title, burnt in by the DVR itself in its own OSD face, sitting # directly over the "PLAYER COMPUTER DVR 4CH REC" boot line ──────── if 0.35 <= t < 3.30: osd_text(d, W/2, H-bh-PXf(66)-PXf(34), "F O U R U P", 30 if big else 26, fill=(246, 248, 250), anchor="ma") # DVR system line for (at, ln, txt, sty) in OSD: if not (at <= t < at+ln): continue age = t-at blink = (sty == "alert" and int(age*7) % 2 == 1) size = 20 if not big else 24 y = H-bh-PXf(66) if sty == "sys": osd_text(d, W/2, y, txt, size, fill=(240, 242, 246), anchor="ma") elif not blink: osd_text(d, W/2, y, txt, size, fill=(252, 248, 236), anchor="ma") return img # ════════════════════════════════════════════════════════════════════════════ # SHOTS # ════════════════════════════════════════════════════════════════════════════ class Shot: __slots__ = ("idx", "i0", "i1", "n", "mode", "cam", "section", "seed") def __init__(self, idx, i0, i1, mode, cam, section): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.mode, self.cam, self.section = mode, cam, section self.seed = 960096 + idx*7919 # Shot lengths in sixteenths, re-cut for broken beat. The dembow version used # 8/6/4/3 — the straight and dotted spine of that groove. The new kit puts its # accents on 3, 6, 7, 10 and 13, so the menu is retuned to those: the DVR's # active-channel box now moves ONTO the displaced hits instead of landing on a # grid the music no longer plays. MENU = { "boot": [16, 16, 12], "drop": [10, 6, 12, 8, 6], "hall": [6, 10, 4, 7, 12, 3], "bay": [6, 4, 7, 3, 10, 5, 2], "swarm": [3, 2, 5, 3, 2, 4, 7], "gate": [4, 7, 3, 6, 10, 2], "out": [10, 12, 8, 16, 6], } BLOW_P = {"boot": 0.0, "drop": .20, "hall": .24, "bay": .28, "swarm": .34, "gate": .30, "out": .34} # how often the DVR follows the guards (the quadrant she just left) instead GUARD_P = {"boot": 0.0, "drop": .34, "hall": .38, "bay": .40, "swarm": .46, "gate": .26, "out": .12} # authored spans that must not be shuffled away SCRIPT = [ (T_PEN_OPEN-0.31, 1.25, "blow", 1), (15.94, 1.25, "blow", 0), (26.56, 1.09, "blow", 2), (35.31, 1.25, "blow", 2), (45.94, 1.41, "blow", 3), (51.34, 1.72, "blow", 3), (54.84, 1.88, "blow", 3), (58.44, 2.50, "blow", 3), # he looks up (60.94, 1.56, "blow", 3), # he looks away (62.50, 2.60, "quad", 3), # NO EVENT LOGGED ] def guard_channel(t): """Where the guards are — deliberately one camera behind her.""" best, bd = 0, 1e9 for g in GUARDS: x, y, _, sp = _path(g["w"], t) if x < 15.9: ci = 1 elif x < 40.0: ci = 0 elif x < 62.0: ci = 2 else: ci = 3 if sp > 0.2 and ci != dog_channel(t): return ci if abs(sp) < bd: best, bd = ci, abs(sp) return best def build_shots(): R = np.random.RandomState(1313) spans = [] for nm, b0, b1 in SECTIONS: menu = MENU[nm] t = b0*BAR last = None while t < b1*BAR - 1e-6: step = menu[R.randint(len(menu))]*S16 t2 = min(t+step, b1*BAR) if (b1*BAR - t2) < S16*2: t2 = b1*BAR mid = (t+t2)/2 if nm == "boot": mode, cam = "quad", (1 if mid > 3.4 else 0) else: cam = dog_channel(mid) if R.rand() < GUARD_P[nm]: gc = guard_channel(mid) if gc != cam: cam = gc elif R.rand() < 0.10: cam = int(R.randint(4)) mode = "blow" if R.rand() < BLOW_P[nm] else "quad" if (mode, cam) == last and R.rand() < 0.7: cam = (cam+1) % 4 last = (mode, cam) spans.append([t, t2, mode, cam, nm]) t = t2 for sa, ln, mode, cam in SCRIPT: sb = sa+ln out = [] for a, b, m, c, nm in spans: if b <= sa or a >= sb: out.append([a, b, m, c, nm]); continue if a < sa: out.append([a, sa, m, c, nm]) if b > sb: out.append([sb, b, m, c, nm]) out.append([sa, sb, mode, cam, sec_of(int(sa/BAR))]) spans = sorted(out, key=lambda s: s[0]) shots = [] for idx, (a, b, m, c, nm) in enumerate( [s for s in spans if s[1]-s[0] > 1.5/FPS]): shots.append(Shot(idx, int(a*FPS), int(b*FPS), m, c, nm)) for j in range(len(shots)-1): shots[j].i1 = shots[j+1].i0 shots[j].n = shots[j].i1 - shots[j].i0 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return [s for s in shots if s.n > 0] # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> (OSD text) -> letterbox # ════════════════════════════════════════════════════════════════════════════ _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*nx+ny*ny)/1.42 _VIG["v"] = np.clip(1.0 - 0.34*r**2.0, 0, 1)[..., None] return _VIG["v"] def post(a, i, t, e, shot): # a beat-locked gain lift — the picture breathes on the dembow a = a*(0.96 + 0.16*e["kick"] + 0.05*e["rms"]) for (at, _l, _c, _s) in ((T_PEN_OPEN, 0, 0, 0), (T_DOG_THROUGH, 0, 0, 0)): if 0 <= t-at < 2.2/FPS: a = a*0.60 + 130.0 # tint — a faint monitor-phosphor cast over the whole wall of screens lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([-6.0, -2.0, 12.0], np.float32) \ + lum*np.array([6.0, 3.0, -4.0], np.float32) # vignette a = a*vignette() # grain — the DVR's own compression/scan-line life, kept light on purpose: # the real texture is the per-channel sensor noise baked into each field, # which is temporally STATIC while a field is held (so it costs almost no # bitrate). Grain here changes every frame and is expensive; a little goes # a long way over the top of the noise that is already there. rng = np.random.RandomState(510510 + i) sd_ = 0.9 + 1.1*e["high"] a = a + nn((H, W, 1), lambda sh: rng.normal(0, sd_, sh)) # the DVR's scan lines: every third line of the 720p master, NEAREST-mapped ln = (np.arange(H)/SCL).astype(np.int32) % 3 == 0 a[ln] *= 0.985 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) return out def render_frame(shot, k, E): i = min(shot.i0+k, N_FRAMES-1) t = i/FPS e = {kk: float(E[kk][i]) for kk in E} canvas, boxes = multiplex(i, t, shot.mode, shot.cam, E) img = post(canvas, i, t, e, shot) img = draw_osd(img, t, boxes, shot.mode, shot.cam, i) d = ImageDraw.Draw(img) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(6, 6, 8)) d.rectangle([0, H-bh, W, H], fill=(6, 6, 8)) return img def render_shot(job): shot, force = job E = env() 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 render_frame(shot, k, E).save(p, compress_level=1) made += 1 return (f"shot {shot.idx:02d} {shot.mode:4s} ch{shot.cam+1} " f"{shot.section:5s} {made}/{shot.n}") def _sheet_one(job): shot, tw, th = job E = env() im = render_frame(shot, shot.n//2, E).resize((tw, th), Image.LANCZOS) return shot.idx, np.asarray(im) def contact_sheet(shots, jobs=8): cols = 8 rows = (len(shots)+cols-1)//cols tw, th = PXi(320), PXi(180) lab = PXi(22) sheet = Image.new("RGB", (cols*tw, rows*(th+lab)), (10, 10, 14)) sd = ImageDraw.Draw(sheet) import multiprocessing as mp payload = [(s, tw, th) for s in shots] with mp.get_context("fork").Pool(jobs) as pool: for idx, arr in pool.imap_unordered(_sheet_one, payload): sh = shots[idx] cx, cy = (idx % cols)*tw, (idx//cols)*(th+lab) sheet.paste(Image.fromarray(arr), (cx, cy)) sd.text((cx+PXi(5), cy+th+PXi(3)), f"{sh.idx:02d} {sh.mode} ch{sh.cam+1} · {sh.section} · " f"{sh.i0/FPS:.1f}s", font=font(12), fill=(190, 195, 205)) 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(14, os.cpu_count() or 4)) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() or a.force: print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {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, jobs=a.jobs); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames " 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) 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" st = (f"generator=renders/{SETDIR}/{NAME}/render.py | git={sha} | " f"branch={br} | {MUSIC_DESC} | {ENGINE_DESC}") subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", "-metadata", f"comment={st}", "-metadata", f"description={st}", "-metadata", f"artist=poop / {SETDIR}", 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"engine: {ENGINE_DESC}\n" f"channels: " + " ".join(f"{c.label}/{c.zone}@{c.fps}fps" for c in CHANS) + "\n" f"shots: {len(shots)}\n" f"voices: Paulina + Rocko (es-MX shouted ad-libs), " f"Eddy (es-MX guard radio), Mónica (es-ES, the last line)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()