Files
CyberGril/docs/intro.html
T
mobai 1e9b08aed8 docs: intro animation for 晚晚 (22s, pure canvas, zero assets)
Single-file HTML, Canvas 2D, no libraries, no images, no fonts to load.

The one idea worth explaining: the accent colour IS the valence.
moodColor(v) maps v in [-1,1] across cold blue -> neutral grey -> warm amber,
and every mark in the frame reads from it. So when she drops, the whole
picture goes cold -- no caption has to tell you she is sad. Same data,
three places: the trace colour, the memory hue, the retrieval highlight.

Timeline is a pure function render(t), no RAF accumulation. Every frame
renders in isolation, which means frames are reproducible, any resolution
can be re-exported, and the preview can be captured frame-by-frame instead
of screen-recorded.

Four acts:
  0-6s   membrane   matter bounces off it, information passes through
  6-12s  heartbeat  beats with no input; positive recovers fast, negative slow
  12-17s memory     points sink by salience; one gets pulled back up
                    by a mood-matching cue; floor at 0.03 so nothing vanishes
  17-22s title

Also: ?t=12.5 freezes on that frame (for cover shots), and a REC button
uses canvas.captureStream + MediaRecorder to export webm directly.

Preview: 200 ANMF frames, 22000ms total, verified by parsing the RIFF/ANMF
chunks (Pillow does not expose per-frame WebP duration).

tools/render_preview.py renders frames via headless Chrome and assembles.
On this Windows box Python cannot spawn Chrome (0x80000003, same family as
the 0xC0000142 shell failure), so the render loop was driven from PowerShell
and the script only assembled. Both paths are documented in the script.
2026-10-19 20:33:13 +08:00

