mx / src /video /Studio.tsx
Ethscriptions's picture
Step 6-7: 播放器 + MP4 导出 + 文案 + 封面
d61897b verified
Raw History Blame Contribute Delete
21.6 kB
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>
)
}