981 lines
47 KiB
TypeScript
981 lines
47 KiB
TypeScript
"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}
|
||
generatedVideos={data.job.generated_videos ?? []}
|
||
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}
|
||
onDeleteVideo={data.onDeleteVideo}
|
||
/>
|
||
) : (
|
||
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)]">候选帧 → pHash 去重 + 清晰度排序 + 时序分桶 → 按当前设置产出参考帧</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 || "Azure OpenAI TTS"}>
|
||
{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 || "当前第一步不默认生成配音文件;后续新配音阶段走 Azure OpenAI TTS"}
|
||
</div>
|
||
)}
|
||
<div className="kanban-meta">{job?.audio_script?.voice_id || "Azure voice"}</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)]">按后端 VIDEO_CREATE_PATHS 提交,模型 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 = "gpt-image-2"
|
||
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">
|
||
<div className="flex-1 rounded-md border border-white/15 bg-black/40 px-2 py-1.5 text-[10.5px] font-semibold text-[var(--text-strong)]">
|
||
gpt-image-2
|
||
</div>
|
||
<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>
|
||
)
|
||
}
|