Files
20260512-skg-tk/web/components/lightbox.tsx
2026-05-14 11:58:48 +08:00

2107 lines
109 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, Upload, Play } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneAssetRole, type SceneMode, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { ProductLibraryPicker } from "@/components/product-library-picker"
import { TRANSPARENT_HUMAN_FRAME_STANDARD, TRANSPARENT_HUMAN_UI_SUMMARY } from "@/lib/workflow-target"
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
clipboard?: ImageRef | null
onCopyImage?: (ref: ImageRef) => void
onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise<void> | void
embedded?: boolean
}
const OBJECT_VIEW_OPTIONS = [
["front", "正面"],
["back", "背面"],
["left", "左侧"],
["right", "右侧"],
["top", "顶部"],
["bottom", "底部"],
]
const LIVING_VIEW_OPTIONS = [
["front", "正面"],
["back", "背面"],
["left", "左侧"],
["right", "右侧"],
["three_quarter_left", "左前 45°"],
["three_quarter_right", "右前 45°"],
]
const LIVING_EXPRESSION_OPTIONS = [
["expression_neutral", "中性脸"],
["expression_smile", "微笑"],
["expression_happy", "开心"],
["expression_serious", "严肃"],
["expression_surprised", "惊讶"],
]
const LIVING_ACTION_OPTIONS = [
["action_walk", "走路"],
["action_turn", "转身"],
["action_hold", "手持"],
["action_use", "使用"],
]
const LIVING_VIEW_GROUPS = [
{ title: "身份标准图", hint: "默认必出 · 用来锁定长相、体型、服装和比例", options: LIVING_VIEW_OPTIONS },
{ title: "表情补充", hint: "需要口播、反应或情绪镜头时再勾", options: LIVING_EXPRESSION_OPTIONS },
{ title: "动作补充", hint: "需要动作镜头时再勾,仍保持同一人物身份", options: LIVING_ACTION_OPTIONS },
]
type LightboxTab = "clean" | "scene" | "subject" | "product" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "subject", label: "主体资产" },
{ key: "scene", label: "首尾帧" },
{ key: "product", label: "产品融合" },
{ key: "review", label: "审核" },
]
const SCENE_MODE_OPTIONS: Array<[SceneMode, string]> = [
["remove_subject", "去主体原场景"],
["similar", "相似新场景"],
["style", "同构换风格"],
]
const SCENE_STYLE_OPTIONS: Array<[SceneStyle, string]> = [
["source", "跟随原图"],
["premium_product", "高端产品感"],
["clean_studio", "干净工作室"],
["warm_lifestyle", "真实生活感"],
["cinematic", "电影感"],
]
const SCENE_LOCATION_OPTIONS = [
["modern living room", "现代客厅"],
["minimal studio", "极简影棚"],
["premium bathroom", "高端浴室"],
["bedroom nightstand", "卧室床头"],
["office desk", "办公桌面"],
["retail display", "零售陈列"],
["outdoor patio", "户外露台"],
]
const SCENE_REFERENCE_OPTIONS = [
["camera angle and composition", "构图/机位"],
["lighting direction", "光线方向"],
["material textures", "材质纹理"],
["color palette", "色彩氛围"],
["spatial layout", "空间层次"],
["social media realism", "真实生活感"],
]
const FUSION_SHOT_COUNT = 6
const FUSION_DURATIONS = [4, 5, 6, 8, 10, 12, 15]
type FusionUploadTarget = {
shotIndex: number
slot: "first_image" | "last_image" | "product_images"
productIndex?: number
}
type FusionFrameRole = "first_image" | "last_image"
const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3"]
const createFusionShots = (): ProductFusionShot[] =>
Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({
id: `shot-${i + 1}`,
first_image: null,
last_image: null,
product_images: [],
product_image: null,
person_image: null,
product_region: null,
scene_image: null,
action_text: "",
duration: 5,
image_model: "gpt-image-2",
video_model: "seedance",
guide_image: null,
}))
const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusionShot[] => {
const base = createFusionShots()
if (!shots?.length) return base
return base.map((item, i) => {
const shot = shots[i] ?? {}
const productImages = shot.product_images?.length
? shot.product_images.slice(0, 3)
: shot.product_image
? [shot.product_image]
: []
return { ...item, ...shot, product_images: productImages, id: shot.id || item.id }
})
}
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, clipboard, onCopyImage, onGenerateProductFusionVideo, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaningFrameIds, setCleaningFrameIds] = useState<Set<number>>(new Set())
const [batchCleaning, setBatchCleaning] = useState(false)
const [batchCleanupProgress, setBatchCleanupProgress] = useState<{ done: number; total: number; failed: number } | null>(null)
const [batchApplying, setBatchApplying] = useState(false)
const [batchApplyProgress, setBatchApplyProgress] = useState<{ done: number; total: number; failed: number } | null>(null)
const [applying, setApplying] = useState(false)
const [sceneGenerating, setSceneGenerating] = useState<SceneAssetRole | null>(null)
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
const [assetSize, setAssetSize] = useState<AssetSize>("source")
const [sceneMode, setSceneMode] = useState<SceneMode>("remove_subject")
const [sceneStyle, setSceneStyle] = useState<SceneStyle>("source")
const [sceneLocation, setSceneLocation] = useState("modern living room")
const [sceneReferenceKeys, setSceneReferenceKeys] = useState<string[]>(["camera angle and composition", "lighting direction", "spatial layout"])
const [sceneExtraKeywords, setSceneExtraKeywords] = useState("")
const [scenePrompt, setScenePrompt] = useState("")
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 [fusionShots, setFusionShots] = useState<ProductFusionShot[]>(() => createFusionShots())
const [activeFusionShot, setActiveFusionShot] = useState(0)
const [fusionUploadTarget, setFusionUploadTarget] = useState<FusionUploadTarget | null>(null)
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionDraftRegion, setFusionDraftRegion] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [fusionDragStart, setFusionDragStart] = useState<{ x: number; y: number } | null>(null)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
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)
const fusionPersonWrapRef = useRef<HTMLDivElement>(null)
const fusionFileInputRef = useRef<HTMLInputElement | null>(null)
const loadedFusionKey = useRef("")
const activeIndexRef = useRef<number | null>(activeIndex)
useEffect(() => setMounted(true), [])
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
useEffect(() => {
if (activeIndex === null) {
loadedFusionKey.current = ""
setFusionShots(createFusionShots())
setActiveFusionShot(0)
return
}
const key = `${jobId}:${activeIndex}`
if (loadedFusionKey.current === key) return
const frame = frames.find((x) => x.index === activeIndex)
setFusionShots(normalizeFusionShots(frame?.storyboard?.product_fusion_shots as ProductFusionShot[] | undefined))
setActiveFusionShot(0)
loadedFusionKey.current = key
}, [activeIndex, frames, jobId])
// 切换分镜时清空选区
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 transparentScore = f.transparent_human_score ?? desc?.transparent_human_assessment
const elements = f.elements ?? []
const hasCleaned = !!f.cleaned_url
const latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null
const isCleaningCurrentFrame = cleaningFrameIds.has(f.index)
const cleanedFrameCount = frames.filter((frame) => frame.cleaned_applied || frame.cleaned_url).length
const pendingCleanFrames = frames.filter((frame) => !frame.cleaned_applied && !frame.cleaned_url)
const pendingApplyFrames = frames.filter((frame) => frame.cleaned_url && !frame.cleaned_applied)
const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b)
const cleanedFrameIndices = frames
.filter((frame) => frame.cleaned_applied || frame.cleaned_url)
.map((frame) => frame.index)
const subjectReferenceFrameIndices = (
cleanedFrameIndices.length > 0 || selectedFrameIndices.length > 0
? [...cleanedFrameIndices, ...selectedFrameIndices]
: frames.map((frame) => frame.index)
)
.filter((idx, pos, arr) => arr.indexOf(idx) === pos)
const subjectReferenceFrames = subjectReferenceFrameIndices
.map((idx) => frames.find((frame) => frame.index === idx))
.filter((frame): frame is KeyFrame => Boolean(frame))
const subjectReferenceLabel = selectedFrameIndices.length > 0
? `${subjectReferenceFrameIndices.length} 清洗/已选帧参考`
: cleanedFrameIndices.length > 0
? `${subjectReferenceFrameIndices.length} 已清洗帧参考`
: `${subjectReferenceFrameIndices.length} 全部帧参考`
const subjectElementRefs = frames.flatMap((frame) =>
(frame.elements ?? []).map((element) => ({
frameIndex: frame.index,
frameLabel: `分镜 ${frame.index + 1}`,
element,
})),
)
const activeSubjectRefs = elements.map((element) => ({
frameIndex: f.index,
frameLabel: `分镜 ${f.index + 1}`,
element,
}))
const subjectDisplayRefs = activeSubjectRefs.length > 0
? activeSubjectRefs
: subjectElementRefs.slice(0, 1)
const hasUnifiedSubject = subjectElementRefs.length > 0
const subjectAssetCount = subjectElementRefs.reduce((sum, item) => sum + (item.element.subject_assets?.length ?? 0), 0)
const hasSubjectAssets = subjectAssetCount > 0
const qualityWarnings = [
...(f.quality_report?.warnings ?? []),
...(latestSceneAsset?.quality_report?.warnings ?? []),
]
const isSubjectTab = activeTab === "subject"
const isSceneTab = activeTab === "scene"
const isProductTab = activeTab === "product"
const isCleanTab = activeTab === "clean"
const sceneReferenceFrameIndices = (selectedFrameIndices.length > 0 ? selectedFrameIndices : [f.index])
.filter((idx, pos, arr) => arr.indexOf(idx) === pos)
const sceneReferenceFrames = sceneReferenceFrameIndices
.map((idx) => frames.find((frame) => frame.index === idx))
.filter((frame): frame is KeyFrame => Boolean(frame))
const unifiedSubjectName = subjectElementRefs[0]?.element.name_zh || "统一主体"
const sceneLocationLabel = SCENE_LOCATION_OPTIONS.find(([value]) => value === sceneLocation)?.[1] ?? sceneLocation
const sceneStyleLabel = SCENE_STYLE_OPTIONS.find(([value]) => value === sceneStyle)?.[1] ?? sceneStyle
const sceneReferenceLabels = sceneReferenceKeys
.map((key) => SCENE_REFERENCE_OPTIONS.find(([value]) => value === key)?.[1] ?? key)
const scenePromptDraft = [
`目标:为透明骨架人视频生成首帧或尾帧,不再生成空背景板。`,
`人物:保持 ${unifiedSubjectName} 的透明/半透明外壳、干净白色骨架、非恐怖广告角色气质。`,
`地点:${sceneLocationLabel}`,
`风格:${sceneStyleLabel}`,
`参考帧:${sceneReferenceFrames.map((frame) => `分镜${frame.index + 1}`).join("、") || `分镜${f.index + 1}`}`,
sceneReferenceLabels.length > 0 ? `保留参考:${sceneReferenceLabels.join("、")}` : "",
sceneExtraKeywords.trim() ? `额外关键词:${sceneExtraKeywords.trim()}` : "",
"要求:单一透明骨架人清晰可见,人物占画面主体,首尾帧可连续生成视频;无文字、水印、平台 UI、恐怖解剖感。",
].filter(Boolean).join("\n")
const currentFusionShot = fusionShots[activeFusionShot] ?? fusionShots[0]
const currentFusionProducts = currentFusionShot?.product_images ?? []
const currentFusionProductCount = currentFusionProducts.filter(Boolean).length
const currentFusionFirstUrl = currentFusionShot?.first_image ? resolveImageRefUrl(jobId, currentFusionShot.first_image) : ""
const currentFusionLastUrl = currentFusionShot?.last_image ? resolveImageRefUrl(jobId, currentFusionShot.last_image) : ""
const currentFusionProductUrl = currentFusionProducts[0] ? resolveImageRefUrl(jobId, currentFusionProducts[0]) : ""
const currentFusionPersonUrl = currentFusionShot?.person_image ? resolveImageRefUrl(jobId, currentFusionShot.person_image) : ""
const currentFusionSceneUrl = currentFusionShot?.scene_image ? resolveImageRefUrl(jobId, currentFusionShot.scene_image) : ""
const fusionReadyCount = fusionShots.filter((shot) =>
shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= 3 && shot.action_text?.trim()
).length
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
setFusionSaving(true)
try {
const updated = await updateStoryboard(jobId, f.index, {
...(f.storyboard ?? { duration: 0 }),
product_fusion_shots: nextShots,
})
onJobUpdate?.(updated)
} catch (e) {
toast.error("产品融合镜头保存失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setFusionSaving(false)
}
}
const updateFusionShot = (index: number, patch: Partial<ProductFusionShot>, persist = false) => {
const next = fusionShots.map((shot, i) => (i === index ? { ...shot, ...patch } : shot))
setFusionShots(next)
if (persist) void persistFusionShots(next)
}
const assignFusionImage = (target: FusionUploadTarget, ref: ImageRef) => {
const index = target.shotIndex
const current = fusionShots[index]
if (!current) return
if (target.slot === "product_images") {
const productImages = [...(current.product_images ?? [])].slice(0, 3)
const productIndex = Math.max(0, Math.min(2, target.productIndex ?? productImages.findIndex((item) => !item)))
const safeIndex = productIndex >= 0 ? productIndex : 0
productImages[safeIndex] = ref
updateFusionShot(index, { product_images: productImages, product_image: productImages[0] ?? null, guide_image: null }, true)
return
}
updateFusionShot(index, { [target.slot]: ref, guide_image: null }, true)
}
const uploadFusionFiles = async (files: FileList | File[]) => {
if (!fusionUploadTarget) return
const file = Array.from(files).find((item) => item.type.startsWith("image/"))
if (!file) {
toast.error("请上传图片文件")
return
}
try {
const ref = await uploadStoryboardAsset(jobId, file)
assignFusionImage(fusionUploadTarget, ref)
toast.success("已加入当前融合镜头")
} catch (e) {
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setFusionUploadTarget(null)
}
}
const openFusionUpload = (target: FusionUploadTarget) => {
setActiveFusionShot(target.shotIndex)
setFusionUploadTarget(target)
requestAnimationFrame(() => fusionFileInputRef.current?.click())
}
const draftFusionDescriptions = async () => {
const actions = [
"人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
"人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
"人物坐在场景中轻按侧边控制区,产品保持在画框指定区域内清晰可见。",
"人物闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。",
"镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。",
"使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。",
]
let descriptions = actions
try {
const result = await generateProductFusionDescriptions(jobId, fusionShots)
descriptions = result.descriptions.length ? result.descriptions : actions
} catch (e) {
toast.error("AI 描述生成失败,已使用本地草稿")
}
const next = fusionShots.map((shot, i) => ({
...shot,
action_text: shot.action_text?.trim() || descriptions[i] || actions[i],
}))
setFusionShots(next)
void persistFusionShots(next)
toast.success("已生成 6 条动作描述草稿,可继续手工修改")
}
const fusionPointerPosition = (ev: React.MouseEvent<HTMLDivElement>) => {
const rect = fusionPersonWrapRef.current?.getBoundingClientRect()
if (!rect || rect.width <= 0 || rect.height <= 0) return null
return {
x: Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height)),
}
}
const onFusionRegionDown = (ev: React.MouseEvent<HTMLDivElement>) => {
if (activeTab !== "product" || !currentFusionPersonUrl) return
ev.preventDefault()
const p = fusionPointerPosition(ev)
if (!p) return
setFusionDragStart(p)
setFusionDraftRegion({ x: p.x, y: p.y, w: 0, h: 0 })
}
const onFusionRegionMove = (ev: React.MouseEvent<HTMLDivElement>) => {
if (!fusionDragStart) return
const p = fusionPointerPosition(ev)
if (!p) return
setFusionDraftRegion({
x: Math.min(fusionDragStart.x, p.x),
y: Math.min(fusionDragStart.y, p.y),
w: Math.abs(p.x - fusionDragStart.x),
h: Math.abs(p.y - fusionDragStart.y),
})
}
const onFusionRegionUp = () => {
if (!fusionDraftRegion || !fusionDragStart) return
const region = fusionDraftRegion.w >= 0.02 && fusionDraftRegion.h >= 0.02 ? fusionDraftRegion : null
if (region) updateFusionShot(activeFusionShot, { product_region: region, guide_image: null }, true)
setFusionDraftRegion(null)
setFusionDragStart(null)
}
const runFusionVideo = async (index: number) => {
const shot = fusionShots[index]
if (!shot?.first_image || !shot.last_image || (shot.product_images ?? []).filter(Boolean).length < 3 || !shot.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺首帧、尾帧、三张产品角度图或描述词`)
return
}
setFusionGenerating(index)
try {
await onGenerateProductFusionVideo?.(f.index, shot)
} finally {
setFusionGenerating(null)
}
}
const runAllFusionVideos = async () => {
const indexes = fusionShots
.map((shot, i) => ({ shot, i }))
.filter(({ shot }) => shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= 3 && shot.action_text?.trim())
.map(({ i }) => i)
if (indexes.length === 0) {
toast.error("还没有完整的融合镜头")
return
}
setFusionGenerating("all")
try {
for (const index of indexes) {
await onGenerateProductFusionVideo?.(f.index, fusionShots[index])
}
toast.success(`已提交 ${indexes.length} 条产品融合视频队列`)
} finally {
setFusionGenerating(null)
}
}
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) => {
const frameIdx = f.index
const usable = useRegions ? regions.filter((r) => r.w >= 0.03 && r.h >= 0.03) : null
setCleaningFrameIds((prev) => new Set(prev).add(frameIdx))
try {
const updated = await cleanupFrame(jobId, frameIdx, usable && usable.length > 0 ? usable : null)
onJobUpdate?.(updated)
toast.success(`分镜 ${frameIdx + 1} 清洗完成${usable && usable.length > 0 ? `${usable.length} 个区域)` : ""}`)
if (useRegions && activeIndexRef.current === frameIdx) {
setCropMode(false); setRegions([]); setDraftRegion(null)
}
} catch (e) {
toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setCleaningFrameIds((prev) => {
const next = new Set(prev)
next.delete(frameIdx)
return next
})
}
}
const handleCleanupAllFrames = async () => {
const targets = (pendingCleanFrames.length > 0 ? pendingCleanFrames : frames)
.filter((frame) => !cleaningFrameIds.has(frame.index))
if (targets.length === 0) return
setBatchCleaning(true)
setBatchCleanupProgress({ done: 0, total: targets.length, failed: 0 })
let failed = 0
try {
for (let i = 0; i < targets.length; i += 1) {
const frame = targets[i]
try {
const updated = await cleanupFrame(jobId, frame.index, null)
onJobUpdate?.(updated)
} catch (e) {
failed += 1
console.error("batch cleanup failed", frame.index, e)
} finally {
setBatchCleanupProgress({ done: i + 1, total: targets.length, failed })
}
}
if (failed > 0) {
toast.error(`批量清洗完成,${failed} 张失败,可单张重试`)
} else {
toast.success(`已生成 ${targets.length} 张清洗版,逐张审核即可`)
}
} finally {
setBatchCleaning(false)
}
}
const handleApplyAllCleaned = async () => {
const targets = pendingApplyFrames.filter((frame) => !cleaningFrameIds.has(frame.index))
if (targets.length === 0) {
toast.message("暂无待替换的清洗版")
return
}
setBatchApplying(true)
setBatchApplyProgress({ done: 0, total: targets.length, failed: 0 })
let failed = 0
try {
for (let i = 0; i < targets.length; i += 1) {
const frame = targets[i]
try {
const updated = await applyCleanedFrame(jobId, frame.index)
onJobUpdate?.(updated)
} catch (e) {
failed += 1
console.error("batch apply cleaned failed", frame.index, e)
} finally {
setBatchApplyProgress({ done: i + 1, total: targets.length, failed })
}
}
if (failed > 0) {
toast.error(`批量替换完成,${failed} 张失败,可单张重试`)
} else {
toast.success(`已替换 ${targets.length} 张为清洗版`)
}
} finally {
setBatchApplying(false)
}
}
const handleGenerateSceneAsset = async (role: Exclude<SceneAssetRole, "scene">) => {
const roleLabel = role === "first_frame" ? "首帧" : "尾帧"
const targetSlot: FusionFrameRole = role === "first_frame" ? "first_image" : "last_image"
if (!hasSubjectAssets) {
toast.message("还没有主体资产,也会按当前参考帧理解人物;一致性可能弱一些")
}
setSceneGenerating(role)
try {
const updated = await generateSceneAsset(jobId, f.index, {
size: assetSize,
scene_mode: "similar",
scene_style: sceneStyle,
asset_role: role,
prompt: [
role === "first_frame"
? "生成这个产品融合镜头的首帧:人物处于动作开始状态,构图稳定,适合作为视频第一帧。"
: "生成这个产品融合镜头的尾帧:人物处于动作完成状态,与首帧连续但画面不要完全相同。",
scenePrompt.trim() || scenePromptDraft,
].join("\n"),
source_frame_indices: sceneReferenceFrameIndices,
})
onJobUpdate?.(updated)
const updatedFrame = updated.frames.find((frame) => frame.index === f.index)
const asset = [...(updatedFrame?.scene_assets ?? [])].reverse().find((item) => item.asset_role === role)
if (asset) {
assignFusionImage({
shotIndex: activeFusionShot,
slot: targetSlot,
}, {
kind: "asset",
frame_idx: f.index,
element_id: asset.id,
cutout_id: asset.id,
label: asset.label,
})
}
toast.success(`分镜 ${f.index + 1} ${roleLabel}已生成,并填入镜头 ${activeFusionShot + 1}`)
} catch (e) {
toast.error(`${roleLabel}生成失败:` + (e instanceof Error ? e.message : String(e)))
} finally {
setSceneGenerating(null)
}
}
const handleGenerateSubjectPackage = async (elementId: string, frameIdx = f.index) => {
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, frameIdx, elementId, {
subject_kind: kind,
background: subjectBackgrounds[elementId] ?? "white",
size: assetSize,
source_frame_indices: subjectReferenceFrameIndices,
views,
})
onJobUpdate?.(updated)
toast.success(`统一主体重绘完成 · ${views.length} 张 · ${subjectReferenceFrameIndices.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
if (hasUnifiedSubject) {
toast.message("当前流程只保留一个主体;如需更换,请先删除现有统一主体")
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, frameIdx = f.index) => {
try {
const updated = await deleteElement(jobId, frameIdx, 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, editingElement.frameIndex, 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 referenceFrameSrc = (frame: KeyFrame) => {
if (frame.cleaned_url) {
const ts = frame.cleaned_url.match(/t=(\d+)/)?.[1]
return cleanedFrameUrl(jobId, frame.index, ts)
}
return `${frameUrl(jobId, frame.index)}${frame.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: isProductTab ? "min(1280px, calc(100vw - 32px))" : 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)
if (tab.key !== "clean") {
setCropMode(false)
setRegions([])
setDraftRegion(null)
setDragStart(null)
}
}}
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>{hasSubjectAssets ? `统一主体 ${subjectAssetCount}` : hasUnifiedSubject ? "统一主体待生成" : "统一主体待选择"}</span>
<span>·</span>
<span>{latestSceneAsset ? "场景已生成" : "场景待生成"}</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={isSubjectTab
? { flex: "1 1 360px", minWidth: 220, maxWidth: 460, minHeight: 0 }
: isSceneTab
? { flex: "1 1 430px", minWidth: 280, maxWidth: 560, minHeight: 0 }
: isProductTab
? { flex: "1 1 760px", minWidth: 520, maxWidth: 980, minHeight: 0 }
: isCleanTab
? { flex: "1 1 500px", minWidth: 300, maxWidth: 600, minHeight: 0 }
: { flex: "1 1 560px", minWidth: 300, maxWidth: 680, minHeight: 0 }}
>
{isSubjectTab ? (
<section className="rounded-lg border border-violet-300/15 bg-violet-500/[0.06] p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<span className="text-[9.5px] font-mono text-white/38">{subjectReferenceLabel}</span>
</div>
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(104px, 1fr))" }}
>
{subjectReferenceFrames.map((frame) => {
const active = frame.index === f.index
return (
<button
key={frame.index}
type="button"
onClick={() => onChange(frame.index)}
className={`group overflow-hidden rounded-md border bg-black/35 text-left transition ${
active
? "border-violet-300/70 shadow-[0_0_0_1px_rgba(196,181,253,0.25)]"
: "border-white/10 hover:border-violet-300/45"
}`}
title={`切换到分镜 ${frame.index + 1}`}
>
<div className="relative aspect-[9/13] bg-black">
<img
src={referenceFrameSrc(frame)}
alt={`subject reference ${frame.index}`}
className="h-full w-full object-contain"
draggable={false}
/>
<span className="absolute left-1 top-1 rounded bg-black/65 px-1 py-0.5 text-[8.5px] font-mono text-white/80">
{String(frame.index + 1).padStart(2, "0")}
</span>
{frame.cleaned_url || frame.cleaned_applied ? (
<span className="absolute right-1 top-1 rounded bg-emerald-500/80 px-1 py-0.5 text-[8px] text-white">
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-1 px-1.5 py-1 text-[9.5px] text-white/52">
<span>{frame.timestamp.toFixed(2)}s</span>
{active && <span className="text-violet-200"></span>}
</div>
</button>
)
})}
</div>
<div className="mt-2 text-[10px] leading-relaxed text-white/38">
</div>
</section>
) : isSceneTab ? (
<section className="rounded-lg border border-emerald-300/15 bg-emerald-500/[0.06] p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<span className="text-[9.5px] font-mono text-white/38">
{selectedFrameIndices.length > 0 ? `${selectedFrameIndices.length} 已选参考` : "默认当前帧"}
</span>
</div>
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(104px, 1fr))" }}
>
{frames.map((frame) => {
const active = frame.index === f.index
const checked = selected.has(frame.index)
return (
<div
key={frame.index}
className={`overflow-hidden rounded-md border bg-black/35 transition ${
active
? "border-emerald-300/70 shadow-[0_0_0_1px_rgba(110,231,183,0.22)]"
: "border-white/10 hover:border-emerald-300/45"
}`}
>
<button
type="button"
onClick={() => onChange(frame.index)}
className="block w-full text-left"
title={`设为生成目标:分镜 ${frame.index + 1}`}
>
<div className="relative aspect-[9/13] bg-black">
<img
src={referenceFrameSrc(frame)}
alt={`scene reference ${frame.index}`}
className="h-full w-full object-contain"
draggable={false}
/>
<span className="absolute left-1 top-1 rounded bg-black/65 px-1 py-0.5 text-[8.5px] font-mono text-white/80">
{String(frame.index + 1).padStart(2, "0")}
</span>
{active && (
<span className="absolute right-1 top-1 rounded bg-emerald-500/80 px-1 py-0.5 text-[8px] text-white">
</span>
)}
</div>
</button>
<div className="flex items-center justify-between gap-1 px-1.5 py-1 text-[9.5px] text-white/52">
<span>{frame.timestamp.toFixed(2)}s</span>
<button
type="button"
onClick={() => onToggleSelect(frame.index)}
className={`rounded px-1.5 py-0.5 transition ${
checked
? "bg-emerald-400/80 text-black"
: "bg-white/10 text-white/55 hover:bg-white/18 hover:text-white"
}`}
title={checked ? "取消场景参考" : "加入场景参考"}
>
{checked ? "参考" : "选"}
</button>
</div>
</div>
)
})}
</div>
<div className="mt-2 text-[10px] leading-relaxed text-white/38">
/
</div>
</section>
) : isProductTab ? (
<section
className="rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5"
onPaste={(e) => {
if (fusionUploadTarget && e.clipboardData.files?.length) void uploadFusionFiles(e.clipboardData.files)
}}
>
<input
ref={fusionFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files) void uploadFusionFiles(files)
e.currentTarget.value = ""
}}
/>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<span className="rounded bg-black/35 px-1.5 py-0.5 text-[9.5px] font-mono text-white/55">
{fusionReadyCount}/6
</span>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5 text-[10px] leading-relaxed text-white/50">
6 使 + + + 3 Seedance
</div>
<div className="space-y-2">
{fusionShots.map((shot, i) => {
const active = i === activeFusionShot
const productImages = shot.product_images ?? []
const firstUrl = shot.first_image ? resolveImageRefUrl(jobId, shot.first_image) : ""
const lastUrl = shot.last_image ? resolveImageRefUrl(jobId, shot.last_image) : ""
const productUrls = [0, 1, 2].map((productIndex) =>
productImages[productIndex] ? resolveImageRefUrl(jobId, productImages[productIndex]) : ""
)
const ready = !!(shot.first_image && shot.last_image && productImages.filter(Boolean).length >= 3 && shot.action_text?.trim())
const busy = fusionGenerating === i || fusionGenerating === "all"
const pasteIntoSlot = (target: FusionUploadTarget, label: string) => {
setActiveFusionShot(i)
if (clipboard) {
assignFusionImage(target, clipboard)
toast.success(`已粘贴到镜头 ${i + 1}${label}」:${clipboard.label || "剪贴板图片"}`)
return
}
setFusionUploadTarget(target)
toast.message(`镜头 ${i + 1} 已选中「${label}」槽位,现在可 Cmd+V 粘贴系统图片`)
}
const imageSlot = (target: FusionUploadTarget, label: string, url: string, ref?: ImageRef | null, white = false) => (
<div className="overflow-hidden rounded-md border border-white/10 bg-black/24">
<div className={`relative aspect-[4/5] ${white ? "bg-white" : "bg-black"}`}>
{url ? (
<button
type="button"
onClick={() => setActiveFusionShot(i)}
className="absolute inset-0 cursor-pointer"
title={`选中镜头 ${i + 1}`}
>
<img src={url} alt={label} className="h-full w-full object-contain" draggable={false} />
</button>
) : (
<button
type="button"
onClick={() => openFusionUpload(target)}
className={`absolute inset-0 flex flex-col items-center justify-center gap-1 text-[9.5px] ${white ? "text-black/35 hover:text-black/65" : "text-white/35 hover:text-white/65"}`}
>
<Upload className="h-3.5 w-3.5" />
{label}
</button>
)}
</div>
<div className="border-t border-white/10 px-1 py-1">
<div className="mb-1 truncate text-[8.5px] text-white/42">{ref?.label || label}</div>
<div className="grid grid-cols-2 gap-1">
<button
type="button"
onClick={() => pasteIntoSlot(target, label)}
className={`rounded px-1 py-0.5 text-[8.5px] transition ${
clipboard ? "bg-violet-500/60 text-white hover:bg-violet-400/70" : "bg-white/10 text-white/58 hover:bg-white/18 hover:text-white"
}`}
>
</button>
<button
type="button"
onClick={() => openFusionUpload(target)}
className="rounded bg-white/10 px-1 py-0.5 text-[8.5px] text-white/65 transition hover:bg-white/18 hover:text-white"
>
</button>
</div>
</div>
</div>
)
const productAngleSlots = (
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2].map((productIndex) => (
<div key={productIndex}>
{imageSlot(
{ shotIndex: i, slot: "product_images", productIndex },
PRODUCT_ANGLE_LABELS[productIndex],
productUrls[productIndex],
productImages[productIndex],
true,
)}
</div>
))}
</div>
)
return (
<div
key={shot.id}
className={`rounded-lg border p-2 transition ${
active
? "border-amber-300/70 bg-amber-500/16 shadow-[0_0_0_1px_rgba(251,191,36,0.14)]"
: "border-white/10 bg-black/20 hover:border-amber-300/35"
}`}
>
<div className="grid grid-cols-[34px_86px_86px_240px_minmax(150px,1fr)_78px] items-start gap-2">
<div className="flex flex-col items-center gap-1 pt-1">
<button
type="button"
onClick={() => setActiveFusionShot(i)}
className={`h-8 w-8 rounded-md border text-[10px] font-mono transition ${
active
? "border-amber-200/70 bg-amber-300/20 text-amber-50"
: "border-white/10 bg-white/7 text-white/48 hover:border-amber-300/35 hover:text-white"
}`}
title={`切到镜头 ${i + 1}`}
>
{String(i + 1).padStart(2, "0")}
</button>
<span className={`rounded px-1 py-0.5 text-[8.5px] ${ready ? "bg-emerald-400/80 text-black" : "bg-white/10 text-white/45"}`}>
{ready ? "就绪" : "待补"}
</span>
</div>
{imageSlot({ shotIndex: i, slot: "first_image" }, "首帧", firstUrl, shot.first_image)}
{imageSlot({ shotIndex: i, slot: "last_image" }, "尾帧", lastUrl, shot.last_image)}
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
<span className={`text-[8.5px] ${productImages.filter(Boolean).length >= 3 ? "text-emerald-200/70" : "text-white/30"}`}>
{productImages.filter(Boolean).length}/3
</span>
</div>
{productAngleSlots}
</div>
<label className="block">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
<span className="text-[8.5px] text-white/30">#{i + 1}</span>
</div>
<textarea
value={shot.action_text ?? ""}
onFocus={() => setActiveFusionShot(i)}
onChange={(e) => updateFusionShot(i, { action_text: e.target.value })}
onBlur={(e) => {
const next = fusionShots.map((item, idx) => (idx === i ? { ...item, action_text: e.currentTarget.value } : item))
void persistFusionShots(next)
}}
placeholder="描述这个镜头里透明骨架人、SKG 产品和动作起止状态。"
className="h-[92px] w-full resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10px] leading-relaxed text-white/75 outline-none placeholder:text-white/25 focus:border-amber-300/45"
/>
</label>
<div className="flex flex-col gap-1.5">
<select
value={shot.duration ?? 5}
onFocus={() => setActiveFusionShot(i)}
onChange={(e) => updateFusionShot(i, { duration: Number(e.target.value) }, true)}
className="h-7 w-full rounded-md border border-white/10 bg-black/35 px-1.5 text-[10px] text-white/75 outline-none focus:border-amber-300/45"
title="视频秒数"
>
{FUSION_DURATIONS.map((seconds) => (
<option key={seconds} value={seconds}>{seconds}s</option>
))}
</select>
<button
type="button"
onClick={() => {
setActiveFusionShot(i)
void runFusionVideo(i)
}}
disabled={!!fusionGenerating || !onGenerateProductFusionVideo}
className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-amber-500/75 px-1.5 text-[10px] font-medium text-white transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => setActiveFusionShot(i)}
className={`h-7 rounded-md border px-1.5 text-[9px] transition ${
active
? "border-amber-300/55 bg-amber-500/18 text-amber-50"
: "border-white/10 bg-white/7 text-white/50 hover:border-amber-300/35 hover:text-white"
}`}
>
{active ? "当前" : "编辑"}
</button>
</div>
</div>
</div>
)
})}
</div>
</section>
) : (
<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: "68vh" }}
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>
)}
</div>
{/* 右侧主体识别 + 主体资产 */}
<div
className="flex flex-col gap-2.5 overflow-y-auto min-h-0"
style={isSubjectTab || isSceneTab || isProductTab
? { flex: "0 0 360px", width: 360, minWidth: 320 }
: { flex: "0 0 320px", width: 320, minWidth: 280, maxWidth: 340 }}
>
{activeTab === "clean" && (
<>
<section className="rounded-lg border border-cyan-300/15 bg-cyan-500/[0.08] p-2.5 text-[10.5px] leading-relaxed text-white/58">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<span className="rounded bg-black/35 px-1.5 py-0.5 text-[9.5px] font-mono text-white/55">
{cleanedFrameCount}/{frames.length}
</span>
</div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<div className="rounded border border-white/10 bg-black/25 px-2 py-1">
<div className="text-[9px] text-white/35"></div>
<div className="text-[10.5px] text-white/80">{f.cleaned_applied ? "已应用" : hasCleaned ? "待审核" : "未清洗"}</div>
</div>
<div className="rounded border border-white/10 bg-black/25 px-2 py-1">
<div className="text-[9px] text-white/35"></div>
<div className="text-[10.5px] text-white/80">{cropMode ? `${regions.length}` : "全图"}</div>
</div>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
<div className="mb-1 flex items-center justify-between text-[9.5px] text-white/45">
<span></span>
<span className="font-mono">{pendingApplyFrames.length} </span>
</div>
<div className="text-[10px] leading-snug text-white/42">
</div>
</div>
{batchCleanupProgress && (
<div className="mb-2">
<div className="mb-1 flex items-center justify-between text-[9.5px] text-white/45">
<span>{batchCleaning ? "批量清洗中" : "最近批量清洗"}</span>
<span>{batchCleanupProgress.done}/{batchCleanupProgress.total}{batchCleanupProgress.failed ? ` · 失败 ${batchCleanupProgress.failed}` : ""}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-cyan-300 transition-all"
style={{ width: `${Math.round((batchCleanupProgress.done / Math.max(1, batchCleanupProgress.total)) * 100)}%` }}
/>
</div>
</div>
)}
{batchApplyProgress && (
<div className="mb-2">
<div className="mb-1 flex items-center justify-between text-[9.5px] text-white/45">
<span>{batchApplying ? "批量替换中" : "最近批量替换"}</span>
<span>{batchApplyProgress.done}/{batchApplyProgress.total}{batchApplyProgress.failed ? ` · 失败 ${batchApplyProgress.failed}` : ""}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-emerald-300 transition-all"
style={{ width: `${Math.round((batchApplyProgress.done / Math.max(1, batchApplyProgress.total)) * 100)}%` }}
/>
</div>
</div>
)}
<button
type="button"
onClick={handleCleanupAllFrames}
disabled={batchCleaning || batchApplying || cropMode || frames.length === 0}
className="mb-1.5 w-full rounded-md bg-cyan-500/75 px-2 py-1.5 text-[11px] font-medium text-white transition hover:bg-cyan-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1.5"
title="自动清洗所有未处理关键帧;不满意的帧再手工框选清洗"
>
{batchCleaning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{batchCleaning
? "批量清洗中…"
: pendingCleanFrames.length > 0
? `一键清洗未处理 ${pendingCleanFrames.length}`
: `重新清洗全部 ${frames.length}`}
</button>
<button
type="button"
onClick={handleApplyAllCleaned}
disabled={batchCleaning || batchApplying || applying || pendingApplyFrames.length === 0}
className="mb-1.5 w-full rounded-md bg-emerald-500/70 px-2 py-1.5 text-[11px] font-medium text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-40 inline-flex items-center justify-center gap-1.5"
title="把所有待应用清洗版替换为当前关键帧;会保留首次原图备份"
>
{batchApplying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{batchApplying
? "批量替换中…"
: pendingApplyFrames.length > 0
? `一键替换待应用 ${pendingApplyFrames.length}`
: "暂无待替换清洗版"}
</button>
{cropMode ? (
<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={isCleaningCurrentFrame || batchCleaning || batchApplying || 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="批量清洗所有框内"
>
{isCleaningCurrentFrame ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
{isCleaningCurrentFrame ? "去掉中" : `去掉${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="mb-1.5 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>
)}
<button
onClick={() => handleCleanup(false)}
disabled={isCleaningCurrentFrame || batchCleaning || batchApplying || 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"
>
{isCleaningCurrentFrame ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkle className="h-3.5 w-3.5" />}
{isCleaningCurrentFrame ? "清洗中…5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "清洗水印"}
</button>
</section>
{hasCleaned && cleanedSrc && (
<section 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: "24vh" }} />
<button
onClick={handleApplyCleaned}
disabled={applying || batchApplying}
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>
</section>
)}
</>
)}
{activeTab === "scene" && (
<section className="rounded-lg border border-emerald-300/15 bg-emerald-500/[0.08] p-2.5 text-[10.5px] leading-relaxed text-white/58">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] 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>
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5 text-[10px] leading-relaxed text-white/50">
prompt
</div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<label className="space-y-1">
<span className="block text-[9px] text-white/35"></span>
<select
value={sceneLocation}
onChange={(e) => setSceneLocation(e.target.value)}
className="w-full rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
{SCENE_LOCATION_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="block text-[9px] text-white/35"></span>
<select
value={sceneMode}
onChange={(e) => setSceneMode(e.target.value as SceneMode)}
className="w-full rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
{SCENE_MODE_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
</div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<label className="space-y-1">
<span className="block text-[9px] text-white/35"></span>
<select
value={sceneStyle}
onChange={(e) => setSceneStyle(e.target.value as SceneStyle)}
className="w-full rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
{SCENE_STYLE_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="block text-[9px] text-white/35"></span>
<input
value={sceneExtraKeywords}
onChange={(e) => setSceneExtraKeywords(e.target.value)}
placeholder="例如:玻璃、金属、夜景"
className="w-full rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none placeholder:text-white/25"
/>
</label>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/20 p-2">
<div className="mb-1 text-[9px] text-white/35"></div>
<div className="flex flex-wrap gap-1">
{SCENE_REFERENCE_OPTIONS.map(([value, label]) => {
const active = sceneReferenceKeys.includes(value)
return (
<button
key={value}
type="button"
onClick={() => setSceneReferenceKeys((prev) => (
prev.includes(value)
? prev.filter((item) => item !== value)
: [...prev, value]
))}
className={`rounded border px-1.5 py-0.5 text-[9.5px] transition ${
active
? "border-emerald-300/60 bg-emerald-500/35 text-white"
: "border-white/10 bg-black/25 text-white/45 hover:text-white"
}`}
>
{label}
</button>
)
})}
</div>
</div>
<label className="mb-2 block">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/35"> prompt</span>
<button
type="button"
onClick={() => setScenePrompt(scenePromptDraft)}
className="rounded bg-white/10 px-1.5 py-0.5 text-[9.5px] text-white/60 hover:bg-white/18 hover:text-white"
>
</button>
</div>
<textarea
value={scenePrompt || scenePromptDraft}
onChange={(e) => setScenePrompt(e.target.value)}
className="h-28 w-full resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10px] leading-relaxed text-white/75 outline-none"
/>
</label>
{!hasSubjectAssets && (
<div className="mb-2 rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/85">
</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-44 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}
{latestSceneAsset.scene_mode && (
<> · {SCENE_MODE_OPTIONS.find(([value]) => value === latestSceneAsset.scene_mode)?.[1] ?? latestSceneAsset.scene_mode}</>
)}
</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>
) : (
<div className="mb-2 rounded border border-white/10 bg-black/25 px-2 py-2 text-white/45">
</div>
)}
{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 || isCleaningCurrentFrame || batchCleaning || !hasSubjectAssets}
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={hasSubjectAssets ? "基于主体资产去主体、补背景并生成场景参考图" : "先生成主体资产"}
>
{sceneGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成去主体场景图"}
</button>
{!hasSubjectAssets && (
<button
type="button"
onClick={() => setActiveTab("subject")}
className="mt-1.5 w-full rounded-md border border-violet-300/25 bg-violet-500/15 px-2 py-1.5 text-[10.5px] font-medium text-violet-100 transition hover:bg-violet-500/25"
>
</button>
)}
</section>
)}
{activeTab === "product" && (
<>
<section className="rounded-lg border border-amber-300/18 bg-amber-500/[0.08] p-2.5 text-[10.5px] leading-relaxed text-white/58">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<span className="text-[9px] text-white/40">{fusionSaving ? "保存中" : "自动保存"}</span>
</div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<div className="rounded border border-white/10 bg-black/25 px-2 py-1">
<div className="text-[9px] text-white/35"></div>
<div className="text-white/80">GPT Image 2</div>
</div>
<div className="rounded border border-white/10 bg-black/25 px-2 py-1">
<div className="text-[9px] text-white/35"></div>
<div className="text-white/80">Seedance</div>
</div>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-amber-100"> {activeFusionShot + 1}</span>
<span className="text-[9px] text-white/38">{currentFusionShot?.duration ?? 5}s</span>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[9.5px]">
<span className={currentFusionShot?.product_image ? "text-emerald-200/80" : "text-white/35"}></span>
<span className={currentFusionShot?.person_image ? "text-emerald-200/80" : "text-white/35"}></span>
<span className={currentFusionShot?.product_region ? "text-emerald-200/80" : "text-white/35"}></span>
<span className={currentFusionShot?.scene_image ? "text-emerald-200/80" : "text-white/35"}></span>
</div>
<div className={`mt-1 truncate text-[9.5px] ${currentFusionShot?.action_text?.trim() ? "text-white/58" : "text-white/32"}`}>
{currentFusionShot?.action_text?.trim() || "描述词未填写"}
</div>
</div>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={draftFusionDescriptions}
className="rounded-md bg-white/10 px-2 py-1.5 text-[11px] font-medium text-white/75 transition hover:bg-white/18 hover:text-white inline-flex items-center justify-center gap-1"
>
<Sparkles className="h-3 w-3" />
AI 6
</button>
<button
type="button"
onClick={() => void runAllFusionVideos()}
disabled={!!fusionGenerating || !onGenerateProductFusionVideo}
className="rounded-md bg-white/10 px-2 py-1.5 text-[11px] font-medium text-white/75 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 inline-flex items-center justify-center gap-1"
>
{fusionGenerating === "all" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
</button>
</div>
</section>
<ProductLibraryPicker
jobId={jobId}
compact
buttonLabel="选用"
title={`镜头 ${activeFusionShot + 1} 产品图`}
onPick={(ref) => assignFusionImage("product_image", ref, activeFusionShot)}
/>
</>
)}
{activeTab === "review" && (
<section className="rounded-lg border border-white/10 bg-white/[0.035] p-2.5 text-[10.5px] leading-relaxed text-white/58">
<div className="mb-2 text-[12px] font-semibold text-white"></div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<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"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}</div>
</div>
<div className={`rounded border px-2 py-1 ${elements.length > 0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{hasUnifiedSubject ? "1 个" : "未选"}</div>
</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"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{subjectAssetCount} </div>
</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"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{latestSceneAsset ? "已生成" : "未生成"}</div>
</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="rounded border border-emerald-300/25 bg-emerald-500/10 px-2 py-1.5 text-[10px] text-emerald-100/80">
</div>
)}
<div className="mt-2 text-[10px] leading-relaxed text-white/42">
</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>
<div className="mb-2 rounded-md border border-cyan-300/18 bg-cyan-500/[0.06] px-2.5 py-2 text-[10.5px] leading-relaxed text-white/55">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-semibold text-cyan-100"></span>
{transparentScore && (
<span className={`rounded px-1.5 py-0.5 text-[9px] font-mono ${
transparentScore.qualified ? "bg-emerald-400/80 text-black" : "bg-amber-400/18 text-amber-100"
}`}>
{transparentScore.qualified ? "合格" : "待复核"} · {transparentScore.total_score ?? (
(transparentScore.transparent_body_score || 0)
+ (transparentScore.skeleton_visible_score || 0)
+ (transparentScore.human_prominence_score || 0)
+ (transparentScore.clarity_score || 0)
+ (transparentScore.commercial_style_score || 0)
+ (transparentScore.product_usefulness_score || 0)
)}/100
</span>
)}
</div>
<div>{TRANSPARENT_HUMAN_UI_SUMMARY}</div>
<div className="mt-1 text-white/38">{TRANSPARENT_HUMAN_FRAME_STANDARD}</div>
{transparentScore?.reject_reason && !transparentScore.qualified && (
<div className="mt-1 rounded border border-amber-300/20 bg-amber-500/10 px-1.5 py-1 text-amber-100/80">
{transparentScore.reject_reason}
</div>
)}
</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>
) : (
<></>
)}
</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)
const locked = hasUnifiedSubject && !alreadyIn
return (
<button
key={i}
onClick={() => !alreadyIn && !locked && handleAddElement(o.name, o.extract_prompt, o.position, "auto")}
disabled={alreadyIn || locked}
title={alreadyIn ? "已选择为统一主体" : locked ? "当前流程只保留一个主体;删除现有主体后可更换" : (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"
: locked
? "bg-white/[0.02] border-white/8 opacity-45 cursor-not-allowed"
: "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" />
{hasUnifiedSubject && (
<span className="text-[10px] text-white/35 font-mono ml-0.5">· </span>
)}
<span className="text-[9.5px] text-white/35 font-normal ml-auto"> {subjectReferenceLabel}</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">
</div>
{!hasUnifiedSubject && (
<div className="mb-2 rounded-lg border border-dashed border-white/15 bg-white/[0.03] p-3 text-[11px] leading-relaxed text-white/48">
使
</div>
)}
{subjectDisplayRefs.length > 0 && (
<div className="space-y-2 mb-2">
{subjectDisplayRefs.map(({ frameIndex, frameLabel, element: e }) => {
const hasRegion = !!e.region
const isEditing = editingElement?.id === e.id && editingElement.frameIndex === frameIndex
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>
<span className="text-[8.5px] text-white/35 font-mono shrink-0">· {frameLabel}</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({
frameIndex,
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, frameIndex)}
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">
{subjectReferenceLabel}
</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, frameIndex)}
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>
{currentKind === "living" ? (
<div className="mb-2 space-y-2">
{LIVING_VIEW_GROUPS.map((group) => (
<div key={group.title} className="rounded-md border border-white/8 bg-black/20 px-2 py-1.5">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="text-[10px] font-medium text-white/78">{group.title}</div>
<div className="text-[8.5px] text-white/32 truncate">{group.hint}</div>
</div>
<div className="flex flex-wrap gap-1">
{group.options.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>
</div>
))}
</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)
}