Spaces:
Running
Running
Download src/video/covers.ts from Ethscriptions/mx: direct link, hf CLI and curl.
- Browser
- Download file 6.49 kB
-
https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/covers.ts
- Command line
-
hf download hf://spaces/Ethscriptions/mx/src/video/covers.ts
-
curl -L -o covers.ts https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/covers.ts
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')) | |
| } | |