747 lines
35 KiB
TypeScript
747 lines
35 KiB
TypeScript
"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<number>
|
||
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<string | null>(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<Region[]>([])
|
||
const [draftRegion, setDraftRegion] = useState<Region | null>(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<HTMLDivElement>(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, background: "white" | "black" = "white") => {
|
||
setCuttingId(id)
|
||
try {
|
||
const updated = await cutoutElement(jobId, f.index, id, background)
|
||
onJobUpdate?.(updated)
|
||
toast.success(`抠图完成(${background === "white" ? "白底" : "黑底"})`)
|
||
} catch (e) {
|
||
toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setCuttingId(null)
|
||
}
|
||
}
|
||
|
||
// cleaned_url 是 /jobs/.../cleaned.jpg?t=<timestamp> 形式(后端写时带)
|
||
// 这里直接当 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 = (
|
||
<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)",
|
||
}}
|
||
>
|
||
{/* 顶部工具栏 */}
|
||
<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-stretch gap-2 overflow-y-auto pr-1"
|
||
style={{ flex: "1 1 320px", minWidth: 200, maxWidth: 420, minHeight: 0 }}
|
||
>
|
||
{/* 上方:主图 + 画框 overlay */}
|
||
<div
|
||
ref={imgWrapRef}
|
||
className={`relative ${cropMode ? "cursor-crosshair select-none" : ""}`}
|
||
onMouseDown={onCropMouseDown}
|
||
onMouseMove={onCropMouseMove}
|
||
onMouseUp={onCropMouseUp}
|
||
onMouseLeave={onCropMouseUp}
|
||
>
|
||
<img
|
||
src={mainSrc}
|
||
alt={`frame ${f.index}`}
|
||
className="rounded-lg object-contain w-full pointer-events-none"
|
||
style={{ maxHeight: hasCleaned ? "38vh" : "62vh" }}
|
||
draggable={false}
|
||
/>
|
||
<div className="absolute top-2 left-2 text-[9.5px] px-1.5 py-0.5 rounded backdrop-blur bg-black/50 text-white/80 pointer-events-none">
|
||
{f.cleaned_applied ? "✨ 已替换为清洗版" : "原图"}
|
||
</div>
|
||
|
||
{/* 已确认的多个选区 */}
|
||
{cropMode && regions.map((r, i) => (
|
||
<div key={i} className="absolute pointer-events-none border-2 border-cyan-300/90 bg-cyan-300/10"
|
||
style={{
|
||
left: `${r.x * 100}%`,
|
||
top: `${r.y * 100}%`,
|
||
width: `${r.w * 100}%`,
|
||
height: `${r.h * 100}%`,
|
||
}}
|
||
>
|
||
<span className="absolute -top-4 left-0 text-[9px] px-1 py-0 rounded-sm bg-cyan-300 text-black font-bold leading-tight">#{i + 1}</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* 当前正在拖的草稿框 */}
|
||
{cropMode && draftRegion && draftRegion.w > 0 && draftRegion.h > 0 && (
|
||
<div className="absolute pointer-events-none border-2 border-cyan-300 border-dashed shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
||
style={{
|
||
left: `${draftRegion.x * 100}%`,
|
||
top: `${draftRegion.y * 100}%`,
|
||
width: `${draftRegion.w * 100}%`,
|
||
height: `${draftRegion.h * 100}%`,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 画框模式角标(小,左上) — 不再遮挡画面 */}
|
||
{cropMode && (
|
||
<div className="absolute top-2 right-2 text-[9.5px] px-1.5 py-0.5 rounded backdrop-blur bg-cyan-500/85 text-white pointer-events-none font-medium">
|
||
画框 · 已选 {regions.length}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 画框工具栏 */}
|
||
{cropMode ? (
|
||
extractNamePrompt ? (
|
||
// 提取模式:要用户填名字(仅 1 框时进入此模式)
|
||
<div className="space-y-1.5">
|
||
<input
|
||
autoFocus
|
||
value={extractName}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
onClick={handleExtractRegion}
|
||
disabled={extracting || !extractName.trim()}
|
||
className="flex-1 px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{extracting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||
{extracting ? "提取中…(5-15 秒)" : "✓ 提取"}
|
||
</button>
|
||
<button
|
||
onClick={() => { setExtractNamePrompt(false); setExtractName("") }}
|
||
className="px-2 py-1.5 rounded-md text-[11px] bg-white/10 hover:bg-white/20 text-white"
|
||
title="返回"
|
||
>
|
||
<ChevronLeft className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// 画框模式 — 可连续画多框
|
||
<div className="space-y-1.5">
|
||
<div className="text-[10px] text-white/55 leading-snug">
|
||
{regions.length === 0
|
||
? "在图上拖动鼠标 → 框选要处理的区域(可连续画多个)"
|
||
: `已框 ${regions.length} 个 · 继续拖鼠标加框 · 「去掉」批量清洗 · 单框可「提取」为元素`}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => handleCleanup(true)}
|
||
disabled={cleaning || regions.length === 0}
|
||
className="flex-1 px-1.5 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition bg-cyan-500 hover:bg-cyan-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="批量清洗所有框内"
|
||
>
|
||
{cleaning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
|
||
{cleaning ? "去掉中" : `✓ 去掉${regions.length > 1 ? ` ${regions.length}` : ""}`}
|
||
</button>
|
||
<button
|
||
onClick={() => { if (regions.length === 1) setExtractNamePrompt(true) }}
|
||
disabled={regions.length !== 1}
|
||
className="flex-1 px-1.5 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title={regions.length === 1 ? "提取框内为元素" : regions.length === 0 ? "先画一个框" : "提取仅在恰好 1 个框时可用"}
|
||
>
|
||
<Wand2 className="h-3 w-3" />
|
||
🪄 提取
|
||
</button>
|
||
<button
|
||
onClick={() => setRegions((prev) => prev.slice(0, -1))}
|
||
disabled={regions.length === 0}
|
||
className="px-1.5 py-1.5 rounded-md text-[10.5px] bg-white/10 hover:bg-white/20 text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="撤销上一个框"
|
||
>
|
||
↶
|
||
</button>
|
||
<button
|
||
onClick={() => { setCropMode(false); setRegions([]); setDraftRegion(null); setDragStart(null) }}
|
||
className="px-1.5 py-1.5 rounded-md text-[10.5px] bg-white/10 hover:bg-white/20 text-white"
|
||
title="退出画框"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
) : (
|
||
<button
|
||
onClick={() => { setCropMode(true); setRegions([]) }}
|
||
className="w-full px-3 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-white/[0.06] hover:bg-cyan-500/30 border border-white/15 hover:border-cyan-300/50 text-white/80 hover:text-white"
|
||
title="可连续画多个框 · 批量清洗或单框提取"
|
||
>
|
||
<Crop className="h-3 w-3" />
|
||
📐 框选区域(可多框)
|
||
</button>
|
||
)}
|
||
|
||
{/* 下方:清洗版(有待应用版本时显示) */}
|
||
{hasCleaned && cleanedSrc && (
|
||
<div className="relative rounded-lg border border-emerald-400/40 bg-emerald-500/5 p-2 space-y-1.5">
|
||
<div className="flex items-center justify-between pr-5">
|
||
<div className="text-[10px] text-emerald-300 inline-flex items-center gap-1 font-medium">
|
||
<Sparkle className="h-2.5 w-2.5" />
|
||
清洗结果
|
||
</div>
|
||
<span className="text-[9px] text-white/40">待应用</span>
|
||
</div>
|
||
<button
|
||
onClick={handleDiscardCleaned}
|
||
title="丢弃这次清洗结果"
|
||
className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-black/40 hover:bg-rose-500/80 text-white/70 hover:text-white inline-flex items-center justify-center transition"
|
||
>
|
||
<X className="h-2.5 w-2.5" />
|
||
</button>
|
||
<img
|
||
src={cleanedSrc}
|
||
alt={`cleaned ${f.index}`}
|
||
className="rounded-md object-contain w-full"
|
||
style={{ maxHeight: "32vh" }}
|
||
/>
|
||
<button
|
||
onClick={handleApplyCleaned}
|
||
disabled={applying}
|
||
className="w-full px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="替换原图为这张干净版 · 后续抠图 / 分镜头编排都基于干净版"
|
||
>
|
||
{applying ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||
{applying ? "替换中…" : "✓ 替换原图"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 清洗按钮(全图) */}
|
||
<button
|
||
onClick={() => handleCleanup(false)}
|
||
disabled={cleaning || cropMode}
|
||
className="w-full px-3 py-1.5 rounded-md text-[11.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-gradient-to-r from-cyan-500/80 to-emerald-500/80 hover:from-cyan-500 hover:to-emerald-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="清掉水印 / @用户名 / 字幕 / 平台 logo"
|
||
>
|
||
{cleaning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkle className="h-3.5 w-3.5" />}
|
||
{cleaning ? "清洗中…(5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "🧹 清洗水印"}
|
||
</button>
|
||
|
||
<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" style={{ minWidth: 240 }}>
|
||
{/* 识别 */}
|
||
<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" />
|
||
Vision 识别
|
||
{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 给出场景 / 风格 / 候选物体</>
|
||
)}
|
||
</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) => {
|
||
const alreadyIn = elements.some((e) => e.name_zh === o.name)
|
||
return (
|
||
<button
|
||
key={i}
|
||
onClick={() => !alreadyIn && handleAddElement(o.name, o.extract_prompt, o.position, "auto")}
|
||
disabled={alreadyIn}
|
||
title={alreadyIn ? "已加入元素清单" : (o.position ? `位置:${o.position}` : undefined)}
|
||
className={`w-full text-left rounded border px-2 py-1 transition group/o ${
|
||
alreadyIn
|
||
? "bg-emerald-500/10 border-emerald-400/30 cursor-default"
|
||
: "bg-white/[0.03] hover:bg-white/[0.08] border-white/8 hover:border-pink-300/40"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
|
||
<span className="truncate">{o.name}</span>
|
||
{alreadyIn ? (
|
||
<Check className="h-2.5 w-2.5 text-emerald-300 shrink-0 ml-auto" />
|
||
) : (
|
||
<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" />
|
||
元素清单
|
||
{elements.length > 0 && (
|
||
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {elements.length}</span>
|
||
)}
|
||
<span className="text-[9.5px] text-white/35 font-normal ml-auto">→ 给下一步「分镜头编排」</span>
|
||
</div>
|
||
|
||
{elements.length > 0 && (
|
||
<div className="space-y-1.5 mb-2">
|
||
{elements.map((e) => {
|
||
const isCutting = cuttingId === e.id
|
||
const hasCutout = !!e.cutout_id
|
||
const bg = e.cutout_background ?? "white"
|
||
return (
|
||
<div
|
||
key={e.id}
|
||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5 flex items-start gap-2"
|
||
>
|
||
{/* 抠图缩略图 / 占位(真实底色:white/black) */}
|
||
<div
|
||
className="flex-shrink-0 rounded border border-white/8 flex items-center justify-center overflow-hidden"
|
||
style={{ width: 36, height: 36, background: hasCutout ? (bg === "black" ? "#000" : "#fff") : "rgba(0,0,0,0.4)" }}
|
||
>
|
||
{hasCutout ? (
|
||
<a
|
||
href={cutoutUrl(jobId, f.index, e.id)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={`点击查看 · ${bg === "white" ? "白底" : "黑底"}`}
|
||
className="block w-full h-full"
|
||
>
|
||
<img
|
||
src={cutoutUrl(jobId, f.index, e.id)}
|
||
alt={e.name_zh}
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
</a>
|
||
) : (
|
||
<Sparkle className="h-3.5 w-3.5 text-white/25" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0 pr-5">
|
||
<div className="flex items-center gap-1 text-white text-[11.5px] font-medium leading-tight truncate">
|
||
<span className="truncate">{e.name_zh}</span>
|
||
{e.source === "auto" && (
|
||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||
)}
|
||
{hasCutout && (
|
||
<span className={`text-[8.5px] font-mono uppercase ${bg === "white" ? "text-white/80" : "text-white/50"}`}>
|
||
{bg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-0.5 text-[10px] font-mono leading-tight truncate text-white/45">
|
||
{e.name_en || <span className="text-white/30">(无英文)</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底色 toggle(小) — 选 white/black 后点抠图 */}
|
||
<div className="shrink-0 inline-flex rounded overflow-hidden border border-white/15">
|
||
<button
|
||
onClick={() => handleCutout(e.id, "white")}
|
||
disabled={isCutting}
|
||
title="白底抠图"
|
||
className={`text-[9.5px] px-1.5 py-0.5 transition ${
|
||
hasCutout && bg === "white"
|
||
? "bg-white text-black"
|
||
: "bg-white/[0.05] text-white/70 hover:bg-white/15"
|
||
}`}
|
||
>W</button>
|
||
<button
|
||
onClick={() => handleCutout(e.id, "black")}
|
||
disabled={isCutting}
|
||
title="黑底抠图"
|
||
className={`text-[9.5px] px-1.5 py-0.5 transition ${
|
||
hasCutout && bg === "black"
|
||
? "bg-black text-white"
|
||
: "bg-black/30 text-white/70 hover:bg-black/60"
|
||
}`}
|
||
>B</button>
|
||
</div>
|
||
|
||
{/* 抠图状态指示(不再是按钮,因为 W/B toggle 即触发) */}
|
||
{isCutting ? (
|
||
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 bg-violet-500/30 text-white/90">
|
||
<Loader2 className="h-2.5 w-2.5 animate-spin" /> 抠图中
|
||
</span>
|
||
) : !hasCutout ? (
|
||
<span className="shrink-0 text-[9.5px] text-white/35 self-center">未抠</span>
|
||
) : null}
|
||
|
||
{/* 删除 */}
|
||
<button
|
||
onClick={() => handleDeleteElement(e.id)}
|
||
className="absolute right-1 top-1 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={(ev) => 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"
|
||
/>
|
||
<button
|
||
onClick={() => { if (addInput.trim() && !addingZh) { handleAddElement(addInput); setAddInput("") } }}
|
||
disabled={!addInput.trim() || addingZh}
|
||
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="添加"
|
||
>
|
||
{addingZh ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||
</button>
|
||
</div>
|
||
<div className="mt-1.5 text-[10px] text-white/35 leading-relaxed">
|
||
元素持久化保存 · 已抠图的元素 + 干净版场景图 → 下一步「分镜头编排」(多视角 / 风格融合 / 布局都在那做)
|
||
</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)
|
||
}
|