Files
20260512-skg-tk/web/components/dashboard.tsx
2026-05-14 10:40:12 +08:00

985 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react"
import { createPortal } from "react-dom"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, Check,
ChevronDown, X, LayoutGrid,
} from "lucide-react"
import { type Job, type KeyFrame, frameUrl, effectiveFrameUrl, videoUrl, generateImage, selectGenerated, generatedImageUrl, apiAssetUrl } from "@/lib/api"
import { type NodeData } from "@/components/nodes"
import { FrameLightbox } from "@/components/lightbox"
import { toast } from "sonner"
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 interface DashboardHandle {
openPanel: (key: string) => void
}
export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({ data }, ref) {
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 [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
useImperativeHandle(ref, () => ({
openPanel: (key: string) => setExpanded(new Set([key])),
}), [])
// drawer 宽度 per panel · 持久化到 localStorage
const drawerKey = (() => {
const keys = Array.from(expanded)
if (keys.length === 0) return ""
const base = keys[0]
if (base === "keyframe" && data.expandedFrame !== null) return "keyframe:lightbox"
return base
})()
const isLightboxMode = drawerKey === "keyframe:lightbox"
const defaultWidth = isLightboxMode ? 760 : 400
const minDrawerWidth = isLightboxMode ? 480 : 280
const [drawerWidths, setDrawerWidths] = useState<Record<string, number>>({})
useEffect(() => {
if (typeof window === "undefined") return
try {
const stored = JSON.parse(localStorage.getItem("skg.drawer.widths") || "{}")
if (stored && typeof stored === "object") setDrawerWidths(stored)
} catch { /* ignore */ }
}, [])
const drawerWidth = drawerKey ? (drawerWidths[drawerKey] ?? defaultWidth) : defaultWidth
const setDrawerWidth = (w: number) => {
if (!drawerKey) return
const clamped = Math.max(minDrawerWidth, Math.min(1400, w))
setDrawerWidths((prev) => {
const next = { ...prev, [drawerKey]: clamped }
try { localStorage.setItem("skg.drawer.widths", JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
// 拖拽
const dragRef = useRef<{ startX: number; startW: number } | null>(null)
const onResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
dragRef.current = { startX: e.clientX, startW: drawerWidth }
const onMove = (ev: MouseEvent) => {
if (!dragRef.current) return
const delta = ev.clientX - dragRef.current.startX
setDrawerWidth(dragRef.current.startW + delta)
}
const onUp = () => {
dragRef.current = null
window.removeEventListener("mousemove", onMove)
window.removeEventListener("mouseup", onUp)
}
window.addEventListener("mousemove", onMove)
window.addEventListener("mouseup", onUp)
}
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 hasAudioRewrite = !!job?.audio_script?.rewritten_text?.trim()
const isAudioRewriting = job?.audio_script?.status === "rewriting"
const audioCompareRows = job?.transcript.slice(0, 8) ?? []
const isFailed = job?.status === "failed"
const colState: Record<string, ColState> = {
// input 状态合并:覆盖原 input + download + split
input: !job ? "pending"
: isDownloading ? "running"
: isSplitting ? "running"
: hasFrames || hasTranscript ? "done"
: hasVideo ? "done"
: isFailed && 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: !job ? "pending" : isAudioRewriting ? "running" : hasAudioRewrite ? "done" : "pending",
imagegen: "pending",
videogen: "pending",
compose: "pending",
}
/* 每列摘要 = tile 副标题 */
const colSummary: Record<string, string> = {
input: !job ? "等待" : isDownloading ? "下载中…" : hasVideo ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : "—",
keyframe: hasFrames ? `${data.selectedFrames.size}/${job!.frames.length} 选用` : "—",
asr: hasTranscript ? `${job!.transcript.length}` : "—",
translate: hasZh ? `${job!.transcript.filter((s) => s.zh).length}` : "—",
rewrite: hasAudioRewrite ? "已生成" : isAudioRewriting ? "生成中…" : "待文案",
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: "keyframe", title: "关键帧", type: "ai", icon: <ImageIcon className="h-3.5 w-3.5" />, step: 2 },
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 3 },
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 4 },
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 5 },
// imagegen分镜头编排已移到顶部 StoryboardBar不在 sidebar 里
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 7 },
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 8 },
]
// 单选展开toggle 同一 key = 收起;点其他 key = 切换
const toggleTile = (key: string) => {
setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key])))
}
const closeTile = (_key: string) => {
setExpanded(new Set())
data.onCloseExpandedFrame()
}
// 点关键帧缩略图时onExpandFrame 触发),自动打开 keyframe drawer
useEffect(() => {
if (data.expandedFrame !== null && !expanded.has("keyframe")) {
setExpanded(new Set(["keyframe"]))
}
}, [data.expandedFrame])
const Tile = ({ tkey }: { tkey: string }) => {
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={`${t.title} · ${colSummary[t.key]}`}
className={`group w-full rounded-md overflow-hidden border flex items-center transition relative ${
isOpen
? "bg-white/[0.09] border-white/25 shadow-[0_0_0_1px_rgba(255,255,255,0.08),0_4px_16px_-4px_rgba(0,0,0,0.5)]"
: "bg-white/[0.035] border-white/8 hover:bg-white/[0.06] hover:border-white/15"
}`}
style={{ height: 26 }}
>
<span
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all"
style={{
height: isOpen ? "72%" : "44%",
background: TYPE_GRAD[t.type],
opacity: isOpen ? 1 : 0.78,
}}
/>
<div className="pl-2.5 pr-1.5 flex items-center gap-1.5 flex-1 min-w-0">
<span className={`shrink-0 transition ${isOpen ? "text-white" : "text-white/70 group-hover:text-white/90"}`}>{t.icon}</span>
<span className={`text-[10.5px] font-medium truncate transition ${isOpen ? "text-white" : "text-white/85"}`}>{t.title}</span>
</div>
<span className={`absolute right-1.5 h-1.5 w-1.5 rounded-full ${STATE_DOT[state]}`} />
</button>
)
}
return (
<div className="h-full flex flex-col gap-1 px-1.5 py-2">
{/* 起点:输入(含下载+拆分) */}
<Tile tkey="input" />
{/* 分叉:上路 关键帧 / 生视频(分镜头编排在顶部 bar */}
<div className="border-l border-violet-400/25 pl-1 ml-[3px] space-y-1">
<Tile tkey="keyframe" />
<Tile tkey="videogen" />
</div>
{/* 分叉:下路 转录/翻译/改写 */}
<div className="border-l border-pink-400/25 pl-1 ml-[3px] space-y-1">
<Tile tkey="asr" />
<Tile tkey="translate" />
<Tile tkey="rewrite" />
</div>
{/* 合流 */}
<Tile tkey="compose" />
{/* 展开面板 — keyframe 有选中帧时变宽容纳 lightbox · 可拖拽改宽 */}
{expanded.size > 0 && mounted && createPortal(
<div
className="fixed z-[100]"
style={{
left: 24,
top: 16,
bottom: 16,
width: drawerWidth,
}}
>
{TILES.filter((t) => expanded.has(t.key)).map((t) => {
const isKeyframeWithExpand = t.key === "keyframe" && data.expandedFrame !== null
return (
<section
key={t.key}
className="rounded-2xl border border-white/15 bg-black/60 backdrop-blur-2xl overflow-hidden flex flex-col h-full relative"
style={{
animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)",
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.05)",
}}
>
{/* 右边拖拽手柄 — hover 时显示 */}
<div
onMouseDown={onResizeMouseDown}
title="拖动改变宽度(双击重置)"
onDoubleClick={() => setDrawerWidth(defaultWidth)}
className="group/rs absolute top-0 bottom-0 right-0 w-1.5 cursor-col-resize hover:bg-violet-400/30 transition z-[110]"
>
<div className="absolute top-1/2 -translate-y-1/2 right-0 w-1 h-12 bg-white/0 group-hover/rs:bg-violet-400/70 rounded-l transition" />
</div>
<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">
{isKeyframeWithExpand && data.job ? (
<FrameLightbox
embedded
jobId={data.job.id}
frames={data.job.frames}
activeIndex={data.expandedFrame}
selected={data.selectedFrames}
onClose={data.onCloseExpandedFrame}
onChange={data.onExpandFrame}
onToggleSelect={data.onToggleFrame}
onJobUpdate={data.onJobUpdate}
onSwitchPanel={(key) => {
data.onCloseExpandedFrame()
setExpanded(new Set([key]))
}}
clipboard={data.clipboard}
onCopyImage={data.onCopyImage}
onGenerateProductFusionVideo={data.onGenerateProductFusionVideo}
/>
) : (
renderSection(t.key)
)}
</div>
</section>
)
})}
</div>,
document.body
)}
</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 && job && (
<>
{/* 元数据 */}
<KanbanCard tone="orange" tags={["视频源", job.url.startsWith("upload://") ? "上传" : "yt-dlp"]} title={`${job.width}×${job.height} · ${job.duration.toFixed(1)}s`}>
<div className="text-[10.5px] text-[var(--text-soft)] truncate font-mono">
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : `🔗 ${job.url}`}
</div>
</KanbanCard>
{/* 拆分流 */}
<KanbanCard tone="amber" tags={["ffmpeg"]} title="拆分音视频流">
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-md bg-violet-500/10 border border-violet-400/20 px-2 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-violet-300"></div>
<div className="text-[var(--text-strong)]"> </div>
</div>
<div className="rounded-md bg-pink-500/10 border border-pink-400/20 px-2 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300"></div>
<div className="text-[var(--text-strong)]"> ASR (wav)</div>
</div>
</div>
</KanbanCard>
{/* 解析按钮 */}
<KanbanCard tone="green" tags={["下一步"]} title={hasFrames ? "重新解析" : "启动全流程"}>
<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>
<div className="kanban-meta">
= ASR
</div>
</KanbanCard>
</>
)}
</>
)}
{/* ---- Download — 只显示元数据,视频播放器移到 Keyframe section ---- */}
{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={["视频源", job!.url.startsWith("upload://") ? "上传" : "yt-dlp"]} title={job!.url.startsWith("upload://") ? "本地上传" : "TK 下载"}>
<div className="text-[11px] text-[var(--text-soft)] truncate font-mono mt-1">
{job!.url.startsWith("upload://") ? `📎 ${job!.url.slice(9)}` : `🔗 ${job!.url}`}
</div>
</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">~9MB</div></div>
</div>
<div className="kanban-meta">
/
</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="violet" tags={["视频"]} title="拖时间轴选帧">
<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"
/>
<div className="kanban-meta">
<span className="font-mono"> {videoT.toFixed(2)}s</span>
<span className="ml-auto">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
</div>
</KanbanCard>
<KanbanCard tone="green" tags={["手动加帧"]} title="把当前时间点抽为关键帧">
<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-[12.5px] py-2 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 inline-flex items-center justify-center gap-1.5 font-medium"
>
{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)
const cleaned = !!f.cleaned_url
const elCount = f.elements?.length ?? 0
const cutCount = f.elements?.filter((e) => e.cutout_id).length ?? 0
const tags = [`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]
if (cleaned) tags.push("已清洗")
if (cutCount > 0) tags.push(`${cutCount}/${elCount} 抠图`)
return (
<KanbanCard
key={f.index}
tone={isSel ? "green" : "pink"}
tags={tags}
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={effectiveFrameUrl(job!.id, f)}
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" ? "音频转写中…" : "需要先完成关键帧抽取"}
</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 — Kanban ---- */}
{key === "rewrite" && (
<>
<KanbanCard tone="green" tags={["对照", "改前"]} title="原音频识别">
{audioCompareRows.length > 0 ? (
<div className="space-y-2">
{audioCompareRows.map((s) => (
<div key={s.index} className="rounded-md border border-white/10 bg-black/20 px-2.5 py-2">
<div className="mb-1 text-[10px] font-mono text-[var(--text-faint)]">
{s.start.toFixed(1)}s {s.end.toFixed(1)}s
</div>
<div className="text-[12px] leading-relaxed text-[var(--text-strong)]">
{s.zh || <span className="text-[var(--text-faint)] italic"></span>}
</div>
{s.en && (
<div className="mt-1.5 text-[11px] leading-relaxed text-[var(--text-soft)]">
{s.en}
</div>
)}
</div>
))}
</div>
) : (
<div className="text-[11px] text-[var(--text-soft)]"> ASR </div>
)}
<div className="kanban-meta"></div>
</KanbanCard>
<KanbanCard tone="green" tags={["对照", "改后"]} title={job?.audio_script?.rewrite_model || "SKG 口播改写"}>
{job?.audio_script?.rewritten_text ? (
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-3 py-2.5 text-[13px] text-[var(--text-strong)] leading-relaxed">
{job.audio_script.rewritten_text}
</div>
) : (
<div className="text-[11px] text-[var(--text-soft)]">
{isAudioRewriting ? "正在把原音频转成 SKG 口播文案…" : "转录完成后自动生成 SKG 口播文案"}
</div>
)}
<div className="kanban-meta"> TTS prompt</div>
</KanbanCard>
<KanbanCard tone="green" tags={["产品依据"]} title="SKG 产品卖点">
<div className="text-[12px] text-[var(--text-soft)] leading-relaxed">
{job?.audio_script?.product_brief || "等待音频转写完成后,按默认 SKG 放松产品卖点生成口播。"}
</div>
</KanbanCard>
<KanbanCard tone="green" tags={["配音"]} title={job?.audio_script?.voice_model || "MiniMax T2A"}>
{job?.audio_script?.voice_url ? (
<audio controls className="h-8 w-full" src={apiAssetUrl(job.audio_script.voice_url)} />
) : (
<div className="text-[11px] text-[var(--text-soft)]">
{job?.audio_script?.error || "配置 MiniMax 后自动生成配音文件"}
</div>
)}
<div className="kanban-meta">{job?.audio_script?.voice_id || "Chinese (Mandarin)_Reliable_Executive"}</div>
</KanbanCard>
</>
)}
{/* ---- 分镜头编排 — 基于关键帧 + 元素 + 场景图编排Phase 2 重写) ---- */}
{key === "imagegen" && (
data.selectedFrames.size === 0 ? (
<KanbanCard tone="pink" tags={["待启动"]} title="未选关键帧">
<div className="text-[11px] text-[var(--text-soft)]"> 1+ 1 </div>
</KanbanCard>
) : (
Array.from(data.selectedFrames).sort((a, b) => a - b).map((frameIdx) => {
const f = data.job?.frames.find((x) => x.index === frameIdx)
if (!f || !data.job) return null
return <ImageGenCard key={frameIdx} job={data.job} frame={f} onJobUpdate={data.onJobUpdate} />
})
)
)}
{/* ---- VideoGen — Kanban ---- */}
{key === "videogen" && (
<>
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
<div className="text-[11px] text-[var(--text-soft)]"> /v1/videos ID </div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Kling">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>
</KanbanCard>
<KanbanCard tone="rose" tags={["占位"]} title="生成视频列表">
<div className="text-[11px] text-[var(--text-soft)]"> 1 5-10s prompt</div>
</KanbanCard>
</>
)}
{/* ---- Compose — Kanban ---- */}
{key === "compose" && (
<>
<KanbanCard tone="cyan" tags={["成品"]} title="最终视频">
<div className="aspect-video bg-black/40 rounded-md flex items-center justify-center text-[11.5px] text-[var(--text-faint)] mt-1">
</div>
</KanbanCard>
<KanbanCard tone="cyan" tags={["ffmpeg"]} title="字幕 + TTS 合成">
<div className="text-[11px] text-[var(--text-soft)]"> + + TTS mp4</div>
<div className="kanban-meta"> · API </div>
</KanbanCard>
</>
)}
</div>
)
}
})
/* ============================================================
ImageGenCard — 单张关键帧的生图卡
============================================================ */
function ImageGenCard({ job, frame, onJobUpdate }: {
job: Job
frame: KeyFrame
onJobUpdate: (j: Job) => void
}) {
const [extra, setExtra] = useState("")
const [negative, setNegative] = useState("水印, @用户名, TikTok logo, 平台文字, 浮水印")
const [model, setModel] = useState("gemini-3-pro-image-preview")
const [mode, setMode] = useState<"edit" | "text">("edit")
const [generating, setGenerating] = useState(false)
const basePrompt = frame.description?.suggested_prompt ?? "(尚未识别 · 点关键帧打开 lightbox 先识别)"
const [editablePrompt, setEditablePrompt] = useState(basePrompt)
const [showPrompt, setShowPrompt] = useState(false)
const [previewGenId, setPreviewGenId] = useState<string | null>(null)
const [previewMounted, setPreviewMounted] = useState(false)
useEffect(() => setPreviewMounted(true), [])
// 当 vision 识别完成后更新默认 prompt
useEffect(() => {
if (frame.description?.suggested_prompt) {
setEditablePrompt(frame.description.suggested_prompt)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [frame.description?.suggested_prompt])
const handleGenerate = async () => {
if (!editablePrompt.trim()) {
toast.error("请先填写 prompt点上方关键帧识别会自动生成")
return
}
setGenerating(true)
try {
const updated = await generateImage(job.id, frame.index, {
prompt: editablePrompt,
extra_prompt: extra,
negative_prompt: negative,
model,
mode,
})
onJobUpdate(updated)
toast.success(`分镜 ${frame.index + 1} 生成完成`)
} catch (e) {
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setGenerating(false)
}
}
const handleSelectGen = async (genId: string, currentlySelected: boolean) => {
try {
const updated = await selectGenerated(job.id, frame.index, genId, !currentlySelected)
onJobUpdate(updated)
} catch (e) {
toast.error("选用失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const gens = frame.generated_images ?? []
const objects = frame.description?.objects ?? []
return (
<KanbanCard tone="rose" tags={[`分镜 ${frame.index + 1}`, `${frame.timestamp.toFixed(1)}s`, "占位 · Phase 2 重写"]} title="单帧编排(旧生图)">
{/* 参考图 + 识别物体 chips */}
<div className="flex gap-2 items-start mt-1">
<img
src={effectiveFrameUrl(job.id, frame)}
alt={`frame ${frame.index}`}
className="rounded-md object-cover flex-shrink-0"
style={{ width: 96, aspectRatio: `${job.width}/${job.height}` }}
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-[var(--text-faint)] uppercase tracking-widest mb-1"></div>
{objects.length > 0 ? (
<div className="flex flex-wrap gap-1">
{objects.slice(0, 6).map((o, i) => (
<button
key={i}
onClick={() => setExtra((p) => p ? `${p}, ${o.name}` : o.name)}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-white/[0.06] hover:bg-white/[0.12] border border-white/15 text-[var(--text-strong)]"
title="点击加入需求"
>
+ {o.name}
</button>
))}
</div>
) : (
<div className="text-[10px] text-[var(--text-faint)] italic"></div>
)}
</div>
</div>
{/* 画面描述AI 自动 · 可展开编辑) */}
<div className="mt-2.5">
<button
type="button"
onClick={() => setShowPrompt((v) => !v)}
className="w-full text-[10px] text-[var(--text-faint)] hover:text-[var(--text-strong)] inline-flex items-center justify-between"
>
<span>AI · prompt {showPrompt ? "▼" : "▶"}</span>
<span className="font-mono">{editablePrompt.length} </span>
</button>
{showPrompt && (
<textarea
value={editablePrompt}
onChange={(e) => setEditablePrompt(e.target.value)}
rows={3}
className="mt-1 w-full text-[11px] px-2 py-1.5 rounded-md bg-black/30 border border-white/10 text-[var(--text-strong)] resize-none focus:ring-1 focus:ring-rose-400/40 outline-none font-mono"
/>
)}
</div>
{/* 正向:我要保留 / 加入 */}
<div className="mt-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-widest text-emerald-300"> / </span>
</div>
<textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={2}
placeholder="例:加 SKG logo、保留瓶子、换实验室背景"
className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-emerald-500/5 border border-emerald-400/30 text-[var(--text-strong)] placeholder:text-[var(--text-faint)] resize-none focus:ring-2 focus:ring-emerald-400/40 outline-none"
/>
</div>
{/* 负向:不需要 / 删除 */}
<div className="mt-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-widest text-rose-300"> / </span>
<button
onClick={() => setNegative("")}
className="text-[9px] text-[var(--text-faint)] hover:text-[var(--text-strong)]"
>
</button>
</div>
<textarea
value={negative}
onChange={(e) => setNegative(e.target.value)}
rows={2}
placeholder="例:水印、@用户名、TikTok logo、文字遮罩"
className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-rose-500/5 border border-rose-400/30 text-[var(--text-strong)] placeholder:text-[var(--text-faint)] resize-none focus:ring-2 focus:ring-rose-400/40 outline-none"
/>
<div className="text-[9.5px] text-[var(--text-faint)] mt-0.5"> / "纯净图"</div>
</div>
{/* 模型 + 模式 + 生成 */}
<div className="mt-2 flex gap-1.5 items-center">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="flex-1 text-[10.5px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 text-[var(--text-strong)]"
>
<option value="gemini-3-pro-image-preview">nano-banana-pro</option>
<option value="gemini-3.1-flash-image-preview">gemini-3.1-flash-image</option>
<option value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
</select>
<select
value={mode}
onChange={(e) => setMode(e.target.value as "edit" | "text")}
className="text-[10.5px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 text-[var(--text-strong)]"
>
<option value="edit">image-to-image</option>
<option value="text">text-only</option>
</select>
</div>
<button
onClick={handleGenerate}
disabled={generating || !editablePrompt.trim()}
className="mt-2 w-full text-[12px] py-2 rounded-md bg-gradient-to-r from-rose-500 to-pink-500 text-white hover:opacity-95 disabled:opacity-40 inline-flex items-center justify-center gap-1.5 font-semibold"
>
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{generating ? "生成中…(约 5-15 秒)" : `⚡ 生成 1 张${gens.length > 0 ? "(再来一张)" : ""}`}
</button>
{/* 生成结果网格 — 按视频原比例 + 点击放大 */}
{gens.length > 0 && (
<div className="mt-2.5">
<div className="text-[10px] text-[var(--text-faint)] uppercase tracking-widest mb-1.5">
({gens.length})
</div>
<div className="grid grid-cols-3 gap-1.5">
{gens.map((g) => (
<div
key={g.id}
className={`relative rounded-md overflow-hidden border-2 transition ${
g.selected
? "border-emerald-400 ring-2 ring-emerald-400/40"
: "border-white/15 hover:border-white/40"
}`}
style={{ aspectRatio: job.height > 0 ? `${job.width}/${job.height}` : "1/1" }}
>
<button
onClick={() => setPreviewGenId(g.id)}
title={`${g.mode} · ${g.model}\n${g.prompt}`}
className="absolute inset-0 w-full h-full cursor-zoom-in"
>
<img
src={generatedImageUrl(job.id, frame.index, g.id)}
alt={`gen ${g.id}`}
className="absolute inset-0 w-full h-full object-cover"
/>
</button>
{/* 右上角独立选用按钮总显示selected=绿) */}
<button
onClick={(e) => { e.stopPropagation(); handleSelectGen(g.id, g.selected) }}
title={g.selected ? "已选用 · 点取消" : "点击选用"}
className={`absolute top-1 right-1 h-5 w-5 rounded-full inline-flex items-center justify-center transition shadow-md ${
g.selected
? "bg-emerald-500 text-white hover:bg-emerald-400"
: "bg-black/60 backdrop-blur text-white/60 hover:bg-emerald-500 hover:text-white"
}`}
>
<Check className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* 大图预览 modal */}
{previewGenId && previewMounted && createPortal(
(() => {
const g = gens.find((x) => x.id === previewGenId)
if (!g) return null
return (
<div
onClick={() => setPreviewGenId(null)}
className="fixed inset-0 z-[200] bg-black/85 backdrop-blur-xl flex items-center justify-center p-6 cursor-zoom-out"
style={{ animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
>
<div
onClick={(e) => e.stopPropagation()}
className="relative max-w-full max-h-full flex flex-col gap-2 cursor-default"
>
<img
src={generatedImageUrl(job.id, frame.index, g.id)}
alt={`gen ${g.id}`}
className="block rounded-lg object-contain"
style={{ maxHeight: "calc(100vh - 140px)", maxWidth: "calc(100vw - 80px)" }}
/>
<div className="flex items-center justify-between gap-3 px-1">
<div className="text-white/70 text-[11px] font-mono truncate">
{frame.index + 1} · {g.mode === "edit" ? "i2i" : "text"} · {g.model}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => handleSelectGen(g.id, g.selected)}
className={`text-[11.5px] px-3 py-1.5 rounded-md inline-flex items-center gap-1.5 transition ${
g.selected
? "bg-emerald-500 text-white hover:bg-emerald-400"
: "bg-white/10 text-white hover:bg-white/20"
}`}
>
<Check className="h-3.5 w-3.5" />
{g.selected ? "已选用" : "选用此图"}
</button>
<button
onClick={() => setPreviewGenId(null)}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/10 text-white hover:bg-white/20 inline-flex items-center gap-1"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
)
})(),
document.body
)}
</KanbanCard>
)
}