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

343 lines
16 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, Plus } from "lucide-react"
import { frameUrl, describeFrame, translateText, type KeyFrame, type Job } from "@/lib/api"
import { toast } from "sonner"
type CustomItem = { id: string; zh: string; en: string; translating: boolean }
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 [describing, setDescribing] = useState(false)
const [mounted, setMounted] = useState(false)
// 自定义提取元素 — 按 frame 隔离,切换 frame 后回到同一帧时还能看到之前加的
const [customsByFrame, setCustomsByFrame] = useState<Record<number, CustomItem[]>>({})
const [addInput, setAddInput] = useState("")
useEffect(() => setMounted(true), [])
const customs = activeIndex !== null ? (customsByFrame[activeIndex] || []) : []
const updateCustoms = (updater: (prev: CustomItem[]) => CustomItem[]) => {
if (activeIndex === null) return
const key = activeIndex
setCustomsByFrame((prev) => ({ ...prev, [key]: updater(prev[key] || []) }))
}
const addCustom = async (zhRaw: string, presetEn?: string) => {
const zh = zhRaw.trim()
if (!zh || activeIndex === null) return
const id = Math.random().toString(36).slice(2, 8)
const skipTranslate = !!presetEn
updateCustoms((prev) => [...prev, { id, zh, en: presetEn || "", translating: !skipTranslate }])
if (skipTranslate) return
try {
const en = await translateText(zh, "en")
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en, translating: false } : c))
} catch (e) {
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en: "", translating: false } : c))
toast.error("翻译失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const removeCustom = (id: string) => updateCustoms((prev) => prev.filter((c) => c.id !== id))
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-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300/90 mb-1.5">
·
</div>
<div className="space-y-1">
{desc.objects.map((o, i) => (
<button
key={i}
onClick={() => addCustom(o.name, o.extract_prompt)}
title={o.position ? `位置:${o.position}` : undefined}
className="w-full text-left rounded bg-white/[0.03] hover:bg-white/[0.08] border border-white/8 hover:border-pink-300/40 px-2 py-1 transition group/o"
>
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
<span className="truncate">{o.name}</span>
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
</div>
{o.extract_prompt && (
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{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" />
{customs.length > 0 && (
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {customs.length}</span>
)}
</div>
{customs.length > 0 && (
<div className="space-y-1.5 mb-2">
{customs.map((c) => (
<div
key={c.id}
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5"
>
<div className="pr-5 text-white text-[11.5px] font-medium leading-tight truncate">{c.zh}</div>
<div className="pr-5 mt-0.5 text-[10px] font-mono leading-tight truncate">
{c.translating ? (
<span className="inline-flex items-center gap-1 text-white/40">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
) : c.en ? (
<span className="text-white/45">{c.en}</span>
) : (
<span className="text-rose-300/60"> · </span>
)}
</div>
<button
onClick={() => removeCustom(c.id)}
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
title="删除"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-1.5">
<input
value={addInput}
onChange={(e) => setAddInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault()
if (addInput.trim()) {
addCustom(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"
/>
<button
onClick={() => { if (addInput.trim()) { addCustom(addInput); setAddInput("") } }}
disabled={!addInput.trim()}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-violet-500/60 hover:bg-violet-500/80 text-white inline-flex items-center justify-center gap-1 disabled:opacity-30 disabled:cursor-not-allowed transition shrink-0"
title="添加"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<button
disabled
className="mt-2 w-full text-[11.5px] py-1.5 rounded-md bg-violet-500/40 text-white inline-flex items-center justify-center gap-1.5 cursor-not-allowed disabled:opacity-50"
title="即将上线:批量调 nano-banana-pro image edit"
>
<Wand2 className="h-3.5 w-3.5" />
</button>
</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)
}