Files
20260512-skg-tk/web/components/lightbox.tsx
2026-05-13 09:26:08 +08:00

261 lines
12 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 { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw } 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<number>
onClose: () => void
onChange: (idx: number) => void
onToggleSelect: (idx: number) => void
onJobUpdate?: (job: Job) => void
embedded?: boolean // true=嵌入到容器里(无 fixedfalse=独立浮动卡(默认)
}
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 content = (
<div
onClick={(e) => 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 橙红配色 */}
<div
className="flex items-center justify-between px-4 py-2 text-white"
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); if (arrayPos > 0) onChange(frames[arrayPos - 1].index) }}
disabled={arrayPos <= 0}
className="h-7 w-7 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); if (arrayPos < frames.length - 1) onChange(frames[arrayPos + 1].index) }}
disabled={arrayPos >= frames.length - 1}
className="h-7 w-7 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="h-4 w-4" />
</button>
<span className="text-[11px] font-mono text-white/70 ml-1">
{String(arrayPos + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")}
<span className="mx-1.5 text-white/30">·</span>
<span className="text-white/60">{f.timestamp.toFixed(2)}s</span>
</span>
</div>
<button
onClick={onClose}
className="h-7 w-7 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center justify-center"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 主体 — 左大图 + 右识别面板 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图 */}
<div className="flex flex-col items-center gap-2 flex-shrink-0">
<img
src={frameUrl(jobId, f.index)}
alt={`frame ${f.index}`}
className="rounded-lg object-contain"
style={{ maxWidth: 320, maxHeight: "70vh" }}
/>
<button
onClick={() => onToggleSelect(f.index)}
className={`w-full px-3 py-1.5 rounded-md text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition ${
isSelected
? "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" />
{isSelected ? "已选用" : "选用此帧"}
</button>
</div>
{/* 右侧识别面板 */}
<div
className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0"
>
{/* 识别到的元素 */}
<section>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-white text-[12.5px] font-semibold">
<Eye className="h-3.5 w-3.5" />
{desc && <span className="text-[10px] text-emerald-400 font-mono ml-1"></span>}
</div>
<button
onClick={handleDescribe}
disabled={describing}
className="text-[10.5px] text-white/70 hover:text-white px-2 py-0.5 rounded border border-white/20 hover:border-white/40 disabled:opacity-50 inline-flex items-center gap-1"
title="调 Gemini Vision 识别"
>
{describing ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RefreshCw className="h-2.5 w-2.5" />}
{desc ? "重新识别" : "识别"}
</button>
</div>
{!desc ? (
<div className="rounded-lg border border-dashed border-white/15 bg-white/[0.03] p-3 text-[11.5px] text-white/50 leading-relaxed">
{describing ? (
<div className="flex items-center gap-1.5 text-white/60">
<Loader2 className="h-3 w-3 animate-spin" />
Gemini Vision
</div>
) : (
<>Gemini 2.5 / / / prompt</>
)}
</div>
) : (
<div className="space-y-2.5 text-[11.5px]">
{desc.scene && (
<div className="rounded-md bg-violet-500/10 border border-violet-400/25 px-2.5 py-2">
<div className="text-[9.5px] uppercase tracking-widest text-violet-300 mb-1"></div>
<div className="text-white leading-relaxed">{desc.scene}</div>
</div>
)}
{desc.style && (
<div className="rounded-md bg-amber-500/10 border border-amber-400/25 px-2.5 py-2">
<div className="text-[9.5px] uppercase tracking-widest text-amber-300 mb-1"></div>
<div className="text-white leading-relaxed">{desc.style}</div>
</div>
)}
{desc.objects && desc.objects.length > 0 && (
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-2">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300 mb-1.5">
</div>
<div className="space-y-1.5">
{desc.objects.map((o, i) => (
<button
key={i}
onClick={() => setExtractPrompt(o.extract_prompt || o.name)}
className="w-full text-left rounded bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 hover:border-pink-300/40 px-2 py-1.5 transition"
>
<div className="text-white text-[11.5px] font-medium">
{o.name}
{o.position && <span className="text-white/40 ml-1.5 text-[10px]">· {o.position}</span>}
</div>
{o.extract_prompt && (
<div className="text-[10px] text-white/40 mt-0.5 truncate font-mono">{o.extract_prompt}</div>
)}
</button>
))}
</div>
</div>
)}
</div>
)}
</section>
{/* 自定义提取 */}
<section>
<div className="flex items-center gap-1.5 mb-2 text-white text-[12.5px] font-semibold">
<Sparkles className="h-3.5 w-3.5" />
</div>
<textarea
value={extractPrompt}
onChange={(e) => setExtractPrompt(e.target.value)}
placeholder="比如rightmost white bottle"
rows={2}
className="w-full 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 resize-none focus:ring-2 focus:ring-violet-400/50"
/>
<button
disabled
className="mt-2 w-full text-[12px] py-1.5 rounded-md bg-violet-500/60 text-white inline-flex items-center justify-center gap-1.5 cursor-not-allowed disabled:opacity-50"
>
<Wand2 className="h-3.5 w-3.5" />
</button>
<div className="mt-1.5 text-[10px] text-white/30 font-mono">
nano-banana-pro image edit
</div>
</section>
{/* 已提取 */}
<section>
<div className="flex items-center gap-1.5 mb-2 text-white text-[12.5px] font-semibold">
<Check className="h-3.5 w-3.5" />
</div>
<div className="rounded-lg border border-dashed border-white/10 bg-white/[0.02] p-3 text-[11px] text-white/40 text-center">
·
</div>
</section>
</div>
</div>
<div className="px-4 py-1.5 text-[10px] text-white/40 font-mono text-center border-t border-white/5 bg-white/[0.02]">
/ · Space · ESC
</div>
</div>
)
return embedded ? content : createPortal(content, document.body)
}