File size: 6,491 Bytes
d61897b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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'))
}