518 lines
25 KiB
TypeScript
518 lines
25 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>
|
||
)
|
||
}
|
||
|
||
type KanbanTone = "violet" | "pink" | "orange" | "blue" | "green" | "cyan" | "rose" | "amber"
|
||
|
||
function KanbanCard({
|
||
tone = "violet",
|
||
tags,
|
||
title,
|
||
className = "",
|
||
onClick,
|
||
meta,
|
||
children,
|
||
}: {
|
||
tone?: KanbanTone
|
||
tags?: string[]
|
||
title?: ReactNode
|
||
className?: string
|
||
onClick?: (e: React.MouseEvent) => void
|
||
meta?: ReactNode
|
||
children?: ReactNode
|
||
}) {
|
||
return (
|
||
<div className={`kanban-card kanban-${tone} ${onClick ? "cursor-pointer" : ""} ${className}`} onClick={onClick}>
|
||
{tags && tags.length > 0 && (
|
||
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||
{tags.map((t) => (
|
||
<span key={t} className="kanban-tag">#{t}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{title && <h3 className="text-[13.5px] font-semibold text-[var(--text-strong)] leading-snug mb-1.5">{title}</h3>}
|
||
{children}
|
||
{meta && <div className="kanban-meta">{meta}</div>}
|
||
</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 tileRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||
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())
|
||
|
||
const Tile = ({ tkey, rowSpan }: { tkey: string; rowSpan?: boolean }) => {
|
||
const t = TILES.find((x) => x.key === tkey)!
|
||
const state = colState[t.key]
|
||
const isOpen = expanded.has(t.key)
|
||
return (
|
||
<button
|
||
ref={(el) => { tileRefs.current[t.key] = el }}
|
||
type="button"
|
||
onClick={() => toggleTile(t.key)}
|
||
title={colSummary[t.key]}
|
||
className={`group rounded-md overflow-hidden border flex items-stretch transition ${
|
||
rowSpan ? "row-span-2" : ""
|
||
} ${isOpen ? "border-violet-400/60 ring-2 ring-violet-400/40" : "border-white/10 hover:border-white/20"}`}
|
||
style={{ height: rowSpan ? "auto" : 30 }}
|
||
>
|
||
<div className="px-2 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-[11.5px] font-medium whitespace-nowrap">{t.title}</span>
|
||
</div>
|
||
<div className="px-1.5 flex items-center gap-1 bg-black/40">
|
||
<span className={`h-1.5 w-1.5 rounded-full ${STATE_DOT[state]}`} />
|
||
<ChevronDown className={`h-3 w-3 text-white/60 transition ${isOpen ? "rotate-180" : ""}`} />
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="w-full">
|
||
{/* Tile Bar — DAG 拓扑布局:input/download/split → (关键帧路 / 转录路) → compose */}
|
||
<div className="px-3 pt-2 pb-1.5 flex items-center gap-1.5">
|
||
<Tile tkey="input" />
|
||
<Tile tkey="download" />
|
||
<Tile tkey="split" />
|
||
{/* 分叉:上下两路 */}
|
||
<div className="flex-1 grid grid-rows-2 gap-1 mx-1">
|
||
<div className="flex gap-1.5 items-center">
|
||
<Tile tkey="keyframe" />
|
||
<Tile tkey="imagegen" />
|
||
<Tile tkey="videogen" />
|
||
</div>
|
||
<div className="flex gap-1.5 items-center">
|
||
<Tile tkey="asr" />
|
||
<Tile tkey="translate" />
|
||
<Tile tkey="rewrite" />
|
||
</div>
|
||
</div>
|
||
<Tile tkey="compose" />
|
||
</div>
|
||
|
||
{/* 展开面板 — 从屏幕左侧滑出,竖向 sidebar drawer */}
|
||
{expanded.size > 0 && (
|
||
<div
|
||
className="fixed z-40 left-4 transition-transform duration-200"
|
||
style={{ top: 80, bottom: 16, width: 380 }}
|
||
>
|
||
{TILES.filter((t) => expanded.has(t.key)).map((t) => (
|
||
<section
|
||
key={t.key}
|
||
className="rounded-xl border border-white/10 bg-black/50 backdrop-blur-xl overflow-hidden flex flex-col shadow-2xl h-full"
|
||
style={{ animation: "drawer-in 0.22s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||
>
|
||
<div className="flex items-center justify-between px-3 py-2" 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/70 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="overflow-y-auto p-3 bg-black/20 flex-1">
|
||
{renderSection(t.key)}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
function renderSection(key: string): ReactNode {
|
||
return (
|
||
<div className="space-y-3">
|
||
|
||
{/* ---- Input — Kanban ---- */}
|
||
{key === "input" && (
|
||
<>
|
||
<KanbanCard tone="violet" tags={["链接", "上传"]} title="输入源">
|
||
<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/30 border border-white/15 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40 mt-1"
|
||
/>
|
||
<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>
|
||
</KanbanCard>
|
||
{hasVideo && (
|
||
<KanbanCard tone="green" tags={["下一步"]} title="解析视频">
|
||
<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 mt-1 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
|
||
>
|
||
{(isAnalyzing || data.analyzing) ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> 解析中…</> : hasFrames ? "重新解析" : "▶ 开始解析"}
|
||
</button>
|
||
{job && (
|
||
<div className="kanban-meta">
|
||
<span className="font-mono truncate">
|
||
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</KanbanCard>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ---- Download — Kanban ---- */}
|
||
{key === "download" && (
|
||
!hasVideo ? (
|
||
<KanbanCard tone="orange" tags={["yt-dlp"]} title={isDownloading ? "下载中…" : "等待提交"}>
|
||
<div className="text-[11.5px] text-[var(--text-soft)]">TikTok / yt-dlp 兼容站点</div>
|
||
</KanbanCard>
|
||
) : (
|
||
<>
|
||
<KanbanCard tone="orange" tags={["视频源"]} title={job!.url.startsWith("upload://") ? "本地上传" : "yt-dlp 下载"}>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoUrl(job!.id)}
|
||
controls
|
||
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
|
||
className="block w-full bg-black rounded-md mt-1"
|
||
/>
|
||
</KanbanCard>
|
||
<KanbanCard tone="amber" tags={["元数据"]} title="视频信息">
|
||
<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-[12.5px] 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-[12.5px] 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-[12.5px] mt-0.5">{job!.url.startsWith("upload://") ? "上传" : "TK"}</div></div>
|
||
</div>
|
||
</KanbanCard>
|
||
</>
|
||
)
|
||
)}
|
||
|
||
{/* ---- Split — Kanban ---- */}
|
||
{key === "split" && (
|
||
<>
|
||
<KanbanCard tone="amber" tags={["ffmpeg", "视频流"]} title="→ 关键帧抽取">
|
||
<div className="text-[11px] text-[var(--text-soft)]">fast seek + Laplacian 方差评分</div>
|
||
</KanbanCard>
|
||
<KanbanCard tone="amber" tags={["ffmpeg", "音频流"]} title="→ ASR 输入">
|
||
<div className="text-[11px] text-[var(--text-soft)]">16kHz mono · pcm_s16le wav</div>
|
||
<div className="kanban-meta">
|
||
<code className="text-[10px]">-vn -ac 1 -ar 16000</code>
|
||
</div>
|
||
</KanbanCard>
|
||
</>
|
||
)}
|
||
|
||
{/* ---- Keyframe — Kanban 卡片 ---- */}
|
||
{key === "keyframe" && (
|
||
<div className="space-y-3">
|
||
{hasVideo && job && (
|
||
<KanbanCard tone="green" tags={["手动加帧"]} title="从视频任意时间点抽 1 张">
|
||
<button
|
||
type="button"
|
||
disabled={addingFrame}
|
||
onClick={async () => {
|
||
const t = videoRef.current?.currentTime ?? videoT
|
||
setAddingFrame(true)
|
||
try { await data.onAddManualFrame(t) } finally { setAddingFrame(false) }
|
||
}}
|
||
className="mt-1 w-full text-[12px] py-1.5 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white 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" />}
|
||
{addingFrame ? "抽帧中…" : `把 ${videoT.toFixed(1)}s 加为关键帧`}
|
||
</button>
|
||
</KanbanCard>
|
||
)}
|
||
{!hasFrames ? (
|
||
<KanbanCard tone="pink" tags={["分镜"]} title="等待解析后抽取">
|
||
<div className="text-[11.5px] text-[var(--text-soft)]">候选 30 张 → pHash 去重 + 清晰度排序 → 时序分桶 → 5 张代表分镜</div>
|
||
</KanbanCard>
|
||
) : (
|
||
job!.frames.map((f) => {
|
||
const isSel = data.selectedFrames.has(f.index)
|
||
return (
|
||
<KanbanCard
|
||
key={f.index}
|
||
tone={isSel ? "green" : "pink"}
|
||
tags={[`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]}
|
||
className={isSel ? "ring-2 ring-emerald-400/60" : ""}
|
||
meta={
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); data.onToggleFrame(f.index) }}
|
||
className={`ml-auto text-[10.5px] px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${
|
||
isSel
|
||
? "bg-emerald-500 text-white"
|
||
: "bg-white/10 text-[var(--text-soft)] border border-white/15 hover:bg-white/20"
|
||
}`}
|
||
>
|
||
<Check className="h-2.5 w-2.5" />
|
||
{isSel ? "已选用" : "选用此帧"}
|
||
</button>
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }}
|
||
className="block w-full rounded-md overflow-hidden bg-black"
|
||
title="点击放大"
|
||
>
|
||
<img
|
||
src={frameUrl(job!.id, f.index)}
|
||
alt={`frame ${f.index}`}
|
||
className="block w-full h-auto"
|
||
style={{ objectFit: "contain" }}
|
||
/>
|
||
</button>
|
||
</KanbanCard>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- ASR / Translate — Kanban 段落卡 ---- */}
|
||
{(key === "asr" || key === "translate") && (
|
||
!hasTranscript ? (
|
||
<KanbanCard tone={key === "asr" ? "blue" : "cyan"} tags={[key === "asr" ? "ASR" : "Translate"]} title="等待数据">
|
||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||
{colState.asr === "running" ? "Gemini 转录中…" : "需要先完成关键帧抽取"}
|
||
</div>
|
||
</KanbanCard>
|
||
) : (
|
||
job!.transcript.map((s) => (
|
||
<KanbanCard
|
||
key={s.index}
|
||
tone={key === "asr" ? "blue" : "cyan"}
|
||
tags={[`段落 ${s.index + 1}`, `${s.start.toFixed(1)}s → ${s.end.toFixed(1)}s`]}
|
||
>
|
||
<div className="text-[12.5px] text-[var(--text-strong)] leading-snug mb-1.5">
|
||
<span className="kanban-tag mr-1.5" style={{ padding: "1px 6px", fontSize: 9.5 }}>EN</span>
|
||
{s.en}
|
||
</div>
|
||
<div className="text-[12.5px] text-[var(--text-strong)] leading-snug">
|
||
<span className="kanban-tag mr-1.5" style={{ padding: "1px 6px", fontSize: 9.5 }}>ZH</span>
|
||
{s.zh || <span className="text-[var(--text-faint)] italic">翻译中…</span>}
|
||
</div>
|
||
</KanbanCard>
|
||
))
|
||
)
|
||
)}
|
||
|
||
{/* ---- 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>
|
||
)
|
||
}
|
||
}
|