"""AFTERLIGHT · One Note — picture. Bright, warm, after the references: a sun low over the sea and its glitter, embers rising from a fire into stars, caustic nets of light, a pale paper ground. Stateless per frame (any frame renders on its own), driven by score.json. python3 render.py still t1,t2,... | python3 render.py part i n | python3 render.py join n""" import sys, os, json, subprocess, numpy as np, cv2 from PIL import Image, ImageDraw, ImageFont D = '/home/claude/afterlight/onenote/' W, H, FPS = 1280, 720, 24 SC = json.load(open(D + 'score.json')); SEC = SC['sec']; HITS = SC['hits']; DUR = SC['dur']; BAR = SC['bar'] def sm(a, b, t): u = np.clip((t - a) / (b - a), 0, 1); return u * u * (3 - 2 * u) def col(h): h = h.lstrip('#'); return np.array([int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)], np.float32) Y = (np.arange(H, dtype=np.float32) / (H - 1))[:, None, None]; X = (np.arange(W, dtype=np.float32) / (W - 1))[None, :, None] def vgrad(stops): """vertical gradient from [(y, '#hex'), ...]""" ys = np.array([s[0] for s in stops]); cs = np.stack([col(s[1]) for s in stops]) g = np.stack([np.interp(Y[:, 0, 0], ys, cs[:, c]) for c in range(3)], 1) return np.broadcast_to(g[:, None, :], (H, W, 3)).astype(np.float32) times = lambda k: np.array([h['t'] for h in HITS if h['k'] == k]) KICK, SNARE, HAT, NOTE, STRIKE, VOX = times('kick'), times('snare'), times('hat'), [h for h in HITS if h['k'] == 'note'], times('strike'), [h for h in HITS if h['k'] == 'vox'] STRIKE = np.concatenate([STRIKE, times('last')]) def pulse(ts, t, tau=0.18, pre=0.0): ts = ts[(ts <= t + pre) & (ts > t - 6 * tau)] return float(np.exp(-(t - ts) / tau).sum()) if len(ts) else 0.0 def last_note(t): best = None for h in NOTE: if h['t'] <= t: best = h else: break return best # ---------------- caustic nets: contour lines of two drifting noise fields ---------------- rng = np.random.default_rng(11) def noise_tex(n=256, octaves=4, seed=0): r = np.random.default_rng(seed); acc = np.zeros((n, n), np.float32); amp = 1 for o in range(octaves): k = 4 * 2 ** o; g = r.standard_normal((k, k)).astype(np.float32) acc += cv2.resize(np.tile(g, (3, 3)), (3 * n, 3 * n), interpolation=cv2.INTER_CUBIC)[n:2 * n, n:2 * n] * amp; amp *= 0.5 return (acc - acc.mean()) / acc.std() N1, N2 = noise_tex(seed=1), noise_tex(seed=2) CW, CH = 400, 225 def sample(tex, ox, oy, sx, sy): n = tex.shape[0]; xs = (np.arange(CW) * sx + ox) % n; ys = (np.arange(CH) * sy + oy) % n return cv2.remap(np.tile(tex, (2, 2)), *np.meshgrid(xs.astype(np.float32), ys.astype(np.float32)), cv2.INTER_LINEAR) GW, GH = 640, 360 _gx, _gy = np.meshgrid(np.arange(GW, dtype=np.float32), np.arange(GH, dtype=np.float32)) _jx = np.random.default_rng(3).random((GH, GW)).astype(np.float32); _jy = np.random.default_rng(4).random((GH, GW)).astype(np.float32) _wr = np.random.default_rng(21); NWAV = 14 _ang = _wr.random(NWAV) * 6.283; _lam = 70 + _wr.random(NWAV) ** 1.5 * 230; _amp = (_lam / 300) ** 1.2 * (0.6 + _wr.random(NWAV) * 0.6); _phs = _wr.random(NWAV) * 6.283 def grad_h(t, scale, speed): gx = np.zeros((GH, GW), np.float32); gy = np.zeros((GH, GW), np.float32) for i in range(NWAV): k = 6.283 / (_lam[i] * scale * 0.5); kx, ky = k * np.cos(_ang[i]), k * np.sin(_ang[i]); w = np.sqrt(9.8 * k * 400) * 0.35 * speed c = np.cos(kx * _gx + ky * _gy - w * t + _phs[i]) * _amp[i] gx += c * kx; gy += c * ky return gx, gy def caustic(t, scale=1.0, speed=1.0, *_, strength=160.0): """real caustics: light through a moving water surface, gathered where the refracted rays converge""" gx_, gy_ = grad_h(t, scale, speed) px = (_gx + _jx + gx_ * strength * scale) * (CW / GW); py = (_gy + _jy + gy_ * strength * scale) * (CH / GH) ok = (px >= 0) & (px < CW) & (py >= 0) & (py < CH) idx = py[ok].astype(np.int32) * CW + px[ok].astype(np.int32) d = np.bincount(idx, minlength=CW * CH).reshape(CH, CW).astype(np.float32) / (GW * GH / (CW * CH)) d = cv2.GaussianBlur(d, (0, 0), 0.8) c = np.clip(d - 1.15, 0, None) ** 1.5 c = c / (0.6 + c) c = c + cv2.GaussianBlur(c, (0, 0), 5) * 0.45 return cv2.resize(c, (W, H), interpolation=cv2.INTER_CUBIC)[..., None] # ---------------- glitter on water ---------------- G_N = 7000 gy = rng.random(G_N) ** 1.6 # 0 at horizon, 1 at the bottom g_ph = rng.random(G_N) * 6.283; g_rate = 1.5 + rng.random(G_N) * 5; g_size = rng.random(G_N) g_dx = rng.standard_normal(G_N) STAR = np.zeros((15, 15), np.float32); STAR[7, :] = np.exp(-np.abs(np.arange(15) - 7) / 2.2); STAR[:, 7] = STAR[7, :]; STAR = np.maximum(STAR, cv2.getGaussianKernel(15, 1.2) @ cv2.getGaussianKernel(15, 1.2).T * 6) def glitter(t, hz, sunx, width, gain=1.0): y = hz + gy * (H - hz); x = sunx + g_dx * (width * (0.25 + gy * 1.2)) tw = np.maximum(0, np.sin(g_rate * t + g_ph)) ** 14 v = tw * np.exp(-(g_dx ** 2) / 2) * (0.35 + gy * 0.9) * gain img = np.zeros((H, W), np.float32); xi = np.clip(x.astype(int), 0, W - 1); yi = np.clip(y.astype(int), 0, H - 1) np.add.at(img, (yi, xi), v) return cv2.filter2D(img, -1, STAR)[..., None] # ---------------- embers: stateless particles ---------------- class Embers: """each particle: spawn time, origin, launch velocity, buoyancy, sway, life. Position is closed-form in its age.""" def __init__(self, spawns, seed): r = np.random.default_rng(seed); s = np.array(spawns, np.float32) # rows: t, x, y, vx, vy, spread, life, heat n = len(s); self.t, self.x, self.y = s[:, 0], s[:, 1], s[:, 2] self.vx = s[:, 3] + r.standard_normal(n) * s[:, 5]; self.vy = s[:, 4] * (0.6 + r.random(n) * 0.8) self.life = s[:, 6] * (0.6 + r.random(n) * 0.8); self.heat = s[:, 7] self.sw = r.random(n) * 40 + 8; self.om = r.random(n) * 2 + 0.6; self.ph = r.random(n) * 6.283; self.drag = 0.6 + r.random(n) * 0.8 self.sz = 0.6 + r.random(n) ** 3 * 2.2 def pos(self, idx, a): tau = 1 / self.drag[idx] x = self.x[idx] + self.vx[idx] * tau * (1 - np.exp(-a / tau)) + self.sw[idx] * np.sin(self.om[idx] * a + self.ph[idx]) * np.minimum(1, a) y = self.y[idx] + self.vy[idx] * tau * (1 - np.exp(-a / tau)) - 22 * a * self.heat[idx] return x, y def draw(self, img, t, streak=0.07, gain=1.0, cool=True): a = t - self.t; idx = np.where((a >= 0) & (a < self.life))[0] if not len(idx): return a = a[idx]; x1, y1 = self.pos(idx, a); x0, y0 = self.pos(idx, np.maximum(0, a - streak)) u = a / self.life[idx]; br = (1 - u) ** 1.5 * np.minimum(1, a * 20) * gain for k in range(len(idx)): uu = u[k]; c = (1.0, 0.85 - 0.45 * uu, 0.45 - 0.4 * uu) if cool else (1.0, 0.9, 0.7) b = br[k]; th = max(1, int(round(self.sz[idx[k]]))) cv2.line(img, (int(x0[k] * 4), int(y0[k] * 4)), (int(x1[k] * 4), int(y1[k] * 4)), (c[0] * b, c[1] * b, c[2] * b), th, cv2.LINE_AA, 2) def spawns_burst(t, x, y, n, vx, vy, spread, life, heat=1.0, xs=0, ys=0, r=None): r = r or rng return [(t + r.random() * 0.05, x + r.standard_normal() * xs, y + r.standard_normal() * ys, vx, vy, spread, life, heat) for _ in range(n)] def build_embers(): r = np.random.default_rng(5); S = {} # sun: sparks thrown out by each strike on paper sp = [] for ts in STRIKE: for _ in range(140): ang = r.random() * 6.283; v = 140 + r.random() * 380 sp.append((ts, 640, 340, np.cos(ang) * v, np.sin(ang) * v, 25, 1.6, 0.2)) S['sun'] = Embers(sp, 1) # sea: sparks thrown up from the glitter path on snares, a continuous haze of motes sp = [] for name in ('drop', 'ret'): a, b = SEC[name] for ts in SNARE[(SNARE >= a) & (SNARE < b)]: sp += spawns_burst(ts, 640, 470, 70, 0, -240, 90, 2.2, 1.0, 120, 30, r) for ts in KICK[(KICK >= a) & (KICK < b)]: sp += spawns_burst(ts, 640, 400, 18, 0, -120, 60, 1.6, 0.6, 60, 6, r) for ts in np.arange(a, b, 0.05): sp.append((ts, 640 + r.standard_normal() * 260, 720, r.standard_normal() * 10, -60, 20, 4.0, 1.4)) S['sea'] = Embers(sp, 2) # fire: a fire bed at the bottom centre feeding embers into the dusk; calls write a stream sp = [] for name in ('call', 'ret'): a, b = SEC[name] for ts in np.arange(a - 2, b, 1 / 90): sp.append((ts, 640 + r.standard_normal() * 120, 700, 0, -160, 40, 3.2, 1.0)) for ts in SNARE[(SNARE >= a) & (SNARE < b)]: sp += spawns_burst(ts, 640, 690, 90, 0, -420, 120, 3.0, 1.0, 70, 5, r) for ts in KICK[(KICK >= a) & (KICK < b)]: sp += spawns_burst(ts, 640, 700, 30, 0, -260, 80, 2.4, 1.0, 50, 5, r) for h in VOX: for i in range(int(h['dur'] * 300)): tt = h['t'] + i / 300; ang = -1.57 + 0.9 * np.sin(i / 300 * 4) sp.append((tt, 640 + 220 * np.sin(i / 300 * 3), 690, np.cos(ang) * 340, np.sin(ang) * 340 - 120, 30, 3.4, 1.2)) S['fire'] = Embers(sp, 3) # dome: embers settle downward and catch on the net sp = []; a, b = SEC['dome'] for ts in np.arange(a - 1, b + 1, 1 / 40): sp.append((ts, r.random() * W, -10, r.standard_normal() * 8, 70, 15, 7.0, -0.6)) S['dome'] = Embers(sp, 4) return S EM = build_embers() # ---------------- scenes ---------------- PAPER = vgrad([(0, '#f6efe2'), (1, '#e9dcc4')]) PEACH = vgrad([(0, '#f7d9b2'), (1, '#efc493')]) SKY = vgrad([(0, '#c4553a'), (0.25, '#e27a45'), (0.45, '#f6a457'), (0.555, '#ffd08a'), (0.556, '#b0603a'), (0.7, '#7c3c2c'), (1, '#3e2026')]) DUSK = vgrad([(0, '#27345e'), (0.45, '#5a5a86'), (0.72, '#d98a5c'), (0.86, '#c0603a'), (1, '#3a1c14')]) DEEP = vgrad([(0, '#2b6b6a'), (1, '#0f2f33')]) HZ = 0.555 * H _cr = np.random.default_rng(31) CLOUD = cv2.resize(cv2.GaussianBlur(_cr.random((40, 12)).astype(np.float32), (0, 0), 1.2), (W * 2, H), interpolation=cv2.INTER_CUBIC) CLOUD = np.clip((CLOUD - CLOUD.mean()) / CLOUD.std() * 0.5 + 0.2, 0, 1) def clouds(t): off = int(t * 6) % W; c = CLOUD[:, off:off + W] band = np.exp(-((Y[..., 0] - 0.18) / 0.12) ** 2) + 0.7 * np.exp(-((Y[..., 0] - 0.38) / 0.06) ** 2) return (c * band)[..., None] def sea_caustic(t, speed=1.0): c = caustic(t, 0.75, speed) sea = cv2.resize(c[:, :, 0], (W, H - int(HZ)), interpolation=cv2.INTER_AREA) full = np.zeros((H, W), np.float32); full[int(HZ):] = sea * np.linspace(0.25, 1.0, H - int(HZ))[:, None] return full[..., None] def glow(img, x, y, r, c, a): d2 = ((X[..., 0] * (W - 1) - x) ** 2 + (Y[..., 0] * (H - 1) - y) ** 2) / (r * r) img += np.exp(-d2)[..., None] * c * a def paper_scene(t, phase): """sun and prism and out: a pale ground; strikes and sparks drawn as ink and light on paper""" base = PAPER * (1 - phase) + PEACH * phase out = base.copy() c = caustic(t, 1.3, 0.6) out = out - c * (0.07 + 0.10 * phase) * (1 - col('#b08a5a')) + c * 0.06 # a grey-gold net on the paper, like light through water # the sun: one dot, flaring on each strike fl = pulse(STRIKE, t, 0.35) if phase < 0.5: glow(out, 640, 340, 10 + 14 * fl, col('#ff8a2a'), 1.0); glow(out, 640, 340, 60 + 80 * fl, col('#ffb060'), 0.25 + 0.4 * fl) # rings from each strike ring = np.zeros((H, W), np.float32) for ts in STRIKE[(STRIKE <= t) & (STRIKE > t - 4)]: a = t - ts; cv2.circle(ring, (640 * 4, 340 * 4), int((40 + a * 210) * 4), float(np.exp(-a / 1.3)), 2, cv2.LINE_AA, 2) ring = cv2.GaussianBlur(ring, (0, 0), 1.2)[..., None] out = out - ring * 0.35 * (1 - col('#d07a2a')) + ring * 0.15 # the prism: each pitch lights its own spot and casts a beam if phase > 0: h = last_note(t) spots = {0: (640, 300, '#ffb347'), -2: (560, 380, '#ff8a4a'), -4: (720, 410, '#f0607a')} for s, (x, y, cc) in spots.items(): a = 0.0 for hh in NOTE: if hh['semis'] == s and hh['t'] <= t and t - hh['t'] < 2.5: a = max(a, np.exp(-(t - hh['t']) / 0.45)) glow(out, x, y, 10 + 14 * a, col(cc), (0.8 + 1.4 * a) * phase); glow(out, x, y, 60, col(cc), 0.25 * a * phase) beam = np.zeros((H, W), np.float32); cv2.line(beam, (x * 4, y * 4), (int((x + (x - 640) * 9) * 4), int((y + (y - 340) * 9 + 400) * 4)), float(a), 3, cv2.LINE_AA, 2) out += cv2.GaussianBlur(beam, (0, 0), 6)[..., None] * col(cc) * 0.6 * phase # sparks: drawn as ink-orange on paper sp = np.zeros((H, W, 3), np.float32); EM['sun'].draw(sp, t, 0.05, 1.0) a = np.clip(sp.max(2, keepdims=True), 0, 1) out = out * (1 - a * 0.75) + a * 0.75 * col('#e0601c') return out def sea_scene(t, energy=1.0): out = SKY.copy() sunx, suny = 640, HZ - 18 k = pulse(KICK, t, 0.16); n = last_note(t); np_ = np.exp(-(t - n['t']) / 0.5) if n else 0 glow(out, sunx, suny, 26, col('#fff1c8'), 1.6); glow(out, sunx, suny, 120 + 30 * np_, col('#ffb15a'), 0.55 + 0.25 * np_) glow(out, sunx, suny, 420, col('#ffa04a'), 0.25) cl = clouds(t) * 0.55; out = out * (1 - cl) + cl * col('#7a3448') c = sea_caustic(t) out += c * col('#ffb070') * (0.12 + 0.12 * k) * energy hp = pulse(HAT, t, 0.08) out += glitter(t, HZ + 2, sunx, 120, (1.0 + 0.6 * hp + 1.5 * k) * energy) * col('#fff4dc') * 1.4 sp = np.zeros((H, W, 3), np.float32); EM['sea'].draw(sp, t, 0.08, 1.2 * energy) out += cv2.GaussianBlur(sp, (0, 0), 3) * 0.8 + sp return out def fire_scene(t, energy=1.0): out = DUSK.copy() fl = 0.7 + 0.3 * np.sin(t * 9) * np.sin(t * 5.3) + 0.4 * pulse(KICK, t, 0.15) glow(out, 640, 760, 260, col('#ff7a2a'), 0.9 * fl * energy); glow(out, 640, 740, 120, col('#ffd27a'), 0.9 * fl * energy) # stars, which embers become st = np.zeros((H, W), np.float32); r = np.random.default_rng(9); sx = r.integers(0, W, 500); sy = (r.random(500) ** 1.6 * H * 0.6).astype(int) st[sy, sx] = r.random(500) * (0.5 + 0.5 * np.sin(t * (1 + r.random(500) * 3) + r.random(500) * 6)) out += cv2.GaussianBlur(st, (0, 0), 0.8)[..., None] * 1.5 sp = np.zeros((H, W, 3), np.float32); EM['fire'].draw(sp, t, 0.09, 1.3 * energy) out += cv2.GaussianBlur(sp, (0, 0), 4) * 0.9 + sp * 1.2 return out def dome_scene(t): out = DEEP.copy() c = caustic(t, 1.1, 0.45) dome = np.exp(-((np.sqrt(((X[..., 0] - 0.5) * W) ** 2 + ((Y[..., 0] - 1.05) * H * 1.6) ** 2) - 980) / 120) ** 2)[..., None] out += c * (0.55 + 0.6 * dome) * col('#d8fff0') * 0.9 out += dome * col('#ffd08a') * 0.18 sp = np.zeros((H, W, 3), np.float32); EM['dome'].draw(sp, t, 0.12, 1.0) out += cv2.GaussianBlur(sp, (0, 0), 3) * 0.7 + sp out += c * cv2.GaussianBlur(sp, (0, 0), 14).max(2, keepdims=True) * col('#ffb060') * 3 # embers catch on the net return out FONT = '/home/claude/afterlight/trailer/fonts/' def title_layer(t): a0 = SEC['out'][1] + 0.6 a = sm(a0, a0 + 1.2, t) if a <= 0: return None im = Image.new('L', (W, H), 0); d = ImageDraw.Draw(im) f1 = ImageFont.truetype(FONT + 'im-fell-english-sc-latin-400-normal.ttf', 64); f2 = ImageFont.truetype(FONT + 'im-fell-english-latin-400-italic.ttf', 30) f3 = ImageFont.truetype(FONT + 'im-fell-english-latin-400-normal.ttf', 17) for s, f, y in [('Afterlight', f1, 470), ('one note', f2, 545), ('A FILM CONCEPT BY SOSUKE & WATSON · CRASH LAB · PAELEO · CINEOSIS LAB', f3, 640)]: w = d.textlength(s, font=f); d.text(((W - w) / 2, y), s, font=f, fill=255) return np.asarray(im, np.float32)[..., None] / 255 * a ORDER = ['sun', 'prism', 'drop', 'call', 'dome', 'ret', 'out'] def weights(t, xf=1.4): w = {} for k in ORDER: a, b = SEC[k]; w[k] = sm(a - xf / 2, a + xf / 2, t) * (1 - sm(b - xf / 2, b + xf / 2, t)) if t < SEC['sun'][0]: w['sun'] = 1 if t > SEC['out'][1]: w['out'] = 1 return w def frame(t): w = weights(t); out = np.zeros((H, W, 3), np.float32); tot = 0 for k, a in w.items(): if a < 0.002: continue if k == 'sun': img = paper_scene(t, 0.0) elif k == 'prism': img = paper_scene(t, sm(SEC['prism'][0], SEC['prism'][1], t)) elif k == 'out': img = paper_scene(t, 0.0) elif k == 'drop': img = sea_scene(t, 0.85 + 0.15 * sm(SEC['drop'][0], SEC['drop'][0] + 8, t)) elif k == 'call': img = fire_scene(t) elif k == 'dome': img = dome_scene(t) elif k == 'ret': img = sea_scene(t, 1.1) f = fire_scene(t, 0.8); img = np.maximum(img, f * 0.0) + np.clip(f - DUSK, 0, None) * 0.9 # embers of the fire over the sea img += sea_caustic(t, 1.3) * col('#fff0d0') * 0.15 out += img * a; tot += a out /= max(tot, 1e-6) tl = title_layer(t) if tl is not None: out = out * (1 - tl * 0.85) + tl * 0.85 * col('#3a2416') # bloom, light grain, fade in and out hi = np.clip(out - 0.85, 0, None); out += cv2.GaussianBlur(hi, (0, 0), 18) * 0.6 over = np.clip(out - 0.82, 0, None); out = np.minimum(out, 0.82) + over / (1 + over * 2.2) # soft shoulder above 0.82 only out += (np.random.default_rng(int(t * FPS)).standard_normal((H, W, 1)).astype(np.float32) * 0.012) out *= sm(0, 0.6, t) * (1 - sm(DUR - 1.2, DUR, t)) return np.clip(out, 0, 1) def to8(img): return (img * 255 + 0.5).astype(np.uint8) if __name__ == '__main__': m = sys.argv[1] if m == 'still': ts = [float(x) for x in sys.argv[2].split(',')] ims = [Image.fromarray(to8(frame(t))).resize((640, 360)) for t in ts] S = Image.new('RGB', (1280, 360 * ((len(ims) + 1) // 2))) for k, im in enumerate(ims): S.paste(im, ((k % 2) * 640, (k // 2) * 360)) S.save('/tmp/claude-0/-home-claude/21362850-8176-5f25-9feb-97722a7915b9/scratchpad/inv/on_still.jpg', quality=85) elif m == 'part': i, n = int(sys.argv[2]), int(sys.argv[3]); nf = int(DUR * FPS); a, b = nf * i // n, nf * (i + 1) // n out = D + f'p{i}.mp4' p = subprocess.Popen(['ffmpeg', '-v', 'error', '-y', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-s', f'{W}x{H}', '-r', str(FPS), '-i', '-', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '17', '-pix_fmt', 'yuv420p', out], stdin=subprocess.PIPE) for f in range(a, b): p.stdin.write(to8(frame(f / FPS)).tobytes()) if f % 120 == 0: print(i, f, flush=True) p.stdin.close(); p.wait() elif m == 'join': n = int(sys.argv[2]); open(D + 'list.txt', 'w').write(''.join(f"file '{D}p{i}.mp4'\n" for i in range(n))) subprocess.run(['ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', D + 'list.txt', '-c', 'copy', D + 'v.mp4'], check=True) subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', D + 'v.mp4', '-i', D + 'onenote.wav', '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-14:TP=-1.0', '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', D + 'afterlight-one-note.mp4'], check=True)