Spaces:
Running
Running
Download ethereum/js/render.js from Ethscriptions/POLS: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/ethereum/js/render.js
- Command line
-
hf download hf://spaces/Ethscriptions/POLS/ethereum/js/render.js
-
curl -L -o render.js https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/ethereum/js/render.js
13.8 kB
| /* ========================================================================== | |
| * render.js — 单帧合成:世界 → 晶体 → 章节层(交叉溶解)→ 章节标题 → HUD → 字幕 → 后期 | |
| * 纯函数:render(ctx, t, L, opts) 在任意时刻得到确定画面(预览与导出共用) | |
| * ========================================================================== */ | |
| (() => { | |
| const { C, E } = K; | |
| const W = window.WORLD; | |
| const TL = K.TL; | |
| function helper(ch, L, t) { | |
| const at = (n) => (n == null ? ch.start : TL.mark(ch.id, n)); | |
| return { | |
| at, m: (n) => t - at(n), | |
| lf: (a, b, i = 0.6, o = 0.5) => K.life(t, at(a), b == null ? Infinity : at(b), i, o), | |
| bx: (lx, ly, px, py) => { const B = L.box; return L.portrait ? [B.x + B.w * px, B.y + B.h * py] : [B.x + B.w * lx, B.y + B.h * ly]; }, | |
| }; | |
| } | |
| /* ---------- 后期素材 ---------- */ | |
| let grain = null; | |
| function grainCanvas() { | |
| if (grain) return grain; | |
| const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d'); | |
| const id = g.createImageData(256, 256); const r = K.rng(9); | |
| for (let i = 0; i < id.data.length; i += 4) { const v = 110 + r() * 40; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255; } | |
| g.putImageData(id, 0, 0); grain = c; return c; | |
| } | |
| /* ---------- 章节标题(跃迁时出现) ---------- */ | |
| function chapterCard(ctx, L, ch, t) { | |
| if (ch.index === 0) return; | |
| const lt = t - ch.start; const first = ch.lines[0].start - ch.start; | |
| const k = K.life(lt, -0.4, first - 0.35, 0.7, 0.5); | |
| if (k <= 0) return; | |
| const x = L.portrait ? L.cx : L.W / 2, y = L.portrait ? L.cy - 40 : L.cy; | |
| const pal = W.palette(t); | |
| ctx.save(); ctx.globalAlpha *= k; | |
| const yr = ch.year; | |
| const s = L.portrait ? 210 : 260; | |
| const rise = (1 - E.out5((lt + 0.4) / 1.1)) * 60; | |
| K.text(ctx, yr, x, y - 30 + rise, { size: s, fam: 'num', weight: 700, color: K.rgba(C.white, 0.95), glow: pal[0], glowR: 60, ls: 6 + 20 * (1 - k) }); | |
| K.text(ctx, String(ch.index).padStart(2, '0') + ' ' + ch.title, x, y + s * 0.5 + 10, { size: L.portrait ? 54 : 60, fam: 'title', weight: 400, color: pal[0], ls: 10 }); | |
| K.neon(ctx, [[x - 220 * k, y + s * 0.5 + 64], [x + 220 * k, y + s * 0.5 + 64]], pal[1], 2, 0.9); | |
| ctx.restore(); | |
| } | |
| /* ---------- HUD ---------- */ | |
| function hud(ctx, L, t, ch) { | |
| const pal = W.palette(t); | |
| const P = L.portrait; | |
| const fadeIn = K.clamp((t - 6) / 1.5); | |
| if (fadeIn <= 0) return; | |
| ctx.save(); ctx.globalAlpha *= fadeIn; | |
| const bx = P ? 60 : 60, by = P ? 256 : 58; | |
| K.glow(ctx, bx + 14, by, 40, C.eth, 0.6); K.neon(ctx, [[bx + 14, by - 20], [bx + 26, by], [bx + 14, by + 7], [bx + 2, by], [bx + 14, by - 20]], C.eth2, 1.5, 1); K.neon(ctx, [[bx + 2, by + 4], [bx + 14, by + 21], [bx + 26, by + 4]], C.eth2, 1.5, 1); | |
| K.text(ctx, '以太坊简史', bx + 48, by + 2, { size: 30, fam: 'title', weight: 400, align: 'left', color: C.white }); | |
| K.text(ctx, String(ch.index).padStart(2, '0') + ' / ' + String(TL.chapters.length - 1).padStart(2, '0') + ' ' + ch.title, bx + 4, by + 46, { size: 24, align: 'left', color: K.rgba(C.white, 0.65), weight: 500 }); | |
| // 年份(滚动) | |
| const yx = P ? L.W - 60 : L.W - 60, yy = P ? 270 : 70; | |
| const cur = ch.year, prev = ch.index > 0 ? TL.chapters[ch.index - 1].year : cur; | |
| const roll = E.io3((t - ch.start + 0.6) / 1.2); | |
| ctx.save(); ctx.beginPath(); ctx.rect(yx - 260, yy - 40, 270, 80); ctx.clip(); | |
| if (roll < 1 && prev !== cur) K.text(ctx, prev, yx, yy - roll * 70, { size: 64, fam: 'num', align: 'right', color: K.rgba(C.white, 0.9 * (1 - roll)) }); | |
| K.text(ctx, cur, yx, yy + (1 - roll) * 70, { size: 64, fam: 'num', align: 'right', color: C.white, glow: pal[0], glowR: 24 }); | |
| ctx.restore(); | |
| // 时间线 | |
| const x0 = P ? 60 : 120, x1 = P ? 1020 : 1800, ly = P ? 360 : 1052; | |
| ctx.strokeStyle = 'rgba(255,255,255,.12)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x0, ly); ctx.lineTo(x1, ly); ctx.stroke(); | |
| const prog = t / TL.total; | |
| K.neon(ctx, [[x0, ly], [x0 + (x1 - x0) * prog, ly]], pal[0], 1.4, 0.9); | |
| TL.chapters.forEach((c, i) => { const px = x0 + (x1 - x0) * c.start / TL.total; const on = t >= c.start; ctx.fillStyle = on ? K.rgba(pal[1], 0.95) : 'rgba(255,255,255,.3)'; ctx.beginPath(); ctx.arc(px, ly, on ? 4 : 3, 0, K.TAU); ctx.fill(); }); | |
| K.glow(ctx, x0 + (x1 - x0) * prog, ly, 26, C.white, 0.9); | |
| ctx.restore(); | |
| } | |
| /* ---------- 卡拉 OK 字幕 ---------- */ | |
| function subtitles(ctx, L, t) { | |
| const ln = TL.lineAt(t); if (!ln) return; | |
| const a = K.clamp((t - ln.start + 0.1) / 0.2) * K.clamp((ln.end + 0.45 - t) / 0.3); | |
| if (a <= 0) return; | |
| const S = L.sub; const lt = t - ln.start; | |
| const text = ln.text; | |
| // 每个字符的状态 | |
| const st = new Array(text.length).fill(0); // 0 未读 1 正在读 2 已读 | |
| let last = 0; | |
| for (const [c0, c1, t0, t1] of ln.words) { const s = lt >= t1 ? 2 : lt >= t0 ? 1 : 0; for (let i = c0; i < c1; i++) st[i] = s; } | |
| for (let i = 0; i < text.length; i++) { if (ln.words.some((w) => i >= w[0] && i < w[1])) last = st[i]; else st[i] = last === 1 ? 2 : last; } | |
| if (lt > ln.dur - 0.05) st.fill(2); | |
| ctx.save(); ctx.globalAlpha *= a; | |
| ctx.font = K.font('body', S.size, 700); | |
| // 断行 | |
| const rows = []; let row = '', rs = 0, start = 0; | |
| for (let i = 0; i < text.length; i++) { | |
| const ch = text[i]; const w = ctx.measureText(row + ch).width; | |
| if (w > S.maxW && row && !/[,。!?、:;”),.!?]/.test(ch)) { | |
| // 不在英文单词/数字中间断行 | |
| let cut = row.length; | |
| if (/[A-Za-z0-9.\-]/.test(ch)) { let j = row.length; while (j > 0 && /[A-Za-z0-9.\-]/.test(row[j - 1])) j--; if (j > 0) cut = j; } | |
| rows.push([row.slice(0, cut), start]); start = start + cut; row = row.slice(cut) + ch; | |
| } | |
| else row += ch; | |
| } | |
| rows.push([row, start]); | |
| if (rows.length === 2) { // 两行均衡 | |
| const half = Math.ceil(text.length / 2); let cut = half; | |
| for (let d = 0; d < 6; d++) { if (/[,。:;、]/.test(text[half + d - 1] || '')) { cut = half + d; break; } if (/[,。:;、]/.test(text[half - d - 1] || '')) { cut = half - d; break; } } | |
| const r1 = text.slice(0, cut), r2 = text.slice(cut); | |
| const midWord = /[A-Za-z0-9]/.test(text[cut - 1] || '') && /[A-Za-z0-9]/.test(text[cut] || ''); | |
| if (!midWord && ctx.measureText(r1).width <= S.maxW && ctx.measureText(r2).width <= S.maxW) { rows.length = 0; rows.push([r1, 0], [r2, cut]); } | |
| } | |
| const lh = S.size * 1.4; | |
| const y0 = S.y - (L.portrait ? 0 : (rows.length - 1) * lh / 2); | |
| // 底衬 | |
| const bandH = rows.length * lh + 40; | |
| const g = ctx.createLinearGradient(0, y0 - lh / 2 - 20, 0, y0 - lh / 2 - 20 + bandH); | |
| g.addColorStop(0, 'rgba(5,6,15,0)'); g.addColorStop(0.3, 'rgba(5,6,15,.55)'); g.addColorStop(0.7, 'rgba(5,6,15,.55)'); g.addColorStop(1, 'rgba(5,6,15,0)'); | |
| ctx.fillStyle = g; ctx.fillRect(0, y0 - lh / 2 - 20, L.W, bandH); | |
| ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; | |
| rows.forEach(([r, s0], ri) => { | |
| const w = ctx.measureText(r).width; let x = S.cx - w / 2; const y = y0 + ri * lh; | |
| for (let i = 0; i < r.length; i++) { | |
| const ch = r[i]; const state = st[s0 + i]; const cw = ctx.measureText(ch).width; | |
| ctx.lineWidth = S.size * 0.16; ctx.strokeStyle = 'rgba(5,6,15,.9)'; ctx.lineJoin = 'round'; ctx.strokeText(ch, x, y); | |
| if (state === 1) { ctx.shadowColor = C.cyan; ctx.shadowBlur = 18; ctx.fillStyle = '#C9F6FF'; } | |
| else { ctx.shadowBlur = 0; ctx.fillStyle = state === 2 ? '#FFFFFF' : 'rgba(255,255,255,.5)'; } | |
| ctx.fillText(ch, x, y); ctx.shadowBlur = 0; | |
| x += cw; | |
| } | |
| }); | |
| ctx.restore(); | |
| } | |
| function guides(ctx, L) { | |
| ctx.save(); | |
| if (L.safe) { | |
| const s = L.safe; ctx.fillStyle = 'rgba(255,40,90,.2)'; | |
| ctx.fillRect(0, 0, L.W, s.top); ctx.fillRect(0, s.bottom, L.W, L.H - s.bottom); ctx.fillRect(s.right, L.H * 0.45, L.W - s.right, s.bottom - L.H * 0.45); | |
| ctx.font = '28px sans-serif'; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; | |
| ctx.fillText('状态栏 / 刘海 / 顶部导航', L.W / 2, s.top / 2); ctx.fillText('账号 / 文案 / 音乐 / 进度条', L.W / 2, s.bottom + 140); | |
| ctx.save(); ctx.translate(s.right + 70, L.H * 0.66); ctx.rotate(Math.PI / 2); ctx.fillText('点赞 · 评论 · 收藏 · 分享', 0, 0); ctx.restore(); | |
| } else { ctx.setLineDash([14, 10]); ctx.strokeStyle = 'rgba(57,228,255,.8)'; ctx.lineWidth = 2; ctx.strokeRect(L.W * 0.05, L.H * 0.05, L.W * 0.9, L.H * 0.9); } | |
| ctx.restore(); | |
| } | |
| /* ---------- 主渲染 ---------- */ | |
| function render(ctx, t, L, opts = {}) { | |
| t = K.clamp(t, 0, TL.total - 1e-3); | |
| const z = opts.scale || 1; | |
| ctx.setTransform(z, 0, 0, z, 0, 0); | |
| ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; | |
| const ch = TL.chapterAt(t); | |
| // 序章开头:星空渐显 | |
| const intro = K.clamp(t / 1.5); | |
| W.background(ctx, L, t, { neb: 0.3 + 0.7 * intro * K.clamp((t - 1) / 3), stars: intro }); | |
| // 晶体 | |
| const pose = W.crystalPose(t, L); | |
| const co = { x: pose.x, y: pose.y + Math.sin(t * 0.9) * pose.s * 0.06, s: pose.s, ry: t * 0.55, rx: -0.2 + Math.sin(t * 0.3) * 0.08, alpha: 1, build: 1, fill: 1 }; | |
| const H = helper(ch, L, t); | |
| const mod = window.CHAPTERS[ch.id] && window.CHAPTERS[ch.id].crystal; | |
| if (mod) mod(co, t, H); | |
| // 跨章节:前一章的晶体修改在 1.2 秒内淡出 | |
| if (ch.index > 0) { | |
| const pc = TL.chapters[ch.index - 1]; const pm = window.CHAPTERS[pc.id] && window.CHAPTERS[pc.id].crystal; | |
| const k = K.clamp((t - ch.start) / 1.2); | |
| if (pm && k < 1) { const po = Object.assign({}, co); pm(po, t, helper(pc, L, t)); ['alpha', 'split', 'glow', 'x', 'y', 's'].forEach((f) => { if (typeof po[f] === 'number' && typeof co[f] === 'number') co[f] = K.lerp(po[f], co[f], E.io3(k)); }); if (po.tint && k < 0.5) co.tint = po.tint; } | |
| } | |
| W.crystal(ctx, co); | |
| // 章节层(与上一章交叉溶解) | |
| if (ch.index > 0 && t - ch.start < 0.9) { | |
| const pc = TL.chapters[ch.index - 1]; | |
| ctx.save(); ctx.globalAlpha = 1 - E.io3((t - ch.start) / 0.9); | |
| try { window.CHAPTERS[pc.id].draw(ctx, L, t, helper(pc, L, t)); } catch (e) { console.error(pc.id, e); } | |
| ctx.restore(); | |
| } | |
| ctx.save(); ctx.globalAlpha = ch.index > 0 ? K.clamp((t - ch.start) / 0.6) : 1; | |
| try { window.CHAPTERS[ch.id].draw(ctx, L, t, H); } catch (e) { console.error(ch.id, e); } | |
| ctx.restore(); | |
| chapterCard(ctx, L, ch, t); | |
| // 后期:暗角 + 颗粒 | |
| const vg = ctx.createRadialGradient(L.W / 2, L.H / 2, Math.min(L.W, L.H) * 0.35, L.W / 2, L.H / 2, Math.hypot(L.W, L.H) * 0.62); | |
| vg.addColorStop(0, 'rgba(5,6,15,0)'); vg.addColorStop(1, 'rgba(5,6,15,.55)'); | |
| ctx.fillStyle = vg; ctx.fillRect(0, 0, L.W, L.H); | |
| ctx.save(); ctx.globalAlpha = 0.05; ctx.globalCompositeOperation = 'overlay'; | |
| const gc = grainCanvas(); const ox = Math.floor(K.hash(Math.floor(t * 24)) * 256), oy = Math.floor(K.hash(Math.floor(t * 24) + 7) * 256); | |
| ctx.translate(-ox, -oy); ctx.fillStyle = ctx.createPattern(gc, 'repeat'); ctx.fillRect(ox, oy, L.W, L.H); ctx.restore(); | |
| hud(ctx, L, t, ch); | |
| subtitles(ctx, L, t); | |
| // 片尾渐黑 | |
| const endK = K.clamp((t - (TL.total - 1.2)) / 1.2); | |
| if (endK > 0) { ctx.fillStyle = `rgba(5,6,15,${endK})`; ctx.fillRect(0, 0, L.W, L.H); } | |
| if (opts.guides) guides(ctx, L); | |
| } | |
| /* ---------- 封面 ---------- */ | |
| function renderCover(ctx, L, z = 1) { | |
| ctx.setTransform(z, 0, 0, z, 0, 0); | |
| const t = 12.5; const P = L.portrait; | |
| W.background(ctx, L, TL.byId.origin.start - 0.2, {}); | |
| const cx = L.portrait ? L.W / 2 : L.W * 0.76, cy = L.portrait ? L.H * 0.475 : L.H * 0.48; | |
| const s = P ? 185 : 205; | |
| W.globe(ctx, { x: cx, y: cy, r: s * 2.2, t: 3, ry: 0.6, arcs: 18, alpha: 0.5, color: C.eth, color2: C.mag }); | |
| W.crystal(ctx, { x: cx, y: cy, s, ry: 0.65, rx: -0.2, alpha: 1, glow: 1.4 }); | |
| if (P) { | |
| K.text(ctx, '以太坊简史', L.W / 2, 330, { size: 150, fam: 'title', weight: 400, color: C.white, glow: C.eth, glowR: 50, ls: 6 }); | |
| K.text(ctx, '从一份白皮书,到世界计算机', L.W / 2, 450, { size: 50, color: K.rgba(C.white, 0.9), ls: 4 }); | |
| K.text(ctx, '2013 — 2026', L.W / 2, 1330, { size: 64, fam: 'num', color: C.eth2, ls: 14, glow: C.eth }); | |
| ['众筹 0.31 美元', 'The DAO 危机', '合并 · 能耗 -99.95%', 'ETF 时代'].forEach((s2, i) => { const x = 270 + (i % 2) * 540, y = 1450 + Math.floor(i / 2) * 110; K.glass(ctx, x - 240, y - 44, 480, 88, { r: 44 }); K.text(ctx, s2, x, y + 2, { size: 36, color: C.white }); }); | |
| } else { | |
| K.text(ctx, '以太坊简史', 120, 380, { size: 170, fam: 'title', weight: 400, color: C.white, align: 'left', glow: C.eth, glowR: 50, ls: 6 }); | |
| K.text(ctx, '从一份白皮书,到世界计算机', 128, 520, { size: 54, color: K.rgba(C.white, 0.9), align: 'left', ls: 4 }); | |
| K.text(ctx, '2013 — 2026', 128, 610, { size: 52, fam: 'num', color: C.eth2, align: 'left', ls: 14, glow: C.eth }); | |
| ['0.31 美元众筹', 'The DAO 危机', '合并 -99.95%', 'ETF 时代'].forEach((s2, i) => { const x = 128 + (i % 2) * 320, y = 700 + Math.floor(i / 2) * 104; K.glass(ctx, x, y, 300, 84, { r: 42 }); K.text(ctx, s2, x + 150, y + 43, { size: 32, color: C.white }); }); | |
| } | |
| const vg = ctx.createRadialGradient(L.W / 2, L.H / 2, Math.min(L.W, L.H) * 0.4, L.W / 2, L.H / 2, Math.hypot(L.W, L.H) * 0.65); | |
| vg.addColorStop(0, 'rgba(5,6,15,0)'); vg.addColorStop(1, 'rgba(5,6,15,.5)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, L.W, L.H); | |
| } | |
| window.RENDER = { render, renderCover }; | |
| })(); | |