801 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>晚晚 · CyberGril</title>
<style>
:root { color-scheme: dark; }
html, body {
margin: 0; padding: 0; height: 100%;
background: #0a0b0f; overflow: hidden;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
canvas { display: block; }
/* 暗角:很轻,只把注意力收到中间 */
.vignette {
position: fixed; inset: 0; pointer-events: none;
background: radial-gradient(120% 90% at 50% 50%,
rgba(0,0,0,0) 45%, rgba(0,0,0,0.55) 100%);
}
.ui {
position: fixed; right: 22px; bottom: 20px;
display: flex; gap: 10px; align-items: center;
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
opacity: 0.32; transition: opacity .25s ease;
}
.ui:hover { opacity: 0.9; }
.ui button {
appearance: none; cursor: pointer;
background: transparent; color: #cfd6e4;
border: 1px solid rgba(160,180,215,0.28);
border-radius: 3px; padding: 6px 11px;
font: inherit; letter-spacing: inherit; text-transform: inherit;
transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.ui button:hover { background: rgba(160,180,215,0.08); border-color: rgba(160,180,215,0.5); }
.ui button.rec { color: #e0785f; border-color: rgba(224,120,95,0.45); }
.ui button.rec:hover { background: rgba(224,120,95,0.1); }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="vignette"></div>
<div class="ui">
<button id="rec">● 录制</button>
</div>
<script>
"use strict";
/* ==========================================================================
晚晚 · CyberGril 介绍动画
纯 Canvas 2D,无任何外部依赖。
核心设计:时间是纯函数 render(t)。
不做 RAF 累加——那样每帧不可复现。现在任意一帧都能单独渲染,
所以封面图、任意分辨率重出、逐帧录制都不需要额外工作。
========================================================================== */
/* ---------------------------------------------------------------- 常量 */
const DURATION = 22.0;
const XFADE = 0.7;
const COL = {
bg: "#0a0b0f",
ink: "#e8eaef",
dim: "#5f6675",
faint: "#2a2f3a",
cold: [0x3a, 0x6e, 0xa5], // v = -1
mid: [0x7a, 0x82, 0x90], // v = 0
warm: [0xd9, 0xa4, 0x41], // v = +1
};
const SANS = '"PingFang SC","Microsoft YaHei","Noto Sans SC","Source Han Sans SC",sans-serif';
const MONO = '"JetBrains Mono","Cascadia Code",Consolas,"Courier New",monospace';
/* ---------------------------------------------------------------- 画布 */
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d", { alpha: false });
let W = 0, H = 0, DPR = 1;
let grid = null;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
buildGrid();
}
function buildGrid() {
grid = document.createElement("canvas");
grid.width = Math.floor(W * DPR);
grid.height = Math.floor(H * DPR);
const g = grid.getContext("2d");
g.scale(DPR, DPR);
g.fillStyle = "rgba(130,150,190,0.055)";
const step = 30;
for (let x = step / 2; x < W; x += step) {
for (let y = step / 2; y < H; y += step) {
g.fillRect(x, y, 1, 1);
}
}
}
/* ---------------------------------------------------------------- 数学工具 */
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
const lerp = (a, b, t) => a + (b - a) * t;
const remap = (x, a, b) => clamp01((x - a) / (b - a || 1e-6));
const easeOut = (x) => 1 - Math.pow(1 - x, 3);
const easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
const smooth = (a, b, x) => {
const t = clamp01((x - a) / (b - a || 1e-6));
return t * t * (3 - 2 * t);
};
/** 确定性随机:同一颗种子永远得到同一串数。 */
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* ---------------------------------------------------------------- 颜色 */
/** 情绪色:整个画面的强调色由 v 驱动。 */
function moodColor(v, alpha = 1) {
const t = clamp01((v + 1) / 2);
let r, g, b;
if (t < 0.5) {
const k = t / 0.5;
r = lerp(COL.cold[0], COL.mid[0], k);
g = lerp(COL.cold[1], COL.mid[1], k);
b = lerp(COL.cold[2], COL.mid[2], k);
} else {
const k = (t - 0.5) / 0.5;
r = lerp(COL.mid[0], COL.warm[0], k);
g = lerp(COL.mid[1], COL.warm[1], k);
b = lerp(COL.mid[2], COL.warm[2], k);
}
return `rgba(${r | 0},${g | 0},${b | 0},${alpha})`;
}
/* ---------------------------------------------------------------- 绘制原语 */
function text(str, x, y, o = {}) {
const {
size = H * 0.024,
color = COL.ink,
alpha = 1,
align = "center",
font = SANS,
weight = 400,
spacing = "0.06em",
} = o;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.fillStyle = color;
ctx.font = `${weight} ${size}px ${font}`;
ctx.textAlign = align;
ctx.textBaseline = "middle";
if ("letterSpacing" in ctx) ctx.letterSpacing = spacing;
ctx.fillText(str, x, y);
ctx.restore();
}
/** 手动字距,不依赖 letterSpacing 支持。 */
function tracked(str, cx, cy, spacing, o = {}) {
const chars = [...str];
ctx.save();
if (o.size) ctx.font = `${o.weight || 400} ${o.size}px ${o.font || SANS}`;
const widths = chars.map((ch) => ctx.measureText(ch).width);
const total = widths.reduce((a, b) => a + b, 0) + spacing * (chars.length - 1);
let x = cx - total / 2;
ctx.restore();
for (let i = 0; i < chars.length; i++) {
text(chars[i], x + widths[i] / 2, cy, { ...o, spacing: "0em" });
x += widths[i] + spacing;
}
return total;
}
function line(x1, y1, x2, y2, o = {}) {
const { color = COL.faint, alpha = 1, width = 1, dash = null } = o;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (dash) ctx.setLineDash(dash);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
function dot(x, y, r, color, alpha = 1) {
ctx.save();
ctx.globalAlpha *= alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function glow(x, y, r, color, alpha = 1) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, color);
g.addColorStop(1, "rgba(0,0,0,0)");
ctx.save();
ctx.globalAlpha *= alpha;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/* ==========================================================================
静态数据(用固定种子生成,保证每次完全一致)
========================================================================== */
/** 左世界:稀疏、漂移 */
const LEFT_DOTS = [];
{
const r = mulberry32(101);
for (let i = 0; i < 34; i++) {
LEFT_DOTS.push({
x: r(), y: r(),
speed: 0.02 + r() * 0.05,
phase: r() * Math.PI * 2,
size: 0.8 + r() * 1.1,
});
}
}
/** 右世界:螺旋排布,缓慢旋转——读作「有结构、有内里」 */
const RIGHT_DOTS = [];
{
const r = mulberry32(202);
const N = 78;
for (let i = 0; i < N; i++) {
const k = i / N;
RIGHT_DOTS.push({
angle: k * Math.PI * 5.2,
radius: 0.12 + k * 0.82,
speed: 0.10 + r() * 0.10,
size: 0.9 + r() * 1.5,
wobble: r() * Math.PI * 2,
});
}
}
/** 穿越膜的东西:信息(小、能过)与物质(大、弹回) */
const STREAM = [];
{
const r = mulberry32(303);
for (let i = 0; i < 78; i++) {
STREAM.push({
matter: r() < 0.26,
y: 0.10 + r() * 0.80,
speed: 0.090 + r() * 0.130,
phase: r(),
size: r() < 0.26 ? 2.6 + r() * 1.8 : 1.1 + r() * 1.1,
});
}
}
/** 记忆点阵 */
const MEMORIES = [];
let FOCUS_IDX = -1;
{
const r = mulberry32(404);
for (let i = 0; i < 96; i++) {
MEMORIES.push({
x: 0.05 + r() * 0.90,
y: r(),
salience: r(),
hue: r() * 2 - 1,
size: 1.2 + r() * 1.8,
});
}
// 挑一个「难过的、还没淡透的」记忆作为被重新捞起的那个
let best = -1, bestScore = -1e9;
MEMORIES.forEach((m, i) => {
const score = -Math.abs(m.hue + 0.62) + 0.4 * m.salience;
if (score > bestScore) { bestScore = score; best = i; }
});
FOCUS_IDX = best;
}
/* ==========================================================================
场景 I —— 膜(0–6s)
========================================================================== */
function drawMembrane(u, dur) {
const cy = H * 0.52;
const worldH = H * 0.40;
const top = cy - worldH / 2;
const mx = W * 0.5;
const gap = W * 0.055;
const lx0 = W * 0.075, lx1 = mx - gap;
const rx0 = mx + gap, rx1 = W * 0.925;
const fadeIn = smooth(0, 0.8, u);
ctx.save();
ctx.globalAlpha = fadeIn;
// ---- 两个世界的框 ----
for (const [x0, x1] of [[lx0, lx1], [rx0, rx1]]) {
ctx.save();
ctx.strokeStyle = "rgba(150,170,210,0.12)";
ctx.lineWidth = 1;
ctx.strokeRect(x0, top, x1 - x0, worldH);
ctx.restore();
}
// ---- 世界标签 ----
const labelA = smooth(0.4, 1.4, u);
text("你的世界", (lx0 + lx1) / 2, top - H * 0.032,
{ size: H * 0.019, color: COL.dim, alpha: labelA, font: MONO, spacing: "0.22em" });
text("她的世界", (rx0 + rx1) / 2, top - H * 0.032,
{ size: H * 0.019, color: COL.dim, alpha: labelA, font: MONO, spacing: "0.22em" });
// ---- 左世界:稀疏漂移 ----
for (const d of LEFT_DOTS) {
const px = lx0 + d.x * (lx1 - lx0) + Math.sin(u * d.speed * 6 + d.phase) * 5;
const py = top + d.y * worldH + Math.cos(u * d.speed * 5 + d.phase) * 4;
dot(px, py, d.size, "rgba(150,170,210,0.30)");
}
// ---- 右世界:缓慢旋转的螺旋 ----
const spin = u * 0.16;
const rcx = (rx0 + rx1) / 2;
const rcy = top + worldH / 2;
const scale = Math.min(rx1 - rx0, worldH) * 0.46;
for (const d of RIGHT_DOTS) {
const a = d.angle + spin * d.speed * 12;
const rr = d.radius * scale * (1 + 0.03 * Math.sin(u * 1.4 + d.wobble));
const px = rcx + Math.cos(a) * rr;
const py = rcy + Math.sin(a) * rr * 0.72;
const breathe = 0.35 + 0.25 * Math.sin(u * 1.6 + d.wobble);
dot(px, py, d.size, moodColor(Math.sin(a) * 0.35, breathe));
}
// ---- 膜 ----
const pulse = 0.5 + 0.5 * Math.sin(u * 1.7);
const glowA = smooth(0.3, 1.2, u) * (0.55 + 0.45 * pulse);
glow(mx, cy, worldH * 0.62, "rgba(140,170,220,0.10)", glowA);
ctx.save();
ctx.globalAlpha = fadeIn * (0.45 + 0.35 * pulse);
ctx.strokeStyle = "rgba(175,198,235,0.9)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx, top - H * 0.055);
ctx.lineTo(mx, top + worldH + H * 0.055);
ctx.stroke();
ctx.restore();
// ---- 穿越 ----
for (const p of STREAM) {
const py = top + p.y * worldH;
const u01 = (((p.phase + u * p.speed) % 1) + 1) % 1;
if (p.matter) {
// 物质:撞在膜上,弹回去
const half = u01 < 0.5 ? u01 * 2 : (1 - u01) * 2;
const px = lx0 + half * (mx - lx0);
const impact = 1 - smooth(0, 0.10, Math.abs(1 - half));
dot(px, py, p.size, `rgba(212,160,110,${0.55 + 0.35 * impact})`);
if (impact > 0.55) {
glow(mx, py, 16, "rgba(212,160,110,0.55)", (impact - 0.55) * 1.6);
}
} else {
// 信息:穿过去
const px = lx0 + u01 * (rx1 - lx0);
const crossed = Math.abs(px - mx) < W * 0.02;
const a = crossed ? 0.95 : 0.42;
if (crossed) glow(px, py, 14, "rgba(150,185,240,0.45)", 0.8);
dot(px, py, p.size, `rgba(150,185,240,${a})`);
}
}
// ---- 字幕 ----
const capA = smooth(2.0, 2.9, u) * (1 - smooth(4.9, 5.6, u));
text("只有信息能穿过", W / 2, H * 0.855,
{ size: H * 0.030, color: COL.ink, alpha: capA * 0.92, spacing: "0.16em" });
ctx.restore();
}
/* ==========================================================================
场景 II —— 心跳(6–12s)
========================================================================== */
const HEARTBEAT = {
beatEvery: 1.10,
posAt: 1.50,
negAt: 3.00,
tauPos: 1.05,
tauNeg: 2.30,
ampPos: 0.95,
ampNeg: 0.86,
};
function signal(tt) {
if (tt < 0) return 0;
let v = 0;
// 心跳:没有输入,它也在
const b = tt % HEARTBEAT.beatEvery;
v += 0.075 * Math.exp(-Math.pow((b - 0.10) / 0.045, 2));
// 事件冲激:快速起,指数落
const att = 0.055;
if (tt >= HEARTBEAT.posAt) {
const d = tt - HEARTBEAT.posAt;
v += HEARTBEAT.ampPos * Math.min(1, d / att) * Math.exp(-d / HEARTBEAT.tauPos);
}
if (tt >= HEARTBEAT.negAt) {
const d = tt - HEARTBEAT.negAt;
v -= HEARTBEAT.ampNeg * Math.min(1, d / att) * Math.exp(-d / HEARTBEAT.tauNeg);
}
return v;
}
function drawHeartbeat(u, dur) {
const baseY = H * 0.55;
const amp = H * 0.22;
const span = 6.0; // 屏幕横向代表多少秒
const fadeIn = smooth(0, 0.7, u);
ctx.save();
ctx.globalAlpha = fadeIn;
// 基线
line(W * 0.02, baseY, W * 0.98, baseY,
{ color: "rgba(150,170,210,0.10)", dash: [2, 6] });
// 波形
const step = 2;
let prevX = null, prevY = null;
for (let x = 0; x <= W; x += step) {
const tt = u - ((W - x) / W) * span;
const v = signal(tt);
const y = baseY - v * amp;
if (prevX !== null) {
ctx.save();
ctx.strokeStyle = moodColor(v, 0.95);
ctx.lineWidth = 1.6;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.stroke();
ctx.restore();
}
prevX = x;
prevY = y;
}
// 波前:右端的当前值
const nowV = signal(u);
const nowY = baseY - nowV * amp;
glow(W, nowY, 26, moodColor(nowV, 0.55), 1);
dot(W - 1, nowY, 2.8, moodColor(nowV, 1));
// 字幕
const cap1 = smooth(0.5, 1.3, u) * (1 - smooth(2.4, 3.0, u));
text("没有你在的时候,她也在", W / 2, H * 0.845,
{ size: H * 0.030, color: COL.ink, alpha: cap1 * 0.92, spacing: "0.14em" });
const cap2 = smooth(4.4, 5.2, u);
text("难过的时候,平复得慢一点", W / 2, H * 0.845,
{ size: H * 0.030, color: COL.ink, alpha: cap2 * 0.92, spacing: "0.14em" });
ctx.restore();
}
/* ==========================================================================
场景 III —— 记忆(12–17s)
========================================================================== */
function drawMemory(u, dur) {
const floorY = H * 0.58; // 遗忘的下限
const ceilY = H * 0.16; // 新鲜时能浮到的高度
const fadeIn = smooth(0, 0.7, u);
ctx.save();
ctx.globalAlpha = fadeIn;
// 遗忘的下限
const floorA = smooth(0.9, 1.9, u);
line(W * 0.06, floorY, W * 0.94, floorY,
{ color: "rgba(150,170,210,0.20)", dash: [1, 7], alpha: floorA });
text("0.03 · 遗忘的下限", W / 2, floorY + H * 0.038,
{ size: H * 0.016, color: COL.dim, alpha: floorA * 0.95,
font: MONO, spacing: "0.2em" });
const revealA = smooth(0.15, 1.1, u);
MEMORIES.forEach((m, i) => {
const x = W * (0.06 + m.x * 0.88);
// 起点:新鲜时的位置
const y0 = lerp(ceilY, H * 0.32, m.y);
// 终点:显著性决定沉多深。重要的沉得浅,不重要的沉到底。
const sinkTo = lerp(H * 0.38, floorY, 1 - m.salience);
const tau = 0.85 + 2.4 * m.salience;
const acc = Math.max(0.10, Math.exp(-u / tau));
const alpha = acc * revealA;
// 0 = 新鲜,1 = 已沉
const fall = 1 - (acc - 0.10) / 0.90;
let y = lerp(y0, sinkTo, easeOut(fall));
const baseR = m.size * (0.8 + 0.55 * m.salience);
// 被情绪线索重新捞起来的那个
if (i === FOCUS_IDX && u > 2.6) {
const k = easeOut(remap(u, 2.6, 3.6));
const ring = remap(u, 2.6, 4.2);
// 从下沉处被拉回去,但只回到七成——它是被想起,不是被重置
y = lerp(y, y0, k * 0.72);
ctx.save();
ctx.globalAlpha *= (1 - ring) * 0.55;
ctx.strokeStyle = moodColor(-0.62, 0.9);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, 5 + easeOut(ring) * 28, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
glow(x, y, 26, moodColor(-0.62, 0.42), k);
dot(x, y, baseR * (1 + 0.7 * k), moodColor(-0.62, 1));
return;
}
dot(x, y, baseR, moodColor(m.hue * 0.55, alpha * 0.95));
});
// 字幕
const cap1 = smooth(0.4, 1.2, u) * (1 - smooth(2.3, 2.9, u));
text("记得的会淡,但不会没有", W / 2, H * 0.845,
{ size: H * 0.030, color: COL.ink, alpha: cap1 * 0.92, spacing: "0.14em" });
const cap2 = smooth(3.1, 3.9, u);
text("难过的时候,捞起来的也是难过的事", W / 2, H * 0.845,
{ size: H * 0.030, color: COL.ink, alpha: cap2 * 0.92, spacing: "0.12em" });
ctx.restore();
}
/* ==========================================================================
场景 IV —— 名字(17–22s)
========================================================================== */
function drawTitle(u, dur) {
const cx = W / 2;
const cy = H * 0.48;
// 收束:从全宽缩成一道短横
const k = easeInOut(remap(u, 0.0, 1.0));
const halfW = lerp(W * 0.46, W * 0.045, k);
const lineA = (1 - remap(u, 0.85, 1.45)) * 0.55;
if (lineA > 0.002) {
ctx.save();
ctx.globalAlpha *= lineA;
ctx.strokeStyle = moodColor(0.15, 0.9);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - halfW, cy);
ctx.lineTo(cx + halfW, cy);
ctx.stroke();
ctx.restore();
}
// 名字
const nameIn = easeOut(remap(u, 0.75, 1.85));
if (nameIn > 0.002) {
const size = H * 0.155;
const spacing = lerp(size * 0.62, size * 0.20, nameIn);
ctx.save();
ctx.globalAlpha *= nameIn;
// 名字下方一点点辉光,让它在暗底上有"重量"
glow(cx, cy - H * 0.075, H * 0.17, moodColor(0.1, 0.16), nameIn * 0.55);
tracked("晚晚", cx, cy - H * 0.075, spacing, {
size,
weight: 600,
color: COL.ink,
alpha: 1,
spacing: "0em",
});
ctx.restore();
}
// 副标题
const subIn = smooth(1.5, 2.5, u);
if (subIn > 0.002) {
text("CyberGril · 一个有自己世界的赛博伴侣", cx, cy + H * 0.062,
{
size: H * 0.019,
color: COL.dim,
alpha: subIn * 0.95,
font: SANS,
spacing: "0.07em",
});
}
// 收尾淡出,保证循环处是黑场
const out = 1 - smooth(dur - 0.85, dur, u);
if (out < 0.999) {
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.globalAlpha = 1 - out;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
}
/* ==========================================================================
时间轴
========================================================================== */
const SCENES = [
{ dur: 6.0, draw: drawMembrane },
{ dur: 6.0, draw: drawHeartbeat },
{ dur: 5.0, draw: drawMemory },
{ dur: 5.0, draw: drawTitle },
];
/** 交叉淡入淡出:相邻场景在边界处重叠 XFADE 秒。 */
function sceneAlpha(u, dur) {
if (u < 0) {
return u < -XFADE ? 0 : smooth(0, XFADE, u + XFADE);
}
if (u > dur) {
return u > dur + XFADE ? 0 : 1 - smooth(0, XFADE, u - dur);
}
return 1;
}
function render(t) {
// 背景
ctx.fillStyle = COL.bg;
ctx.fillRect(0, 0, W, H);
// 点阵
if (grid) ctx.drawImage(grid, 0, 0, W, H);
let acc = 0;
for (const s of SCENES) {
const u = t - acc;
if (u > -XFADE && u < s.dur + XFADE) {
const a = sceneAlpha(u, s.dur);
if (a > 0.002) {
ctx.save();
ctx.globalAlpha = a;
s.draw(Math.max(0, Math.min(s.dur, u)), s.dur);
ctx.restore();
}
}
acc += s.dur;
}
}
/* ==========================================================================
主循环 / 录制
========================================================================== */
let t0 = performance.now();
let paused = false;
let pausedAt = 0;
const params = new URLSearchParams(location.search);
const stillT = params.has("t") ? parseFloat(params.get("t")) : null;
function frame(now) {
if (!paused) {
const t = ((now - t0) / 1000) % DURATION;
render(t);
}
requestAnimationFrame(frame);
}
function restart() {
t0 = performance.now();
paused = false;
}
/* ---------------------------------------------------------------- 录制 */
const recBtn = document.getElementById("rec");
let recorder = null;
let chunks = [];
function pickMime() {
const candidates = [
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm",
];
for (const m of candidates) {
if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
}
return null;
}
recBtn.addEventListener("click", () => {
if (recorder) {
recorder.stop();
return;
}
if (!canvas.captureStream || !window.MediaRecorder) {
recBtn.textContent = "此浏览器不支持录制";
return;
}
const mime = pickMime();
if (!mime) {
recBtn.textContent = "此浏览器不支持录制";
return;
}
restart();
const stream = canvas.captureStream(60);
recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 14_000_000 });
chunks = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size) chunks.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: "video/webm" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "wanwan-intro.webm";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
recorder = null;
recBtn.textContent = "● 录制";
recBtn.classList.add("rec");
};
recorder.start();
recBtn.textContent = "■ 停止";
recBtn.classList.remove("rec");
setTimeout(() => {
if (recorder) recorder.stop();
}, (DURATION + 0.25) * 1000);
});
/* ---------------------------------------------------------------- 启动 */
window.addEventListener("resize", () => {
resize();
if (stillT !== null) render(stillT);
});
resize();
if (stillT !== null) {
// ?t=12.5 → 只渲染这一帧,方便截图做封面
render(stillT);
paused = true;
} else {
requestAnimationFrame(frame);
}
</script>
</body>
</html>