Spaces:
Running
Running
Download solana-clock/js/engine.js from Ethscriptions/POLS: direct link, hf CLI and curl.
- Browser
- Download file 10.5 kB
-
https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/solana-clock/js/engine.js
- Command line
-
hf download hf://spaces/Ethscriptions/POLS/solana-clock/js/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/solana-clock/js/engine.js
10.5 kB
| // Frame compositor: background, scene, chapter header, subtitles, transitions. | |
| import { buildTimeline, layout, subChunks } from './timeline.js'; | |
| import { SCENES } from './scenes.js'; | |
| import { clamp, lerp, inv, eOut, eIn, rng, glow, text, gradText, hexA, mix, measure, rr, F_SC, F_GR, F_MONO, PURPLE, GREEN, WHITE, GOLD, wrap } from './draw.js'; | |
| export const TL = buildTimeline(); | |
| const MOODS = { | |
| hook: { top: '#120a2e', bot: '#030209', accent: PURPLE, grid: .0, stars: .9 }, | |
| night: { top: '#0b1030', bot: '#04040c', accent: '#5a6cff', grid: 0, stars: .6 }, | |
| poh: { top: '#0c1a2e', bot: '#02060a', accent: GREEN, grid: .55, stars: .5 }, | |
| name: { top: '#3a1440', bot: '#0a0612', accent: '#ff8a5c', grid: 0, stars: .2 }, | |
| build: { top: '#101a3a', bot: '#03040c', accent: '#00D1FF', grid: .5, stars: .6 }, | |
| mainnet: { top: '#2a0810', bot: '#050205', accent: '#ff3b5c', grid: .3, stars: .3 }, | |
| rise: { top: '#1c0b3a', bot: '#03020a', accent: GREEN, grid: .7, stars: .7 }, | |
| outage: { top: '#2a0a0a', bot: '#060203', accent: '#ff3b5c', grid: .45, stars: .2 }, | |
| ftx: { top: '#0b1220', bot: '#020306', accent: '#7aa0c8', grid: .15, stars: .25 }, | |
| rebuild: { top: '#2a1406', bot: '#070302', accent: '#ffa63d', grid: .35, stars: .5 }, | |
| meme: { top: '#2a0b3a', bot: '#05020a', accent: '#ff5ad1', grid: .6, stars: .7 }, | |
| ath: { top: '#06281f', bot: '#020806', accent: GREEN, grid: .7, stars: .9 }, | |
| wall: { top: '#0d1626', bot: '#030508', accent: GOLD, grid: .35, stars: .3 }, | |
| now: { top: '#140c30', bot: '#030209', accent: PURPLE, grid: .5, stars: .7 }, | |
| outro: { top: '#10082a', bot: '#020106', accent: GREEN, grid: .25, stars: 1 }, | |
| }; | |
| const STARS = (() => { const r = rng(42); return Array.from({ length: 260 }, () => ({ x: r(), y: r(), s: r() * 1.8 + .4, tw: r() * 6.28, sp: r() * .6 + .1 })); })(); | |
| const subCache = {}; | |
| function chunksFor(orient) { | |
| if (subCache[orient]) return subCache[orient]; | |
| return (subCache[orient] = TL.lines.flatMap(l => subChunks(l, orient === 'portrait' ? 15 : 25).map(c => ({ ...c, news: l.v === 'news' })))); | |
| } | |
| export function sceneAt(T) { | |
| const sc = TL.scenes; | |
| for (let i = sc.length - 1; i >= 0; i--) if (T >= sc[i].start) return sc[i]; | |
| return sc[0]; | |
| } | |
| function moodAt(T) { | |
| const s = sceneAt(T), nx = TL.scenes[s.si + 1]; | |
| const a = MOODS[s.id]; | |
| if (!nx) return a; | |
| const k = eOut(inv(nx.start - 1.2, nx.start + .3, T)); | |
| if (k <= 0) return a; | |
| const b = MOODS[nx.id]; | |
| return { top: mix(a.top, b.top, k), bot: mix(a.bot, b.bot, k), accent: mix(a.accent, b.accent, k), grid: lerp(a.grid, b.grid, k), stars: lerp(a.stars, b.stars, k) }; | |
| } | |
| function background(ctx, L, T, m) { | |
| const { W, H } = L; | |
| const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, m.top); g.addColorStop(1, m.bot); | |
| ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); | |
| // nebulae | |
| glow(ctx, W * (.25 + .05 * Math.sin(T * .07)), H * .28, Math.max(W, H) * .45, m.accent, .22); | |
| glow(ctx, W * (.8 + .04 * Math.cos(T * .05)), H * .62, Math.max(W, H) * .4, PURPLE, .16); | |
| // stars | |
| if (m.stars > 0) { | |
| // batched into 5 brightness buckets (one fill per bucket instead of one per star) | |
| const B = 5, paths = Array.from({ length: B }, () => new Path2D()); | |
| for (const s of STARS) { | |
| const x = ((s.x * W - T * s.sp * 6) % W + W) % W, y = s.y * H; | |
| const tw = .5 + .5 * Math.sin(T * 1.3 + s.tw); | |
| paths[Math.min(B - 1, Math.floor(tw * B))].rect(x, y, s.s, s.s); | |
| } | |
| ctx.save(); ctx.fillStyle = '#fff'; | |
| for (let k = 0; k < B; k++) { ctx.globalAlpha = m.stars * (.35 + .65 * (k + .5) / B) * .8; ctx.fill(paths[k]); } | |
| ctx.restore(); | |
| } | |
| // perspective time-grid floor | |
| if (m.grid > .01) { | |
| const hy = H * (L.portrait ? .7 : .66), cx = W / 2; | |
| ctx.save(); ctx.globalAlpha = m.grid * .55; | |
| const fg = ctx.createLinearGradient(0, hy, 0, H); fg.addColorStop(0, hexA(m.accent, 0)); fg.addColorStop(1, hexA(m.accent, .9)); | |
| ctx.strokeStyle = fg; ctx.lineWidth = 1.5; | |
| for (let i = -14; i <= 14; i++) { ctx.beginPath(); ctx.moveTo(cx + i * 30, hy); ctx.lineTo(cx + i * W * .22, H); ctx.stroke(); } | |
| const ph = (T * .45) % 1; | |
| for (let j = 0; j < 14; j++) { | |
| const z = (j + 1 - ph) / 14, y = hy + (H - hy) * Math.pow(z, 2.4); | |
| ctx.globalAlpha = m.grid * .55 * z; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| glow(ctx, cx, hy, W * .5, m.accent, m.grid * .18); | |
| } | |
| } | |
| function header(ctx, L, sc, t) { | |
| if (!sc.year && !sc.chapter) return; | |
| if (sc.id === 'hook' || sc.id === 'outro') return; | |
| const a = eOut(inv(.15, .8, t)) * (1 - eIn(inv(sc.dur - .5, sc.dur - .05, t))); | |
| if (a <= 0) return; | |
| const { x, y } = L.head; | |
| const slide = (1 - eOut(inv(.15, .9, t))) * -40; | |
| ctx.save(); ctx.globalAlpha = a; ctx.translate(slide, 0); | |
| const ys = L.portrait ? 64 : 58; | |
| const w = gradText(ctx, sc.year, x, y, { size: ys, fam: F_GR, weight: 700, ls: 2 }); | |
| ctx.fillStyle = hexA(GREEN, .8); ctx.fillRect(x + w + 18, y - 22, 4, 44); | |
| text(ctx, sc.chapter, x + w + 38, y + 2, { size: L.portrait ? 40 : 36, weight: 800, color: WHITE }); | |
| // chapter index | |
| text(ctx, `CH.${String(sc.si).padStart(2, '0')} / ${TL.scenes.length - 2}`, x + 2, y + (L.portrait ? 54 : 48), { size: 20, fam: F_MONO, weight: 500, color: hexA(WHITE, .5), ls: 3 }); | |
| ctx.restore(); | |
| } | |
| function slotCounter(ctx, L, T, sc) { | |
| if (sc.id === 'hook' && T < 3) return; | |
| const slot = Math.floor(T / .4); | |
| const a = .55; | |
| const s = 'SLOT ' + String(slot).padStart(6, '0'); | |
| if (L.portrait) text(ctx, s, L.safe.r, L.safe.t + 52, { size: 22, fam: F_MONO, weight: 600, color: hexA(GREEN, a), align: 'right', ls: 2 }); | |
| else text(ctx, s, L.W - 96, 92, { size: 22, fam: F_MONO, weight: 600, color: hexA(GREEN, a), align: 'right', ls: 2 }); | |
| // tick dot pulses every slot | |
| const ph = (T % .4) / .4; | |
| const x = L.portrait ? L.safe.r - measure(ctx, s, 22, 600, F_MONO, 2) - 20 : L.W - 96 - measure(ctx, s, 22, 600, F_MONO, 2) - 20; | |
| const y = L.portrait ? L.safe.t + 52 : 92; | |
| ctx.save(); ctx.globalAlpha = (1 - ph) * .9; ctx.fillStyle = GREEN; ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); ctx.restore(); | |
| } | |
| function subtitles(ctx, L, T) { | |
| const cs = chunksFor(L.orient); | |
| const c = cs.find(c => T >= c.start - .05 && T < c.end + .12); | |
| if (!c) return; | |
| const a = Math.min(eOut(inv(c.start - .05, c.start + .12, T)), 1 - eIn(inv(c.end, c.end + .12, T))); | |
| const { cx, y, maxW, size } = L.sub; | |
| const lines = wrap(ctx, c.text, maxW - 40, size, 800); | |
| const lh = size * 1.3; | |
| const y0 = y - (lines.length - 1) * lh; | |
| ctx.save(); ctx.globalAlpha = a; | |
| let wmax = 0; for (const l of lines) wmax = Math.max(wmax, measure(ctx, l, size, 800)); | |
| rr(ctx, cx - wmax / 2 - 26, y0 - lh / 2 - 6, wmax + 52, lh * lines.length + 12, 16); | |
| ctx.fillStyle = 'rgba(6,4,16,.55)'; ctx.fill(); | |
| lines.forEach((l, i) => text(ctx, l, cx, y0 + i * lh + 2, { size, weight: 800, align: 'center', color: c.news ? GOLD : '#fff', stroke: 7, strokeColor: 'rgba(0,0,0,.85)' })); | |
| ctx.restore(); | |
| } | |
| function transition(ctx, L, T) { | |
| for (const sc of TL.scenes) { | |
| if (sc.si === 0) continue; | |
| const d = T - sc.start; | |
| if (d < -.45 || d > .55) continue; | |
| const p = inv(-.45, .55, d); | |
| // diagonal light sweep | |
| const { W, H } = L, x = lerp(-W * .6, W * 1.6, eIO(p)); | |
| ctx.save(); ctx.globalCompositeOperation = 'lighter'; | |
| const g = ctx.createLinearGradient(x - 420, 0, x + 420, H * .3); | |
| g.addColorStop(0, 'rgba(153,69,255,0)'); g.addColorStop(.45, 'rgba(153,69,255,.35)'); g.addColorStop(.5, 'rgba(255,255,255,.55)'); g.addColorStop(.55, 'rgba(20,241,149,.35)'); g.addColorStop(1, 'rgba(20,241,149,0)'); | |
| ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); | |
| ctx.restore(); | |
| const f = Math.max(0, 1 - Math.abs(d) / .22) * .32; | |
| if (f > 0) { ctx.save(); ctx.fillStyle = `rgba(255,255,255,${f})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore(); } | |
| } | |
| } | |
| function eIO(t) { t = clamp(t); return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } | |
| const vigCache = {}; | |
| function vignette(ctx, L) { | |
| const { W, H } = L; | |
| let c = vigCache[L.orient]; | |
| if (!c) { | |
| const w = W / 4, h = H / 4; | |
| c = vigCache[L.orient] = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : Object.assign(document.createElement('canvas'), { width: w, height: h }); | |
| const x = c.getContext('2d'); | |
| const g = x.createRadialGradient(w / 2, h / 2, Math.min(w, h) * .35, w / 2, h / 2, Math.max(w, h) * .75); | |
| g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,.55)'); | |
| x.fillStyle = g; x.fillRect(0, 0, w, h); | |
| } | |
| ctx.drawImage(c, 0, 0, W, H); | |
| } | |
| export function sceneState(sc, T) { | |
| const t = T - sc.start; | |
| const cue = i => sc.lines[Math.min(i, sc.lines.length - 1)].local; | |
| const end = i => { const l = sc.lines[Math.min(i, sc.lines.length - 1)]; return l.local + l.dur; }; | |
| const at = (i, sub) => { const l = sc.lines[i]; const k = Math.max(0, l.t.indexOf(sub)); return l.local + l.dur * k / Array.from(l.t).length; }; | |
| let li = -1; sc.lines.forEach((l, i) => { if (t >= l.local - .05) li = i; }); | |
| return { t, T, d: sc.dur, cue, end, at, li, sc }; | |
| } | |
| /** Render one frame. ctx must already be scaled so that 1 unit = 1 design pixel. */ | |
| export function renderFrame(ctx, orient, T, opts = {}) { | |
| const L = layout(orient); | |
| T = clamp(T, 0, TL.total - 1e-4); | |
| const sc = sceneAt(T); | |
| const m = moodAt(T); | |
| ctx.save(); | |
| background(ctx, L, T, m); | |
| const S = SCENES[sc.id]; | |
| const st = sceneState(sc, T); | |
| // render previous scene's tail briefly under the transition for continuity | |
| ctx.save(); S.draw(ctx, L, st); ctx.restore(); | |
| vignette(ctx, L); | |
| if (S.post) S.post(ctx, L, st, opts); | |
| header(ctx, L, sc, st.t); | |
| slotCounter(ctx, L, T, sc); | |
| transition(ctx, L, T); | |
| if (opts.subs !== false) subtitles(ctx, L, T); | |
| // global fade in/out | |
| const fi = 1 - inv(0, .6, T), fo = inv(TL.total - 1.6, TL.total - .1, T); | |
| const k = Math.max(fi, fo); | |
| if (k > 0) { ctx.fillStyle = `rgba(0,0,0,${k})`; ctx.fillRect(0, 0, L.W, L.H); } | |
| ctx.restore(); | |
| } | |
| /** all SFX events (absolute time) collected from scenes */ | |
| export function allSfx() { | |
| const ev = []; | |
| for (const sc of TL.scenes) { | |
| const S = SCENES[sc.id]; | |
| const st = sceneState(sc, sc.start); | |
| const list = S.sfx ? S.sfx(st) : []; | |
| for (const e of list) ev.push({ ...e, t: sc.start + e.t }); | |
| if (sc.si > 0) ev.push({ t: sc.start - .35, k: 'whoosh', v: .55 }); | |
| } | |
| return ev.sort((a, b) => a.t - b.t); | |
| } | |