mx / src /video /covers.ts
Ethscriptions's picture
Step 6-7: 播放器 + MP4 导出 + 文案 + 封面
d61897b verified
Raw History Blame Contribute Delete
6.49 kB
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<Blob> {
const c = drawCover(spec, document.createElement('canvas'))
return new Promise((res, rej) => c.toBlob(b => (b ? res(b) : rej(new Error('toBlob'))), 'image/png'))
}