"""AFTERLIGHT, sixth cut: a music video of light alone, for Warm Dome & Golden Record. Nothing on screen but embers, wind, caustics and stars. A fire bed below the frame feeds one continuous field of embers. The song drives it: kicks flare the fire and gust the wind, hats throw sparks, each section changes the speed of time. Voices are drawn as light: the lead as a warm oscilloscope thread, the archive as a cooler, broken one. usage: python3 trailer6.py WORKER NWORKERS | python3 trailer6.py stills t1,t2,...""" import sys, os, math, json, numpy as np sys.path.insert(0, '/home/claude/afterlight/trailer6') import timeline6 as T from scipy.ndimage import gaussian_filter from PIL import Image, ImageDraw, ImageFont import cv2 W, H = 1280, 720; FPS = T.FPS; DT = 1.0 / FPS D6 = '/home/claude/afterlight/trailer6/' OUT = D6 + 'frames'; os.makedirs(OUT, exist_ok=True) ENV = np.load(D6 + 'env6.npy'); SCOPE = np.load(D6 + 'scope6.npy'); BEATS = json.load(open(D6 + 'beats6.json')) LB = 92 # letterbox: 2.39:1 inside 16:9 def ease(u): u = min(1.0, max(0.0, u)); return u * u * (3 - 2 * u) def sm(a, b, t): return ease((t - a) / (b - a)) def F(t): i = min(len(ENV) - 1, max(0, int(t * FPS))); e = ENV[i] return dict(mix=e[0], low=e[1], kick=e[2], hat=e[3], stab=e[4], vl=e[5], va=e[6], lo=e[7], hi=e[8]) TST = np.array([[0.02, 0.006, 0.01], [0.28, 0.04, 0.02], [0.70, 0.16, 0.03], [0.98, 0.46, 0.10], [1.0, 0.78, 0.45], [1.0, 0.95, 0.85]], np.float32) def thermal(x): x = np.clip(x, 0, 1) * 5; i = np.clip(np.floor(x).astype(int), 0, 4); f = (x - i)[..., None]; return TST[i] * (1 - f) + TST[i + 1] * f A0, A1 = T.SEC['A']; B0, B1 = T.SEC['B']; C0, C1 = T.SEC['C']; D0, D1 = T.SEC['D'] def section_speed(t): """time itself changes speed: slow in the private room, quick in the groove, slow motion in the break, fastest in the return, nearly still after""" s = 0.55 + 0.45 * sm(B0 - 0.3, B0 + 0.4, t) s = s * (1 - 0.6 * sm(C0 - 0.3, C0 + 0.8, t) * (1 - sm(C1 - 0.6, C1 + 0.1, t))) s = s * (1 + 0.35 * sm(D0 - 0.2, D0 + 0.3, t) * (1 - sm(D1 - 0.3, D1 + 0.3, t))) return s * (1 - 0.85 * sm(T.SILENCE - 0.2, T.SILENCE + 2.0, t)) def fire_amt(t): return 0.25 + 0.75 * sm(B0 - 0.2, B0 + 0.3, t) * (1 - 0.55 * sm(C0, C0 + 1, t) * (1 - sm(C1 - 0.6, C1, t))) * (1 - sm(D1 - 0.1, D1 + 0.4, t)) # ---------------- wind: a slow base drift plus the curl of a moving potential ---------------- MODES = [(0.0031, 0.0042, 0.31, 0.0, 1.0), (0.0057, -0.0023, 0.47, 1.3, 0.6), (-0.0019, 0.0068, 0.23, 2.1, 0.7), (0.0091, 0.0079, 0.71, 0.4, 0.35), (-0.012, 0.0053, 0.9, 3.0, 0.25)] def wind(x, y, t, gust): u = np.zeros_like(x); v = np.zeros_like(x) for kx, ky, w, p, a in MODES: c = np.cos(kx * x + ky * y + w * t + p) * a * 900 u += c * ky; v -= c * kx # u = dpsi/dy, v = -dpsi/dx bx = 95 + 55 * math.sin(0.13 * t + 0.5); by = -6 return (u * 16 + bx) * (1 + 1.1 * gust), (v * 16 + by) * (1 + 0.6 * gust) # ---------------- the particle field, simulated from the start in every worker (deterministic) ---------------- CAP = 70000 class Field: def __init__(self): self.r = np.random.default_rng(2026); n = CAP self.x = np.zeros(n, np.float32); self.y = np.zeros(n, np.float32); self.z = np.ones(n, np.float32) self.vx = np.zeros(n, np.float32); self.vy = np.zeros(n, np.float32); self.T = np.zeros(n, np.float32) self.age = np.zeros(n, np.float32); self.life = np.ones(n, np.float32); self.kind = np.zeros(n, np.int8); self.alive = np.zeros(n, bool) self.ph = self.r.random(n).astype(np.float32) * 6.283; self.rr = np.zeros(n, np.float32); self.th = np.zeros(n, np.float32) self.stars = np.zeros((0, 4), np.float32); self.t = 0.0; self.gust = 0.0; self.flare = 0.0 def spawn(self, n, **kw): if n <= 0: return free = np.nonzero(~self.alive)[0][:n]; n = len(free) if n == 0: return for k, v in kw.items(): getattr(self, k)[free] = v(n) if callable(v) else v self.alive[free] = True; self.age[free] = 0 def step(self, t, f): r = self.r; sp = section_speed(t); dt = DT * sp self.gust = max(self.gust * 0.86, f['kick'] if t > B0 - 0.2 else f['stab'] * 0.5) self.flare = max(self.flare * 0.8, f['kick']) fa = fire_amt(t) if t < T.SILENCE else 0.0 # the fire bed: embers rise from below the frame nb = r.poisson((26 + 190 * f['low'] * (t > B0 - 0.2)) * fa * DT * 30 * 0.12 + 420 * f['kick'] * fa * (t > B0 - 0.2) * 0.5) if t < B0 - 0.2: nb += r.poisson(16 * f['stab'] + 1.2) cx = W * 0.5 self.spawn(nb, x=lambda n: cx + r.normal(0, W * 0.22, n), y=lambda n: H - LB + 8 + r.random(n) * 30, z=lambda n: 0.3 + r.random(n) ** 0.8 * 1.5, vx=lambda n: r.normal(0, 30, n), vy=lambda n: -(50 + r.random(n) * 150) * (1 + 1.0 * f['kick']), T=lambda n: 0.8 + 0.2 * r.random(n), life=lambda n: 2.5 + r.random(n) * 5.0, kind=0) # sparks on the hats: fast, short, thin if t > B0 - 0.2 and t < T.SILENCE: ns = r.poisson(380 * f['hat'] * fa * 0.4) self.spawn(ns, x=lambda n: cx + r.normal(0, W * 0.16, n), y=lambda n: H - LB + 4 + 0 * n, z=lambda n: 0.6 + r.random(n) * 0.6, vx=lambda n: r.normal(40, 160, n), vy=lambda n: -(380 + r.random(n) * 420), T=1.0, life=lambda n: 0.35 + r.random(n) * 0.5, kind=1) # voices shed dust where their thread moves for which, lv, yy in ((0, f['vl'], 0.0), (1, f['va'], 0.0)): if lv > 0.12 and t < T.SILENCE: nd = r.poisson(18 * lv) xs = lambda n: W * (0.18 + 0.64 * r.random(n)) self.spawn(nd, x=xs, y=lambda n: thread_y(t) + r.normal(0, 6, n), z=lambda n: 0.7 + r.random(n) * 0.6, vx=lambda n: r.normal(0, 12, n), vy=lambda n: -(30 + r.random(n) * 60), T=lambda n: (0.75 if which == 0 else 0.55) + 0.2 * r.random(n), life=lambda n: 0.8 + r.random(n) * 1.2, kind=4 + which) # the record: orbiting light on grooves, in the break rec = sm(C0 - 1.0, C0 + 1.0, t) * (1 - sm(C1 - 0.5, C1 + 0.5, t)) if rec > 0.02: no = r.poisson(900 * rec * DT * 30 * 0.12 + 160 * f['stab'] * rec) self.spawn(no, rr=lambda n: H * (0.11 + 0.012 * r.integers(0, 26, n)), th=lambda n: r.random(n) * 6.283, z=1.0, T=lambda n: 0.6 + 0.3 * r.random(n), life=lambda n: 2.5 + r.random(n) * 3, kind=2, x=0.0, y=0.0, vx=0.0, vy=0.0) # the dome: light running up a hemisphere, in the return dm = sm(D0 - 0.4, D0 + 0.8, t) * (1 - sm(D1 - 0.2, D1 + 0.5, t)) if dm > 0.02: nd = r.poisson(1500 * dm * DT * 30 * 0.12 + 700 * f['kick'] * dm) self.spawn(nd, th=lambda n: r.random(n) * math.pi, rr=lambda n: r.random(n) * 0.2, z=1.0, T=lambda n: 0.7 + 0.3 * r.random(n), life=lambda n: 1.4 + r.random(n) * 2.2, kind=3, x=0.0, y=0.0, vx=0.0, vy=0.0) # integrate a = self.alive; self.age[a] += dt free = a & (self.kind <= 1) | a & (self.kind >= 4) wx, wy = wind(self.x[free], self.y[free], t, self.gust) zf = 1.0 / self.z[free] self.vx[free] += ((wx * zf) - self.vx[free]) * min(1, dt * 1.6); self.vy[free] += (wy * zf - 26 * (self.kind[free] == 0) - self.vy[free] * 0.0) * min(1, dt * 0.9) * 0.5 self.x[free] += self.vx[free] * dt; self.y[free] += self.vy[free] * dt o = a & (self.kind == 2) self.th[o] += dt * (0.55 + 0.004 * (H * 0.4 - self.rr[o])) ; self.x[o] = W * 0.58 + np.cos(self.th[o]) * self.rr[o] * 1.35; self.y[o] = H * 0.5 + np.sin(self.th[o]) * self.rr[o] d = a & (self.kind == 3) self.rr[d] += dt * 0.32; Rd = W * 0.62 * (1 + 0.012 * self.flare) lat = self.rr[d] * math.pi * 0.5 self.x[d] = W * 0.5 + np.cos(self.th[d] + t * 0.05) * np.cos(lat) * Rd; self.y[d] = H * 1.06 - np.sin(lat) * Rd * 0.98 - np.abs(np.sin(self.th[d] + t * 0.05)) * 0 self.T[a] -= dt / np.maximum(self.life[a], 0.1) * 0.85 dead = a & ((self.age > self.life) | (self.y < -40) | (self.x < -60) | (self.x > W + 60)) # embers that die high and far become stars far = dead & (self.kind == 0) & (self.z > 1.2) & (self.y < H * 0.62) & (self.y > LB) if far.any() and len(self.stars) < 5000: k = np.nonzero(far)[0][:40] self.stars = np.concatenate([self.stars, np.stack([self.x[k], self.y[k], self.ph[k], 0.25 + 0.5 * r.random(len(k))], 1).astype(np.float32)]) self.alive[dead] = False; self.t = t def thread_y(t): return H * (0.5 + 0.16 * sm(T.SILENCE - 0.2, T.SILENCE + 0.3, t)) # ---------------- light layers ---------------- _c = np.random.default_rng(31) def _nf(sig): f = gaussian_filter(_c.standard_normal((180, 320)), sig); return (f / f.std()).astype(np.float32) CN = [_nf(7), _nf(7), _nf(9), _nf(9)] YY, XX = np.mgrid[0:H, 0:W].astype(np.float32) def caustic(t, sharp=0.07): f1 = CN[0] * math.cos(0.31 * t) + CN[1] * math.sin(0.31 * t); f2 = CN[2] * math.cos(0.23 * t + 1.3) + CN[3] * math.sin(0.23 * t + 1.3) c = np.exp(-(f1 / sharp) ** 2) + np.exp(-(f2 / sharp) ** 2) * 0.8 return cv2.resize(c.astype(np.float32), (W, H), interpolation=cv2.INTER_CUBIC) GOLD = np.array([1.0, 0.74, 0.40], np.float32) def oscillo(tr, t, amp, warm, broken): """a voice drawn as a thread of light across the middle of the frame""" if amp < 0.02: return 0 x0, x1 = W * 0.16, W * 0.84; xs = np.linspace(x0, x1, 400) s = np.interp(np.linspace(0, 95, 400), np.arange(96), tr); s = s / (np.abs(s).max() + 0.08) taper = np.sin(np.linspace(0, math.pi, 400)) ** 0.8 ys = thread_y(t) + s * 70 * min(1.0, amp * 1.6) * taper img = np.zeros((H, W), np.float32) pts = np.stack([xs, ys], 1).astype(np.int32) if broken: # the radio boundary: the line breaks into dashes that flicker on = (np.sin(xs * 0.11 + t * 23) > -0.2) & (np.sin(xs * 0.013 - t * 3) > -0.6) for i in range(len(pts) - 1): if on[i]: cv2.line(img, tuple(pts[i]), tuple(pts[i + 1]), 1.0, 1, cv2.LINE_AA) else: cv2.polylines(img, [pts.reshape(-1, 1, 2)], False, 1.0, 1, cv2.LINE_AA) img = img * amp g = img * 2.5 + gaussian_filter(img, 2.0) * 5.0 + gaussian_filter(img, 9) * 5.0 col = np.array([1.0, 0.72, 0.38], np.float32) if warm else np.array([0.86, 0.90, 0.95], np.float32) return g[..., None] * col GL = np.random.default_rng(91) GLINTS = [dict(x=W * (0.12 + 0.075 * i + 0.03 * GL.random()), y=LB + (H - 2 * LB) * (0.1 + 0.7 * GL.random()), t0=D0 + 0.55 * i, rel=1.8 + 1.1 * ((i * 5) % 7)) for i in range(9)] def glints(t): out = np.zeros((H, W, 3), np.float32) for g in GLINTS: u = t - g['t0'] if u < 0: continue life = sm(0, 0.2, u) * (1 - sm(g['rel'], g['rel'] + 1.0, u)) if life <= 0.005: continue x0, y0 = g['x'], g['y']; x1, x2 = int(max(0, x0 - 90)), int(min(W, x0 + 520)); y1, y2 = int(max(0, y0 - 90)), int(min(H, y0 + 90)) xx = XX[y1:y2, x1:x2] - x0; yy = YY[y1:y2, x1:x2] - y0 star = np.exp(-(xx ** 2 + yy ** 2) / 6) * 1.6 + np.exp(-(yy / 1.0) ** 2 - (xx / 70) ** 2) * 0.7 + np.exp(-(xx / 1.0) ** 2 - (yy / 55) ** 2) * 0.7 halo = np.exp(-(xx ** 2 + yy ** 2) / 260) * 0.35 L_ = min(1.0, u / 2.2) * 440 * (0.5 + 0.5 * ((g['x'] * 13) % 1)) pix = (xx > 4) & (xx < L_) & (np.abs(yy) < 1.6) & (((xx.astype(np.int32) // 4) % 3) == 0) tail = pix * np.clip(1 - xx / max(L_, 1), 0, 1) ** 1.3 * 0.9 out[y1:y2, x1:x2] += (star[..., None] * np.array([1.0, 0.95, 0.85]) + halo[..., None] * np.array([0.8, 0.35, 0.7]) + tail[..., None] * np.array([1.0, 0.66, 0.3])).astype(np.float32) * life return out def splat(fd, t): """every live particle, drawn as a short streak along its motion (a long exposure), near ones out of focus""" a = np.nonzero(fd.alive)[0] sharp = np.zeros((H, W, 3), np.float32); soft = np.zeros((H, W, 3), np.float32) if len(a) == 0: return sharp, soft x, y, z, Tm, k = fd.x[a], fd.y[a], fd.z[a], np.clip(fd.T[a], 0, 1), fd.kind[a] life_u = np.clip(fd.age[a] / np.maximum(fd.life[a], 0.1), 0, 1) fade = np.clip(fd.age[a] / 0.25, 0, 1) * (1 - life_u) ** 0.7 flick = 0.7 + 0.3 * np.sin(t * 17 + fd.ph[a] * 9) br = fade * flick * np.where(k == 1, 1.6, 1.0) / np.maximum(z, 0.35) ** 0.6 * (0.6 + 1.6 * Tm ** 2) col = thermal(0.15 + 0.85 * Tm) col = np.where((k == 5)[:, None], col * 0.4 + np.array([0.62, 0.66, 0.72], np.float32) * 0.6, col) if (k == 2).any(): # record grooves: lit by a turning sector m = k == 2; th = np.arctan2((y[m] - H * 0.5), (x[m] - W * 0.58) / 1.35) br[m] *= 0.15 + 1.2 * np.maximum(np.cos(th - t * 0.45), 0) ** 4 col[m] = GOLD * 0.9 + col[m] * 0.1 if (k == 3).any(): m = k == 3; col[m] = GOLD * 0.8 + col[m] * 0.2; br[m] *= 0.8 sp = section_speed(t) + 0.15 vx, vy = fd.vx[a], fd.vy[a] nsub = 5 for j in range(nsub): f_ = j / (nsub - 1) L_ = np.where(k == 1, 0.16, np.where(k <= 0, 0.11, 0.03)) * sp * 1.6 xs = x - vx * L_ * f_; ys = y - vy * L_ * f_ w = br * (1 - 0.6 * f_) / nsub * np.where(k <= 1, 9.0, 2.5) ok = (xs >= 0) & (xs < W - 1) & (ys >= 0) & (ys < H - 1) near = z < 0.55 for buf, m in ((sharp, ok & ~near), (soft, ok & near)): if not m.any(): continue ix = xs[m].astype(np.int32) + W * ys[m].astype(np.int32) for ch in range(3): buf[..., ch] += np.bincount(ix, w[m] * col[m, ch], W * H).reshape(H, W) return sharp, soft def stars(fd, t, f): img = np.zeros((H, W), np.float32) if len(fd.stars): s = fd.stars; ang = 0.006 * t; cx, cy = W * 0.5, H * 2.6 x = (s[:, 0] - cx) * math.cos(ang) - (s[:, 1] - cy) * math.sin(ang) + cx; y = (s[:, 0] - cx) * math.sin(ang) + (s[:, 1] - cy) * math.cos(ang) + cy b = s[:, 3] * (0.6 + 0.4 * np.sin(t * 1.7 + s[:, 2] * 5)) * (1 + 0.5 * f['hat']) ok = (x >= 0) & (x < W - 1) & (y >= 0) & (y < H - 1) img += np.bincount(x[ok].astype(int) + W * y[ok].astype(int), b[ok], W * H).reshape(H, W) g = gaussian_filter(img, 0.8) * 3.0 return (g + gaussian_filter(img, 3) * 1.5)[..., None] * np.array([1.0, 0.9, 0.78], np.float32) # ---------------- the title, gathered from embers ---------------- TP = None def title_pts(): global TP if TP is not None: return TP im = Image.new('L', (W, H), 0); d = ImageDraw.Draw(im); f = ImageFont.truetype('/usr/share/fonts/opentype/inter/InterDisplay-ExtraLight.otf', 74) word = 'A F T E R L I G H T'; tw = d.textlength(word, font=f); d.text(((W - tw) / 2, H * 0.40), word, font=f, fill=255) m = np.asarray(im, np.float32) / 255; ys, xs = np.nonzero(m > 0.35); r = np.random.default_rng(11); n = 26000; idx = r.choice(len(xs), n) TP = dict(x=xs[idx] + r.random(n), y=ys[idx] + r.random(n), r=r.random(n).astype(np.float32), j=r.standard_normal((n, 2)).astype(np.float32), ph=r.random(n) * 6.283) return TP def title_layer(t): if t < T.SILENCE: return 0 img = np.zeros((H, W), np.float32); cx, cy = W * 0.5, H - LB - 6 # one ember lingers above the dead fire bed, then rises to become the title u0 = sm(T.SILENCE, T.TITLE0 + 0.6, t); ex, ey = cx, cy - (cy - H * 0.47) * u0 g = 0.65 + 0.35 * math.sin(t * 3.1) one = np.exp(-((XX - ex) ** 2 + (YY - ey) ** 2) / 5.0) * (1 - sm(T.TITLE0 + 0.4, T.TITLE0 + 1.6, t)) * 1.4 halo = np.exp(-((XX - ex) ** 2 + (YY - ey) ** 2) / (500 + 400 * g)) * 0.3 * (1 - sm(T.TITLE0 + 0.4, T.TITLE0 + 1.6, t)) out = one[..., None] * np.array([1, 0.9, 0.7]) + halo[..., None] * np.array([1, 0.45, 0.12]) if t >= T.TITLE0: P = title_pts(); u = ease((t - T.TITLE0) / 2.6); uu = np.clip(u * 1.4 - P['r'] * 0.4, 0, 1) x = ex + (P['x'] - ex) * uu + P['j'][:, 0] * 160 * (1 - uu) ** 1.5; y = ey + (P['y'] - ey) * uu + P['j'][:, 1] * 60 * (1 - uu) ** 1.5 dis = sm(T.END - 5.2, T.END - 2.0, t); tau = dis * 3 x = x + (tau * 40 + tau ** 2 * 50) * (0.4 + P['r']) * 0.6 + np.sin(tau + P['ph']) * 10 * tau; y = y - (tau * 30 + tau ** 2 * 40) * (0.5 + P['r']) br = (0.8 + 0.2 * np.sin(t * 2 + P['ph'])) * (1 - 0.6 * dis) * (0.15 + 0.85 * uu ** 2) ok = (x >= 0) & (x < W - 1) & (y >= 0) & (y < H - 1) acc = np.bincount(x[ok].astype(int) + W * y[ok].astype(int), br[ok], W * H).reshape(H, W) a = 1 - np.exp(-gaussian_filter(acc, 0.7) * 1.4); col = np.array([1.0, 0.93, 0.82]) * (1 - dis) + np.array([1.0, 0.55, 0.18]) * dis out = out + a[..., None] * col + gaussian_filter(a, 6)[..., None] * np.array([1.0, 0.6, 0.25]) * 0.25 return out.astype(np.float32) def text_layer(img, t): if t < T.TITLE0 + 1.4: return img pil = Image.fromarray((np.clip(img, 0, 1) * 255).astype(np.uint8)); d = ImageDraw.Draw(pil, 'RGBA') fe = 1 - sm(T.END - 5.4, T.END - 4.2, t) def line(s, f, y, a, col=(230, 220, 205)): if a <= 0: return tw = d.textlength(s, font=f); d.text(((W - tw) / 2, y), s, font=f, fill=col + (int(255 * a),)) fl = lambda n: ImageFont.truetype('/usr/share/fonts/opentype/inter/InterDisplay-Light.otf', n) line('W a r m D o m e & G o l d e n R e c o r d', fl(21), H * 0.535, sm(T.TITLE0 + 1.4, T.TITLE0 + 2.4, t) * fe, (225, 200, 165)) a2 = sm(T.CRED0, T.CRED0 + 0.8, t) * fe line('A FILM CONCEPT BY SOSUKE & WATSON', fl(17), H * 0.64, a2, (240, 205, 150)) line('CRASH LAB · PAELEO · CINEOSIS LAB', fl(14), H * 0.68, a2 * 0.85) line('Archive voices from movingimagearchive.com', fl(13), H * 0.715, a2 * 0.7, (190, 182, 170)) return np.asarray(pil, np.float32) / 255 def last_thread(t): a0 = T.END - 4.0 if t < a0: return 0 u = t - a0; y = H * 0.30; x0, x1 = W * 0.30, W * 0.30 + W * 0.42 * (1 - sm(1.2, 2.6, u) * 0.85) th = np.exp(-((YY - y) / (0.9 - 0.4 * sm(0, 2.0, u))) ** 2) * ((XX > x0) & (XX < x1)) * sm(0, 0.8, u) * (1 - sm(1.4, 2.6, u)) out = th[..., None] * np.array([1.0, 0.6, 0.22], np.float32) * 0.8 for k in range(4): px = W * (0.44 + 0.052 * k); pa = sm(1.6 + 0.1 * k, 2.2, u) out = out + (np.exp(-((XX - px) ** 2 + (YY - y) ** 2) / 2.2) * pa)[..., None] * np.array([1.0, 0.8, 0.45], np.float32) return out VIG = (1 - 0.55 * np.clip(((XX - W / 2) / (W / 2)) ** 2 + ((YY - H / 2) / (H / 2)) ** 2, 0, 1) ** 1.6)[..., None].astype(np.float32) GRAIN = np.random.default_rng(5).standard_normal((H + 64, W + 64)).astype(np.float32) def frame(fd, t): f = F(t) img = np.zeros((H, W, 3), np.float32) + np.array([0.008, 0.006, 0.006], np.float32) img += stars(fd, t, f) # the fire bed: a thermal horizon just below the frame, breathing with the bass and flaring on kicks fa = fire_amt(t) if t < T.SILENCE + 0.6 else 0 hz = np.exp(-np.maximum(H - LB - YY, 0) / (38 + 50 * f['low'] + 60 * fd.flare)) * (0.75 + 0.25 * np.sin(XX * 0.006 + t * 0.7)) * np.exp(-((XX - W / 2) / (W * 0.42)) ** 2) img += hz[..., None] * thermal(np.full((1, 1), 0.55 + 0.25 * fd.flare))[0, 0] * fa * (0.35 + 0.5 * f['low'] + 0.7 * fd.flare) # caustics at night: in the groove they ripple at the top of the frame; in the return they cover the dome cg = sm(B0 - 0.5, B0 + 2.5, t) * (1 - sm(B1 - 0.5, B1 + 1.0, t)); cd = sm(D0 - 0.4, D0 + 1.2, t) * (1 - sm(D1 - 0.2, D1 + 0.8, t)) if cg + cd > 0.01: c = caustic(t * (0.7 + 0.6 * section_speed(t)), 0.035) top = np.exp(-np.maximum(YY - LB, 0) / 170) r = np.sqrt((XX - W / 2) ** 2 + (YY - H * 1.06) ** 2); Rd = W * 0.62 * (1 + 0.012 * fd.flare) shell = np.exp(-np.maximum(Rd - r, 0) / 120) * (r < Rd) * 0.6 + np.exp(-((r - Rd) / 4.0) ** 2) * 0.9 amt = c * top * cg * (0.05 + 0.25 * fd.flare) + (c * shell + np.exp(-((r - Rd) / 4.0) ** 2) * 0.6) * cd * (0.18 + 0.4 * fd.flare) img += amt[..., None] * GOLD sharp, soft = splat(fd, t) img += sharp * 1.0 + gaussian_filter(sharp, (1.6, 1.6, 0)) * 1.4 + gaussian_filter(sharp, (7, 7, 0)) * 1.1 img += gaussian_filter(soft, (5, 5, 0)) * 5.0 # near embers out of focus img += oscillo(SCOPE[min(len(SCOPE) - 1, int(t * FPS)), 0], t, f['vl'], True, False) img += oscillo(SCOPE[min(len(SCOPE) - 1, int(t * FPS)), 1], t, f['va'], False, True) if t >= D0 - 0.2: img += glints(t) img += title_layer(t) if t >= T.SILENCE else 0 img += last_thread(t) img = 1 - np.exp(-img * 1.25) # filmic shoulder: light piles up softly img = text_layer(img, t) img = img * VIG gx, gy = int(t * 97) % 64, int(t * 53) % 64 img = img + GRAIN[gy:gy + H, gx:gx + W, None] * 0.013 img[:LB] = 0; img[H - LB:] = 0 img = img * (1 - sm(T.END - 0.8, T.END, t)) * sm(0.0, 0.6, t + 0.1) return np.clip(img, 0, 1) def run(times=None, worker=0, nw=1): fd = Field(); n = int(T.END * FPS); want = set(int(round(x * FPS)) for x in times) if times else None; got = {} for i in range(n): t = i / FPS; fd.step(t, F(t)) if want is not None: if i in want: got[i] = frame(fd, t) if len(got) == len(want): break elif i % nw == worker: fn = f'{OUT}/{i:05d}.jpg' if not os.path.exists(fn): Image.fromarray((frame(fd, t) * 255).astype(np.uint8)).save(fn + '.tmp.jpg', quality=92); os.replace(fn + '.tmp.jpg', fn) if i % 300 == worker: print(t, flush=True) return got if __name__ == '__main__': if sys.argv[1] == 'stills': ts = [float(x) for x in sys.argv[2].split(',')]; got = run(ts) ims = [Image.fromarray((got[k] * 255).astype(np.uint8)).resize((640, 360)) for k in sorted(got)] S = Image.new('RGB', (1920, 360 * ((len(ims) + 2) // 3))) for i, im in enumerate(ims): S.paste(im, ((i % 3) * 640, (i // 3) * 360)) S.save('/tmp/claude-0/t6.jpg'); sys.exit() run(None, int(sys.argv[1]), int(sys.argv[2]))