import { Engine } from './engine' import { frameAt, SHOTS } from './scenes' import * as O from './overlay' import { orbit } from './state' import type { SceneState } from './model' export interface CoverSpec { key: string; platform: string; label: string; w: number; h: number; l1: string; l2: string; hot: string; color: string } export const COVERS: CoverSpec[] = [ { key: 'xhs-34', platform: '小红书', label: '3:4 竖版', w: 1080, h: 1440, l1: '拇指一动就疼?', l2: '3D看懂腱鞘炎', hot: '腱鞘炎', color: '#ff2442' }, { key: 'xhs-43', platform: '小红书', label: '4:3 横版', w: 1440, h: 1080, l1: '拇指一动就疼?', l2: '3D看懂腱鞘炎', hot: '腱鞘炎', color: '#ff2442' }, { key: 'dy-34', platform: '抖音', label: '3:4 竖版', w: 1080, h: 1440, l1: '一用力就疼?', l2: '透视腱鞘炎', hot: '腱鞘炎', color: '#25f4ee' }, { key: 'dy-43', platform: '抖音', label: '4:3 横版', w: 1440, h: 1080, l1: '一用力就疼?', l2: '透视腱鞘炎', hot: '腱鞘炎', color: '#25f4ee' }, { key: 'bili-43', platform: 'B站', label: '4:3 投稿封面', w: 1440, h: 1080, l1: '【3D解剖】', l2: '看懂腱鞘炎', hot: '腱鞘炎', color: '#00aeec' }, { key: 'bili-169', platform: 'B站', label: '16:9 个人空间', w: 1920, h: 1080, l1: '拇指一动就疼?', l2: '90秒看懂腱鞘炎', hot: '腱鞘炎', color: '#00aeec' }, { key: 'sph-34', platform: '视频号', label: '3:4 主页卡片', w: 1080, h: 1440, l1: '拇指疼?', l2: '看懂腱鞘炎', hot: '腱鞘炎', color: '#fa9d3b' }, { key: 'sph-43', platform: '视频号', label: '4:3 分享卡片', w: 1440, h: 1080, l1: '拇指疼?', l2: '看懂腱鞘炎', hot: '腱鞘炎', color: '#fa9d3b' }, ] let eng: Engine | null = null function coverState(portrait: boolean): SceneState { const t = SHOTS[1].start + 8.6 const s = frameAt(t).views[0].state return { ...s, pain: 1, friction: 1, thick: 1, xray: 1, skin: 0.8, bone: 1, tendon: 1, sheath: 1, focus: 0.5, cam: { pos: orbit([0, 0, 0], portrait ? 2.35 : 2.0, 0.05, 0.14), target: [0, 0, 0], fov: 35, follow: 1, shift: portrait ? [0.56, 0.64] : [0.66, 0.54] }, } } function rr(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); 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() } /** Draw a cover onto the given canvas (sized to spec) */ export function drawCover(spec: CoverSpec, canvas: HTMLCanvasElement) { const { w, h } = spec canvas.width = w; canvas.height = h const ctx = canvas.getContext('2d')! if (!eng) eng = new Engine() eng.setSize(w, h) const portrait = h > w const st = coverState(portrait) const t = st.time eng.render({ views: [{ rect: [0, 0, 1, 1], state: st, bg: 'lab' }], overlay: (c, api) => { const m = Math.min(w, h), F = O.FONT // text-side scrim const g = portrait ? c.createLinearGradient(0, 0, 0, h * 0.55) : c.createLinearGradient(0, 0, w * 0.6, 0) g.addColorStop(0, 'rgba(2,8,18,0.92)'); g.addColorStop(0.6, 'rgba(2,8,18,0.55)'); g.addColorStop(1, 'rgba(2,8,18,0)') c.fillStyle = g; c.fillRect(0, 0, w, h) // pain marker + labels const p = api.anchor('pain'), sh = api.anchor('sheath') O.reticle(c, p.x, p.y, m * 0.075, 1, t, O.COL.red, 1) c.save(); c.scale(m / 1080, m / 1080) const k = 1080 / m O.label(c, sh.x * k, sh.y * k, sh.x * k + (portrait ? 170 : 190), sh.y * k + (portrait ? 230 : 210), '腱鞘增厚 · 通道变窄', 1, O.COL.yellow, 34) c.restore() // text block const x0 = m * 0.07, y0 = portrait ? m * 0.09 : m * 0.12 c.textBaseline = 'top'; c.textAlign = 'left' // badge const bs = m * 0.036 c.font = `900 ${bs}px ${F}` const btxt = '3D 解剖动画 · 医学科普' const bw = c.measureText(btxt).width + bs * 1.4 rr(c, x0, y0, bw, bs * 1.8, bs * 0.9); c.fillStyle = 'rgba(95,212,255,0.16)'; c.fill() c.strokeStyle = O.COL.cyan; c.lineWidth = 2; c.stroke() c.fillStyle = O.COL.cyan; c.fillText(btxt, x0 + bs * 0.7, y0 + bs * 0.4) // headline const h1 = m * (spec.l1.length > 6 ? 0.105 : 0.125) c.font = `900 ${h1}px ${F}` c.shadowColor = 'rgba(0,0,0,0.6)'; c.shadowBlur = 20 c.fillStyle = '#fff'; c.fillText(spec.l1, x0, y0 + bs * 2.6) const h2 = m * (spec.l2.length > 6 ? 0.115 : 0.135) c.font = `900 ${h2}px ${F}` const y2 = y0 + bs * 2.6 + h1 * 1.2 const idx = spec.l2.indexOf(spec.hot) const pre = idx >= 0 ? spec.l2.slice(0, idx) : spec.l2 c.fillStyle = '#fff'; c.fillText(pre, x0, y2) if (idx >= 0) { const px = x0 + c.measureText(pre).width c.shadowColor = 'rgba(255,40,40,0.9)'; c.shadowBlur = 40 const gg = c.createLinearGradient(0, y2, 0, y2 + h2); gg.addColorStop(0, '#ff7a6a'); gg.addColorStop(1, '#ff2a2a') c.fillStyle = gg; c.fillText(spec.hot, px, y2) c.fillText(spec.l2.slice(idx + spec.hot.length), px + c.measureText(spec.hot).width, y2) } c.shadowBlur = 0 // flow chips const fs = m * 0.03 c.font = `800 ${fs}px ${F}` let cx = x0 const cy = y2 + h2 * 1.35 const flow = ['疼痛', '定位', '处理', '松解', '恢复'] flow.forEach((f, i) => { const tw = c.measureText(f).width + fs * 1.2 rr(c, cx, cy, tw, fs * 1.7, fs * 0.5) c.fillStyle = i === 0 ? 'rgba(255,59,59,0.85)' : i === 4 ? 'rgba(62,224,143,0.85)' : 'rgba(255,255,255,0.14)'; c.fill() c.fillStyle = '#fff'; c.fillText(f, cx + fs * 0.6, cy + fs * 0.35) cx += tw if (i < flow.length - 1) { c.fillStyle = 'rgba(255,255,255,0.6)'; c.fillText('›', cx + fs * 0.3, cy + fs * 0.3); cx += fs * 1.1 } }) // platform accent bar + footer c.fillStyle = spec.color; c.fillRect(0, h - m * 0.012, w, m * 0.012) c.font = `600 ${m * 0.022}px ${F}`; c.fillStyle = 'rgba(255,255,255,0.6)'; c.textBaseline = 'bottom' c.fillText('原创3D医学示意动画 · 仅供科普,不替代面诊', x0, h - m * 0.04) }, }, ctx, t, { w, h }) return canvas } export function coverBlob(spec: CoverSpec): Promise { const c = drawCover(spec, document.createElement('canvas')) return new Promise((res, rej) => c.toBlob(b => (b ? res(b) : rej(new Error('toBlob'))), 'image/png')) }