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