Spaces:
Running
Running
File size: 21,598 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 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 | 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<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const engRef = useRef<Engine | null>(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<string | null>(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 (
<div ref={wrapRef}
className={full ? 'fixed inset-0 z-50 bg-black flex items-center justify-center' : 'relative w-full flex items-center justify-center'}>
<div className="relative bg-black overflow-hidden shadow-[0_0_80px_rgba(60,180,255,0.15)] sm:rounded-2xl"
style={{ aspectRatio: '9 / 16', height: full ? '100%' : 'min(100svh - 24px, calc(100vw * 16 / 9))', maxWidth: '100%' }}>
<canvas ref={canvasRef} className="w-full h-full block cursor-pointer" onClick={toggle} />
{!playing && (
<button onClick={toggle} className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/25 hover:bg-black/15 transition">
<span className="w-20 h-20 rounded-full bg-white/90 flex items-center justify-center shadow-2xl">
<Play className="w-9 h-9 text-slate-900 ml-1" fill="currentColor" />
</span>
{!started && <span className="text-white/90 text-sm font-semibold tracking-wide px-3 py-1 rounded-full bg-black/40">点击播放 · 有声音</span>}
{load && <span className="text-cyan-200 text-xs">{load.label} {Math.round(load.p * 100)}%</span>}
</button>
)}
{err && <div className="absolute top-3 left-3 right-3 text-xs bg-red-900/80 text-red-100 rounded px-3 py-2">{err}</div>}
{/* controls */}
<div className={`absolute left-0 right-0 bottom-0 px-3 ${full ? 'pb-3' : 'pb-12 sm:pb-3'} pt-8 bg-gradient-to-t from-black/80 to-transparent`}
onClick={e => e.stopPropagation()}>
<div className="relative h-5 flex items-center group">
<div className="absolute inset-x-0 h-1 rounded bg-white/20 top-1/2 -translate-y-1/2" />
{SHOTS.map(s => <div key={s.index} className="absolute w-px h-2 bg-white/50 top-1/2 -translate-y-1/2" style={{ left: `${(s.start / DURATION) * 100}%` }} />)}
<div className="absolute h-1 rounded bg-cyan-400 top-1/2 -translate-y-1/2 left-0" style={{ width: `${(t / DURATION) * 100}%` }} />
<input type="range" min={0} max={DURATION} step={0.01} value={t} onChange={e => seek(+e.target.value)}
className="absolute inset-0 w-full opacity-0 cursor-pointer" aria-label="进度" />
</div>
<div className="flex items-center gap-3 text-white text-xs mt-1">
<button onClick={toggle} aria-label="播放/暂停">{playing ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}</button>
<span className="tabular-nums opacity-80">{fmt(t)} / {fmt(DURATION)}</span>
<span className="truncate opacity-70 flex-1">{String(cur + 1).padStart(2, '0')} · {SHOTS[cur].title}</span>
<button onClick={() => setMuted(m => !m)} aria-label="静音">{muted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}</button>
<button onClick={toggleFull} aria-label="全屏">{full ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}</button>
</div>
</div>
</div>
</div>
)
}
/* ============================== Export ============================== */
function ExportPanel() {
const [fps, setFps] = useState<Fps>(30)
const [prog, setProg] = useState<ExportProgress | null>(null)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<{ blob: Blob; fps: number; sec: number } | null>(null)
const [err, setErr] = useState<string | null>(null)
const ab = useRef<AbortController | null>(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 (
<div className="space-y-5">
<div>
<div className="text-sm text-slate-400 mb-2">帧率</div>
<div className="grid grid-cols-3 gap-2">
{([24, 30, 60] as Fps[]).map(f => (
<button key={f} disabled={busy} onClick={() => setFps(f)}
className={`rounded-xl py-3 border text-center transition ${fps === f ? 'border-cyan-400 bg-cyan-400/10 text-white' : 'border-white/10 text-slate-300 hover:border-white/30'}`}>
<div className="text-xl font-black">{f}<span className="text-xs font-semibold ml-0.5">fps</span></div>
<div className="text-[11px] opacity-60">{f === 24 ? '电影感 · 体积小' : f === 30 ? '平台推荐' : '最流畅'}</div>
</button>
))}
</div>
</div>
<div className="text-xs text-slate-400 leading-relaxed bg-white/5 rounded-xl p-3">
输出:1080×1920 竖屏 MP4 · H.264 + AAC 立体声 · 时长 {DURATION.toFixed(1)} 秒 · 预计约 {Math.round(sizeMb)} MB<br />
全程在你的浏览器本地逐帧渲染,不上传任何数据。导出时请保持此页面在前台。
</div>
{!supported && <div className="text-xs text-amber-300">当前浏览器不支持本地视频编码,请使用电脑版 Chrome / Edge 最新版。</div>}
{busy ? (
<div className="space-y-2">
<div className="flex justify-between text-sm"><span className="text-cyan-300">{prog?.phase ?? '准备中'}</span><span className="tabular-nums">{Math.round((prog?.p ?? 0) * 100)}%</span></div>
<div className="h-2 rounded bg-white/10 overflow-hidden"><div className="h-full bg-gradient-to-r from-cyan-400 to-emerald-400 transition-[width]" style={{ width: `${(prog?.p ?? 0) * 100}%` }} /></div>
<div className="flex justify-between text-xs text-slate-400">
<span>{prog?.frame ? `第 ${prog.frame} / ${prog.total} 帧` : ''}</span>
<span>{prog?.eta != null ? `剩余约 ${fmt(prog.eta)}` : ''}</span>
</div>
<button onClick={() => ab.current?.abort()} className="w-full mt-2 rounded-xl py-2.5 border border-white/15 text-slate-200 flex items-center justify-center gap-2 hover:bg-white/5"><X className="w-4 h-4" />取消导出</button>
</div>
) : (
<button onClick={start} disabled={!supported}
className="w-full rounded-xl py-3.5 font-bold text-slate-950 bg-gradient-to-r from-cyan-300 to-emerald-300 hover:brightness-110 disabled:opacity-40 flex items-center justify-center gap-2">
<Download className="w-5 h-5" />导出 MP4({fps}fps)
</button>
)}
{result && (
<div className="rounded-xl bg-emerald-500/10 border border-emerald-400/30 p-3 text-sm flex items-center gap-3">
<Check className="w-5 h-5 text-emerald-300 shrink-0" />
<div className="flex-1">已导出 {result.fps}fps · {(result.blob.size / 1048576).toFixed(1)} MB · 用时 {fmt(result.sec)}</div>
<button onClick={() => download(result.blob, `腱鞘炎3D科普_1080x1920_${result.fps}fps.mp4`)} className="text-emerald-300 underline">再次下载</button>
</div>
)}
{err && <div className="text-sm text-red-300 bg-red-500/10 rounded-xl p-3">导出失败:{err}</div>}
</div>
)
}
/* ============================== 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 (
<button onClick={go} className={`shrink-0 text-xs px-2.5 py-1 rounded-lg border flex items-center gap-1 transition ${ok ? 'border-emerald-400 text-emerald-300' : 'border-white/15 text-slate-300 hover:border-white/40'}`}>
{ok ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{ok ? '已复制' : label}
</button>
)
}
function Count({ n, max }: { n: number; max: number | null }) {
const ok = max == null || n <= max
return <span className={`text-[11px] tabular-nums px-1.5 py-0.5 rounded ${ok ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/20 text-red-300'}`}>{n}{max != null ? ` / ${max}` : ' 字'}</span>
}
function CopyCard({ p }: { p: PlatformCopy }) {
const all = `${p.title}\n\n${bodyWithTopics(p)}`
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/10">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: p.color }} />
<span className="font-bold">{p.name}</span>
<span className="text-[11px] text-slate-500 flex-1 truncate">{p.tips}</span>
<CopyBtn text={all} label="复制全部" />
</div>
<div className="p-4 space-y-4 text-sm">
<div>
<div className="flex items-center gap-2 mb-1.5"><span className="text-slate-400 text-xs">标题</span><Count n={charLen(p.title)} max={p.limits.title} /><span className="flex-1" /><CopyBtn text={p.title} /></div>
<div className="font-bold text-white text-base leading-snug">{p.title}</div>
</div>
<div>
<div className="flex items-center gap-2 mb-1.5"><span className="text-slate-400 text-xs">话题</span><Count n={p.topics.length} max={p.limits.topics} /><span className="text-[11px] text-slate-500">个</span><span className="flex-1" /><CopyBtn text={topicText(p)} /></div>
<div className="flex flex-wrap gap-1.5">{p.topics.map(tp => <span key={tp} className="text-xs px-2 py-0.5 rounded-full bg-cyan-400/10 text-cyan-200">#{tp}</span>)}</div>
</div>
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-slate-400 text-xs">正文{p.limits.bodyIncludesTopics && p.limits.body ? '(含话题)' : ''}</span>
<Count n={bodyCount(p)} max={p.limits.body} /><span className="flex-1" />
<CopyBtn text={p.limits.bodyIncludesTopics ? bodyWithTopics(p) : p.body} />
</div>
<pre className="whitespace-pre-wrap font-sans text-slate-300 text-[13px] leading-relaxed max-h-64 overflow-auto bg-black/30 rounded-xl p-3">{p.limits.bodyIncludesTopics ? bodyWithTopics(p) : p.body}</pre>
</div>
</div>
</div>
)
}
/* ============================== Covers ============================== */
function CoverPanel() {
const refs = useRef<Record<string, HTMLCanvasElement | null>>({})
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 (
<div className="space-y-5">
<div className="flex items-center gap-3">
<div className="text-xs text-slate-400 flex-1">由动画真实画面实时渲染,PNG 原尺寸下载。{ready < COVERS.length ? `生成中 ${ready}/${COVERS.length}…` : ''}</div>
<button onClick={dlAll} className="text-xs px-3 py-1.5 rounded-lg bg-cyan-400/15 text-cyan-200 border border-cyan-400/30 flex items-center gap-1"><Download className="w-3.5 h-3.5" />全部下载</button>
</div>
{groups.map(g => (
<div key={g}>
<div className="text-sm font-bold mb-2">{g}</div>
<div className="grid grid-cols-2 gap-3 items-start">
{COVERS.filter(c => c.platform === g).map(s => (
<div key={s.key} className="rounded-xl overflow-hidden border border-white/10 bg-black/40">
<canvas ref={el => { refs.current[s.key] = el }} className="w-full block" style={{ aspectRatio: `${s.w} / ${s.h}` }} />
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
<span className="text-slate-300">{s.label} <span className="text-slate-500">{s.w}×{s.h}</span></span>
<button onClick={() => dl(s.key)} className="text-cyan-300 flex items-center gap-1"><Download className="w-3.5 h-3.5" />PNG</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
/* ============================== 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 (
<div className="min-h-screen bg-[#04080f] text-slate-100" style={{ backgroundImage: 'radial-gradient(1200px 600px at 20% -10%, rgba(40,140,255,0.12), transparent), radial-gradient(800px 500px at 100% 100%, rgba(255,60,60,0.06), transparent)' }}>
<div className="mx-auto max-w-6xl lg:flex lg:gap-8 lg:p-6 lg:items-start">
<div className="lg:sticky lg:top-6 sm:pt-3 lg:pt-0 lg:w-auto">
<Player />
</div>
<div className="flex-1 min-w-0 px-4 py-6 lg:px-0 lg:py-0">
<div className="mb-5">
<div className="text-[11px] tracking-[0.25em] text-cyan-300/80 font-bold">ORIGINAL 3D MEDICAL ANIMATION</div>
<h1 className="text-2xl lg:text-3xl font-black mt-1">腱鞘炎 · 从疼痛到恢复</h1>
<p className="text-sm text-slate-400 mt-1.5">实时 3D 解剖动画 · 竖屏 9:16 · {DURATION.toFixed(0)} 秒 · 旁白 + 配乐 + 音效</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{['疼痛', '定位', '处理', '松解', '恢复'].map((s, i) => (
<span key={s} className={`text-xs px-2.5 py-1 rounded-full ${i === 0 ? 'bg-red-500/20 text-red-200' : i === 4 ? 'bg-emerald-500/20 text-emerald-200' : 'bg-white/5 text-slate-300'}`}>{s}</span>
))}
</div>
</div>
<div className="flex gap-1 p-1 rounded-xl bg-white/5 mb-5 sticky top-0 z-10 backdrop-blur">
{tabs.map(({ k, n, i: I }) => (
<button key={k} onClick={() => setTab(k)} className={`flex-1 py-2 rounded-lg text-sm font-semibold flex items-center justify-center gap-1.5 transition ${tab === k ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-slate-200'}`}>
<I className="w-4 h-4" />{n}
</button>
))}
</div>
{tab === 'export' && <ExportPanel />}
{tab === 'copy' && <div className="space-y-4">{COPY.map(p => <CopyCard key={p.key} p={p} />)}</div>}
{tab === 'cover' && <CoverPanel />}
<div className="text-[11px] text-slate-500 mt-8 leading-relaxed flex gap-2">
<RotateCcw className="w-3.5 h-3.5 shrink-0 mt-0.5" />
所有模型、器械、动画、配乐与音效均为原创程序化生成;内容仅供健康科普,不替代医生面诊。
</div>
</div>
</div>
</div>
)
}
|