"use client" import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save } from "lucide-react" import { frameUrl, cleanedFrameUrl, apiAssetUrl, describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, generateSceneAsset, generateSubjectAssets, type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind, } from "@/lib/api" import { toast } from "sonner" interface Props { jobId: string frames: KeyFrame[] activeIndex: number | null selected: Set onClose: () => void onChange: (idx: number) => void onToggleSelect: (idx: number) => void onJobUpdate?: (job: Job) => void onSwitchPanel?: (key: string) => void onCopyImage?: (ref: ImageRef) => void embedded?: boolean } const OBJECT_VIEW_OPTIONS = [ ["front", "正面"], ["back", "背面"], ["left", "左侧"], ["right", "右侧"], ["top", "顶部"], ["bottom", "底部"], ] const LIVING_VIEW_OPTIONS = [ ["front", "正面"], ["back", "背面"], ["side", "侧面"], ["side_walk", "走路"], ["expression_happy", "喜"], ["expression_angry", "怒"], ["expression_sad", "哀"], ["expression_relaxed", "乐/放松"], ["action_sit", "坐"], ["action_hold", "手持"], ["action_use", "使用"], ] type LightboxTab = "clean" | "scene" | "subject" | "review" const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [ { key: "clean", label: "原图/清洗" }, { key: "scene", label: "场景图" }, { key: "subject", label: "主体资产" }, { key: "review", label: "审核" }, ] export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, embedded = false }: Props) { const [describing, setDescribing] = useState(false) const [cleaningFrameIds, setCleaningFrameIds] = useState>(new Set()) const [batchCleaning, setBatchCleaning] = useState(false) const [batchCleanupProgress, setBatchCleanupProgress] = useState<{ done: number; total: number; failed: number } | null>(null) const [applying, setApplying] = useState(false) const [sceneGenerating, setSceneGenerating] = useState(false) const [subjectGenerating, setSubjectGenerating] = useState(null) const [assetSize, setAssetSize] = useState("source") const [subjectKinds, setSubjectKinds] = useState>({}) const [subjectBackgrounds, setSubjectBackgrounds] = useState>({}) const [subjectViews, setSubjectViews] = useState>({}) const [activeTab, setActiveTab] = useState("clean") const [editingElement, setEditingElement] = useState<{ id: string name_zh: string name_en: string position: string } | null>(null) const [mounted, setMounted] = useState(false) // 画框模式 + 多选区(相对坐标 0-1) type Region = { x: number; y: number; w: number; h: number } const [cropMode, setCropMode] = useState(false) const [regions, setRegions] = useState([]) const [draftRegion, setDraftRegion] = useState(null) // 当前正在拖的 const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null) const imgWrapRef = useRef(null) const activeIndexRef = useRef(activeIndex) useEffect(() => setMounted(true), []) useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex]) // 切换分镜时清空选区 useEffect(() => { setCropMode(false) setRegions([]) setDraftRegion(null) setDragStart(null) }, [activeIndex]) useEffect(() => { if (activeIndex === null) return const onKey = (e: KeyboardEvent) => { const inField = ["INPUT", "TEXTAREA"].includes((e.target as HTMLElement).tagName) if (e.key === "Escape") onClose() if (!inField && activeIndex !== null) { const pos = frames.findIndex((x) => x.index === activeIndex) if (e.key === "ArrowLeft" && pos > 0) onChange(frames[pos - 1].index) if (e.key === "ArrowRight" && pos < frames.length - 1) onChange(frames[pos + 1].index) } } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [activeIndex, frames.length, onClose, onChange]) const f = activeIndex !== null ? frames.find((x) => x.index === activeIndex) : undefined const arrayPos = f ? frames.findIndex((x) => x.index === f.index) : -1 if (activeIndex === null || !f || !mounted) return null const desc = f.description const elements = f.elements ?? [] const hasCleaned = !!f.cleaned_url const latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null const isCleaningCurrentFrame = cleaningFrameIds.has(f.index) const cleanedFrameCount = frames.filter((frame) => frame.cleaned_applied || frame.cleaned_url).length const pendingCleanFrames = frames.filter((frame) => !frame.cleaned_applied && !frame.cleaned_url) const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b) const sharedSubjectFrameIndices = selectedFrameIndices.length > 1 ? selectedFrameIndices : [f.index] const subjectAssetCount = elements.reduce((sum, item) => sum + (item.subject_assets?.length ?? 0), 0) const qualityWarnings = [ ...(f.quality_report?.warnings ?? []), ...(latestSceneAsset?.quality_report?.warnings ?? []), ] const handleDescribe = async () => { setDescribing(true) try { const updated = await describeFrame(jobId, f.index) onJobUpdate?.(updated) toast.success(`分镜 ${f.index + 1} 识别完成`) } catch (e) { toast.error("识别失败:" + (e instanceof Error ? e.message : String(e))) } finally { setDescribing(false) } } const handleCleanup = async (useRegions = false) => { const frameIdx = f.index const usable = useRegions ? regions.filter((r) => r.w >= 0.03 && r.h >= 0.03) : null setCleaningFrameIds((prev) => new Set(prev).add(frameIdx)) try { const updated = await cleanupFrame(jobId, frameIdx, usable && usable.length > 0 ? usable : null) onJobUpdate?.(updated) toast.success(`分镜 ${frameIdx + 1} 清洗完成${usable && usable.length > 0 ? `(${usable.length} 个区域)` : ""}`) if (useRegions && activeIndexRef.current === frameIdx) { setCropMode(false); setRegions([]); setDraftRegion(null) } } catch (e) { toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e))) } finally { setCleaningFrameIds((prev) => { const next = new Set(prev) next.delete(frameIdx) return next }) } } const handleCleanupAllFrames = async () => { const targets = (pendingCleanFrames.length > 0 ? pendingCleanFrames : frames) .filter((frame) => !cleaningFrameIds.has(frame.index)) if (targets.length === 0) return setBatchCleaning(true) setBatchCleanupProgress({ done: 0, total: targets.length, failed: 0 }) let failed = 0 try { for (let i = 0; i < targets.length; i += 1) { const frame = targets[i] try { const updated = await cleanupFrame(jobId, frame.index, null) onJobUpdate?.(updated) } catch (e) { failed += 1 console.error("batch cleanup failed", frame.index, e) } finally { setBatchCleanupProgress({ done: i + 1, total: targets.length, failed }) } } if (failed > 0) { toast.error(`批量清洗完成,${failed} 张失败,可单张重试`) } else { toast.success(`已生成 ${targets.length} 张清洗版,逐张审核即可`) } } finally { setBatchCleaning(false) } } const handleGenerateSceneAsset = async () => { setSceneGenerating(true) try { const updated = await generateSceneAsset(jobId, f.index, { size: assetSize }) onJobUpdate?.(updated) toast.success(`分镜 ${f.index + 1} 场景图已生成`) } catch (e) { toast.error("场景图生成失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSceneGenerating(false) } } const handleGenerateSubjectPackage = async (elementId: string) => { const kind = subjectKinds[elementId] ?? "object" const defaultViews = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value) const views = subjectViews[elementId]?.length ? subjectViews[elementId] : defaultViews setSubjectGenerating(elementId) try { const updated = await generateSubjectAssets(jobId, f.index, elementId, { subject_kind: kind, background: subjectBackgrounds[elementId] ?? "white", size: assetSize, source_frame_indices: sharedSubjectFrameIndices, views, }) onJobUpdate?.(updated) toast.success(`主体资产包已生成 · ${views.length} 张`) } catch (e) { toast.error("主体资产包生成失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubjectGenerating(null) } } const toggleSubjectView = (elementId: string, view: string, kind: SubjectKind) => { const defaults = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value) setSubjectViews((prev) => { const current = prev[elementId] ?? defaults const next = current.includes(view) ? current.filter((x) => x !== view) : [...current, view] return { ...prev, [elementId]: next } }) } // 画框 mouse handlers — 坐标基于 img wrapper 相对位置 const getRelXY = (clientX: number, clientY: number) => { const el = imgWrapRef.current if (!el) return null const r = el.getBoundingClientRect() return { x: Math.max(0, Math.min(1, (clientX - r.left) / r.width)), y: Math.max(0, Math.min(1, (clientY - r.top) / r.height)), } } const onCropMouseDown = (e: React.MouseEvent) => { if (!cropMode) return e.preventDefault() const p = getRelXY(e.clientX, e.clientY) if (!p) return setDragStart(p) setDraftRegion({ x: p.x, y: p.y, w: 0, h: 0 }) } const onCropMouseMove = (e: React.MouseEvent) => { if (!cropMode || !dragStart) return const p = getRelXY(e.clientX, e.clientY) if (!p) return setDraftRegion({ x: Math.min(dragStart.x, p.x), y: Math.min(dragStart.y, p.y), w: Math.abs(p.x - dragStart.x), h: Math.abs(p.y - dragStart.y), }) } const onCropMouseUp = () => { if (draftRegion && draftRegion.w >= 0.02 && draftRegion.h >= 0.02) { setRegions((prev) => [...prev, draftRegion]) } setDraftRegion(null) setDragStart(null) } const handleApplyCleaned = async () => { setApplying(true) try { const updated = await applyCleanedFrame(jobId, f.index) onJobUpdate?.(updated) toast.success(`分镜 ${f.index + 1} 已替换为清洗版`) } catch (e) { toast.error("替换失败:" + (e instanceof Error ? e.message : String(e))) } finally { setApplying(false) } } const handleDiscardCleaned = async () => { try { const updated = await discardCleanedFrame(jobId, f.index) onJobUpdate?.(updated) toast.success(`已丢弃清洗版`) } catch (e) { toast.error("丢弃失败:" + (e instanceof Error ? e.message : String(e))) } } const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => { const zh = name_zh.trim() if (!zh) return try { const updated = await addElement(jobId, f.index, { name_zh: zh, name_en, position, source }) onJobUpdate?.(updated) } catch (e) { toast.error("加入失败:" + (e instanceof Error ? e.message : String(e))) } } const handleDeleteElement = async (id: string) => { try { const updated = await deleteElement(jobId, f.index, id) onJobUpdate?.(updated) if (editingElement?.id === id) setEditingElement(null) } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } } const handleUpdateElement = async () => { if (!editingElement || !editingElement.name_zh.trim()) return try { const updated = await updateElement(jobId, f.index, editingElement.id, { name_zh: editingElement.name_zh, name_en: editingElement.name_en, position: editingElement.position, }) onJobUpdate?.(updated) setEditingElement(null) toast.success("主体已更新") } catch (e) { toast.error("更新失败:" + (e instanceof Error ? e.message : String(e))) } } // cleaned_url 是 /jobs/.../cleaned.jpg?t= 形式(后端写时带) // 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust const cleanedSrc = (() => { if (!f.cleaned_url) return null const ts = f.cleaned_url.match(/t=(\d+)/)?.[1] return cleanedFrameUrl(jobId, f.index, ts) })() // bust cache:替换后 frames/{idx}.jpg 内容已变,要刷新 const mainSrc = `${frameUrl(jobId, f.index)}${f.cleaned_applied ? "?applied=1" : ""}` const content = (
e.stopPropagation()} className={embedded ? "h-full overflow-hidden flex flex-col" : "fixed z-[100] rounded-2xl border border-white/15 bg-black/70 backdrop-blur-2xl overflow-hidden flex flex-col"} style={embedded ? { height: "100%", background: "transparent", } : { top: 80, right: 16, width: 740, maxHeight: "calc(100vh - 96px)", boxShadow: "0 40px 100px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.05)", animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)", }} > {/* 顶部工具栏 */} {!embedded && (
分镜 {String(arrayPos + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")} · {f.timestamp.toFixed(2)}s
)}
{LIGHTBOX_TABS.map((tab) => ( ))}
{latestSceneAsset ? "场景已生成" : "场景待生成"} · {subjectAssetCount > 0 ? `${subjectAssetCount} 主体资产` : "主体待生成"}
{/* 主体 — 左:大图 + 清洗状态;右:主体识别 + 主体资产 */}
{/* 左侧大图区 */}
{/* 上方:主图 + 画框 overlay */}
{`frame
{f.cleaned_applied ? "✨ 已替换为清洗版" : "原图"}
{/* 已确认的多个选区 */} {cropMode && regions.map((r, i) => (
#{i + 1}
))} {/* 当前正在拖的草稿框 */} {cropMode && draftRegion && draftRegion.w > 0 && draftRegion.h > 0 && (
)} {/* 画框模式角标(小,左上) — 不再遮挡画面 */} {cropMode && (
画框 · 已选 {regions.length}
)}
{activeTab === "clean" && ( <>
批量清洗
先批量生成待审核清洗版,不直接覆盖原图
{cleanedFrameCount}/{frames.length}
{batchCleanupProgress && (
{batchCleaning ? "清洗中" : "最近批量清洗"} {batchCleanupProgress.done}/{batchCleanupProgress.total}{batchCleanupProgress.failed ? ` · 失败 ${batchCleanupProgress.failed}` : ""}
)}
{/* 画框工具栏 */} {cropMode ? (
{regions.length === 0 ? "在图上拖动鼠标 → 框选要清洗的水印、字幕、平台 UI 或杂物" : `已框 ${regions.length} 个 · 继续加框或点击「去掉」批量清洗`}
) : ( )} {/* 下方:清洗版(有待应用版本时显示) */} {hasCleaned && cleanedSrc && (
清洗结果
待应用
{`cleaned
)} {/* 清洗按钮(全图) */} )} {activeTab === "scene" && (
场景图
{latestSceneAsset ? (
{latestSceneAsset.label}
{latestSceneAsset.width}×{latestSceneAsset.height} {onCopyImage && ( )}
) : null} {latestSceneAsset?.quality_report?.warnings?.length ? (
{latestSceneAsset.quality_report.warnings[0]}
) : null}
)} {activeTab === "review" && (
素材审核
{f.cleaned_applied ? "已应用清洗" : hasCleaned ? "清洗待确认" : "未清洗"}
{latestSceneAsset ? "场景图已生成" : "场景图未生成"}
0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}> {subjectAssetCount > 0 ? `${subjectAssetCount} 张主体资产` : "主体包未生成"}
{qualityWarnings.length ? `${qualityWarnings.length} 个风险` : "质量可用"}
{qualityWarnings.length > 0 && (
{qualityWarnings.slice(0, 3).map((warning, i) => (
{warning}
))}
)}
审核通过后,把场景图和主体资产复制到分镜槽位;当前不会自动覆盖素材,避免模型误改细节。
)}
已在素材准备流程
抽帧留下的关键帧默认都会参与清洗、场景图和主体资产准备,不需要在这里单独选用。
{/* 右侧主体识别 + 主体资产 */}
{activeTab === "clean" && (
清洗审核
可以先一键清洗全部素材帧,系统只生成待审核清洗版,不直接覆盖原图。看到没处理好的帧,再回到这张图用框选区域做局部清洗。
)} {activeTab === "scene" && (
场景资产规则
每张已选关键帧只需要一张主场景图。它用于后续生视频的空间、构图和光线参考;生成后仍需人工确认水印和细节是否被误改。 {f.scene_assets?.length ? (
{f.scene_assets.slice(-4).map((asset) => (
{asset.label}
{asset.width}×{asset.height}
))}
) : null}
)} {activeTab === "review" && (
当前帧状态
清洗:{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}
场景图:{latestSceneAsset ? `${latestSceneAsset.width}×${latestSceneAsset.height}` : "未生成"}
主体候选:{elements.length} 个
主体资产:{subjectAssetCount} 张
)} {/* 主体识别 */}
主体识别 {desc && 已识别}
{!desc ? (
{describing ? (
画面主体识别中…
) : ( <>点击「识别主体」让 Vision 给出可制作主体资产的候选对象 )}
) : (
{desc.scene && (
场景
{desc.scene}
)} {desc.style && (
风格
{desc.style}
)} {desc.objects && desc.objects.length > 0 && (
候选主体 · 点击加入「主体清单」
{desc.objects.map((o, i) => { const alreadyIn = elements.some((e) => e.name_zh === o.name) return ( ) })}
)}
)}
{/* 主体清单(持久化) */}
主体清单 {elements.length > 0 && ( · {elements.length} )} → 先修正,再生成资产
Vision 只负责给主体候选。这里确认名称、类型、背景和需要的视角/动作/表情,再生成主体资产包。
{elements.length > 0 && (
{elements.map((e) => { const hasRegion = !!e.region const isEditing = editingElement?.id === e.id const currentKind = subjectKinds[e.id] ?? e.subject_kind ?? "object" const currentBg = subjectBackgrounds[e.id] ?? e.cutout_background ?? "white" const viewOptions = currentKind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS const activeViews = subjectViews[e.id] ?? viewOptions.map(([value]) => value) const subjectAssets = e.subject_assets ?? [] const isSubjectGenerating = subjectGenerating === e.id return (
{/* 顶部:名字 + 操作 */}
{isEditing ? (
setEditingElement({ ...editingElement, name_zh: ev.target.value })} placeholder="主体名称" className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[12px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60" /> setEditingElement({ ...editingElement, name_en: ev.target.value })} placeholder="英文主体提示,可手动修正" className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] font-mono text-white outline-none placeholder:text-white/30 focus:border-violet-300/60" /> setEditingElement({ ...editingElement, position: ev.target.value })} placeholder="位置 / 备注,例如:画面左下角、手里拿着" className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60" />
) : (
{e.name_zh} {e.source === "auto" && ( 识别 )} {e.source === "region" && ( 框选 )} {hasRegion && ( · 有区域 )} {subjectAssets.length > 0 && ( · {subjectAssets.length} 张资产 )}
{e.name_en || (无英文,可点改名补充)}
{e.position && (
{e.position}
)}
)}
{isEditing ? ( <> ) : ( <> )}
主体资产包
{sharedSubjectFrameIndices.length > 1 ? `${sharedSubjectFrameIndices.length} 帧参考` : "当前帧参考"}
{viewOptions.map(([value, label]) => { const active = activeViews.includes(value) return ( ) })}
{subjectAssets.length > 0 && (
{subjectAssets.slice(-12).map((asset) => (
{asset.label}
{asset.label.replace(`${e.name_zh} · `, "")}
{onCopyImage && ( )}
))}
)}
) })}
)}
主体候选来自识别结果;确认名称、类型和视角后生成主体资产包。
←/→ 切换关键帧 · ESC 关闭
) return embedded ? content : createPortal(content, document.body) }