458 lines
24 KiB
TypeScript
458 lines
24 KiB
TypeScript
"use client"
|
||
import { useRef, useState, type ReactNode } from "react"
|
||
import {
|
||
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
||
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, Check,
|
||
ChevronDown, X,
|
||
} from "lucide-react"
|
||
import { type Job, frameUrl, videoUrl } from "@/lib/api"
|
||
import { type NodeData } from "@/components/nodes"
|
||
|
||
type ColType = "input" | "process" | "ai" | "output"
|
||
const TYPE_GRAD: Record<ColType, string> = {
|
||
input: "linear-gradient(135deg, #6366f1, #a855f7)",
|
||
process: "linear-gradient(135deg, #f59e0b, #ef4444)",
|
||
ai: "linear-gradient(135deg, #d946ef, #ec4899)",
|
||
output: "linear-gradient(135deg, #10b981, #06b6d4)",
|
||
}
|
||
|
||
type ColState = "pending" | "running" | "done" | "failed"
|
||
const STATE_DOT: Record<ColState, string> = {
|
||
pending: "bg-white/25",
|
||
running: "bg-violet-300 shadow-[0_0_8px_rgba(167,139,250,0.8)] animate-pulse",
|
||
done: "bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.7)]",
|
||
failed: "bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)]",
|
||
}
|
||
|
||
function MiniCard({ children, className = "", onClick }: { children: ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void }) {
|
||
return (
|
||
<div
|
||
className={`rounded-lg bg-white/[0.04] dark:bg-white/[0.04] border border-white/10 backdrop-blur-md p-2.5 text-[var(--text-strong)] ${onClick ? "cursor-pointer hover:bg-white/[0.07] hover:border-white/20 transition" : ""} ${className}`}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface Props {
|
||
data: NodeData
|
||
}
|
||
|
||
export function Dashboard({ data }: Props) {
|
||
const { job } = data
|
||
const [url, setUrl] = useState("")
|
||
const [videoT, setVideoT] = useState(0)
|
||
const [addingFrame, setAddingFrame] = useState(false)
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||
const fileRef = useRef<HTMLInputElement>(null)
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
|
||
/* 状态推导 */
|
||
const hasVideo = !!job?.video_url
|
||
const isDownloading = job?.status === "downloading" || job?.status === "created"
|
||
const isSplitting = job?.status === "splitting"
|
||
const isAnalyzing = !!job && ["splitting", "frames_extracted", "transcribing"].includes(job.status)
|
||
const hasFrames = (job?.frames.length ?? 0) > 0
|
||
const hasTranscript = (job?.transcript.length ?? 0) > 0
|
||
const hasZh = job?.transcript.some((s) => s.zh) ?? false
|
||
const isFailed = job?.status === "failed"
|
||
|
||
const colState: Record<string, ColState> = {
|
||
input: !job ? "pending" : "done",
|
||
download: !job ? "pending" : isDownloading ? "running" : hasVideo ? "done" : isFailed && job.progress < 30 ? "failed" : "pending",
|
||
split: !job ? "pending" : isSplitting ? "running" : hasFrames ? "done" : isFailed && job.progress >= 30 && job.progress < 50 ? "failed" : "pending",
|
||
keyframe: !job ? "pending" : (isSplitting && !hasFrames) ? "running" : hasFrames ? "done" : isFailed && job.progress >= 50 && job.progress < 70 ? "failed" : "pending",
|
||
asr: !job ? "pending" : job.status === "transcribing" ? "running" : hasTranscript ? "done" : isFailed && job.progress >= 70 ? "failed" : "pending",
|
||
translate: !job ? "pending" : job.status === "transcribing" ? "running" : hasZh ? "done" : "pending",
|
||
rewrite: "pending",
|
||
imagegen: "pending",
|
||
videogen: "pending",
|
||
compose: "pending",
|
||
}
|
||
|
||
/* 每列摘要 = tile 副标题 */
|
||
const colSummary: Record<string, string> = {
|
||
input: job ? (job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接") : "等待",
|
||
download: hasVideo && job ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : isDownloading ? "下载中…" : "—",
|
||
split: hasFrames ? "wav 已生成" : isSplitting ? "拆轨中…" : "—",
|
||
keyframe: hasFrames ? `${data.selectedFrames.size}/${job!.frames.length} 选用` : "—",
|
||
asr: hasTranscript ? `${job!.transcript.length} 段` : "—",
|
||
translate: hasZh ? `${job!.transcript.filter((s) => s.zh).length} 段` : "—",
|
||
rewrite: "占位",
|
||
imagegen: data.selectedFrames.size > 0 ? `${data.selectedFrames.size} 张待生` : "占位",
|
||
videogen: "占位",
|
||
compose: "占位",
|
||
}
|
||
|
||
const TILES: Array<{ key: string; title: string; type: ColType; icon: ReactNode; step: number }> = [
|
||
{ key: "input", title: "输入", type: "input", icon: <Link2 className="h-3.5 w-3.5" />, step: 1 },
|
||
{ key: "download", title: "下载", type: "process", icon: <Download className="h-3.5 w-3.5" />, step: 2 },
|
||
{ key: "split", title: "拆分", type: "process", icon: <Scissors className="h-3.5 w-3.5" />, step: 3 },
|
||
{ key: "keyframe", title: "关键帧", type: "ai", icon: <ImageIcon className="h-3.5 w-3.5" />, step: 4 },
|
||
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 5 },
|
||
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 6 },
|
||
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 7 },
|
||
{ key: "imagegen", title: "生图", type: "ai", icon: <Sparkles className="h-3.5 w-3.5" />, step: 8 },
|
||
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 9 },
|
||
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 10 },
|
||
]
|
||
|
||
// 单选展开:toggle 同一 key = 收起;点其他 key = 切换
|
||
const toggleTile = (key: string) => setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key])))
|
||
const closeTile = (_key: string) => setExpanded(new Set())
|
||
|
||
return (
|
||
<div className="w-full">
|
||
{/* Tile Bar — 一直显示 */}
|
||
<div className="px-4 pt-3 pb-2 flex items-center gap-1.5 overflow-x-auto">
|
||
{TILES.map((t) => {
|
||
const state = colState[t.key]
|
||
const isOpen = expanded.has(t.key)
|
||
return (
|
||
<button
|
||
key={t.key}
|
||
type="button"
|
||
onClick={() => toggleTile(t.key)}
|
||
className={`shrink-0 group rounded-lg overflow-hidden border transition flex flex-col text-left ${
|
||
isOpen ? "border-violet-400/60 ring-2 ring-violet-400/40" : "border-white/8 hover:border-white/20"
|
||
}`}
|
||
style={{ width: 134 }}
|
||
>
|
||
<div className="relative px-2.5 py-1.5 flex items-center gap-1.5" style={{ background: TYPE_GRAD[t.type] }}>
|
||
<span className="text-white/70 text-[9px] font-mono">{String(t.step).padStart(2, "0")}</span>
|
||
<span className="text-white">{t.icon}</span>
|
||
<span className="text-white text-[12px] font-medium">{t.title}</span>
|
||
<span className="ml-auto flex items-center gap-1">
|
||
<span className={`h-2 w-2 rounded-full ${STATE_DOT[state]}`} />
|
||
<ChevronDown className={`h-3 w-3 text-white/70 transition ${isOpen ? "rotate-180" : ""}`} />
|
||
</span>
|
||
</div>
|
||
<div className="px-2.5 py-1 bg-black/40 text-[10px] text-[var(--text-soft)] truncate">
|
||
{colSummary[t.key]}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 展开面板 — 多卡同时展开,按 step 顺序排列 */}
|
||
{expanded.size > 0 && (
|
||
<div className="relative mx-4 mb-3 rounded-xl border border-white/10 bg-black/30 backdrop-blur-xl overflow-hidden" style={{ maxHeight: "52vh" }}>
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-white/8">
|
||
<span className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">展开详情</span>
|
||
<button
|
||
onClick={() => setExpanded(new Set())}
|
||
className="text-[var(--text-faint)] hover:text-[var(--text-strong)]"
|
||
title="收起"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: "calc(52vh - 40px)" }}>
|
||
{TILES.filter((t) => expanded.has(t.key)).map((t) => (
|
||
<section key={t.key} className="rounded-lg border border-white/8 overflow-hidden">
|
||
<div className="flex items-center justify-between px-3 py-1.5" style={{ background: TYPE_GRAD[t.type] }}>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-white/70 text-[9px] font-mono">{String(t.step).padStart(2, "0")}</span>
|
||
<span className="text-white">{t.icon}</span>
|
||
<span className="text-[12.5px] font-semibold text-white">{t.title}</span>
|
||
<span className="text-[10px] text-white/60 ml-2">{colSummary[t.key]}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => closeTile(t.key)}
|
||
className="text-white/70 hover:text-white"
|
||
title="收起此卡"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="p-3 bg-black/20">
|
||
{renderSection(t.key)}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
function renderSection(key: string): ReactNode {
|
||
return (
|
||
<>
|
||
|
||
{/* ---- Input ---- */}
|
||
{key === "input" && (
|
||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">链接 / 上传</div>
|
||
<input
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
placeholder="粘贴 TikTok 链接"
|
||
disabled={isDownloading || data.submitting}
|
||
className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
|
||
/>
|
||
<div className="mt-2 flex gap-1.5">
|
||
<button
|
||
type="button"
|
||
disabled={isDownloading || data.submitting || !url.trim()}
|
||
onClick={() => data.onSubmitUrl(url.trim())}
|
||
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-white text-black hover:bg-white/90 disabled:opacity-30 inline-flex items-center justify-center gap-1"
|
||
>
|
||
{(data.submitting || isDownloading) && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
{isDownloading ? "下载中" : "提交"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isDownloading || data.submitting}
|
||
onClick={() => fileRef.current?.click()}
|
||
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/[0.06] border border-white/15 hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
|
||
>
|
||
<Upload className="h-3 w-3" /> 上传
|
||
</button>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm,.mkv,.m4v"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0]
|
||
if (f) data.onUploadFile(f)
|
||
e.target.value = ""
|
||
}}
|
||
/>
|
||
</div>
|
||
</MiniCard>
|
||
{hasVideo && (
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">下一步</div>
|
||
<button
|
||
type="button"
|
||
disabled={isAnalyzing || data.analyzing}
|
||
onClick={data.onAnalyze}
|
||
className={`w-full text-[12.5px] py-2 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 inline-flex items-center justify-center gap-1.5 font-semibold shadow-lg shadow-violet-500/30 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
|
||
>
|
||
{(isAnalyzing || data.analyzing) ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> 解析中…</> : hasFrames ? "重新解析" : "▶ 开始解析"}
|
||
</button>
|
||
{job && (
|
||
<div className="mt-2 text-[10px] font-mono text-[var(--text-faint)] truncate">
|
||
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
|
||
</div>
|
||
)}
|
||
</MiniCard>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- Download ---- */}
|
||
{key === "download" && hasVideo && job && (
|
||
<div className="grid grid-cols-[auto_1fr] gap-4 max-w-4xl items-start">
|
||
<MiniCard className="p-0 overflow-hidden" >
|
||
<video
|
||
ref={videoRef}
|
||
src={videoUrl(job.id)}
|
||
controls
|
||
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
|
||
className="block bg-black"
|
||
style={{ width: 260 }}
|
||
/>
|
||
</MiniCard>
|
||
<div className="space-y-2">
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">元数据</div>
|
||
<div className="grid grid-cols-3 gap-3 text-[11px] font-mono">
|
||
<div><div className="text-[var(--text-faint)] text-[9.5px]">分辨率</div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.width}×{job.height}</div></div>
|
||
<div><div className="text-[var(--text-faint)] text-[9.5px]">时长</div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.duration.toFixed(1)}s</div></div>
|
||
<div><div className="text-[var(--text-faint)] text-[9.5px]">来源</div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.url.startsWith("upload://") ? "上传" : "yt-dlp"}</div></div>
|
||
</div>
|
||
</MiniCard>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{key === "download" && !hasVideo && (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center">{isDownloading ? "yt-dlp 下载中…" : "等待提交"}</div>
|
||
)}
|
||
|
||
{/* ---- Split ---- */}
|
||
{key === "split" && (
|
||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1">视频流</div>
|
||
<div className="text-[13px] text-[var(--text-strong)]">→ 关键帧抽取</div>
|
||
<div className="text-[10.5px] text-[var(--text-faint)] mt-1">ffmpeg fast seek + Laplacian 评分</div>
|
||
</MiniCard>
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1">音频流</div>
|
||
<div className="text-[13px] text-[var(--text-strong)]">→ ASR (16kHz mono wav)</div>
|
||
<div className="text-[10.5px] text-[var(--text-faint)] mt-1">ffmpeg -vn -ac 1 -ar 16000</div>
|
||
</MiniCard>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- Keyframe ---- */}
|
||
{key === "keyframe" && (
|
||
<div className="space-y-3">
|
||
{hasVideo && job && (
|
||
<MiniCard>
|
||
<button
|
||
type="button"
|
||
disabled={addingFrame}
|
||
onClick={async () => {
|
||
const t = videoRef.current?.currentTime ?? videoT
|
||
setAddingFrame(true)
|
||
try { await data.onAddManualFrame(t) } finally { setAddingFrame(false) }
|
||
}}
|
||
className="w-full text-[12px] py-2 rounded-md border border-dashed border-emerald-400/40 bg-emerald-400/5 hover:bg-emerald-400/10 text-emerald-300 disabled:opacity-50 inline-flex items-center justify-center gap-1.5"
|
||
>
|
||
{addingFrame ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||
+ 把视频 {videoT.toFixed(1)}s 加为关键帧
|
||
</button>
|
||
</MiniCard>
|
||
)}
|
||
{!hasFrames ? (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center">等待解析后抽取(默认 5 张)</div>
|
||
) : (
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{job!.frames.map((f) => {
|
||
const isSel = data.selectedFrames.has(f.index)
|
||
return (
|
||
<MiniCard key={f.index} className={`p-0 overflow-hidden ${isSel ? "ring-2 ring-emerald-400 border-emerald-400" : ""}`}>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }}
|
||
className="block w-full aspect-video bg-black relative overflow-hidden"
|
||
>
|
||
<img src={frameUrl(job!.id, f.index)} alt={`frame ${f.index}`} className="absolute inset-0 w-full h-full object-cover" />
|
||
<div className="absolute top-1 left-1 bg-black/70 text-white text-[9px] font-mono px-1.5 py-0.5 rounded">#{f.index + 1}</div>
|
||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-[9px] font-mono px-1.5 py-0.5 rounded">{f.timestamp.toFixed(1)}s</div>
|
||
</button>
|
||
<div className="px-2 py-1.5 flex items-center justify-between">
|
||
<span className="text-[10.5px] text-[var(--text-soft)]">分镜 {f.index + 1}</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); data.onToggleFrame(f.index) }}
|
||
className={`text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-0.5 ${isSel ? "bg-emerald-500 text-white" : "bg-white/10 text-[var(--text-soft)] border border-white/10"}`}
|
||
>
|
||
<Check className="h-2.5 w-2.5" />
|
||
{isSel ? "已选" : "选用"}
|
||
</button>
|
||
</div>
|
||
</MiniCard>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- ASR / Translate ---- */}
|
||
{(key === "asr" || key === "translate") && (
|
||
!hasTranscript ? (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center">
|
||
{colState.asr === "running" ? "Gemini 转录中…" : "等待关键帧抽取"}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{job!.transcript.map((s) => (
|
||
<MiniCard key={s.index}>
|
||
<div className="text-[9.5px] font-mono text-[var(--text-faint)] mb-1.5">
|
||
{s.start.toFixed(1)}s → {s.end.toFixed(1)}s
|
||
</div>
|
||
<div className="text-[12px] text-[var(--text-strong)] leading-snug mb-1">
|
||
<span className="text-[var(--text-faint)] mr-1 text-[10px]">EN</span>{s.en}
|
||
</div>
|
||
<div className="text-[12px] text-[var(--text-strong)] leading-snug">
|
||
<span className="text-[var(--text-faint)] mr-1 text-[10px]">ZH</span>{s.zh || <span className="text-[var(--text-faint)]">翻译中…</span>}
|
||
</div>
|
||
</MiniCard>
|
||
))}
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* ---- Rewrite ---- */}
|
||
{key === "rewrite" && (
|
||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">产品信息</div>
|
||
<textarea
|
||
rows={4}
|
||
placeholder="SKG 产品关键卖点(占位)"
|
||
disabled
|
||
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/30 border border-dashed border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
|
||
/>
|
||
</MiniCard>
|
||
<MiniCard>
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">模型 & 状态</div>
|
||
<div className="text-[11.5px] text-[var(--text-soft)] mb-1">模型:<span className="font-mono text-[var(--text-strong)]">gemini-2.5-pro</span></div>
|
||
<div className="text-[10.5px] text-[var(--text-faint)]">下一冲刺接入</div>
|
||
</MiniCard>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- ImageGen ---- */}
|
||
{key === "imagegen" && (
|
||
<div className="space-y-3">
|
||
<MiniCard>
|
||
<div className="grid grid-cols-2 gap-2 text-[11.5px]">
|
||
<div className="rounded-md bg-violet-500/10 border border-violet-400/30 px-2.5 py-2">
|
||
<div className="text-[9.5px] text-violet-300 uppercase tracking-widest">推荐</div>
|
||
<div className="text-[var(--text-strong)]">nano-banana-pro</div>
|
||
<div className="text-[10px] text-[var(--text-faint)]">Gemini 3 Pro Image</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-2">
|
||
<div className="text-[9.5px] text-[var(--text-faint)] uppercase tracking-widest">备选</div>
|
||
<div className="text-[var(--text-strong)]">gpt-image-2</div>
|
||
<div className="text-[10px] text-[var(--text-faint)]">OpenAI</div>
|
||
</div>
|
||
</div>
|
||
</MiniCard>
|
||
{data.selectedFrames.size === 0 ? (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-3 text-center">在「关键帧」里勾选后启动</div>
|
||
) : (
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{Array.from({ length: data.selectedFrames.size }).map((_, i) => (
|
||
<MiniCard key={i} className="aspect-video flex items-center justify-center">
|
||
<span className="text-[11px] text-[var(--text-faint)]">#{i + 1} 待生</span>
|
||
</MiniCard>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- VideoGen ---- */}
|
||
{key === "videogen" && (
|
||
<div className="grid grid-cols-3 gap-3 max-w-2xl">
|
||
{["Sora 2 · SKG", "Seedance · 外部", "Kling · 外部"].map((m) => (
|
||
<MiniCard key={m}>
|
||
<div className="text-[10.5px] text-[var(--text-soft)]">{m}</div>
|
||
</MiniCard>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- Compose ---- */}
|
||
{key === "compose" && (
|
||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||
<MiniCard className="aspect-video flex items-center justify-center">
|
||
<span className="text-[11.5px] text-[var(--text-faint)]">成品视频 · 待合成</span>
|
||
</MiniCard>
|
||
<MiniCard>
|
||
<div className="text-[11px] text-[var(--text-soft)]">
|
||
视频片段 + 字幕 / TTS
|
||
</div>
|
||
<div className="text-[10px] text-[var(--text-faint)] mt-1">本地 ffmpeg · 零 API</div>
|
||
</MiniCard>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|