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