"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 } from "lucide-react" import { frameUrl, cleanedFrameUrl, cutoutUrl, describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, type KeyFrame, type Job, } 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 embedded?: boolean } export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, embedded = false }: Props) { const [describing, setDescribing] = useState(false) const [cleaning, setCleaning] = useState(false) const [applying, setApplying] = useState(false) const [cuttingId, setCuttingId] = useState(null) const [addingZh, setAddingZh] = useState(false) const [addInput, setAddInput] = useState("") 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 [extractNamePrompt, setExtractNamePrompt] = useState(false) // 提取模式:要用户填名字 const [extractName, setExtractName] = useState("") const [extracting, setExtracting] = useState(false) const imgWrapRef = useRef(null) useEffect(() => setMounted(true), []) // 切换分镜时清空选区 useEffect(() => { setCropMode(false) setRegions([]) setDraftRegion(null) setDragStart(null) setExtractNamePrompt(false) setExtractName("") }, [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) if (e.key === " " || e.key === "Enter") { e.preventDefault() onToggleSelect(activeIndex) } } } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [activeIndex, frames.length, onClose, onChange, onToggleSelect]) 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 isSelected = selected.has(f.index) const desc = f.description const elements = f.elements ?? [] const hasCleaned = !!f.cleaned_url 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) => { setCleaning(true) try { const usable = useRegions ? regions.filter((r) => r.w >= 0.03 && r.h >= 0.03) : null const updated = await cleanupFrame(jobId, f.index, usable && usable.length > 0 ? usable : null) onJobUpdate?.(updated) toast.success(`分镜 ${f.index + 1} 清洗完成${usable && usable.length > 0 ? `(${usable.length} 个区域)` : ""}`) if (useRegions) { setCropMode(false); setRegions([]); setDraftRegion(null) } } catch (e) { toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e))) } finally { setCleaning(false) } } const handleExtractRegion = async () => { // 提取语义只在恰好 1 个框时支持 if (regions.length !== 1 || !extractName.trim()) return const r = regions[0] setExtracting(true) try { const added = await addElement(jobId, f.index, { name_zh: extractName.trim(), source: "region", region: r, }) onJobUpdate?.(added) const fr = added.frames.find((x) => x.index === f.index) const newEl = fr?.elements?.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0] if (newEl) { const cut = await cutoutElement(jobId, f.index, newEl.id) onJobUpdate?.(cut) toast.success(`「${extractName.trim()}」已提取并加入元素清单`) } else { toast.success(`「${extractName.trim()}」已加入元素清单 · 但抠图未触发`) } setCropMode(false); setRegions([]); setExtractNamePrompt(false); setExtractName("") } catch (e) { toast.error("提取失败:" + (e instanceof Error ? e.message : String(e))) } finally { setExtracting(false) } } // 画框 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 setAddingZh(true) 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))) } finally { setAddingZh(false) } } const handleDeleteElement = async (id: string) => { try { const updated = await deleteElement(jobId, f.index, id) onJobUpdate?.(updated) } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } } const handleCutout = async (id: string) => { setCuttingId(id) try { const updated = await cutoutElement(jobId, f.index, id) onJobUpdate?.(updated) toast.success("裁切完成") } catch (e) { toast.error("裁切失败:" + (e instanceof Error ? e.message : String(e))) } finally { setCuttingId(null) } } // 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={`rounded-2xl border border-white/15 overflow-hidden flex flex-col ${embedded ? "" : "fixed z-[100] bg-black/70 backdrop-blur-2xl"}`} 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)", }} > {/* 顶部工具栏 */}
分镜 {String(arrayPos + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")} · {f.timestamp.toFixed(2)}s
{/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
{/* 左侧大图区 */}
{/* 上方:主图 + 画框 overlay */}
{`frame
{f.cleaned_applied ? "✨ 已替换为清洗版" : "原图"}
{/* 已确认的多个选区 */} {cropMode && regions.map((r, i) => (
#{i + 1}
))} {/* 当前正在拖的草稿框 */} {cropMode && draftRegion && draftRegion.w > 0 && draftRegion.h > 0 && (
)} {/* 画框模式角标(小,左上) — 不再遮挡画面 */} {cropMode && (
画框 · 已选 {regions.length}
)}
{/* 画框工具栏 */} {cropMode ? ( extractNamePrompt ? ( // 提取模式:要用户填名字(仅 1 框时进入此模式)
setExtractName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing && extractName.trim()) { e.preventDefault() handleExtractRegion() } if (e.key === "Escape") { setExtractNamePrompt(false); setExtractName("") } }} placeholder="给这个元素起个中文名(如:左下角药瓶)" className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-black/40 border border-violet-300/50 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40" />
) : ( // 画框模式 — 可连续画多框
{regions.length === 0 ? "在图上拖动鼠标 → 框选要处理的区域(可连续画多个)" : `已框 ${regions.length} 个 · 继续拖鼠标加框 · 「去掉」批量清洗 · 单框可「提取」为元素`}
) ) : ( )} {/* 下方:清洗版(有待应用版本时显示) */} {hasCleaned && cleanedSrc && (
清洗结果
待应用
{`cleaned
)} {/* 清洗按钮(全图) */}
{/* 右侧识别 + 元素清单 */}
{/* 识别 */}
Vision 识别 {desc && 已识别}
{!desc ? (
{describing ? (
Gemini Vision 识别中…
) : ( <>点击「识别」让 Gemini 给出场景 / 风格 / 候选物体 )}
) : (
{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} )} → 给下一步「分镜头编排」
{elements.length > 0 && (
{elements.map((e) => { const isCutting = cuttingId === e.id const hasCutout = !!e.cutout_id const hasRegion = !!e.region return (
{/* 裁切缩略图 / 占位 */}
{hasCutout ? ( {e.name_zh} ) : ( )}
{e.name_zh} {e.source === "auto" && ( auto )} {e.source === "region" && ( box )}
{e.name_en || (无英文)}
{/* 裁切按钮 — 只在有 region 时可用 */} {hasRegion ? ( ) : ( 仅文字 )} {/* 删除 */}
) })}
)} {/* 添加 */}
setAddInput(ev.target.value)} onKeyDown={(ev) => { if (ev.key === "Enter" && !ev.nativeEvent.isComposing) { ev.preventDefault() if (addInput.trim() && !addingZh) { handleAddElement(addInput) setAddInput("") } } }} placeholder="加自定义元素 · 中文回车自动翻英文" className="flex-1 text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40" />
元素持久化保存 · 已抠图的元素 + 干净版场景图 → 下一步「分镜头编排」(多视角 / 风格融合 / 布局都在那做)
←/→ 切换 · Space 选用 · ESC 关闭
) return embedded ? content : createPortal(content, document.body) }