import { useCallback, useEffect, useRef, useState } from 'react' import { Engine } from './engine' import { frameAt, DURATION, SHOTS, shotAt } from './scenes' import { getSoundtrack } from './audio' import { exportMp4, type Fps, type ExportProgress } from './exporter' import { COPY, charLen, bodyCount, bodyWithTopics, topicText, type PlatformCopy } from './copy' import { COVERS, drawCover, coverBlob } from './covers' import { Play, Pause, Maximize, Minimize, Volume2, VolumeX, Download, Copy, Check, Film, FileText, Image as ImageIcon, X, RotateCcw } from 'lucide-react' const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` function download(blob: Blob, name: string) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name document.body.appendChild(a); a.click(); a.remove() setTimeout(() => URL.revokeObjectURL(a.href), 60000) } /* ============================== Player ============================== */ function Player() { const wrapRef = useRef(null) const canvasRef = useRef(null) const engRef = useRef(null) const st = useRef({ t: 0, playing: false, startAt: 0, startT: 0, dirty: true }) const audio = useRef<{ ctx: AudioContext | null; buf: AudioBuffer | null; src: AudioBufferSourceNode | null; gain: GainNode | null }>({ ctx: null, buf: null, src: null, gain: null }) const [t, setT] = useState(0) const [playing, setPlaying] = useState(false) const [muted, setMuted] = useState(false) const [full, setFull] = useState(false) const [load, setLoad] = useState<{ p: number; label: string } | null>({ p: 0, label: '加载音频' }) const [err, setErr] = useState(null) const [started, setStarted] = useState(false) useEffect(() => { getSoundtrack((p, label) => setLoad({ p, label })) .then(b => { audio.current.buf = b; setLoad(null) }) .catch(e => { setErr('音频加载失败:' + (e as Error).message); setLoad(null) }) }, []) // render loop useEffect(() => { const c = canvasRef.current! const small = Math.min(window.innerWidth, window.innerHeight) < 600 const W = small ? 540 : 720, H = small ? 960 : 1280 c.width = W; c.height = H let eng: Engine try { eng = new Engine(); eng.setSize(W, H); engRef.current = eng } catch (e) { setErr('当前浏览器不支持 WebGL:' + (e as Error).message); return } const ctx = c.getContext('2d', { alpha: false })! let raf = 0, lastUi = 0 const loop = () => { raf = requestAnimationFrame(loop) const s = st.current if (s.playing) { const a = audio.current.ctx s.t = s.startT + (a ? a.currentTime - s.startAt : (performance.now() / 1000 - s.startAt)) if (s.t >= DURATION) { s.t = DURATION; stopInternal(); setPlaying(false) } s.dirty = true } if (s.dirty) { s.dirty = false eng.render(frameAt(s.t), ctx, s.t) const now = performance.now() if (now - lastUi > 100 || !s.playing) { lastUi = now; setT(s.t) } } } loop() return () => { cancelAnimationFrame(raf); eng.renderer.dispose() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const stopInternal = () => { const a = audio.current try { a.src?.stop() } catch { /* already stopped */ } a.src = null st.current.playing = false } const play = useCallback(async () => { const a = audio.current, s = st.current if (s.t >= DURATION - 0.05) s.t = 0 if (!a.ctx) { a.ctx = new AudioContext({ sampleRate: 48000 }) a.gain = a.ctx.createGain(); a.gain.connect(a.ctx.destination) } await a.ctx.resume() a.gain!.gain.value = muted ? 0 : 1 if (a.buf) { const src = a.ctx.createBufferSource(); src.buffer = a.buf; src.connect(a.gain!) src.start(a.ctx.currentTime + 0.03, Math.min(s.t, a.buf.duration - 0.01)) a.src = src s.startAt = a.ctx.currentTime + 0.03 } else s.startAt = a.ctx.currentTime s.startT = s.t; s.playing = true setPlaying(true); setStarted(true) }, [muted]) const pause = useCallback(() => { stopInternal(); setPlaying(false) }, []) const toggle = () => (st.current.playing ? pause() : play()) const seek = (v: number) => { const was = st.current.playing if (was) stopInternal() st.current.t = v; st.current.dirty = true; setT(v) if (was) play() } useEffect(() => { if (audio.current.gain) audio.current.gain.gain.value = muted ? 0 : 1 }, [muted]) const toggleFull = async () => { const el = wrapRef.current! if (document.fullscreenElement) { await document.exitFullscreen(); return } if (full) { setFull(false); return } if (el.requestFullscreen) { try { await el.requestFullscreen(); return } catch { /* fall through */ } } setFull(true) // iOS fallback: CSS theater } useEffect(() => { const h = () => setFull(!!document.fullscreenElement) document.addEventListener('fullscreenchange', h) return () => document.removeEventListener('fullscreenchange', h) }, []) useEffect(() => { const k = (e: KeyboardEvent) => { if ((e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT') return if (e.code === 'Space') { e.preventDefault(); toggle() } if (e.code === 'ArrowRight') seek(Math.min(DURATION, st.current.t + 5)) if (e.code === 'ArrowLeft') seek(Math.max(0, st.current.t - 5)) } window.addEventListener('keydown', k); return () => window.removeEventListener('keydown', k) }) const cur = shotAt(t) return (
{!playing && ( )} {err &&
{err}
} {/* controls */}
e.stopPropagation()}>
{SHOTS.map(s =>
)}
seek(+e.target.value)} className="absolute inset-0 w-full opacity-0 cursor-pointer" aria-label="进度" />
{fmt(t)} / {fmt(DURATION)} {String(cur + 1).padStart(2, '0')} · {SHOTS[cur].title}
) } /* ============================== Export ============================== */ function ExportPanel() { const [fps, setFps] = useState(30) const [prog, setProg] = useState(null) const [busy, setBusy] = useState(false) const [result, setResult] = useState<{ blob: Blob; fps: number; sec: number } | null>(null) const [err, setErr] = useState(null) const ab = useRef(null) const supported = typeof window !== 'undefined' && 'VideoEncoder' in window const start = async () => { setErr(null); setResult(null); setBusy(true) ab.current = new AbortController() const t0 = performance.now() try { const blob = await exportMp4(fps, setProg, ab.current.signal) setResult({ blob, fps, sec: (performance.now() - t0) / 1000 }) download(blob, `腱鞘炎3D科普_1080x1920_${fps}fps.mp4`) } catch (e) { if ((e as Error).name !== 'AbortError') setErr((e as Error).message) } finally { setBusy(false); setProg(null) } } const sizeMb = (fps === 60 ? 16 : fps === 30 ? 12 : 10) * DURATION / 8 + 2 return (
帧率
{([24, 30, 60] as Fps[]).map(f => ( ))}
输出:1080×1920 竖屏 MP4 · H.264 + AAC 立体声 · 时长 {DURATION.toFixed(1)} 秒 · 预计约 {Math.round(sizeMb)} MB
全程在你的浏览器本地逐帧渲染,不上传任何数据。导出时请保持此页面在前台。
{!supported &&
当前浏览器不支持本地视频编码,请使用电脑版 Chrome / Edge 最新版。
} {busy ? (
{prog?.phase ?? '准备中'}{Math.round((prog?.p ?? 0) * 100)}%
{prog?.frame ? `第 ${prog.frame} / ${prog.total} 帧` : ''} {prog?.eta != null ? `剩余约 ${fmt(prog.eta)}` : ''}
) : ( )} {result && (
已导出 {result.fps}fps · {(result.blob.size / 1048576).toFixed(1)} MB · 用时 {fmt(result.sec)}
)} {err &&
导出失败:{err}
}
) } /* ============================== Copy ============================== */ function CopyBtn({ text, label = '复制' }: { text: string; label?: string }) { const [ok, setOk] = useState(false) const go = async () => { try { await navigator.clipboard.writeText(text) } catch { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove() } setOk(true); setTimeout(() => setOk(false), 1400) } return ( ) } function Count({ n, max }: { n: number; max: number | null }) { const ok = max == null || n <= max return {n}{max != null ? ` / ${max}` : ' 字'} } function CopyCard({ p }: { p: PlatformCopy }) { const all = `${p.title}\n\n${bodyWithTopics(p)}` return (
{p.name} {p.tips}
标题
{p.title}
话题个
{p.topics.map(tp => #{tp})}
正文{p.limits.bodyIncludesTopics && p.limits.body ? '(含话题)' : ''}
{p.limits.bodyIncludesTopics ? bodyWithTopics(p) : p.body}
) } /* ============================== Covers ============================== */ function CoverPanel() { const refs = useRef>({}) const [ready, setReady] = useState(0) useEffect(() => { let alive = true, i = 0 const next = () => { if (!alive || i >= COVERS.length) return const s = COVERS[i]; const c = refs.current[s.key] if (c) { try { drawCover(s, c) } catch (e) { console.error(e) } } i++; setReady(i); setTimeout(next, 30) } setTimeout(next, 50) return () => { alive = false } }, []) const dl = async (k: string) => { const s = COVERS.find(c => c.key === k)!; download(await coverBlob(s), `封面_${s.platform}_${s.label.split(' ')[0].replace(':', 'x')}_${s.w}x${s.h}.png`) } const dlAll = async () => { for (const s of COVERS) { await dl(s.key); await new Promise(r => setTimeout(r, 350)) } } const groups = [...new Set(COVERS.map(c => c.platform))] return (
由动画真实画面实时渲染,PNG 原尺寸下载。{ready < COVERS.length ? `生成中 ${ready}/${COVERS.length}…` : ''}
{groups.map(g => (
{g}
{COVERS.filter(c => c.platform === g).map(s => (
{ refs.current[s.key] = el }} className="w-full block" style={{ aspectRatio: `${s.w} / ${s.h}` }} />
{s.label} {s.w}×{s.h}
))}
))}
) } /* ============================== Studio ============================== */ export default function Studio() { const [tab, setTab] = useState<'export' | 'copy' | 'cover'>('export') const tabs = [ { k: 'export' as const, n: '导出视频', i: Film }, { k: 'copy' as const, n: '平台文案', i: FileText }, { k: 'cover' as const, n: '封面', i: ImageIcon }, ] return (
ORIGINAL 3D MEDICAL ANIMATION

腱鞘炎 · 从疼痛到恢复

实时 3D 解剖动画 · 竖屏 9:16 · {DURATION.toFixed(0)} 秒 · 旁白 + 配乐 + 音效

{['疼痛', '定位', '处理', '松解', '恢复'].map((s, i) => ( {s} ))}
{tabs.map(({ k, n, i: I }) => ( ))}
{tab === 'export' && } {tab === 'copy' &&
{COPY.map(p => )}
} {tab === 'cover' && }
所有模型、器械、动画、配乐与音效均为原创程序化生成;内容仅供健康科普,不替代医生面诊。
) }