Files
20260512-skg-tk/web/components/lightbox.tsx
2026-05-14 05:38:23 +08:00

993 lines
50 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, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind,
} 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
onCopyImage?: (ref: ImageRef) => void
embedded?: boolean
}
const OBJECT_VIEW_OPTIONS = [
["front", "正面"],
["back", "背面"],
["left", "左侧"],
["right", "右侧"],
["top", "顶部"],
["bottom", "底部"],
]
const LIVING_VIEW_OPTIONS = [
["front", "正面"],
["back", "背面"],
["side", "侧面"],
["side_walk", "走路"],
["expression_happy", "喜"],
["expression_angry", "怒"],
["expression_sad", "哀"],
["expression_relaxed", "乐/放松"],
["action_sit", "坐"],
["action_hold", "手持"],
["action_use", "使用"],
]
type LightboxTab = "clean" | "scene" | "subject" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "scene", label: "场景图" },
{ key: "subject", label: "主体资产" },
{ key: "review", label: "审核" },
]
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [applying, setApplying] = useState(false)
const [sceneGenerating, setSceneGenerating] = useState(false)
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
const [assetSize, setAssetSize] = useState<AssetSize>("source")
const [subjectKinds, setSubjectKinds] = useState<Record<string, SubjectKind>>({})
const [subjectBackgrounds, setSubjectBackgrounds] = useState<Record<string, AssetBackground>>({})
const [subjectViews, setSubjectViews] = useState<Record<string, string[]>>({})
const [activeTab, setActiveTab] = useState<LightboxTab>("clean")
const [editingElement, setEditingElement] = useState<{
id: string
name_zh: string
name_en: string
position: string
} | null>(null)
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 imgWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => setMounted(true), [])
// 切换分镜时清空选区
useEffect(() => {
setCropMode(false)
setRegions([])
setDraftRegion(null)
setDragStart(null)
}, [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)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [activeIndex, frames.length, onClose, onChange])
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 desc = f.description
const elements = f.elements ?? []
const hasCleaned = !!f.cleaned_url
const latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null
const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b)
const sharedSubjectFrameIndices = selectedFrameIndices.length > 1 ? selectedFrameIndices : [f.index]
const subjectAssetCount = elements.reduce((sum, item) => sum + (item.subject_assets?.length ?? 0), 0)
const qualityWarnings = [
...(f.quality_report?.warnings ?? []),
...(latestSceneAsset?.quality_report?.warnings ?? []),
]
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 handleGenerateSceneAsset = async () => {
setSceneGenerating(true)
try {
const updated = await generateSceneAsset(jobId, f.index, { size: assetSize })
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 场景图已生成`)
} catch (e) {
toast.error("场景图生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSceneGenerating(false)
}
}
const handleGenerateSubjectPackage = async (elementId: string) => {
const kind = subjectKinds[elementId] ?? "object"
const defaultViews = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
const views = subjectViews[elementId]?.length ? subjectViews[elementId] : defaultViews
setSubjectGenerating(elementId)
try {
const updated = await generateSubjectAssets(jobId, f.index, elementId, {
subject_kind: kind,
background: subjectBackgrounds[elementId] ?? "white",
size: assetSize,
source_frame_indices: sharedSubjectFrameIndices,
views,
})
onJobUpdate?.(updated)
toast.success(`主体资产包已生成 · ${views.length}`)
} catch (e) {
toast.error("主体资产包生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectGenerating(null)
}
}
const toggleSubjectView = (elementId: string, view: string, kind: SubjectKind) => {
const defaults = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
setSubjectViews((prev) => {
const current = prev[elementId] ?? defaults
const next = current.includes(view) ? current.filter((x) => x !== view) : [...current, view]
return { ...prev, [elementId]: next }
})
}
// 画框 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
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)))
}
}
const handleDeleteElement = async (id: string) => {
try {
const updated = await deleteElement(jobId, f.index, id)
onJobUpdate?.(updated)
if (editingElement?.id === id) setEditingElement(null)
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const handleUpdateElement = async () => {
if (!editingElement || !editingElement.name_zh.trim()) return
try {
const updated = await updateElement(jobId, f.index, editingElement.id, {
name_zh: editingElement.name_zh,
name_en: editingElement.name_en,
position: editingElement.position,
})
onJobUpdate?.(updated)
setEditingElement(null)
toast.success("主体已更新")
} catch (e) {
toast.error("更新失败:" + (e instanceof Error ? e.message : String(e)))
}
}
// 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={embedded
? "h-full overflow-hidden flex flex-col"
: "fixed z-[100] rounded-2xl border border-white/15 bg-black/70 backdrop-blur-2xl overflow-hidden flex flex-col"}
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)",
}}
>
{/* 顶部工具栏 */}
{!embedded && (
<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 items-center gap-1 border-b border-white/10 bg-black/28 px-3 py-2">
{LIGHTBOX_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition ${
activeTab === tab.key
? "bg-white text-black shadow"
: "bg-white/[0.06] text-white/58 hover:bg-white/[0.12] hover:text-white"
}`}
>
{tab.label}
</button>
))}
<div className="ml-auto hidden items-center gap-2 text-[10px] text-white/42 sm:flex">
<span>{latestSceneAsset ? "场景已生成" : "场景待生成"}</span>
<span>·</span>
<span>{subjectAssetCount > 0 ? `${subjectAssetCount} 主体资产` : "主体待生成"}</span>
</div>
</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>
{activeTab === "clean" && (
<>
{/* 画框工具栏 */}
{cropMode ? (
<div className="space-y-1.5">
<div className="text-[10px] text-white/55 leading-snug">
{regions.length === 0
? "在图上拖动鼠标 → 框选要清洗的水印、字幕、平台 UI 或杂物"
: `已框 ${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={() => 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>
</>
)}
{activeTab === "scene" && (
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11.5px] font-semibold text-white"></div>
<select
value={assetSize}
onChange={(e) => setAssetSize(e.target.value as AssetSize)}
className="rounded border border-white/10 bg-black/35 px-1.5 py-0.5 text-[10px] text-white/75 outline-none"
title="资产输出尺寸"
>
<option value="source"></option>
<option value="1024">1024</option>
<option value="1536">1536</option>
<option value="2048">2048</option>
</select>
</div>
{latestSceneAsset ? (
<div className="mb-2 overflow-hidden rounded-md border border-emerald-300/25 bg-black/30">
<img src={apiAssetUrl(latestSceneAsset.url)} alt={latestSceneAsset.label} className="max-h-36 w-full object-contain bg-black" />
<div className="flex items-center justify-between gap-2 border-t border-white/10 px-2 py-1 text-[9.5px] text-white/50">
<span>{latestSceneAsset.width}×{latestSceneAsset.height}</span>
{onCopyImage && (
<button
type="button"
onClick={() => onCopyImage({ kind: "asset", frame_idx: f.index, element_id: latestSceneAsset.id, cutout_id: latestSceneAsset.id, label: latestSceneAsset.label })}
className="inline-flex items-center gap-1 rounded bg-violet-500/70 px-1.5 py-0.5 text-white hover:bg-violet-400"
>
<Copy className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
) : null}
{latestSceneAsset?.quality_report?.warnings?.length ? (
<div className="mb-2 rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1 text-[10px] leading-snug text-amber-100/85">
{latestSceneAsset.quality_report.warnings[0]}
</div>
) : null}
<button
type="button"
onClick={handleGenerateSceneAsset}
disabled={sceneGenerating || cleaning}
className="w-full rounded-md bg-emerald-500/65 px-2 py-1.5 text-[11px] font-medium text-white transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
title="生成一张去水印、高清增强后的场景参考图"
>
{sceneGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成场景图"}
</button>
</div>
)}
{activeTab === "review" && (
<div className="space-y-2 rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="text-[11.5px] font-semibold text-white"></div>
<div className="grid grid-cols-2 gap-1.5 text-[10.5px]">
<div className={`rounded border px-2 py-1 ${f.cleaned_applied || hasCleaned ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{f.cleaned_applied ? "已应用清洗" : hasCleaned ? "清洗待确认" : "未清洗"}
</div>
<div className={`rounded border px-2 py-1 ${latestSceneAsset ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{latestSceneAsset ? "场景图已生成" : "场景图未生成"}
</div>
<div className={`rounded border px-2 py-1 ${subjectAssetCount > 0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{subjectAssetCount > 0 ? `${subjectAssetCount} 张主体资产` : "主体包未生成"}
</div>
<div className={`rounded border px-2 py-1 ${qualityWarnings.length ? "border-amber-300/35 bg-amber-500/12 text-amber-100" : "border-emerald-300/30 bg-emerald-500/10 text-emerald-100"}`}>
{qualityWarnings.length ? `${qualityWarnings.length} 个风险` : "质量可用"}
</div>
</div>
{qualityWarnings.length > 0 && (
<div className="rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/85">
{qualityWarnings.slice(0, 3).map((warning, i) => (
<div key={i}>{warning}</div>
))}
</div>
)}
<div className="text-[10px] leading-relaxed text-white/42">
</div>
</div>
)}
<div className="rounded-md border border-emerald-300/20 bg-emerald-500/10 px-3 py-2 text-[11px] leading-relaxed text-emerald-50/80">
<div className="mb-0.5 inline-flex items-center gap-1 font-medium text-emerald-100">
<Check className="h-3 w-3" />
</div>
<div className="text-emerald-50/55">
</div>
</div>
</div>
{/* 右侧主体识别 + 主体资产 */}
<div className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0" style={{ minWidth: 240 }}>
{activeTab === "clean" && (
<section className="rounded-lg border border-cyan-300/15 bg-cyan-500/[0.08] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-1.5 text-[12.5px] font-semibold text-white"></div>
UI
</section>
)}
{activeTab === "scene" && (
<section className="rounded-lg border border-emerald-300/15 bg-emerald-500/[0.08] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-1.5 text-[12.5px] font-semibold text-white"></div>
线
{f.scene_assets?.length ? (
<div className="mt-2 grid grid-cols-2 gap-2">
{f.scene_assets.slice(-4).map((asset) => (
<div key={asset.id} className="overflow-hidden rounded-md border border-white/10 bg-black/35">
<img src={apiAssetUrl(asset.url)} alt={asset.label} className="h-24 w-full object-contain" />
<div className="px-1.5 py-1 text-[9.5px] font-mono text-white/45">{asset.width}×{asset.height}</div>
</div>
))}
</div>
) : null}
</section>
)}
{activeTab === "review" && (
<section className="rounded-lg border border-white/10 bg-white/[0.035] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-2 text-[12.5px] font-semibold text-white"></div>
<div className="space-y-1.5">
<div>{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}</div>
<div>{latestSceneAsset ? `${latestSceneAsset.width}×${latestSceneAsset.height}` : "未生成"}</div>
<div>{elements.length} </div>
<div>{subjectAssetCount} </div>
</div>
</section>
)}
{/* 主体识别 */}
<section className={activeTab === "subject" ? "" : "hidden"}>
<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="识别画面主体候选"
>
{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" />
</div>
) : (
<> Vision </>
)}
</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 className={activeTab === "subject" ? "" : "hidden"}>
<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>
<div className="mb-2 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-[10.5px] leading-relaxed text-white/45">
Vision //
</div>
{elements.length > 0 && (
<div className="space-y-2 mb-2">
{elements.map((e) => {
const hasRegion = !!e.region
const isEditing = editingElement?.id === e.id
const currentKind = subjectKinds[e.id] ?? e.subject_kind ?? "object"
const currentBg = subjectBackgrounds[e.id] ?? e.cutout_background ?? "white"
const viewOptions = currentKind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS
const activeViews = subjectViews[e.id] ?? viewOptions.map(([value]) => value)
const subjectAssets = e.subject_assets ?? []
const isSubjectGenerating = subjectGenerating === e.id
return (
<div
key={e.id}
className={`relative rounded-md border p-2 ${
isEditing
? "bg-violet-500/10 border-violet-300/45"
: "bg-white/[0.04] border-white/10"
}`}
>
{/* 顶部:名字 + 操作 */}
<div className="mb-2 space-y-2">
{isEditing ? (
<div className="space-y-1.5">
<input
value={editingElement.name_zh}
onChange={(ev) => setEditingElement({ ...editingElement, name_zh: ev.target.value })}
placeholder="主体名称"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[12px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
<input
value={editingElement.name_en}
onChange={(ev) => setEditingElement({ ...editingElement, name_en: ev.target.value })}
placeholder="英文主体提示,可手动修正"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] font-mono text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
<input
value={editingElement.position}
onChange={(ev) => setEditingElement({ ...editingElement, position: ev.target.value })}
placeholder="位置 / 备注,例如:画面左下角、手里拿着"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
</div>
) : (
<div className="min-w-0">
<div className="flex items-center gap-1 text-white text-[12px] font-medium leading-tight">
<span className="truncate">{e.name_zh}</span>
{e.source === "auto" && (
<span className="text-[8.5px] text-pink-300/70 font-mono"></span>
)}
{e.source === "region" && (
<span className="text-[8.5px] text-cyan-300/80 font-mono"></span>
)}
{hasRegion && (
<span className="text-[8.5px] text-white/40 font-mono">· </span>
)}
{subjectAssets.length > 0 && (
<span className="text-[8.5px] text-white/40 font-mono">· {subjectAssets.length} </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>
{e.position && (
<div className="mt-0.5 text-[9.5px] leading-tight truncate text-white/35">
{e.position}
</div>
)}
</div>
)}
<div className="flex items-center gap-1">
{isEditing ? (
<>
<button
onClick={handleUpdateElement}
disabled={!editingElement.name_zh.trim()}
className="flex-1 rounded px-2 py-1 text-[10.5px] font-medium inline-flex items-center justify-center gap-1 bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-40"
>
<Save className="h-3 w-3" />
</button>
<button
onClick={() => setEditingElement(null)}
className="rounded px-2 py-1 text-[10.5px] bg-white/10 hover:bg-white/20 text-white"
>
</button>
</>
) : (
<>
<button
onClick={() => setEditingElement({
id: e.id,
name_zh: e.name_zh,
name_en: e.name_en || "",
position: e.position || "",
})}
className="rounded px-2 py-1 text-[10.5px] inline-flex items-center gap-1 bg-white/8 hover:bg-white/15 text-white/75 hover:text-white border border-white/10"
>
<PencilLine className="h-3 w-3" />
</button>
<button
onClick={() => handleDeleteElement(e.id)}
className="ml-auto rounded px-2 py-1 text-[10.5px] inline-flex items-center gap-1 bg-rose-500/15 hover:bg-rose-500/30 text-rose-100 border border-rose-300/20"
title="删除这个主体候选和它的主体资产"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
</div>
<div className="mt-2 rounded-md border border-violet-300/15 bg-violet-500/[0.08] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-white/90"></div>
<span className="text-[9.5px] font-mono text-white/35">
{sharedSubjectFrameIndices.length > 1 ? `${sharedSubjectFrameIndices.length} 帧参考` : "当前帧参考"}
</span>
</div>
<div className="mb-2 grid grid-cols-3 gap-1">
<select
value={currentKind}
onChange={(ev) => {
const next = ev.target.value as SubjectKind
setSubjectKinds((prev) => ({ ...prev, [e.id]: next }))
setSubjectViews((prev) => ({ ...prev, [e.id]: (next === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value) }))
}}
className="rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
<option value="object"></option>
<option value="living">/</option>
</select>
<select
value={currentBg}
onChange={(ev) => setSubjectBackgrounds((prev) => ({ ...prev, [e.id]: ev.target.value as AssetBackground }))}
className="rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
<option value="white"></option>
<option value="black"></option>
</select>
<button
type="button"
onClick={() => handleGenerateSubjectPackage(e.id)}
disabled={isSubjectGenerating || activeViews.length === 0}
className="rounded bg-violet-500/70 px-1.5 py-1 text-[10px] font-medium text-white transition hover:bg-violet-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
title="生成多视角 / 动作 / 表情主体资产"
>
{isSubjectGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{isSubjectGenerating ? "生成" : "生成"}
</button>
</div>
<div className="mb-2 flex flex-wrap gap-1">
{viewOptions.map(([value, label]) => {
const active = activeViews.includes(value)
return (
<button
key={value}
type="button"
onClick={() => toggleSubjectView(e.id, value, currentKind)}
className={`rounded border px-1.5 py-0.5 text-[9.5px] transition ${
active
? "border-violet-300/60 bg-violet-500/40 text-white"
: "border-white/10 bg-black/25 text-white/45 hover:text-white"
}`}
>
{label}
</button>
)
})}
</div>
{subjectAssets.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{subjectAssets.slice(-12).map((asset) => (
<div key={asset.id} className="relative overflow-hidden rounded-md border border-white/10 bg-white" style={{ width: 88, height: 112 }}>
<img src={apiAssetUrl(asset.url)} alt={asset.label} className="h-[82px] w-full object-contain" />
<div className="absolute left-0 top-0 rounded-br bg-black/70 px-1 text-[8.5px] text-white">
{asset.label.replace(`${e.name_zh} · `, "")}
</div>
<div className="flex h-[30px] border-t border-black/10 bg-black text-white">
{onCopyImage && (
<button
type="button"
onClick={(ev) => {
ev.preventDefault(); ev.stopPropagation()
onCopyImage({
kind: "asset",
frame_idx: f.index,
element_id: asset.id,
cutout_id: asset.id,
label: asset.label,
})
}}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-violet-500/70 transition"
>
<Copy className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
})}
</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]">
/ · ESC
</div>
</div>
)
return embedded ? content : createPortal(content, document.body)
}