// 首屏动态演示(1:1) // 纯代码绘制:不占包体积(无视频/图片资源),小程序实时播放与宣传视频渲染共用同一条时间轴。 // 演示用的拼豆作品由真实配色引擎实时生成,并且确实可以被微信扫出 “Happy Birthday!”。 const { makeQr, createPlacement } = require('./qr') const { preparePalette } = require('./palette') const { computeArtwork } = require('./engine') const DEMO_TEXT = 'Happy Birthday!' const DURATION = 12 const U = 1080 // 设计稿边长,绘制时按实际像素缩放 const TAU = Math.PI * 2 const FLIGHT = 0.85 // 豆子半径(相对格宽):略大于 0.5,相邻豆子轻微相接,像熨烫后的成品,扫码更稳 const BEAD_R = 0.52 const C = { bg: '#f4efe6', ink: '#16211f', muted: '#6b7471', brand: '#0f766e', brand2: '#14b8a6', brandSoft: '#dcf1ec', board: '#fffdf8', dark: '#22302e', light: '#fbf8f1', wechat: '#07c160', } const SCENES = [ { a: 0, b: 2.6, title: '写一句话', sub: '扫码后想让人看到的话' }, { a: 2.6, b: 5.0, title: '自动编成二维码', sub: '每一颗拼豆,都是一个像素' }, { a: 5.0, b: 7.6, title: '融进你的照片', sub: '换上照片的颜色,依然能扫' }, { a: 7.6, b: 10.0, title: '微信扫一扫', sub: '拼好的作品,一扫就读出这句话' }, ] const CONFETTI = [ [0.17, 0.19, 0.045, '#ffd166'], [0.85, 0.2, 0.04, '#4cc9f0'], [0.13, 0.8, 0.04, '#2ec4b6'], [0.86, 0.82, 0.045, '#ffd166'], [0.5, 0.11, 0.03, '#2ec4b6'], [0.91, 0.51, 0.03, '#ff9f1c'], [0.08, 0.5, 0.03, '#4cc9f0'], ] const DECOR = [ [90, 150, 22, '#14b8a6'], [990, 120, 16, '#ffb703'], [60, 960, 18, '#ff7a45'], [1010, 930, 24, '#8ecae6'], [170, 1040, 12, '#ffb703'], [930, 330, 12, '#ff7a45'], [120, 520, 10, '#8ecae6'], [980, 700, 14, '#14b8a6'], ] // —— 工具 —— const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v) const lerp = (a, b, t) => a + (b - a) * t const prog = (t, a, b) => clamp01((t - a) / (b - a)) const easeOut = (t) => 1 - Math.pow(1 - t, 3) const easeIn = (t) => t * t * t const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2) const easeBack = (t) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) } const bump = (t, a, b) => { const p = prog(t, a, b); return p > 0 && p < 1 ? Math.sin(Math.PI * p) : 0 } function rng(seed) { let a = seed >>> 0 return () => { 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 } } function rr(ctx, x, y, w, h, r) { r = Math.max(0, Math.min(r, w / 2, h / 2)) ctx.moveTo(x + r, y) ctx.arcTo(x + w, y, x + w, y + h, r) ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r) ctx.arcTo(x, y, x + w, y, r) ctx.closePath() } // 三层叠加的柔和阴影:比 shadowBlur 便宜得多,手机上逐帧绘制也流畅 function softShadow(ctx, x, y, w, h, r, alpha) { for (let i = 3; i >= 1; i -= 1) { const g = i * 8 ctx.fillStyle = `rgba(52,38,18,${(alpha / (i + 1)).toFixed(3)})` ctx.beginPath() rr(ctx, x - g / 2, y - g / 2 + g * 0.8, w + g, h + g, r + g / 2) ctx.fill() } } function heartPath(ctx, cx, cy, s) { ctx.moveTo(cx, cy + s * 0.9) ctx.bezierCurveTo(cx - s * 1.3, cy + s * 0.05, cx - s * 0.95, cy - s * 1.0, cx, cy - s * 0.42) ctx.bezierCurveTo(cx + s * 0.95, cy - s * 1.0, cx + s * 1.3, cy + s * 0.05, cx, cy + s * 0.9) ctx.closePath() } // 演示“照片”:暖色渐变 + 爱心 + 彩纸屑(矢量绘制,任意尺寸都清晰) function drawIllustration(ctx, x, y, s) { ctx.save() ctx.beginPath() ctx.rect(x, y, s, s) ctx.clip() let g = ctx.createLinearGradient(x, y, x, y + s) g.addColorStop(0, '#63c7b8') g.addColorStop(1, '#4aa3c9') ctx.fillStyle = g ctx.fillRect(x, y, s, s) g = ctx.createRadialGradient(x + s * 0.5, y + s * 0.48, 0, x + s * 0.5, y + s * 0.48, s * 0.55) g.addColorStop(0, 'rgba(255,255,255,0.35)') g.addColorStop(1, 'rgba(255,255,255,0)') ctx.fillStyle = g ctx.fillRect(x, y, s, s) CONFETTI.forEach(([cx, cy, r, col]) => { ctx.fillStyle = col ctx.beginPath() ctx.arc(x + cx * s, y + cy * s, r * s, 0, TAU) ctx.fill() }) g = ctx.createLinearGradient(x, y + s * 0.2, x, y + s * 0.85) g.addColorStop(0, '#ff6b88') g.addColorStop(1, '#d42a55') ctx.fillStyle = g ctx.beginPath() heartPath(ctx, x + s * 0.5, y + s * 0.55, s * 0.34) ctx.fill() ctx.fillStyle = 'rgba(255,255,255,0.55)' ctx.save() ctx.translate(x + s * 0.37, y + s * 0.42) ctx.rotate(-0.6) ctx.scale(1, 0.55) ctx.beginPath() ctx.arc(0, 0, s * 0.06, 0, TAU) ctx.fill() ctx.restore() ctx.restore() } // 用真实引擎生成演示作品:插画按格面积取平均色 → 配色引擎 function buildDemo(workCanvas, createCanvas) { const qr = makeQr(DEMO_TEXT, 'H') const placement = createPlacement(qr.size, { scale: 100, quiet: 1, hasImage: true }) const size = placement.size const k = 6 const px = size * k const canvas = createCanvas ? createCanvas(px, px) : workCanvas canvas.width = px canvas.height = px const ctx = canvas.getContext('2d') ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, px, px) drawIllustration(ctx, 0, 0, px) const data = ctx.getImageData(0, 0, px, px).data const rgb = new Uint8Array(size * size * 3) for (let r = 0; r < size; r += 1) { for (let c = 0; c < size; c += 1) { let sr = 0; let sg = 0; let sb = 0 for (let y = 0; y < k; y += 1) { let o = ((r * k + y) * px + c * k) * 4 for (let x = 0; x < k; x += 1, o += 4) { sr += data[o]; sg += data[o + 1]; sb += data[o + 2] } } const i = (r * size + c) * 3 rgb[i] = sr / (k * k); rgb[i + 1] = sg / (k * k); rgb[i + 2] = sb / (k * k) } } const art = computeArtwork({ size, rgb }, qr, placement, preparePalette('221'), { modeId: 'balanced', coherence: 40, cleanup: true }) return { qr, placement, art } } // —— 豆子批量绘制:按颜色合并路径,每帧只需十几次填充 —— function addBead(groups, hex, x, y, r) { let list = groups.get(hex) if (!list) { list = []; groups.set(hex, list) } list.push(x, y, r) } // 小批量合并路径:一条路径里子路径太多时,部分渲染引擎反而更慢,每 12 颗提交一次最稳 const CHUNK = 12 function fillCircles(ctx, list, rs, ox, oy) { let n = 0 ctx.beginPath() for (let i = 0; i < list.length; i += 3) { const r = list[i + 2] * rs const x = list[i] + list[i + 2] * ox const y = list[i + 1] + list[i + 2] * oy ctx.moveTo(x + r, y) ctx.arc(x, y, r, 0, TAU) n += 1 if (n === CHUNK) { ctx.fill(); ctx.beginPath(); n = 0 } } if (n) ctx.fill() } function flushBeads(ctx, groups) { const all = [] groups.forEach((list, hex) => { ctx.fillStyle = hex fillCircles(ctx, list, 1, 0, 0) all.push(list) }) // 中孔:拼豆是一颗颗小管子 ctx.fillStyle = 'rgba(70,48,24,0.17)' all.forEach((list) => fillCircles(ctx, list, 0.3, 0, 0)) // 高光 ctx.fillStyle = 'rgba(255,255,255,0.4)' all.forEach((list) => fillCircles(ctx, list, 0.2, -0.42, -0.42)) groups.clear() } /** * @param opts { workCanvas?, createCanvas?(w,h), font?, loop? } * @returns { duration, text, artwork, draw(ctx, t, pixelWidth) } */ function createIntro(opts = {}) { const font = opts.font || 'sans-serif' const loop = opts.loop !== false const F = (weight, px) => `${weight} ${Math.round(px)}px ${font}` const { qr, placement, art } = buildDemo(opts.workCanvas, opts.createCanvas) const size = art.size const R = rng(20260929) const cells = [] for (let r = 0; r < size; r += 1) { for (let c = 0; c < size; c += 1) { const qc = c - placement.left const qrow = r - placement.top const dark = qc >= 0 && qrow >= 0 && qc < qr.size && qrow < qr.size && qr.dark[qrow * qr.size + qc] === 1 const idx = art.cells[r * size + c] cells.push({ c, r, qrHex: dark ? C.dark : C.light, finalHex: idx >= 0 ? art.pal.hexes[idx] : C.board, k: Math.max(0, Math.min(DEMO_TEXT.length - 1, Math.floor((c / size) * DEMO_TEXT.length + (R() - 0.5) * 2.5))), jx: R(), jy: R() - 0.5, bend: R() - 0.5, t0: 2.72 + (c / size) * 0.55 + R() * 0.55, tw: 5.95 + ((c + r) / (2 * (size - 1))) * 0.95 + R() * 0.08, }) } } const finders = [[0, 0], [qr.size - 7, 0], [0, qr.size - 7]].map(([x, y]) => [placement.left + x, placement.top + y]) const topColors = art.usage.slice(0, 8).map((u) => u.hex) const burst = Array.from({ length: 26 }, () => ({ a: -Math.PI / 2 + (R() - 0.5) * 2.2, v: 520 + R() * 520, r: 9 + R() * 8, hex: topColors[Math.floor(R() * topColors.length)] || C.brand2, })) const groups = new Map() const pegDots = [] for (let y = 20; y < U; y += 40) for (let x = 20; x < U; x += 40) pegDots.push(x, y, 2.4) let layout = null // 文本排版缓存(首帧测量) const textLayout = (ctx) => { if (layout) return layout ctx.font = F('bold', 64) const x0 = 170 const charX = [] for (let i = 0; i <= DEMO_TEXT.length; i += 1) charX.push(x0 + ctx.measureText(DEMO_TEXT.slice(0, i)).width) layout = { charX, y: 616 } return layout } const boardAt = (t) => { let cx = 540; let cy = 590; let B = 640 const a = easeInOut(prog(t, 7.6, 8.2)) cx = lerp(cx, 356, a); B = lerp(B, 560, a) const b = easeInOut(prog(t, 10.05, 10.75)) cx = lerp(cx, 540, b); cy = lerp(cy, 410, b); B = lerp(B, 450, b) return { cx, cy, B } } const gridOf = (L) => { const pad = L.B * 0.05 const cell = (L.B - pad * 2) / size return { cell, ox: L.cx - L.B / 2 + pad, oy: L.cy - L.B / 2 + pad } } const drawPlate = (ctx, L, alpha) => { const x = L.cx - L.B / 2 const y = L.cy - L.B / 2 const r = L.B * 0.045 ctx.globalAlpha = alpha softShadow(ctx, x, y, L.B, L.B, r, 0.16) ctx.fillStyle = C.board ctx.beginPath(); rr(ctx, x, y, L.B, L.B, r); ctx.fill() ctx.globalAlpha = 1 } // 背景:米色钉板 + 缓慢漂浮的装饰豆 const drawBackground = (ctx, t) => { ctx.fillStyle = C.bg ctx.fillRect(0, 0, U, U) ctx.fillStyle = 'rgba(60,45,25,0.065)' fillCircles(ctx, pegDots, 1, 0, 0) DECOR.forEach(([x, y, r, hex], i) => { const dx = Math.sin(t * 0.9 + i * 1.7) * 10 const dy = Math.cos(t * 0.7 + i * 2.3) * 12 ctx.globalAlpha = 0.55 addBead(groups, hex, x + dx, y + dy, r) }) flushBeads(ctx, groups) ctx.globalAlpha = 1 } const drawCaption = (ctx, t) => { ctx.textAlign = 'center' ctx.textBaseline = 'middle' SCENES.forEach((s, i) => { if (t < s.a || t > s.b) return const inP = easeOut(prog(t, s.a + 0.05, s.a + 0.5)) const outP = easeIn(prog(t, s.b - 0.32, s.b)) const alpha = inP * (1 - outP) if (alpha <= 0) return const dy = (1 - inP) * 26 - outP * 14 ctx.globalAlpha = alpha ctx.font = F('bold', 24) const label = `第 ${i + 1} 步` const w = ctx.measureText(label).width + 40 ctx.fillStyle = C.brandSoft ctx.beginPath(); rr(ctx, 540 - w / 2, 70 + dy, w, 46, 23); ctx.fill() ctx.fillStyle = C.brand ctx.fillText(label, 540, 94 + dy) ctx.fillStyle = C.ink ctx.font = F('bold', 60) ctx.fillText(s.title, 540, 170 + dy * 1.3) ctx.fillStyle = C.muted ctx.font = F('normal', 30) ctx.fillText(s.sub, 540, 232 + dy * 1.6) }) ctx.globalAlpha = 1 } const drawProgress = (ctx, t) => { const alpha = prog(t, 0, 0.35) * (1 - prog(t, 10.0, 10.4)) if (alpha <= 0) return ctx.globalAlpha = alpha const w = 110; const gap = 16; const x0 = 540 - (w * 4 + gap * 3) / 2 SCENES.forEach((s, i) => { const x = x0 + i * (w + gap) ctx.fillStyle = 'rgba(22,33,31,0.1)' ctx.beginPath(); rr(ctx, x, 1010, w, 8, 4); ctx.fill() const p = prog(t, s.a, s.b) if (p > 0) { ctx.fillStyle = C.brand ctx.beginPath(); rr(ctx, x, 1010, Math.max(8, w * p), 8, 4); ctx.fill() } }) ctx.globalAlpha = 1 } // 第 1 幕:输入框 + 打字 const drawInput = (ctx, t) => { if (t > 3.35) return const inP = easeBack(prog(t, 0.1, 0.65)) const outP = easeInOut(prog(t, 2.6, 3.15)) const alpha = prog(t, 0.1, 0.4) if (alpha <= 0) return const L = textLayout(ctx) const press = 1 - 0.08 * bump(t, 2.1, 2.4) const scale = lerp(0.88, 1, inP) * lerp(1, 0.95, outP) ctx.save() ctx.translate(540, 590) ctx.scale(scale, scale) ctx.translate(-540, -590) ctx.globalAlpha = alpha * (1 - outP) softShadow(ctx, 130, 495, 820, 190, 40, 0.14) ctx.fillStyle = '#ffffff' ctx.beginPath(); rr(ctx, 130, 495, 820, 190, 40); ctx.fill() ctx.textAlign = 'left' ctx.textBaseline = 'middle' ctx.fillStyle = C.muted ctx.font = F('normal', 24) ctx.fillText('扫码后显示的内容', 170, 548) // 发送按钮 ctx.save() ctx.translate(880, 590) ctx.scale(press, press) ctx.fillStyle = C.brand ctx.beginPath(); ctx.arc(0, 0, 44, 0, TAU); ctx.fill() ctx.strokeStyle = '#fff' ctx.lineWidth = 7 ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.moveTo(2, -13); ctx.lineTo(15, 0); ctx.lineTo(2, 13); ctx.stroke() ctx.restore() ctx.restore() // 文字逐字出现,随后逐字化作豆子 const count = Math.floor(prog(t, 0.55, 1.9) * DEMO_TEXT.length + 0.0001) ctx.font = F('bold', 64) ctx.textAlign = 'left' ctx.fillStyle = C.ink for (let i = 0; i < count; i += 1) { const start = 2.7 + (i / DEMO_TEXT.length) * 0.55 const a = alpha * (1 - prog(t, start, start + 0.35)) if (a <= 0) continue ctx.globalAlpha = a ctx.fillText(DEMO_TEXT[i], L.charX[i], L.y) } const typing = t >= 0.55 && t <= 1.9 if (t < 2.55 && (typing || Math.floor(t / 0.4) % 2 === 0)) { ctx.globalAlpha = alpha ctx.fillStyle = C.brand2 ctx.fillRect(L.charX[count] + 4, L.y - 34, 5, 68) } ctx.globalAlpha = 1 } // 主画板上的豆子:飞入 → 黑白二维码 → 扫过换色 const drawMainBeads = (ctx, t, L) => { const G = gridOf(L) const TL = textLayout(ctx) const baseR = G.cell * BEAD_R for (let i = 0; i < cells.length; i += 1) { const b = cells[i] if (t < b.t0) continue const dx = G.ox + (b.c + 0.5) * G.cell const dy = G.oy + (b.r + 0.5) * G.cell let x = dx; let y = dy; let s = 1 const f = prog(t, b.t0, b.t0 + FLIGHT) if (f < 1) { const sx = TL.charX[b.k] + b.jx * (TL.charX[b.k + 1] - TL.charX[b.k]) const sy = TL.y + b.jy * 44 const cx = (sx + dx) / 2 + b.bend * 220 const cy = Math.min(sy, dy) - 110 - Math.abs(b.bend) * 160 const e = easeInOut(f) const u = 1 - e x = u * u * sx + 2 * u * e * cx + e * e * dx y = u * u * sy + 2 * u * e * cy + e * e * dy s = lerp(0.55, 1, easeOut(f)) } else { s = 1 + 0.16 * bump(t, b.t0 + FLIGHT, b.t0 + FLIGHT + 0.2) } s += 0.3 * bump(t, b.tw - 0.05, b.tw + 0.25) addBead(groups, t >= b.tw ? b.finalHex : b.qrHex, x, y, baseR * s) } flushBeads(ctx, groups) } const drawFinalBeads = (ctx, L) => { const G = gridOf(L) const r = G.cell * BEAD_R for (let i = 0; i < cells.length; i += 1) { const b = cells[i] addBead(groups, b.finalHex, G.ox + (b.c + 0.5) * G.cell, G.oy + (b.r + 0.5) * G.cell, r) } flushBeads(ctx, groups) } // 二维码定位图形高亮 const drawFinders = (ctx, t, L) => { const p = prog(t, 4.15, 4.95) if (p <= 0 || p >= 1) return const G = gridOf(L) const alpha = Math.sin(Math.PI * p) const grow = (1 - easeOut(p)) * 18 ctx.strokeStyle = C.brand2 ctx.lineWidth = 6 ctx.globalAlpha = alpha finders.forEach(([c, r]) => { const x = G.ox + c * G.cell - 6 - grow const y = G.oy + r * G.cell - 6 - grow const w = 7 * G.cell + 12 + grow * 2 ctx.beginPath(); rr(ctx, x, y, w, w, 16); ctx.stroke() }) ctx.globalAlpha = 1 } // 沿对角线扫过的光带 const drawGleam = (ctx, L, p, strength) => { if (p <= 0 || p >= 1) return const x = L.cx - L.B / 2 const y = L.cy - L.B / 2 const g = ctx.createLinearGradient(x, y, x + L.B, y + L.B) const s0 = clamp01(p - 0.12); const s1 = clamp01(p); const s2 = clamp01(p + 0.04) g.addColorStop(0, 'rgba(255,255,255,0)') if (s0 > 0) g.addColorStop(s0, 'rgba(255,255,255,0)') g.addColorStop(s1, `rgba(255,255,255,${strength})`) if (s2 < 1) g.addColorStop(s2, 'rgba(255,255,255,0)') g.addColorStop(1, 'rgba(255,255,255,0)') ctx.save() ctx.beginPath(); rr(ctx, x, y, L.B, L.B, L.B * 0.045); ctx.clip() ctx.fillStyle = g ctx.fillRect(x, y, L.B, L.B) ctx.restore() } // 第 3 幕:照片飞入并铺到画板上 const drawPhoto = (ctx, t, L) => { if (t < 4.95 || t > 6.65) return const a = easeOut(prog(t, 5.0, 5.55)) const b = easeInOut(prog(t, 5.6, 6.05)) let x = lerp(1320, 800, a); let y = lerp(250, 330, a); let rot = lerp(0.4, -0.12, a); let s = 300 x = lerp(x, L.cx, b); y = lerp(y, L.cy, b); rot = lerp(rot, 0, b); s = lerp(s, L.B * 0.9, b) const frame = lerp(14, 0, b) const alpha = 1 - prog(t, 6.0, 6.6) ctx.save() ctx.globalAlpha = alpha ctx.translate(x, y) ctx.rotate(rot) const half = s / 2 + frame if (b < 1) softShadow(ctx, -half, -half, half * 2, half * 2, 18, 0.2 * (1 - b)) ctx.fillStyle = '#ffffff' ctx.beginPath(); rr(ctx, -half, -half, half * 2, half * 2, 18); ctx.fill() ctx.beginPath(); rr(ctx, -s / 2, -s / 2, s, s, 10); ctx.clip() drawIllustration(ctx, -s / 2, -s / 2, s) ctx.restore() ctx.globalAlpha = 1 } // 第 4 幕:手机扫码 const drawPhone = (ctx, t) => { const enter = easeOut(prog(t, 7.75, 8.35)) const exit = easeIn(prog(t, 9.95, 10.4)) if (enter <= 0 || exit >= 1) return const w = 340; const h = 660 const px = 800 const py = 590 + (1 - enter) * 860 + exit * 860 const rot = lerp(0.16, -0.035, enter) ctx.save() ctx.translate(px, py) ctx.rotate(rot) softShadow(ctx, -w / 2, -h / 2, w, h, 60, 0.28) ctx.fillStyle = '#1b2321' ctx.beginPath(); rr(ctx, -w / 2, -h / 2, w, h, 60); ctx.fill() ctx.fillStyle = '#2c3633' ctx.fillRect(w / 2 - 1, -h / 2 + 150, 5, 70) // 屏幕 const sw = w - 24; const sh = h - 24 ctx.save() ctx.beginPath(); rr(ctx, -sw / 2, -sh / 2, sw, sh, 48); ctx.clip() ctx.fillStyle = '#0e1312' ctx.fillRect(-sw / 2, -sh / 2, sw, sh) const jx = Math.sin(t * 3.1) * 3 const jy = Math.cos(t * 2.3) * 3 const cam = { cx: jx, cy: -30 + jy, B: 250 } ctx.globalAlpha = 0.92 ctx.fillStyle = C.bg ctx.fillRect(-sw / 2, -sh / 2, sw, sh) ctx.globalAlpha = 1 drawPlate(ctx, cam, 1) drawFinalBeads(ctx, cam) ctx.fillStyle = 'rgba(10,15,14,0.45)' ctx.fillRect(-sw / 2, -sh / 2, sw, sh) // 取景框 const detect = prog(t, 9.0, 9.3) const snap = t >= 9.0 ? 1 + 0.1 * (1 - easeOut(detect)) : 1 const half = 146 * snap ctx.strokeStyle = t >= 9.0 ? C.wechat : '#ffffff' ctx.lineWidth = 6 ctx.lineCap = 'round' const arm = 34 ctx.beginPath() ;[[-1, -1], [1, -1], [1, 1], [-1, 1]].forEach(([sx, sy]) => { const x = cam.cx + sx * half const y = cam.cy + sy * half ctx.moveTo(x, y - sy * arm); ctx.lineTo(x, y); ctx.lineTo(x - sx * arm, y) }) ctx.stroke() // 扫描线 const sp = prog(t, 8.3, 9.0) if (sp > 0 && sp < 1) { const top = cam.cy - 140 const y = lerp(top, cam.cy + 140, easeInOut(sp)) const g = ctx.createLinearGradient(0, y - 80, 0, y) g.addColorStop(0, 'rgba(7,193,96,0)') g.addColorStop(1, 'rgba(7,193,96,0.35)') ctx.fillStyle = g ctx.fillRect(cam.cx - 140, Math.max(top, y - 80), 280, y - Math.max(top, y - 80)) ctx.fillStyle = C.wechat ctx.fillRect(cam.cx - 140, y - 2, 280, 4) } ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillStyle = '#ffffff' ctx.font = F('bold', 24) ctx.fillText('扫一扫', 0, -sh / 2 + 78) if (t < 9.0) { ctx.globalAlpha = 0.7 ctx.font = F('normal', 19) ctx.fillText('将二维码放入框内', 0, cam.cy + 185) ctx.globalAlpha = 1 } const flash = t >= 9.0 ? 0.35 * (1 - prog(t, 9.0, 9.25)) : 0 if (flash > 0) { ctx.fillStyle = `rgba(255,255,255,${flash.toFixed(3)})` ctx.fillRect(-sw / 2, -sh / 2, sw, sh) } // 识别结果卡片 const rp = prog(t, 9.1, 9.5) if (rp > 0) { const cw = sw - 36; const ch = 170 const cy = sh / 2 - 18 - ch + (1 - easeBack(rp)) * 220 ctx.fillStyle = '#ffffff' ctx.beginPath(); rr(ctx, -cw / 2, cy, cw, ch, 28); ctx.fill() ctx.fillStyle = C.wechat ctx.beginPath(); ctx.arc(0, cy + 44, 22, 0, TAU); ctx.fill() ctx.strokeStyle = '#fff' ctx.lineWidth = 5 ctx.lineJoin = 'round' ctx.beginPath(); ctx.moveTo(-9, cy + 44); ctx.lineTo(-2, cy + 51); ctx.lineTo(10, cy + 37); ctx.stroke() ctx.fillStyle = C.ink ctx.font = F('bold', 30) ctx.fillText(DEMO_TEXT, 0, cy + 100) ctx.fillStyle = C.muted ctx.font = F('normal', 18) ctx.fillText('扫码结果', 0, cy + 138) } ctx.restore() // 灵动岛 ctx.fillStyle = '#000' ctx.beginPath(); rr(ctx, -52, -h / 2 + 26, 104, 30, 15); ctx.fill() ctx.restore() // 识别成功:豆子礼花 const bt = t - 9.2 if (bt > 0 && bt < 1.4) { const ox = px; const oy = py + 200 const fade = 1 - prog(bt, 0.9, 1.4) ctx.globalAlpha = fade burst.forEach((p) => { const x = ox + Math.cos(p.a) * p.v * bt const y = oy + Math.sin(p.a) * p.v * bt + 900 * bt * bt addBead(groups, p.hex, x, y, p.r) }) flushBeads(ctx, groups) ctx.globalAlpha = 1 } } // 片尾 const drawEnd = (ctx, t) => { if (t < 10.4) return ctx.textAlign = 'center' ctx.textBaseline = 'middle' const a = easeOut(prog(t, 10.45, 10.95)) ctx.globalAlpha = a ctx.fillStyle = C.ink ctx.font = F('bold', 64) ctx.fillText('豆维 · 可扫码的拼豆', 540, 745 + (1 - a) * 30) const b = easeOut(prog(t, 10.6, 11.1)) ctx.globalAlpha = b ctx.fillStyle = C.muted ctx.font = F('normal', 32) ctx.fillText('把一句话藏进拼豆里,微信一扫就能读出来', 540, 812 + (1 - b) * 30) const chips = ['MARD 标准色号', 'A4 打印图纸', '微信一扫即读'] ctx.font = F('bold', 26) const widths = chips.map((s) => ctx.measureText(s).width + 44) const total = widths.reduce((s, v) => s + v, 0) + 18 * (chips.length - 1) let x = 540 - total / 2 chips.forEach((label, i) => { const p = easeBack(prog(t, 10.85 + i * 0.12, 11.3 + i * 0.12)) if (p > 0) { ctx.globalAlpha = clamp01(p) const cy = 892 + (1 - p) * 24 ctx.fillStyle = C.brandSoft ctx.beginPath(); rr(ctx, x, cy - 26, widths[i], 52, 26); ctx.fill() ctx.fillStyle = C.brand ctx.fillText(label, x + widths[i] / 2, cy) } x += widths[i] + 18 }) ctx.globalAlpha = 1 } const draw = (ctx, time, pixelWidth) => { const t = loop ? ((time % DURATION) + DURATION) % DURATION : Math.min(time, DURATION) const k = pixelWidth / U ctx.setTransform(k, 0, 0, k, 0, 0) ctx.globalAlpha = 1 drawBackground(ctx, t) const L = boardAt(t) if (t >= 2.65) { const p = easeBack(prog(t, 2.65, 3.15)) const s = lerp(0.92, 1, p) const LS = { cx: L.cx, cy: L.cy, B: L.B * s } drawPlate(ctx, LS, prog(t, 2.65, 3.0)) } drawInput(ctx, t) if (t >= 2.72) drawMainBeads(ctx, t, L) drawFinders(ctx, t, L) drawGleam(ctx, L, prog(t, 5.9, 7.0), 0.55) drawGleam(ctx, L, prog(t, 11.0, 11.7), 0.4) drawPhoto(ctx, t, L) drawPhone(ctx, t) drawCaption(ctx, t) drawProgress(ctx, t) drawEnd(ctx, t) if (loop) { const fade = easeInOut(prog(t, 11.45, 12)) if (fade > 0) { ctx.globalAlpha = fade ctx.fillStyle = C.bg ctx.fillRect(0, 0, U, U) ctx.globalAlpha = 1 } } ctx.setTransform(1, 0, 0, 1, 0, 0) } // 关键时间点(供视频配乐对齐) const timeline = { landings: cells.map((b) => b.t0 + FLIGHT), wipes: cells.map((b) => b.tw) } return { duration: DURATION, text: DEMO_TEXT, artwork: art, draw, timeline } } module.exports = { createIntro, DEMO_TEXT, DURATION }