"use client" import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Copy } from "lucide-react" import { frameUrl, describeFrame, 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 embedded?: boolean // true=嵌入到容器里(无 fixed),false=独立浮动卡(默认) } export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, embedded = false }: Props) { const [extractPrompt, setExtractPrompt] = useState("") const [describing, setDescribing] = useState(false) const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) 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]) // activeIndex 是 KeyFrame.index 稳定 ID,而 frames 数组按 timestamp 排序——必须用 find 不能用 [index] 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 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 copyText = (text: string) => { navigator.clipboard.writeText(text).then(() => toast.success("已复制")) } 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)", }} > {/* 顶部工具栏 — 切换 / 关闭,用 keyframe 橙红配色 */}
分镜 {String(arrayPos + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")} · {f.timestamp.toFixed(2)}s
{/* 主体 — 左大图 + 右识别面板 */}
{/* 左侧大图 */}
{`frame
{/* 右侧识别面板 */}
{/* 识别到的元素 */}
识别到的元素 {desc && 已识别}
{!desc ? (
{describing ? (
Gemini Vision 识别中…
) : ( <>点击右上角「识别」按钮,Gemini 2.5 看图给出场景描述 / 物体列表 / 风格 / 适合下游的 prompt。 )}
) : (
{desc.scene && (
场景
{desc.scene}
)} {desc.style && (
风格
{desc.style}
)} {desc.objects && desc.objects.length > 0 && (
物体(点击 → 自动填入提取框)
{desc.objects.map((o, i) => ( ))}
)} {desc.suggested_prompt && (
建议 Prompt
{desc.suggested_prompt}
)}
)}
{/* 自定义提取 */}
自定义提取元素