POLS / solana-clock /js /engine.js
Ethscriptions's picture
solana-clock: 竖屏改为普通构图 + 4:3/3:4 封面
0dd44c1 verified
Raw History Blame Contribute Delete
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);
}