409 lines
19 KiB
TypeScript
409 lines
19 KiB
TypeScript
"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, generateImage, generatedImageUrl, 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=嵌入到容器里(无 fixed),false=独立浮动卡(默认)
|
||
}
|
||
|
||
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, embedded = false }: Props) {
|
||
const [describing, setDescribing] = useState(false)
|
||
const [generating, setGenerating] = 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 handleGenerateMat = async () => {
|
||
if (activeIndex === null || !f) return
|
||
const base = f.description?.suggested_prompt?.trim()
|
||
if (!base) {
|
||
toast.error("请先识别此分镜(右上角『识别』按钮)")
|
||
return
|
||
}
|
||
// 自动选用此帧 → ImageGenCard 才会渲染
|
||
if (!selected.has(f.index)) onToggleSelect(f.index)
|
||
|
||
const extraEn = customs.filter((c) => c.en).map((c) => c.en).join(", ")
|
||
setGenerating(true)
|
||
try {
|
||
const updated = await generateImage(jobId, f.index, {
|
||
prompt: base,
|
||
extra_prompt: extraEn,
|
||
negative_prompt: "水印, @用户名, TikTok logo, 平台文字, 浮水印",
|
||
model: "gemini-3-pro-image-preview",
|
||
mode: "edit",
|
||
})
|
||
onJobUpdate?.(updated)
|
||
toast.success(`分镜 ${f.index + 1} 垫图生成完成 · 已加入生图卡`)
|
||
} catch (e) {
|
||
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setGenerating(false)
|
||
}
|
||
}
|
||
|
||
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
|
||
onClick={handleGenerateMat}
|
||
disabled={generating || !desc?.suggested_prompt}
|
||
className="mt-2 w-full text-[12px] py-2 rounded-md bg-gradient-to-r from-rose-500 to-pink-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5 font-semibold transition"
|
||
title={!desc?.suggested_prompt ? "先识别此分镜" : `合成元素到关键帧场景(${customs.length} 条自定义 + 识别结果)`}
|
||
>
|
||
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||
{generating ? "生成中…(5-15 秒)" : "⚡ 生成垫图"}
|
||
</button>
|
||
{!desc?.suggested_prompt && (
|
||
<div className="mt-1 text-[10px] text-white/40 text-center">先识别此分镜,再生成垫图</div>
|
||
)}
|
||
{desc?.suggested_prompt && customs.length === 0 && (
|
||
<div className="mt-1 text-[10px] text-white/35 text-center">未加自定义元素 · 将仅按识别结果生成</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* 已生成的垫图(与生图卡同源) */}
|
||
{f.generated_images && f.generated_images.length > 0 && (
|
||
<section>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<div className="flex items-center gap-1.5 text-white text-[12.5px] font-semibold">
|
||
<Check className="h-3.5 w-3.5" />
|
||
已生成垫图
|
||
<span className="text-[10px] text-white/35 font-mono">· {f.generated_images.length}</span>
|
||
</div>
|
||
<span className="text-[9.5px] text-white/35">同步到 →「生图」节点</span>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
{f.generated_images.map((g) => (
|
||
<a
|
||
key={g.id}
|
||
href={generatedImageUrl(jobId, f.index, g.id)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={g.prompt}
|
||
className={`relative aspect-square rounded-md overflow-hidden border-2 transition ${
|
||
g.selected ? "border-emerald-400 ring-2 ring-emerald-400/40" : "border-white/15 hover:border-white/40"
|
||
}`}
|
||
>
|
||
<img
|
||
src={generatedImageUrl(jobId, f.index, g.id)}
|
||
alt={`gen ${g.id}`}
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
/>
|
||
{g.selected && (
|
||
<div className="absolute top-1 right-1 bg-emerald-500 text-white rounded-full p-0.5">
|
||
<Check className="h-2.5 w-2.5" />
|
||
</div>
|
||
)}
|
||
</a>
|
||
))}
|
||
</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)
|
||
}
|