mx / src /video /scenes.ts
Ethscriptions's picture
Step 3-4: 3D 解剖模型与分镜时间轴
213d393 verified
Raw History Blame Contribute Delete
40.1 kB
import { TL } from './timelineData'
import type { Frame, View, OverlayApi } from './engine'
import { POSE, type SceneState, type Pose } from './model'
import { baseState, orbit } from './state'
import { clamp, kf, lerp, lerpArr, ramp, smooth, win, easeOut, noise1, type V3 } from './math'
import * as O from './overlay'
export const DURATION = TL.total
export const SHOTS = TL.shots
export const LINES = TL.lines
type C = CanvasRenderingContext2D
/* ---------------- helpers ---------------- */
const shotLines = (i: number) => LINES.filter((l) => l.shot === i).map((l) => ({ ...l, s: l.start - SHOTS[i].start, e: l.start - SHOTS[i].start + l.dur }))
const SL = SHOTS.map((_, i) => shotLines(i))
const withThumb = (p: Pose, th: number[]) => { const q = [...p]; for (let i = 0; i < 5; i++) q[16 + i] = th[i]; return q }
const THUMB_FLEX = [-0.08, 0.75, 0.1, 0.9, 0.65]
const THUMB_EXT = [0.35, -0.1, 0.0, -0.05, -0.05]
/** thumb flex/extension cycle on top of a base pose */
function thumbCycle(base: Pose, u: number, hz: number, amp: number, jerky = 0): Pose {
let k = 0.5 - 0.5 * Math.cos(u * hz * Math.PI * 2)
if (jerky > 0) {
// stick–slip: hold then jump
const stepK = Math.round(k * 4) / 4
k = lerp(k, stepK, jerky) + noise1(u * 13) * 0.04 * jerky
}
const ext = withThumb(base, THUMB_EXT)
const flx = withThumb(base, THUMB_FLEX)
return lerpArr(ext, flx, clamp(k * amp + (1 - amp) * 0.35))
}
const mixPose = (a: Pose, b: Pose, k: number) => lerpArr(a, b, clamp(k))
const setW = (p: Pose, flex: number, dev: number, pron = p[2]) => { const q = [...p]; q[0] = flex; q[1] = dev; q[2] = pron; return q }
const full = (state: SceneState, bg = 'lab'): View[] => [{ rect: [0, 0, 1, 1], state, bg }]
const painCam = (dist: number, yaw: number, pitch: number, fov = 35) => ({ pos: orbit([0, 0, 0], dist, yaw, pitch), target: [0, 0, 0] as V3, fov, follow: 1 })
/* =========================================================================
SHOT 1 · 疼痛开始
========================================================================= */
function shot1(u: number, d: number, t: number): Frame {
const L = SL[0]
const a = L[0].s, b = L[1].s
// pose
let pose: Pose
if (u < a - 0.2) pose = thumbCycle(POSE.rest, u, 0.85, 0.9)
else pose = mixPose(mixPose(POSE.rest, POSE.fist, smooth(ramp(u, a - 0.2, a + 0.6))), POSE.tuck, smooth(ramp(u, a + 0.55, a + 1.15)))
const hit = a + 1.05
const painK = smooth(ramp(u, hit, hit + 0.35))
// relax after the hit, back to near rest when going inside
pose = mixPose(pose, mixPose(POSE.rest, POSE.tuck, 0.25), smooth(ramp(u, b + 0.9, b + 1.6)))
const shakeA = Math.max(0, 1 - Math.abs(u - hit - 0.15) / 0.35) * 0.035
const shake: V3 = [noise1(u * 40) * shakeA, noise1(u * 37 + 5) * shakeA, 0]
const push = smooth(ramp(u, b - 0.25, b + 1.0))
const xr = smooth(ramp(u, b + 0.7, b + 1.3))
const camWide = { pos: orbit([0, 0.45, 0], lerp(5.6, 4.8, smooth(u / a)), 0.25 - 0.15 * smooth(u / d), 0.08), target: [0, 0.45, 0] as V3, fov: 35 }
const state = baseState({
time: t, pose, shake,
arm: { pos: [0, 0, 0], rot: [0.0, lerp(0.45, 0.95, push), 0.04] },
cam: push < 0.001 ? camWide : (() => {
const pc = painCam(lerp(4.8, 1.75, push), lerp(0.1, 0.05, push), lerp(0.08, 0.12, push))
// blend from hand-centered to pain-follow
return { ...pc, follow: push, pos: lerpVec(camWide.pos, pc.pos, push), target: lerpVec(camWide.target, pc.target, push) }
})(),
pain: painK * (1 - 0.2 * xr),
xray: xr, skin: 1,
bone: smooth(ramp(u, b + 1.0, b + 1.7)),
tendon: smooth(ramp(u, b + 1.25, b + 1.95)),
sheath: smooth(ramp(u, b + 1.5, d + 0.4)),
friction: 0.0,
})
return {
views: full(state),
fade: 1 - smooth(u / 0.8),
overlay: (ctx, api) => {
const p = api.anchor('pain')
O.painBurst(ctx, p.x, p.y, Math.max(0, 1 - Math.abs(u - hit - 0.4) / 1.2) * painK, t)
O.reticle(ctx, p.x, p.y, 70, win(u, b, d, 0.3, 0.4) * (1 - xr * 0.5), t, O.COL.red, smooth(ramp(u, b, b + 0.6)))
O.bigWord(ctx, '?', p.x + 150, p.y - 150, ramp(u, b + 0.2, b + 0.6) * (1 - ramp(u, b + 1.3, b + 1.6)), O.COL.red, 150)
// layer labels
const lb = api.anchor('bone'), lt = api.anchor('tendon'), ls = api.anchor('sheath')
O.label(ctx, lb.x, lb.y, lb.x + 230, lb.y + 120, '骨骼', ramp(u, b + 1.2, b + 1.5), '#f2e7cf')
O.label(ctx, lt.x, lt.y, lt.x + 260, lt.y - 60, '肌腱', ramp(u, b + 1.45, b + 1.75), O.COL.white)
O.label(ctx, ls.x, ls.y, ls.x - 230, ls.y - 170, '腱鞘', ramp(u, b + 1.7, b + 2.0), O.COL.cyan)
},
}
}
const lerpVec = (a: V3 | number[], b: V3 | number[], k: number): V3 => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]
/* =========================================================================
SHOT 2 · 腱鞘炎到底发生了什么
========================================================================= */
function shot2(u: number, d: number, t: number): Frame {
const L = SL[1]
const thick = smooth(ramp(u, L[2].s, L[3].e))
const fr = Math.max(0.45 * smooth(ramp(u, L[2].s, L[3].s)), 0.95 * smooth(ramp(u, L[4].s, L[5].s + 0.6)))
const jerk = smooth(ramp(u, L[3].s, L[5].s))
const pose = thumbCycle(POSE.rest, u, lerp(0.9, 1.1, jerk), lerp(1, 0.7, jerk), jerk)
const state = baseState({
time: t, pose,
arm: { pos: [0, 0, 0], rot: [0.0, lerp(0.95, 0.75, smooth(u / d)), 0.04] },
cam: painCam(lerp(1.75, 1.08, easeOut(u / 3)), lerp(0.05, -0.12, smooth(u / d)), 0.12),
skin: 0.75, xray: 1, bone: 1, tendon: 1, sheath: 1,
focus: smooth(ramp(u, 0, 1.0)), thick, friction: fr,
pain: 0.85 * smooth(ramp(u, L[5].s, L[5].s + 0.6)),
})
return {
views: full(state),
overlay: (ctx, api) => {
const s = api.anchor('sheath'), tn = api.anchor('tendon')
O.label(ctx, s.x, s.y, s.x - 200, s.y - 260, '腱鞘 = 小通道', win(u, L[0].s, L[2].s + 0.3), O.COL.cyan)
O.label(ctx, tn.x, tn.y, tn.x + 150, tn.y + 200, '肌腱', win(u, L[0].s + 0.4, L[2].s + 0.3), O.COL.white)
O.chip(ctx, 540, 1300, '正常:顺畅滑动', { icon: 'check', color: O.COL.green, alpha: win(u, L[1].s, L[2].s + 0.2) })
O.crossSection(ctx, 830, 560, 105, thick, fr, smooth(ramp(u, 0.5, 1.0)), t)
const k = (i: number) => ramp(u, L[i].s + 0.1, L[i].s + 0.5)
O.chip(ctx, 540, 1150, '腱鞘增厚', { icon: 'warn', color: O.COL.yellow, alpha: k(3), scale: 0.8 + 0.2 * easeOut(k(3)) })
O.chip(ctx, 540, 1268, '空间变窄', { icon: 'warn', color: '#ff9a3c', alpha: k(4), scale: 0.8 + 0.2 * easeOut(k(4)) })
O.chip(ctx, 540, 1386, '活动疼痛', { icon: 'warn', color: O.COL.red, alpha: k(5), scale: 0.8 + 0.2 * easeOut(k(5)) })
},
}
}
/* =========================================================================
SHOT 3 · 为什么一动就疼 (抓 / 捏 / 拧)
========================================================================= */
function shot3(u: number, d: number, t: number): Frame {
const seg = d / 3
const i = Math.min(2, Math.floor(u / seg)), v = u - i * seg
const squeeze = 0.5 - 0.5 * Math.cos(v * Math.PI * 2 * 1.3)
let pose: Pose, props: SceneState['props'] = {}
let twist: { pivot: V3; axis: V3; angle: number } | undefined
if (i === 0) { pose = mixPose(POSE.open, POSE.gripBall, smooth(ramp(v, 0, 0.35)) * (0.8 + 0.2 * squeeze)); props = { ball: { vis: smooth(ramp(v, 0, 0.2)) } } }
else if (i === 1) { pose = mixPose(POSE.open, POSE.pinch, smooth(ramp(v, 0, 0.35)) * (0.9 + 0.1 * squeeze)); props = { bead: { vis: smooth(ramp(v, 0.25, 0.4)) } } }
else {
pose = mixPose(POSE.open, POSE.gripBall, smooth(ramp(v, 0, 0.3)))
pose = setW(pose, pose[0], pose[1] + 0.25 * squeeze)
const ang = -0.8 * squeeze
twist = { pivot: [-0.02, 0.6, -0.3], axis: [0, 0, 1], angle: ang }
props = { bottle: { vis: smooth(ramp(v, 0, 0.2)), twist: ang } }
}
const lvl = [0.4, 0.7, 1.0][i]
const state = baseState({
time: t, pose, props,
arm: { pos: [0, 0, 0], rot: [0.05, 0.85, 0.05], twist },
cam: { ...painCam(2.5, 0.25, 0.18), pos: orbit([0, 0, 0], 2.5, 0.25, 0.18) },
skin: 1, xray: 0.62, bone: 1, tendon: 1, sheath: 1, focus: 0.25,
thick: 1, friction: lvl * (0.7 + 0.3 * squeeze), pain: lvl * (0.75 + 0.25 * squeeze),
})
// camera looks slightly above the pain point so the hand & prop are visible
state.cam.target = [0, 0.35, 0]; state.cam.pos = [state.cam.pos[0], state.cam.pos[1] + 0.35, state.cam.pos[2]]
return {
views: full(state),
overlay: (ctx, api) => {
const names = ['抓', '捏', '拧'] as const
const kinds = ['grab', 'pinch', 'twist'] as const
names.forEach((n, k) => {
const on = k === i ? 1 : k < i ? 0.55 : 0.3
const x = 250 + k * 290, y = 1230
ctx.save(); ctx.globalAlpha = on
ctx.fillStyle = k === i ? 'rgba(255,59,59,0.22)' : 'rgba(4,14,30,0.7)'
ctx.strokeStyle = k === i ? O.COL.red : 'rgba(95,212,255,0.5)'; ctx.lineWidth = k === i ? 5 : 2
ctx.beginPath(); ctx.arc(x, y, 92 * (k === i ? 1 + 0.06 * squeeze : 1), 0, 7); ctx.fill(); ctx.stroke()
ctx.restore()
O.actionIcon(ctx, kinds[k], x, y - 22, 90, k === i ? '#fff' : O.COL.cyan, on)
ctx.save(); ctx.globalAlpha = on; ctx.font = `900 46px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(n, x, y + 48); ctx.restore()
})
// pain meter
const p = api.anchor('pain')
O.painBurst(ctx, p.x, p.y, lvl * 0.5 * squeeze, t)
const mx = 975, my0 = 520, mh = 480
ctx.save()
ctx.fillStyle = 'rgba(4,14,30,0.75)'; ctx.fillRect(mx - 20, my0, 40, mh)
const fillH = mh * lvl * (0.85 + 0.15 * squeeze)
const g = ctx.createLinearGradient(0, my0 + mh, 0, my0); g.addColorStop(0, O.COL.yellow); g.addColorStop(1, O.COL.red)
ctx.fillStyle = g; ctx.fillRect(mx - 20, my0 + mh - fillH, 40, fillH)
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; ctx.strokeRect(mx - 20, my0, 40, mh)
ctx.font = `800 30px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText('疼痛', mx, my0 + mh + 40)
ctx.restore()
},
}
}
/* =========================================================================
SHOT 4 · 哪些人更容易出现
========================================================================= */
function shot4(u: number, d: number, t: number): Frame {
const L = SL[3]
const s0 = L[1].s - 0.05
const segD = (d - s0) / 4
const intro = u < s0
const i = intro ? -1 : Math.min(3, Math.floor((u - s0) / segD))
const v = intro ? u : u - s0 - i * segD
const words = ['重复用手', '长期抓握', '频繁捏拧', '抱孩子']
const icons = ['keyboard', 'tool', 'home', 'baby'] as const
let state: SceneState
const painFlash = intro ? 0 : smooth(ramp(v, 0.35, 0.6)) * (0.75 + 0.25 * Math.sin(t * 9))
if (intro) {
state = baseState({
time: t, pose: mixPose(POSE.rest, POSE.open, 0.5 + 0.5 * Math.sin(u * 3)),
arm: { pos: [0, 0, 0], rot: [0, -0.4 + u * 0.9, 0] },
cam: { pos: orbit([0, 0.4, 0], 5.4, 0, 0.1), target: [0, 0.4, 0], fov: 35 },
})
} else if (i === 0) {
const tap = Math.max(0, Math.sin(v * 18))
const pose = setW(POSE.mouse, -0.25, 0.1 + 0.18 * Math.sin(v * 5))
pose[4] += tap * 0.18
state = baseState({
time: t, pose,
arm: { pos: [0, 0, 0], rot: [-1.25, 0, 0] },
props: { keyboard: { vis: 1, x: -1.75, y: 0.7, z: -0.38, rx: Math.PI / 2 } },
cam: { pos: [-1.0, 2.4, 2.6], target: [-0.35, 0.15, -0.6], fov: 38 },
pain: painFlash,
})
} else if (i === 1) {
const hamK = Math.pow(0.5 + 0.5 * Math.sin(v * 9), 2)
state = baseState({
time: t, pose: setW(POSE.grip, 0.05, -0.15 + 0.5 * hamK),
arm: { pos: [0, 0, 0], rot: [0, 0.85, 0.15] },
props: { hammer: { vis: 1 } },
cam: { pos: orbit([-0.2, 0.55, 0], 4.2, 0.05, 0.12), target: [-0.2, 0.55, 0], fov: 35 },
pain: painFlash,
})
} else if (i === 2) {
const w = Math.sin(v * 6)
state = baseState({
time: t, pose: setW(POSE.grip, 0.1, 0.15 + 0.15 * w, 0.55 * w),
arm: { pos: [0, 0, 0], rot: [0, 0.75, -0.2] },
props: { towel: { vis: 1 } },
cam: { pos: orbit([0.1, 0.5, 0], 4.4, 0.1, 0.15), target: [0.1, 0.5, 0], fov: 35 },
pain: painFlash,
})
} else {
state = baseState({
time: t, pose: setW(POSE.baby, -0.25, 0.05 * Math.sin(v * 4)),
arm: { pos: [0, -0.1 * Math.sin(v * 3), 0], rot: [1.25, 0.7, 0] },
props: { baby: { vis: 1 } },
cam: { pos: [1.6, 3.0, 3.6], target: [0.0, 0.35, 0.3], fov: 38 },
pain: painFlash,
})
}
return {
views: full(state, intro ? 'lab' : 'warm'),
overlay: (ctx, api) => {
if (!intro && v < 0.14) O.flash(ctx, 0.35 * (1 - v / 0.14))
if (intro) {
icons.forEach((ic, k) => {
const a = ramp(u, 0.2 + k * 0.12, 0.45 + k * 0.12)
const x = 195 + k * 230, y = 1230
ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = 'rgba(4,14,30,0.75)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3
ctx.beginPath(); ctx.arc(x, y, 80, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore()
O.actionIcon(ctx, ic, x, y, 80, O.COL.cyan, a)
})
O.bigWord(ctx, '哪些人更容易出现?', 540, 1050, ramp(u, 0.1, 0.5), '#ffffff', 74)
return
}
const p = api.anchor('pain')
O.painBurst(ctx, p.x, p.y, painFlash * 0.6, t)
// scene counter + keyword
ctx.save(); ctx.font = `900 34px ${O.FONT}`; ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.textAlign = 'right'
ctx.fillText(`0${i + 1} / 04`, 1030, 330); ctx.restore()
O.chip(ctx, 540, 1290, words[i], { color: O.COL.red, size: 60, alpha: ramp(v, 0.05, 0.25), scale: 0.85 + 0.15 * easeOut(ramp(v, 0.05, 0.35)), icon: 'dot' })
O.actionIcon(ctx, icons[i], 120, 330, 90, O.COL.yellow, 0.9)
},
}
}
/* =========================================================================
SHOT 5 · 不要一上来就直接治疗
========================================================================= */
function shot5(u: number, d: number, t: number): Frame {
const L = SL[4]
const lock = smooth(ramp(u, L[1].s - 0.2, L[1].s + 0.6))
const motion = ramp(u, L[2].s, L[2].s + 0.2)
const probeK = smooth(ramp(u, L[3].s - 0.1, L[3].s + 0.4))
const usK = smooth(ramp(u, L[3].s + 0.5, L[3].s + 1.0))
const pose = motion > 0 ? thumbCycle(POSE.rest, u, 1.1, 0.9) : POSE.rest
const rotY = lerp(-0.7, 0.9, smooth(ramp(u, 0, L[1].s)))
const wide = { pos: orbit([0, 0.35, 0], 4.9, 0, 0.1), target: [0, 0.35, 0] as V3, fov: 35 }
const pc = painCam(lerp(4.9, 1.6, lock), 0.05, 0.12)
const state = baseState({
time: t, pose,
arm: { pos: [0, 0, 0], rot: [0.03, rotY, 0.04] },
cam: { ...pc, follow: lock, pos: lerpVec(wide.pos, pc.pos, lock), target: lerpVec(wide.target, pc.target, lock) },
skin: 1, xray: 0.75, bone: 1, tendon: 1, sheath: 1, thick: 0.8,
focus: smooth(ramp(u, L[2].s, L[2].s + 0.5)) * 0.9,
friction: 0.6 * motion, pain: 0.55 + 0.3 * lock,
props: { probe: { vis: probeK, lift: (1 - probeK) * 0.6 } },
})
return {
views: full(state),
overlay: (ctx, api) => {
O.chip(ctx, 540, 1180, '直接治疗', { icon: 'cross', color: O.COL.red, alpha: win(u, 0.2, L[1].s + 0.2), strike: ramp(u, 0.9, 1.5) })
O.chip(ctx, 540, 1300, '先判断,再治疗', { icon: 'check', color: O.COL.teal, alpha: win(u, 1.2, L[2].s + 0.1) })
const p = api.anchor('pain')
O.reticle(ctx, p.x, p.y, 90, win(u, L[1].s - 0.1, L[3].s + 0.4), t, O.COL.red, lock)
O.label(ctx, p.x, p.y, p.x - 240, p.y - 250, '疼痛位置', win(u, L[1].s + 0.4, L[3].s + 0.4), O.COL.red)
// tendon trajectory (animated dashes along tendon path)
const tr = win(u, L[2].s, L[3].s + 0.5)
if (tr > 0.01) {
const a1 = api.anchor('tendon'), a2 = api.anchor('pain'), a3 = api.anchor('thumbBase')
ctx.save(); ctx.globalAlpha = tr; ctx.strokeStyle = O.COL.yellow; ctx.lineWidth = 7; ctx.setLineDash([22, 18]); ctx.lineDashOffset = -t * 120
ctx.shadowColor = O.COL.yellow; ctx.shadowBlur = 14
ctx.beginPath(); ctx.moveTo(a1.x, a1.y); ctx.quadraticCurveTo(a2.x, a2.y, a3.x, a3.y); ctx.stroke(); ctx.restore()
O.chip(ctx, 540, 1300, '肌腱活动轨迹', { color: O.COL.yellow, alpha: tr, icon: 'dot' })
}
O.ultrasound(ctx, 110, 470, 860, 620, t, { alpha: usK, thick: 0.85, labels: ramp(u, L[4].s, L[4].s + 1.2), narrowMark: ramp(u, L[4].s + 0.6, L[4].s + 1.0) })
},
}
}
/* =========================================================================
SHOT 6 · 常见治疗方法 (四宫格)
========================================================================= */
function shot6(u: number, d: number, t: number): Frame {
const L = SL[5]
const act = (k: number) => (u >= L[k].s - 0.1 && (k === 3 || u < L[k + 1].s - 0.1) ? 1 : 0)
const prog = (k: number) => clamp((u - L[k].s) / (k === 3 ? d - L[k].s : L[k + 1].s - L[k].s))
const y0 = 0.16, ph = 0.3
const rects: [number, number, number, number][] = [[0, y0, 0.5, ph], [0.5, y0, 0.5, ph], [0, y0 + ph, 0.5, ph], [0.5, y0 + ph, 0.5, ph]]
const cam = (dist: number, tgt: V3, yaw = 0, pitch = 0.1) => ({ pos: orbit(tgt, dist, yaw, pitch), target: tgt, fov: 35 })
// 1 减少抓握
const p0 = prog(0)
const v0 = baseState({ time: t, pose: mixPose(POSE.grip, POSE.open, smooth(ramp(p0, 0.1, 0.5))), arm: { pos: [0, 0, 0], rot: [0, 0.5, 0] }, cam: cam(3.3, [0, 0.55, 0]), pain: 0.5 * (1 - p0) })
// 2 支具
const p1 = prog(1)
const v1 = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0, 0.75 + 0.2 * Math.sin(t * 0.8), 0] }, cam: cam(3.5, [0, 0.2, 0]), props: { splint: { vis: smooth(ramp(p1, 0.05, 0.3)) }, splintThumb: { vis: smooth(ramp(p1, 0.05, 0.3)) }, splintPalm: { vis: smooth(ramp(p1, 0.05, 0.3)) } }, pain: 0.4 })
// 3 局部治疗示意
const p2 = prog(2)
const v2 = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0, -0.35, 0] }, cam: painCam(1.5, 0.0, 0.1), skin: 1, xray: 0.9, bone: 1, tendon: 1, sheath: 1, thick: 0.9, focus: 0.7, pain: 0.8 * (1 - smooth(ramp(p2, 0.6, 1)) * 0.6), props: { syringe: { vis: smooth(ramp(p2, 0, 0.15)), depth: lerp(0.8, 0.12, smooth(ramp(p2, 0.05, 0.45))), push: smooth(ramp(p2, 0.5, 0.85)) }, cloud: { vis: smooth(ramp(p2, 0.5, 0.7)) * 0.8, r: 0.04 + 0.1 * smooth(ramp(p2, 0.5, 0.9)) } } })
// 4 手术松解示意
const p3 = prog(3)
const rel = smooth(ramp(p3, 0.35, 0.75))
const v3 = baseState({ time: t, pose: thumbCycle(POSE.rest, t, 0.8, lerp(0.3, 1, rel)), arm: { pos: [0, 0, 0], rot: [0, 0.85, 0] }, cam: painCam(1.25, 0.05, 0.1), skin: 0.7, xray: 1, bone: 1, tendon: 1, sheath: 1, thick: 1 - 0.8 * rel, friction: 0.8 * (1 - rel), focus: 1, pain: 0.9 * (1 - rel * 0.8), props: { ring: { vis: win(p3, 0.35, 0.8, 0.05, 0.2), r: rel } } })
const states = [v0, v1, v2, v3]
const titles = ['减少抓握', '支具固定', '局部治疗示意', '手术松解示意']
return {
views: states.map((s, k) => ({ rect: rects[k], state: s, bg: 'lab' })),
overlay: (ctx, api) => {
ctx.save(); ctx.fillStyle = '#02060d'; ctx.fillRect(0, 0, 1080, y0 * 1920); ctx.fillRect(0, (y0 + 2 * ph) * 1920, 1080, 1920 - (y0 + 2 * ph) * 1920); ctx.restore()
states.forEach((_, k) => {
const [rx, ry, rw, rh] = rects[k]
O.panel(ctx, rx * 1080, ry * 1920, rw * 1080, rh * 1920, titles[k], k + 1, act(k), act(k) ? 0 : (u >= L[k].s ? 0.35 : 0.7))
})
// panel 1: no-grip icon
const a0 = act(0) ? 1 : 0.5
ctx.save(); ctx.globalAlpha = a0; ctx.translate(430, 420); ctx.strokeStyle = O.COL.red; ctx.lineWidth = 7
ctx.beginPath(); ctx.arc(0, 0, 52, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-36, -36); ctx.lineTo(36, 36); ctx.stroke(); ctx.restore()
O.actionIcon(ctx, 'grab', 430, 420, 60, '#fff', a0)
// panel 4 incision dashed line (on sheath)
if (act(3)) {
const s = api.anchor('pain', 3)
ctx.save(); ctx.strokeStyle = '#fff'; ctx.globalAlpha = win(p3, 0.15, 0.6, 0.1, 0.1); ctx.lineWidth = 4; ctx.setLineDash([12, 10])
ctx.beginPath(); ctx.moveTo(s.x - 10, s.y - 120); ctx.lineTo(s.x + 10, s.y + 120); ctx.stroke(); ctx.restore()
}
O.chip(ctx, 540, 1528 - 1528 + 245, '常见处理方式 · 需医生评估', { size: 36, color: O.COL.cyan, alpha: ramp(u, 0, 0.4) })
},
}
}
/* =========================================================================
SHOT 7 · 重点进入小针刀
========================================================================= */
function shot7(u: number, d: number, t: number): Frame {
const L = SL[6]
const depth = u < L[3].s ? lerp(1.7, 0.72, smooth(ramp(u, 0.2, L[1].e))) : lerp(0.72, 0.1, smooth(ramp(u, L[3].s, d - 0.2)))
const state = baseState({
time: t, pose: POSE.rest,
arm: { pos: [0, 0, 0], rot: [0.12, -0.3, 0.0] },
cam: painCam(lerp(2.6, 1.35, smooth(ramp(u, 0, L[2].s))), 0.1, 0.06),
skin: 1, xray: lerp(0.55, 1, smooth(ramp(u, L[2].s - 0.4, L[2].s + 0.3))), bone: 1, tendon: 1, sheath: 1,
thick: 1, friction: 0.5, pain: 0.85, focus: lerp(0.2, 0.8, smooth(ramp(u, L[2].s, L[2].s + 0.5))),
props: { knife: { vis: smooth(ramp(u, 0.1, 0.5)), depth } },
})
return {
views: full(state),
overlay: (ctx, api) => {
O.bigWord(ctx, '小针刀在做什么?', 540, 380, win(u, 0.2, L[1].s, 0.3, 0.3), '#ffffff', 70)
O.chip(ctx, 540, 1250, '扎一下就结束', { icon: 'cross', color: O.COL.red, alpha: win(u, L[1].s, L[2].s + 0.2), strike: ramp(u, L[1].s + 0.4, L[1].e) })
const n = api.anchor('narrow')
O.reticle(ctx, n.x, n.y, 80, win(u, L[2].s, d + 1, 0.3, 0.3), t, O.COL.red, smooth(ramp(u, L[2].s, L[2].s + 0.6)))
O.label(ctx, n.x, n.y, n.x + 210, n.y + 230, '狭窄位置', ramp(u, L[2].s + 0.6, L[2].s + 1.0), O.COL.red)
const k = api.anchor('knife')
O.label(ctx, k.x, k.y, k.x - 40, k.y - 170, '原创器械示意', win(u, 0.8, L[2].s), O.COL.teal, 34)
O.chip(ctx, 540, 1300, '精准松解', { icon: 'check', color: O.COL.teal, alpha: ramp(u, L[3].s + 0.8, L[3].s + 1.2), size: 52 })
},
}
}
/* =========================================================================
SHOT 8 · 小针刀治疗过程
========================================================================= */
function shot8(u: number, d: number, t: number): Frame {
const L = SL[7]
const step = u < L[1].s - 0.1 ? 0 : u < L[2].s - 0.1 ? 1 : u < L[3].s - 0.1 ? 2 : 3
const sL = [L[0].s, L[1].s, L[2].s, L[3].s]
const loc = win(u, 0, L[1].s - 0.05, 0.2, 0.25)
const dis = win(u, L[1].s - 0.1, L[2].s, 0.2, 0.25)
const ane = win(u, L[2].s - 0.1, L[3].s, 0.2, 0.3)
const fin = smooth(ramp(u, L[3].s - 0.2, L[3].s + 0.4))
const relK = smooth(ramp(u, L[5].s, L[5].s + 0.9))
const xray = step === 0 ? 0.6 : step === 1 ? lerp(0.6, 0.12, smooth(ramp(u, L[1].s - 0.1, L[1].s + 0.3))) : step === 2 ? lerp(0.12, 0.5, smooth(ramp(u, L[2].s, L[2].s + 0.4))) : lerp(0.5, 1, fin)
const kDepth = lerp(0.9, 0.0, smooth(ramp(u, L[3].s + 0.3, L[4].e)))
const state = baseState({
time: t, pose: POSE.rest,
arm: { pos: [0, 0, 0], rot: [0.12, -0.3, 0.0] },
cam: painCam(step === 3 ? lerp(1.6, 1.2, fin) : 1.6, 0.1, 0.06),
skin: 1, xray, bone: 1, tendon: 1, sheath: 1,
thick: 1 - 0.7 * relK, friction: 0.5 * (1 - relK), pain: 0.85 - 0.6 * relK, focus: 0.75,
slide: relK * Math.sin(t * 5) * 0.03,
props: {
probe: { vis: Math.max(loc, 0), lift: 0 },
locator: { vis: Math.max(loc, step === 3 ? win(u, L[3].s, L[4].e, 0.2, 0.3) * 0.8 : 0) },
swab: { vis: dis, r: 0.03 + 0.12 * smooth(ramp(u, L[1].s, L[2].s - 0.2)) },
iodine: { vis: Math.max(dis, step >= 2 ? 0.55 * (1 - fin) : 0), r: 0.04 + 0.26 * smooth(ramp(u, L[1].s, L[2].s - 0.2)) },
syringe: { vis: ane, depth: lerp(0.7, 0.13, smooth(ramp(u, L[2].s, L[2].s + 0.7))), push: smooth(ramp(u, L[2].s + 0.7, L[3].s - 0.3)) },
cloud: { vis: ane * smooth(ramp(u, L[2].s + 0.7, L[2].s + 1.1)) * 0.8, r: 0.03 + 0.1 * smooth(ramp(u, L[2].s + 0.7, L[3].s - 0.2)) },
knife: { vis: fin, depth: kDepth, release: relK * (1 - ramp(u, d - 0.3, d)) },
ring: { vis: relK > 0.01 ? (0.5 + 0.5 * Math.sin(t * 14)) * win(u, L[5].s, d, 0.1, 0.3) : 0, r: relK },
},
})
return {
views: full(state),
overlay: (ctx, api) => {
const active = step + (step < 3 ? smooth(ramp(u, sL[step + 1] - 0.4, sL[step + 1])) * 0 : 0)
O.steps(ctx, ['定位', '消毒', '麻醉', '松解'], active, 1, 330)
O.ultrasound(ctx, 190, 520, 700, 400, t, { alpha: loc, thick: 1, locator: ramp(u, 0.2, 0.5), title: '超声定位 · 示意' })
const us2 = win(u, L[3].s + 0.3, d, 0.3, 0.3)
O.ultrasound(ctx, 620, 520, 400, 240, t, { alpha: us2, thick: 1 - 0.7 * relK, knife: smooth(ramp(u, L[3].s + 0.3, L[4].e)), release: relK, title: '影像引导' })
const p = api.anchor('pain')
if (step === 0) O.label(ctx, p.x, p.y, p.x - 250, p.y + 230, '目标区域', ramp(u, 0.4, 0.8), '#ffffff')
if (step === 1) O.chip(ctx, 540, 1300, '皮肤消毒', { icon: 'check', color: '#e0a050', alpha: dis })
if (step === 2) O.chip(ctx, 540, 1300, '局部麻醉(必要时)', { icon: 'dot', color: '#59c8ff', alpha: ane })
if (step === 3) {
const k = api.anchor('knife'), tn = api.anchor('tendon'), sh = api.anchor('sheath')
const la = win(u, L[3].s + 0.6, L[5].s + 0.2, 0.3, 0.3)
O.label(ctx, k.x, k.y, k.x - 60, k.y - 150, '器械位置', la, O.COL.teal, 36)
O.label(ctx, tn.x, tn.y, tn.x + 200, tn.y + 150, '肌腱位置', la, '#ffffff', 36)
O.label(ctx, sh.x, sh.y, sh.x - 200, sh.y + 300, '腱鞘位置', la, O.COL.cyan, 36)
const ws = ['精准', '有限', '局部']
ws.forEach((w, i) => O.chip(ctx, 290 + i * 250, 1330, w, { color: O.COL.teal, fill: true, size: 50, alpha: ramp(u, L[5].s + 0.1 + i * 0.25, L[5].s + 0.35 + i * 0.25), scale: 0.8 + 0.2 * easeOut(ramp(u, L[5].s + i * 0.25, L[5].s + 0.4 + i * 0.25)) }))
if (relK > 0) O.flash(ctx, 0.25 * win(u, L[5].s, L[5].s + 0.3, 0.05, 0.25), '200,255,240')
}
},
}
}
/* =========================================================================
SHOT 9 · 松解前后对比
========================================================================= */
function shot9(u: number, d: number, t: number): Frame {
const L = SL[8]
const after = smooth(ramp(u, L[1].s, L[2].e))
const base = { arm: { pos: [0, 0, 0] as V3, rot: [0, 0.85, 0.04] as V3 }, cam: painCam(1.35, 0.05, 0.1, 40), skin: 0.7, xray: 1, bone: 1, tendon: 1, sheath: 1, focus: 1, time: t }
const left = baseState({ ...base, pose: thumbCycle(POSE.rest, u, 1.0, 0.45, 1), thick: 1, friction: 0.95, pain: 0.95 })
const right = baseState({ ...base, pose: thumbCycle(POSE.rest, u, 1.0, lerp(0.45, 1, after), 1 - after), thick: lerp(1, 0.12, after), friction: lerp(0.95, 0, after), pain: lerp(0.95, 0.08, after) })
const y0 = 0.15, h = 0.62
return {
views: [{ rect: [0, y0, 0.5, h], state: left, bg: 'red' }, { rect: [0.5, y0, 0.5, h], state: right, bg: after > 0.5 ? 'clean' : 'lab' }],
overlay: (ctx) => {
ctx.save(); ctx.fillStyle = '#02060d'; ctx.fillRect(0, 0, 1080, y0 * 1920); ctx.fillRect(0, (y0 + h) * 1920, 1080, 1920); ctx.restore()
const yT = y0 * 1920 + 60
const lOn = u < L[1].s ? 1 : 0.55, rOn = u >= L[1].s - 0.1 ? 1 : 0.5
O.chip(ctx, 270, yT, '治疗前', { color: O.COL.red, size: 48, alpha: lOn, fill: lOn > 0.9 })
O.chip(ctx, 810, yT, '治疗后示意', { color: O.COL.teal, size: 48, alpha: rOn, fill: rOn > 0.9 })
ctx.save(); const g = ctx.createLinearGradient(0, y0 * 1920, 0, (y0 + h) * 1920); g.addColorStop(0, 'rgba(95,212,255,0)'); g.addColorStop(0.5, 'rgba(95,212,255,0.9)'); g.addColorStop(1, 'rgba(95,212,255,0)')
ctx.fillStyle = g; ctx.fillRect(538, y0 * 1920, 4, h * 1920); ctx.restore()
const yb = (y0 + h) * 1920 - 70
O.chip(ctx, 160, yb, '受限', { color: O.COL.red, size: 38, icon: 'cross', alpha: ramp(u, 0.6, 1.0) })
O.chip(ctx, 390, yb, '摩擦', { color: O.COL.red, size: 38, icon: 'warn', alpha: ramp(u, 1.0, 1.4) })
O.chip(ctx, 690, yb, '空间改善', { color: O.COL.teal, size: 38, icon: 'check', alpha: ramp(u, L[1].s + 0.3, L[1].s + 0.7) })
O.chip(ctx, 940, yb, '更顺畅', { color: O.COL.teal, size: 38, icon: 'check', alpha: ramp(u, L[2].s, L[2].s + 0.4) })
O.crossSection(ctx, 270, 1330, 80, 1, 0.95, 0.9, t, '狭窄')
O.crossSection(ctx, 810, 1330, 80, lerp(1, 0.12, after), lerp(0.95, 0, after), 0.9, t, '改善')
},
}
}
/* =========================================================================
SHOT 10 · 从疼痛到恢复
========================================================================= */
function shot10(u: number, d: number, t: number): Frame {
const n = 6, seg = d / n
const i = Math.min(n - 1, Math.floor(u / seg)), v = u - i * seg
const labels = ['治疗前:活动困难', '早期:轻微活动', '正常抓握', '正常拿杯子', '正常使用手机', '自然活动']
const pains = [1, 0.7, 0.45, 0.25, 0.1, 0]
const pain = lerp(pains[i], pains[Math.min(n - 1, i + 1)], smooth(ramp(v, seg * 0.6, seg)))
let pose: Pose, props: SceneState['props'] = {}
let rot: V3 = [0, 0.6, 0.04]
let cam = { pos: orbit([0, 0.5, 0], 4.3, 0.05, 0.1), target: [0, 0.5, 0] as V3, fov: 35 }
const blendIn = smooth(ramp(v, 0, 0.35))
if (i === 0) pose = thumbCycle(POSE.rest, v, 1.4, 0.3, 1)
else if (i === 1) pose = thumbCycle(POSE.rest, v, 0.7, 0.6)
else if (i === 2) { pose = mixPose(POSE.open, POSE.gripBall, blendIn * (0.85 + 0.15 * Math.sin(v * 6))); props = { ball: { vis: blendIn } } }
else if (i === 3) { pose = mixPose(POSE.open, POSE.cup, blendIn); props = { cup: { vis: blendIn } }; rot = [0, 0.5, 0.04 + 0.15 * smooth(ramp(v, 0.4, 1))] }
else if (i === 4) { pose = mixPose(POSE.open, POSE.phone, blendIn); pose[17] += 0.25 * Math.max(0, Math.sin(v * 12)); pose[19] += 0.15 * Math.max(0, Math.sin(v * 12)); props = { phone: { vis: blendIn } }; rot = [0.1, -0.25, 0]; cam = { pos: orbit([0, 0.6, 0], 4.1, 0.0, 0.12), target: [0, 0.6, 0], fov: 35 } }
else { pose = mixPose(POSE.open, POSE.rest, 0.5 + 0.5 * Math.sin(v * 5)); rot = [0, 0.4 + 0.3 * Math.sin(v * 2), 0.04] }
const state = baseState({ time: t, pose, props, arm: { pos: [0, 0, 0], rot }, cam, pain, skin: 1 })
return {
views: full(state, i >= 3 ? 'clean' : 'lab'),
overlay: (ctx, api) => {
if (v < 0.12 && i > 0) O.flash(ctx, 0.18 * (1 - v / 0.12))
// progress track
const y = 1300, x0 = 140, x1 = 940
ctx.save()
const g = ctx.createLinearGradient(x0, 0, x1, 0); g.addColorStop(0, O.COL.red); g.addColorStop(0.5, O.COL.yellow); g.addColorStop(1, O.COL.green)
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 10; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke()
ctx.strokeStyle = g; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + (x1 - x0) * clamp(u / d), y); ctx.stroke()
for (let k = 0; k < n; k++) {
const x = x0 + (x1 - x0) * k / (n - 1)
ctx.fillStyle = k <= i ? '#fff' : 'rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.arc(x, y, k === i ? 16 : 10, 0, 7); ctx.fill()
}
ctx.restore()
O.chip(ctx, 540, 1200, labels[i], { color: i === 0 ? O.COL.red : i < 2 ? O.COL.yellow : O.COL.green, size: 50, alpha: smooth(ramp(v, 0, 0.2)), icon: i === 0 ? 'warn' : 'check' })
if (pain > 0.3) { const p = api.anchor('pain'); O.painBurst(ctx, p.x, p.y, (pain - 0.3) * 0.6, t) }
ctx.save(); ctx.font = `800 30px ${O.FONT}`; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.textAlign = 'left'; ctx.fillText('疼痛', x0 - 10, y - 34); ctx.textAlign = 'right'; ctx.fillText('功能恢复', x1 + 10, y - 34); ctx.restore()
},
}
}
/* =========================================================================
SHOT 11 · 最后的记忆点
========================================================================= */
function shot11(u: number, d: number, t: number): Frame {
const L = SL[10]
const out = smooth(ramp(u, L[1].s + 0.6, d - 0.3))
const pc = painCam(lerp(1.4, 5.4, out), lerp(0.05, 0.0, out), 0.1)
const wide = { pos: orbit([0, 0.45, 0], 5.4, 0.0, 0.1), target: [0, 0.45, 0] as V3, fov: 35 }
const fol = 1 - out
const state = baseState({
time: t, pose: mixPose(POSE.rest, POSE.open, out * 0.5),
arm: { pos: [0, 0, 0], rot: [0, lerp(0.85, 0.35, out), 0.04] },
cam: { ...pc, follow: fol, pos: lerpVec(wide.pos, pc.pos, fol), target: lerpVec(wide.target, pc.target, fol) },
skin: 1, xray: 1 - smooth(ramp(u, L[2].s, d - 0.5)), bone: 1, tendon: 1, sheath: 1,
thick: lerp(0.7, 0.1, smooth(ramp(u, L[1].s, L[2].s))), pain: 0.75 * (1 - smooth(ramp(u, L[1].s, L[3].s))), focus: 0.4 * (1 - out),
})
return {
views: full(state, u > L[2].s ? 'clean' : 'lab'),
overlay: (ctx) => {
O.chip(ctx, 540, 1180, '忍住疼', { icon: 'cross', color: O.COL.red, size: 54, alpha: win(u, 0.3, L[1].s + 0.1), strike: ramp(u, L[0].s + 1.4, L[0].e) })
O.chip(ctx, 540, 1110, '找到疼痛原因', { icon: 'check', color: O.COL.teal, size: 50, alpha: win(u, L[1].s + 0.1, L[2].s + 0.1) })
O.chip(ctx, 540, 1240, '选择合适方式', { icon: 'check', color: O.COL.teal, size: 50, alpha: win(u, L[1].s + 1.1, L[2].s + 0.1) })
// slogan
const sA = ramp(u, L[3].s - 0.1, L[3].s + 0.4)
if (sA > 0) {
ctx.save()
ctx.globalAlpha = sA * 0.75; ctx.fillStyle = 'rgba(2,8,18,0.7)'; ctx.fillRect(0, 1000, 1080, 330); ctx.restore()
O.bigWord(ctx, '手越疼,', 540, 1090, sA, '#ffffff', 96)
O.bigWord(ctx, '越不能只靠硬扛。', 540, 1230, ramp(u, L[3].s + 0.35, L[3].s + 0.8), O.COL.yellow, 96)
}
O.chip(ctx, 540, 960, '记住一句话', { size: 40, color: O.COL.cyan, alpha: win(u, L[2].s, d + 1, 0.2, 0.2) })
},
}
}
/* =========================================================================
SHOT 12 · 评论区互动
========================================================================= */
function shot12(u: number, d: number, t: number): Frame {
const state = baseState({
time: t, pose: mixPose(POSE.open, POSE.rest, 0.5 + 0.5 * Math.sin(u * 2.4)),
arm: { pos: [0, 0.15, 0], rot: [0, 0.35 + 0.2 * Math.sin(u * 1.5), 0.04 + 0.08 * Math.sin(u * 3)] },
cam: { pos: orbit([0, 0.7, 0], 6.2, 0, 0.1), target: [0, 0.2, 0], fov: 35 },
})
const items = [['grab', '抓'], ['pinch', '捏'], ['twist', '拧'], ['baby', '抱孩子']] as const
return {
views: full(state, 'clean'),
fade: smooth(ramp(u, d - 0.5, d)),
overlay: (ctx) => {
const a = smooth(ramp(u, 0.1, 0.5))
ctx.save(); ctx.globalAlpha = a
ctx.fillStyle = 'rgba(3,12,26,0.86)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3
ctx.beginPath(); ctx.roundRect(70, 980, 940, 430, 36); ctx.fill(); ctx.stroke()
ctx.font = `900 50px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'
ctx.fillText('评论区告诉我', 540, 1060)
ctx.font = `800 40px ${O.FONT}`; ctx.fillStyle = '#bfe9ff'
ctx.fillText('是抓、捏、拧,还是抱孩子?', 540, 1125)
ctx.restore()
items.forEach(([ic, w], k) => {
const p = ramp(u, 0.6 + k * 0.25, 1.0 + k * 0.25)
const x = 200 + k * 227, y = 1265
const bob = Math.sin(t * 4 + k) * 6
ctx.save(); ctx.globalAlpha = p; ctx.translate(x, y + bob); const sc = 0.6 + 0.4 * easeOut(p); ctx.scale(sc, sc)
ctx.fillStyle = 'rgba(95,212,255,0.12)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3
ctx.beginPath(); ctx.arc(0, 0, 82, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore()
O.actionIcon(ctx, ic, x, y - 16 + bob, 70, '#fff', p)
ctx.save(); ctx.globalAlpha = p; ctx.font = `900 34px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText(w, x, y + 50 + bob); ctx.restore()
})
O.actionIcon(ctx, 'comment', 540, 880, 120 * (1 + 0.06 * Math.sin(t * 6)), O.COL.yellow, a)
},
}
}
const SHOT_FNS = [shot1, shot2, shot3, shot4, shot5, shot6, shot7, shot8, shot9, shot10, shot11, shot12]
export function shotAt(t: number) {
let i = SHOTS.length - 1
for (let k = 0; k < SHOTS.length; k++) if (t < SHOTS[k].end) { i = k; break }
return i
}
export function subtitleAt(t: number) {
let cur: (typeof LINES)[number] | null = null
for (let k = 0; k < LINES.length; k++) {
const l = LINES[k]
const next = LINES[k + 1]
const hold = next ? Math.min(next.start, l.start + l.dur + 0.5) : l.start + l.dur + 0.8
if (t >= l.start - 0.05 && t < hold) cur = l
}
return cur
}
/** The whole film: pure function of time */
export function frameAt(t: number): Frame {
t = clamp(t, 0, DURATION - 1e-4)
const i = shotAt(t)
const sh = SHOTS[i]
const u = t - sh.start, d = sh.end - sh.start
const f = SHOT_FNS[i](u, d, t)
const inner = f.overlay
const sub = subtitleAt(t)
f.overlay = (ctx: C, api: OverlayApi) => {
inner?.(ctx, api)
// transition flash at shot start
if (i > 0 && u < 0.2) O.flash(ctx, 0.22 * (1 - u / 0.2), '180,230,255')
O.header(ctx, i + 1, sh.title, i === 0 ? win(u, 0.8, 3.2, 0.4, 0.5) : win(u, 0.05, 2.4, 0.3, 0.5))
if (sub) {
const a = Math.min(smooth(ramp(t, sub.start - 0.05, sub.start + 0.12)), 1)
O.subtitle(ctx, sub.text, a)
}
O.footer(ctx)
}
return f
}
/* ---------------- sound cue list (for the procedural mix) ---------------- */
export type CueKind = 'whoosh' | 'pain' | 'tick' | 'scan' | 'pop' | 'release' | 'chime' | 'thud' | 'click' | 'swipe'
export function soundCues(): { t: number; k: CueKind; v?: number }[] {
const c: { t: number; k: CueKind; v?: number }[] = []
const S = (i: number) => SHOTS[i].start
const Ls = (i: number, j: number) => S(i) + SL[i][j].s
SHOTS.forEach((s, i) => { if (i > 0) c.push({ t: s.start - 0.15, k: 'whoosh', v: 0.6 }) })
// shot 1
c.push({ t: Ls(0, 0) + 1.05, k: 'pain', v: 1 }, { t: Ls(0, 1) - 0.2, k: 'whoosh', v: 0.5 }, { t: Ls(0, 1) + 0.7, k: 'scan', v: 0.6 })
c.push({ t: Ls(0, 1) + 1.2, k: 'tick' }, { t: Ls(0, 1) + 1.45, k: 'tick' }, { t: Ls(0, 1) + 1.7, k: 'tick' })
// shot 2
for (const j of [3, 4, 5]) c.push({ t: Ls(1, j) + 0.1, k: 'pop', v: 0.7 })
c.push({ t: Ls(1, 5) + 0.1, k: 'pain', v: 0.6 })
// shot 3
const d3 = SHOTS[2].end - S(2)
for (let k = 0; k < 3; k++) { c.push({ t: S(2) + k * d3 / 3 + 0.05, k: 'thud', v: 0.6 + k * 0.2 }); c.push({ t: S(2) + k * d3 / 3 + 0.35, k: 'pain', v: 0.4 + k * 0.25 }) }
// shot 4
const s0 = S(3) + SL[3][1].s - 0.05, seg4 = (SHOTS[3].end - s0) / 4
for (let k = 0; k < 4; k++) { c.push({ t: s0 + k * seg4, k: 'swipe', v: 0.7 }); c.push({ t: s0 + k * seg4 + 0.45, k: 'pain', v: 0.35 }) }
// shot 5
c.push({ t: Ls(4, 1), k: 'click', v: 0.8 }, { t: Ls(4, 3) + 0.5, k: 'scan', v: 0.8 }, { t: Ls(4, 4), k: 'tick' }, { t: Ls(4, 4) + 0.3, k: 'tick' }, { t: Ls(4, 4) + 0.6, k: 'tick' })
// shot 6
for (let j = 0; j < 4; j++) c.push({ t: Ls(5, j) - 0.1, k: 'click', v: 0.7 })
// shot 7
c.push({ t: Ls(6, 1) + 0.4, k: 'pop', v: 0.6 }, { t: Ls(6, 2), k: 'click', v: 0.8 }, { t: Ls(6, 3) + 0.9, k: 'chime', v: 0.5 })
// shot 8
for (let j = 0; j < 4; j++) c.push({ t: S(7) + SL[7][[0, 1, 2, 3][j]].s, k: 'click', v: 0.9 })
c.push({ t: Ls(7, 0) + 0.2, k: 'scan', v: 0.7 }, { t: Ls(7, 5), k: 'release', v: 1 })
// shot 9
c.push({ t: Ls(8, 1) + 0.3, k: 'release', v: 0.6 })
// shot 10
const d10 = SHOTS[9].end - S(9)
for (let k = 1; k < 6; k++) c.push({ t: S(9) + k * d10 / 6, k: 'tick', v: 0.8 })
// shot 11
c.push({ t: Ls(10, 0) + 1.4, k: 'pop', v: 0.6 }, { t: Ls(10, 3), k: 'chime', v: 1 })
// ending
for (let k = 0; k < 4; k++) c.push({ t: S(11) + 0.6 + k * 0.25, k: 'pop', v: 0.5 })
return c.sort((a, b) => a.t - b.t)
}