Files
20260512-skg-tk/web/components/lightbox.tsx
2026-05-27 17:18:45 +08:00

2355 lines
128 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, Play } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets, updateStoryboard, copyProductLibraryAsset,
listCharacterLibrary, copyCharacterLibraryAssets, characterLibraryImageUrl, listProductLibrary,
type AssetBackground, type AssetSize, type CharacterLibraryItem, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type ProductLibraryItem, type SceneAssetRole, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { TRANSPARENT_HUMAN_FRAME_STANDARD, TRANSPARENT_HUMAN_UI_SUMMARY } from "@/lib/workflow-target"
import { HoverPreview } from "@/components/nodes/hover-preview"
import { toast } from "sonner"
interface Props {
jobId: string
frames: KeyFrame[]
generatedVideos?: NonNullable<Job["generated_videos"]>
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
onDeleteVideo?: (videoId: string) => 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_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]
const PRODUCT_ANGLE_COUNT = 4
const DESKTOP_PRODUCT_ANGLE_IDS = [
"desktop-skg-product-angle-01",
"desktop-skg-product-angle-02",
"desktop-skg-product-angle-03",
"desktop-skg-product-angle-04",
]
const DEFAULT_CHARACTER_ID = "character-01"
const DEFAULT_CHARACTER_NAME = "运动阳光男"
type FusionPreviewAnchor = { kind: "product" | "video"; id: string; x: number; y: number }
type FusionUploadTarget = {
shotIndex: number
slot: "first_image" | "last_image"
}
type FusionFrameRole = "first_image" | "last_image"
const FUSION_PROMPT_MARKER_PREFIX = "产品融合镜头ID"
function lightboxPreviewAnchor(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return { x: 320, y: 0 }
const rootRect = root.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 }
return {
x: targetRect.left + targetRect.width / 2 - rootRect.left,
y: targetRect.top - rootRect.top,
}
}
const LEGACY_PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
"现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
"居家办公桌前,透明骨架人轻按 SKG 侧边控制键,颈部骨架区域清晰可见,神情安静享受。",
"暖色卧室床边,透明骨架人佩戴 SKG 后轻轻仰头,白色骨架与透明外壳干净明亮,画面高级。",
"落地窗自然光下透明骨架人坐姿端正SKG 产品贴合后颈,嘴角微扬呈现轻松舒缓状态。",
"简洁浴室镜前,透明骨架人用双手调整 SKG 贴合角度,眼神柔和,产品白色机身清楚可辨。",
"午后阳台休息区,透明骨架人戴着 SKG 慢慢侧头伸展,肩颈线条舒展,表情舒适而不夸张。",
"高端影棚白色背景中,透明骨架人平稳转身展示 SKG 佩戴效果,产品比例真实,轮廓清晰。",
"健身后休息长椅上,透明骨架人把 SKG 放上肩颈,呼吸放慢,脸上出现明显放松感。",
"办公会议间隙,透明骨架人靠在椅背上佩戴 SKG轻轻闭眼画面传达短暂恢复和舒适休息。",
"夜晚卧室暖灯下,透明骨架人坐在床沿使用 SKG肩颈骨架被柔和光线照亮神情安稳享受。",
"城市公寓客厅里,透明骨架人一边看向窗外一边使用 SKG动作自然产品贴合不漂移。",
"极简桌面场景中,透明骨架人拿起 SKG 靠近颈部,镜头轻推展示产品材质和佩戴准备动作。",
"木质休闲椅上,透明骨架人佩戴 SKG 后轻轻呼气,肩部下沉,脸部呈现舒缓满足的微笑。",
"白色商业摄影场景里,透明骨架人用指尖轻触 SKG 按键,产品细节清晰,人物状态轻松专业。",
"温暖客厅地毯旁透明骨架人坐姿放松SKG 稳定贴合后颈,闭眼感受舒适放松的瞬间。",
"窗边阅读角落中,透明骨架人戴着 SKG 翻开书页,动作慢而自然,表情平和享受。",
"办公室午休场景里,透明骨架人把 SKG 戴稳后靠回椅背,眼睛半闭,颈肩明显放松。",
"干净产品广告场景中,透明骨架人轻扶 SKG 两端展示佩戴贴合度,微笑自然,产品不变形。",
"收尾特写镜头里,透明骨架人佩戴 SKG 后缓慢抬头微笑,白色骨架清楚,整体干净高级。",
]
const PRODUCT_FUSION_LENS_STAGES = [
"01 建立出场",
"02 产品入画",
"03 佩戴贴合",
"04 使用感受",
"05 生活延展",
"06 收尾记忆",
]
const PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"镜头01建立出场半身中景透明骨架人先自然出现在清晨卧室柔光里SKG 白色产品放在桌面或手边;镜头慢慢推近,让人物透明外壳和白色骨架先成立,结尾手准备伸向产品。",
"镜头02产品入画从桌面产品近景开始透明骨架人的手把 SKG 产品拿起带入画面;镜头从产品轻移到人物肩颈,产品尺寸真实,不能漂浮,结尾靠近后颈。",
"镜头03佩戴贴合肩颈侧面近景透明骨架人双手扶住 SKG 两端贴合后颈并微调角度;镜头轻微环绕展示 U 形结构、触点和颈椎位置,结尾产品稳定贴合。",
"镜头04使用感受半身近景产品已佩戴透明骨架人闭眼呼吸放慢、肩线下沉、嘴角微笑镜头慢推不换场景突出舒适享受但不要医疗治疗暗示。",
"镜头05生活延展现代客厅、办公桌或窗边休息场景透明骨架人保持佩戴 SKG 做轻松阅读或休息动作;镜头横移或轻绕,产品位置稳定,人物透明身体和骨架清晰。",
"镜头06收尾记忆产品和人物肩颈半身特写透明骨架人缓慢抬头微笑定格SKG 产品轮廓清楚可辨;镜头停在干净高级的广告收尾,不出现文字和 logo 字幕。",
"备用01镜中出场浴室或卧室镜前透明骨架人先在镜面中出现手边放着 SKG 产品;镜头从镜中人物拉到真实人物,最后拿起产品准备佩戴。",
"备用02手部带入产品先在白色桌面上清楚出现透明骨架人的手进入画面拿起 SKG镜头跟随手部移动到肩颈区域强调产品被真实拿起而不是凭空出现。",
"备用03侧面贴合45 度侧面近景,透明骨架人把 SKG 产品从颈侧滑入正确位置,轻轻按压贴合;镜头短距离环绕,确保产品透视、比例和身体接触真实。",
"备用04按键反馈产品佩戴后透明骨架人用指尖轻按侧边按键肩颈骨架区域被柔和光线照亮镜头从按键细节回到人物放松表情。",
"备用05办公舒缓居家办公桌前透明骨架人佩戴 SKG 靠回椅背,手从键盘移开,肩部慢慢放松;镜头从电脑桌面横移到人物半身。",
"备用06沙发休息现代客厅沙发上透明骨架人戴着 SKG 闭眼休息,呼吸节奏变慢;镜头轻微推近产品和肩颈,最后停在舒适表情。",
"备用07窗边阅读窗边阅读角中透明骨架人一边翻书一边稳定佩戴 SKG镜头从书页过渡到产品再到透明骨架人的平和微笑。",
"备用08影棚展示高端白色影棚里透明骨架人佩戴 SKG 缓慢转身展示正面和侧面贴合效果;镜头平稳环绕,产品外观不能变形。",
"备用09床边放松暖色卧室床边透明骨架人坐下后把 SKG 戴稳,肩线下沉,脸部从疲惫转为舒适;镜头慢慢推近收住。",
"备用10阳台伸展午后阳台休息区透明骨架人戴着 SKG 缓慢侧头伸展,颈椎白骨清楚可见;镜头横移跟随动作,产品不漂移。",
"备用11产品特写转人开场为 SKG 产品白底特写,随后自然切到透明骨架人佩戴后的半身画面;镜头语言干净商业,强调产品身份一致。",
"备用12拿起到佩戴一镜到底透明骨架人从桌面拿起 SKG抬手、对准后颈、轻轻戴上一镜到底完成动作产品始终保持真实尺寸和方向。",
"备用13舒适反应特写肩颈近景转脸部特写透明骨架人闭眼微笑骨架和透明外壳保持同一角色镜头稳定不夸张表演。",
"备用14最终定格透明骨架人佩戴 SKG 面向镜头轻轻微笑,产品清晰贴合后颈,背景干净高级;最后 1 秒稳定定格作为广告收尾。",
"备用15门口入场透明骨架人从现代公寓门口走入画面肩颈略显紧绷SKG 产品放在玄关台面;镜头中景跟随,结尾视线落到产品。",
"备用16从包中取出透明骨架人坐到办公椅上从随身包里拿出白色 SKG 产品;镜头从包内产品切到人物手部,强调产品自然进入生活场景。",
"备用17正面佩戴正面半身镜头透明骨架人双手把 SKG 从胸前抬到后颈,动作慢而准确;产品贴合后颈时停顿,比例和接触关系真实。",
"备用18颈肩舒展产品已佩戴透明骨架人缓慢转动脖颈、肩部下沉脸部露出轻松表情镜头轻推近肩颈不出现夸张疗效表达。",
"备用19移动生活镜头透明骨架人佩戴 SKG 在客厅和窗边之间轻松移动,透明身体和白色骨架始终清楚;镜头平稳横移,产品位置不变。",
"备用20产品轮廓收束收尾以肩颈侧面特写呈现 SKG 轮廓,透明骨架人微笑停住,背景柔和虚化;画面干净高级,无文字和水印。",
"备用21影棚人物建立高端白色影棚中透明骨架人站立转向镜头白色骨架清晰可见SKG 产品置于旁边展台;镜头缓慢推近建立商业感。",
"备用22产品旋转展示白底产品图感的 SKG 产品在人物手中被轻轻转动展示正侧面,随后靠近透明骨架人的后颈;镜头跟随产品,不让产品变形。",
"备用23侧后方落位从人物侧后方观察 SKG 落到后颈位置,透明皮肤包裹白色颈椎骨架,双手轻调两端;镜头短距离环绕确认贴合。",
"备用24安静闭眼透明骨架人佩戴后闭眼停留呼吸放慢、肩部放松产品与颈部阴影自然镜头不切换只做细微推进。",
"备用25场景转为日常镜头从肩颈近景拉开到完整生活场景人物继续佩戴 SKG 阅读、休息或看窗外;产品清楚但不抢走人物主体。",
"备用26广告式收束透明骨架人面对镜头轻轻微笑手放下不再遮挡产品SKG 稳定贴合后颈;镜头保持半身构图作为干净收尾。",
"备用27疲惫到放松开场透明骨架人坐在办公桌前揉肩桌面有 SKG 产品;镜头从疲惫状态慢慢推近,结尾人物准备拿起产品。",
"备用28桌面到肩颈产品从桌面被拿起镜头沿手部轨迹移动到肩颈透明骨架人的颈椎和肋骨清晰产品必须跟随手部运动。",
"备用29双手校准透明骨架人用双手对称扶住 SKG 两端,微调到后颈正确位置;镜头正侧之间轻微移动,强调贴合和尺寸可信。",
"备用30舒适微表情佩戴稳定后透明骨架人眼神变柔和、嘴角轻扬肩颈线条放松镜头从产品细节回到脸部保持广告级质感。",
]
const FUSION_STAGE_PLANS = [
{
refs: "正面 + 半身近景",
product: "产品先在桌面、床头或手边出现,人物尚未佩戴,负责建立角色和场景",
result: "角色成立、场景成立、观众看到 SKG 产品即将被使用",
},
{
refs: "正面 + 左45度 + 产品正面/侧面",
product: "手把 SKG 从桌面或包中拿起带入画面,产品沿手部运动靠近后颈",
result: "产品进入画面自然,不悬浮,尺寸开始和人物肩颈建立关系",
},
{
refs: "侧面 + 背部特写 + 产品侧面/背面",
product: "SKG 作为外置刚性实物准确落到后颈外侧,两端沿颈侧向前,双手扶两端微调接触角度",
result: "重点解决佩戴位置、产品真实尺寸、透视、遮挡关系和不穿模",
},
{
refs: "半身近景 + 侧面",
product: "产品已经稳定外置佩戴,人物轻按控制区或保持佩戴状态",
result: "表现闭眼、肩部下沉、呼吸放慢、舒适享受,但不做医疗疗效承诺",
},
{
refs: "正面 + 左45度/右45度",
product: "人物在生活场景中继续佩戴 SKG产品位置稳定不漂移不变形",
result: "展示真实使用中的生活方式,人物、场景和产品统一",
},
{
refs: "半身近景 + 背部特写 + 产品主视角",
product: "收尾停在肩颈和真实产品清楚可辨的位置,手不遮挡产品关键轮廓",
result: "形成广告记忆点:角色舒适、产品清楚、画面干净高级",
},
]
const CHARACTER_PROMPT_PROFILES: Record<string, { name: string; subject: string; stageScenes: string[]; usage: string; enjoyment: string }> = {
"character-01": {
name: "运动阳光男",
subject: "清爽运动阳光男透明骨架人,身形挺拔、年轻有活力,整体气质健康明亮",
stageScenes: [
"晨练后的明亮卧室阳光从窗边进入SKG 放在床头或运动包旁",
"健身房休息长椅旁,人物从运动包中拿起 SKG",
"阳台休息区侧面近景,阳光照到后颈和白色骨架",
"运动后客厅或卧室半身近景,背景清爽明亮",
"窗边拉伸区或瑜伽垫旁,人物轻松活动肩颈",
"干净白色广告背景或明亮卧室收尾特写",
],
usage: "运动后双手拿起 SKG准确戴到后颈和颈肩交界处轻按侧边控制区并微调贴合",
enjoyment: "从运动后的颈肩紧绷变成轻松舒展,闭眼深呼吸、肩部下沉、露出阳光放松微笑",
},
"character-02": {
name: "都市型男",
subject: "都市通勤型男透明骨架人,穿搭利落,高层公寓和商务感更强",
stageScenes: [
"高层公寓客厅或落地窗前,桌面上摆着 SKG",
"办公桌前或商务休息区,人物从桌面拿起 SKG",
"办公椅侧面近景,后颈和颈肩区域清楚可见",
"夜晚城市窗边半身近景,灯光克制高级",
"会议间隙的安静休息区,人物靠回椅背继续佩戴",
"高层公寓半身收尾,产品在后颈位置清楚可辨",
],
usage: "久坐办公后从桌面拿起 SKG戴到后颈手指轻按按键让产品稳定贴合颈肩",
enjoyment: "从低头疲惫、揉脖子变为靠回椅背放松,眼神平静、嘴角微扬,呈现高效恢复感",
},
"character-03": {
name: "优雅白领女",
subject: "优雅白领女透明骨架人,姿态端正、干净精致,商业广告感更高级",
stageScenes: [
"精致公寓客厅或柔和落地窗旁SKG 放在小桌上",
"办公室午休区或化妆镜前,人物优雅拿起 SKG",
"镜前或窗边侧面近景,后颈线条和白色骨架清楚",
"极简卧室半身近景,柔和光线突出放松表情",
"办公室休息区继续佩戴,动作克制自然",
"高级广告式半身收尾,产品轮廓和人物微笑都清楚",
],
usage: "工作间隙优雅拿起 SKG轻放到后颈双手细致调整两端贴合动作慢而自然",
enjoyment: "从肩颈紧绷转为安静舒缓,闭眼微笑、呼吸放慢,呈现精致、自洽、舒服的状态",
},
"character-04": {
name: "运动辣妹",
subject: "运动辣妹透明骨架人,线条利落、活力强,适合健身后和生活方式广告",
stageScenes: [
"健身房更衣区或瑜伽垫旁SKG 放在水杯和毛巾旁",
"运动后休息区,人物从健身包或桌面拿起 SKG",
"浴室镜前或瑜伽垫旁侧面近景,后颈和肩线清楚",
"阳光客厅半身近景,人物闭眼放松",
"运动后休息区继续佩戴,轻轻侧头伸展",
"健身生活方式广告收尾,产品贴合后颈且轮廓清晰",
],
usage: "训练后拿起 SKG 戴到后颈,侧头伸展时保持产品稳定贴合,手部不遮挡产品轮廓",
enjoyment: "从训练后的酸胀紧绷变为轻松舒展,闭眼享受、肩颈打开,表情自信舒服",
},
"character-05": {
name: "绅士大叔",
subject: "成熟绅士大叔透明骨架人,气质稳重高级,适合书房、客厅和商务休息场景",
stageScenes: [
"木质书房或皮质休闲椅旁SKG 放在书桌边",
"窗边阅读角,人物从桌边稳稳拿起 SKG",
"书房侧后方近景,后颈、颈椎和背部特写参考清楚",
"安静客厅半身近景,灯光温暖克制",
"商务酒店房间或书房里继续佩戴阅读休息",
"成熟高级的半身收尾,产品清楚、人物从容微笑",
],
usage: "阅读或工作后从桌边拿起 SKG稳稳戴到后颈轻扶两端调整到颈肩正确位置",
enjoyment: "从沉稳疲惫变为从容舒适,缓慢闭眼、肩部放松、轻轻微笑,表达成熟克制的享受",
},
}
const fusionDescriptionForCharacter = (characterId: string, presetIndex: number) => {
const base = PRODUCT_FUSION_DESCRIPTION_PRESETS[presetIndex % PRODUCT_FUSION_DESCRIPTION_PRESETS.length]
const profile = CHARACTER_PROMPT_PROFILES[characterId] ?? CHARACTER_PROMPT_PROFILES[DEFAULT_CHARACTER_ID]
const stage = presetIndex % FUSION_SHOT_COUNT
const stagePlan = FUSION_STAGE_PLANS[stage]
const scene = profile.stageScenes[stage] || profile.stageScenes[0]
return [
base,
`角色设定:${profile.subject}`,
`自主图像编排:本镜头主要参考角色图【${stagePlan.refs}】,场景生成【${scene}】。`,
`产品调度:${stagePlan.product}`,
`镜头目标:${stagePlan.result}`,
`真实产品合成:四张 SKG 产品图是真实实物真源,只能作为外置刚性设备佩戴在透明身体外侧;后颈外侧承托,两端沿左右颈侧向前,不能穿进透明皮肤、骨架、喉咙或肩部。`,
`产品使用:${profile.usage};产品只能在脖子、后颈、颈肩交界处外置使用,尺寸必须符合真实颈部按摩仪比例。`,
`享受状态:${profile.enjoyment};不要医疗治疗承诺,不要恐怖解剖感。`,
].join("\n")
}
const legacyFusionDescriptionSet = new Set(LEGACY_PRODUCT_FUSION_DESCRIPTION_PRESETS)
const shouldUseDefaultFusionDescription = (value?: string | null) => {
const text = value?.trim()
return !text || legacyFusionDescriptionSet.has(text)
}
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,
character_id: DEFAULT_CHARACTER_ID,
character_name: DEFAULT_CHARACTER_NAME,
subject_image: null,
subject_images: [],
person_image: null,
product_region: null,
scene_image: null,
action_text: fusionDescriptionForCharacter(DEFAULT_CHARACTER_ID, i),
duration: 5,
image_model: "auto",
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] ?? {}
return {
...item,
...shot,
product_images: shot.product_images?.slice(0, PRODUCT_ANGLE_COUNT) ?? [],
character_id: shot.character_id || item.character_id,
character_name: shot.character_name || item.character_name,
subject_image: shot.subject_image ?? item.subject_image,
subject_images: shot.subject_images ?? item.subject_images,
action_text: shouldUseDefaultFusionDescription(shot.action_text) ? item.action_text : shot.action_text,
id: shot.id || item.id,
}
})
}
export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, clipboard, onCopyImage, onGenerateProductFusionVideo, onDeleteVideo, 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 [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 [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionPresetPage, setFusionPresetPage] = useState(0)
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
const [productLibrary, setProductLibrary] = useState<ProductLibraryItem[]>([])
const [selectedCharacterId, setSelectedCharacterId] = useState(DEFAULT_CHARACTER_ID)
const [fusionHoverPreview, setFusionHoverPreview] = useState<FusionPreviewAnchor | 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 fusionPreviewRootRef = useRef<HTMLDivElement>(null)
const loadedFusionKey = useRef("")
const activeIndexRef = useRef<number | null>(activeIndex)
useEffect(() => setMounted(true), [])
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
useEffect(() => {
let cancelled = false
listCharacterLibrary()
.then((items) => {
if (!cancelled) setCharacterLibrary(items)
})
.catch((e) => {
if (!cancelled) toast.error("角色库读取失败:" + (e instanceof Error ? e.message : String(e)))
})
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
listProductLibrary()
.then((items) => {
if (!cancelled) setProductLibrary(items)
})
.catch((e) => {
if (!cancelled) toast.error("产品图读取失败:" + (e instanceof Error ? e.message : String(e)))
})
return () => { cancelled = true }
}, [])
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)
const nextShots = normalizeFusionShots(frame?.storyboard?.product_fusion_shots as ProductFusionShot[] | undefined)
setFusionShots(nextShots)
setSelectedCharacterId(nextShots[0]?.character_id || DEFAULT_CHARACTER_ID)
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 fusionReadyCount = fusionShots.filter((shot) => shot.action_text?.trim()).length
const selectedCharacter = characterLibrary.find((item) => item.id === selectedCharacterId) ?? characterLibrary[0]
const fixedProductItems = DESKTOP_PRODUCT_ANGLE_IDS
.map((id) => productLibrary.find((item) => item.id === id))
.filter((item): item is ProductLibraryItem => Boolean(item))
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
updateFusionShot(index, target.slot === "first_image"
? { first_image: ref, guide_image: null }
: { last_image: ref, guide_image: null }, true)
}
const prepareFusionReferences = async (indexes: number[]) => {
try {
const reusableRefs = fusionShots.find((shot) => (shot.product_images ?? []).filter(Boolean).length >= PRODUCT_ANGLE_COUNT)
?.product_images?.slice(0, PRODUCT_ANGLE_COUNT)
const productRefs = reusableRefs?.length === PRODUCT_ANGLE_COUNT
? reusableRefs
: await Promise.all(DESKTOP_PRODUCT_ANGLE_IDS.map((id) => copyProductLibraryAsset(jobId, id)))
const reusableSubjectRefs = fusionShots.find((shot) =>
shot.character_id === selectedCharacterId && (shot.subject_images ?? []).filter(Boolean).length > 0
)?.subject_images?.filter(Boolean)
const copiedCharacter = reusableSubjectRefs?.length
? {
character_id: selectedCharacterId,
character_name: selectedCharacter?.name || fusionShots.find((shot) => shot.character_id === selectedCharacterId)?.character_name || DEFAULT_CHARACTER_NAME,
images: reusableSubjectRefs,
}
: await copyCharacterLibraryAssets(jobId, selectedCharacterId)
const next = fusionShots.map((shot, index) => (
indexes.includes(index)
? {
...shot,
product_images: productRefs,
product_image: productRefs[0] ?? null,
character_id: copiedCharacter.character_id,
character_name: copiedCharacter.character_name,
subject_image: copiedCharacter.images[0] ?? null,
subject_images: copiedCharacter.images,
guide_image: null,
}
: shot
))
setFusionShots(next)
void persistFusionShots(next)
return next
} catch (e) {
toast.error("内置角色/产品参考准备失败:" + (e instanceof Error ? e.message : String(e)))
return null
}
}
const rotateFusionDescriptions = () => {
const page = fusionPresetPage + 1
const start = (page * FUSION_SHOT_COUNT) % PRODUCT_FUSION_DESCRIPTION_PRESETS.length
const next = fusionShots.map((shot, i) => ({
...shot,
action_text: fusionDescriptionForCharacter(selectedCharacterId, start + i),
}))
setFusionPresetPage(page)
setFusionShots(next)
void persistFusionShots(next)
toast.success(`已换第 ${Math.floor(start / FUSION_SHOT_COUNT) + 1} 组镜头语言`)
}
const selectFusionCharacter = (characterId: string) => {
const character = characterLibrary.find((item) => item.id === characterId)
const start = (fusionPresetPage * FUSION_SHOT_COUNT) % PRODUCT_FUSION_DESCRIPTION_PRESETS.length
const next = fusionShots.map((shot, i) => ({
...shot,
character_id: characterId,
character_name: character?.name || shot.character_name || DEFAULT_CHARACTER_NAME,
action_text: fusionDescriptionForCharacter(characterId, start + i),
subject_image: null,
subject_images: [],
}))
setSelectedCharacterId(characterId)
setFusionShots(next)
void persistFusionShots(next)
}
const runFusionVideo = async (index: number) => {
const shot = fusionShots[index]
if (!shot?.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺场景/使用描述`)
return
}
setFusionGenerating(index)
try {
const next = await prepareFusionReferences([index])
if (!next) return
await onGenerateProductFusionVideo?.(f.index, next[index])
} finally {
setFusionGenerating(null)
}
}
const runAllFusionVideos = async () => {
const indexes = fusionShots
.map((shot, i) => ({ shot, i }))
.filter(({ shot }) => shot.action_text?.trim())
.map(({ i }) => i)
if (indexes.length === 0) {
toast.error("还没有可生成的融合镜头")
return
}
setFusionGenerating("all")
try {
const next = await prepareFusionReferences(indexes)
if (!next) return
for (const index of indexes) {
await onGenerateProductFusionVideo?.(f.index, next[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"
const targetShotIndex = activeFusionShot
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: targetShotIndex,
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}已生成,并填入镜头 ${targetShotIndex + 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 100%", minWidth: 520, maxWidth: "none", 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 ref={fusionPreviewRootRef} className="relative overflow-visible rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5">
{(() => {
if (!fusionHoverPreview) return null
if (fusionHoverPreview.kind === "product") {
const item = fixedProductItems.find((product) => product.id === fusionHoverPreview.id)
if (!item) return null
return (
<HoverPreview
imgSrc={apiAssetUrl(item.url)}
aspect="1/1"
label={`真实产品图 ${item.image_index}`}
caption={`${item.width}×${item.height}`}
borderClass="border-amber-300/70"
visible
anchorX={fusionHoverPreview.x}
anchorY={fusionHoverPreview.y}
/>
)
}
const item = generatedVideos.find((video) => video.id === fusionHoverPreview.id)
if (!item) return null
const videoSrc = apiAssetUrl(item.url)
const posterSrc = apiAssetUrl(item.poster_url)
const ready = item.status === "completed" && !!videoSrc
if (!ready && !posterSrc) return null
return (
<HoverPreview
videoSrc={ready ? videoSrc : undefined}
imgSrc={!ready ? posterSrc : undefined}
poster={posterSrc}
aspect="9/16"
label={`产品融合 · 分镜 ${item.frame_idx + 1}`}
caption={item.status === "completed" ? `${item.duration.toFixed(0)}s` : `${item.status} · ${item.progress ?? 0}%`}
borderClass={ready ? "border-emerald-300/70" : "border-amber-300/70"}
visible
anchorX={fusionHoverPreview.x}
anchorY={fusionHoverPreview.y}
/>
)
})()}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<div className="flex items-center gap-1.5">
<span className="rounded bg-black/35 px-1.5 py-0.5 text-[9.5px] font-mono text-white/55">
{fusionReadyCount}/6
</span>
<button
type="button"
onClick={rotateFusionDescriptions}
disabled={!!fusionGenerating}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-white/10 px-2 text-[9.5px] font-medium text-white/65 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
title="换一组内置镜头语言,不改变角色和视频结果"
>
<RefreshCw className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => void runAllFusionVideos()}
disabled={!!fusionGenerating || !onGenerateProductFusionVideo}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-amber-500/70 px-2 text-[9.5px] font-medium text-white transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
>
{fusionGenerating === "all" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
</button>
</div>
</div>
<div className="mb-2 grid grid-cols-[minmax(260px,1fr)_minmax(390px,520px)] gap-2 rounded-md border border-white/10 bg-black/25 p-2">
<div className="min-w-0">
<label className="block">
<div className="mb-1 text-[9px] text-white/38"></div>
<select
value={selectedCharacterId}
onChange={(e) => selectFusionCharacter(e.target.value)}
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white/80 outline-none focus:border-amber-300/45"
>
{(characterLibrary.length ? characterLibrary : [{ id: DEFAULT_CHARACTER_ID, name: DEFAULT_CHARACTER_NAME } as CharacterLibraryItem]).map((character) => (
<option key={character.id} value={character.id}>{character.name}</option>
))}
</select>
</label>
<div className="mt-2">
<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"></span>
</div>
<div className="flex gap-1.5 overflow-hidden">
{(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
<div key={image.id} className="h-12 w-10 overflow-hidden rounded border border-white/10 bg-black/35">
<img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
))}
{!selectedCharacter?.images?.length && (
<div className="flex h-12 items-center rounded border border-dashed border-white/10 px-2 text-[9.5px] text-white/32">
</div>
)}
</div>
</div>
</div>
<div className="min-w-0 rounded-md border border-amber-300/25 bg-amber-300/[0.08] p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-amber-50"> · 4 </span>
<span className="text-[8.5px] text-amber-100/45"></span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{fixedProductItems.length > 0 ? fixedProductItems.map((item, index) => (
<div
key={item.id}
className="group relative overflow-visible rounded-md border border-white/15 bg-white shadow-[0_8px_24px_rgba(0,0,0,0.22)]"
onMouseEnter={(e) => setFusionHoverPreview({ kind: "product", id: item.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
onMouseLeave={() => setFusionHoverPreview(null)}
title={`真实产品角度 ${index + 1}`}
>
<div className="relative aspect-square">
<img src={apiAssetUrl(item.url)} alt={`真实产品角度 ${index + 1}`} className="absolute inset-0 h-full w-full object-contain p-1.5" draggable={false} />
<div className="absolute left-1 top-1 rounded bg-black/75 px-1 py-0.5 text-[8px] font-mono text-white">
P{index + 1}
</div>
</div>
<div className="border-t border-black/8 bg-black/5 px-1 py-0.5 text-center text-[8px] font-mono text-black/55">
{item.width}×{item.height}
</div>
</div>
)) : DESKTOP_PRODUCT_ANGLE_IDS.map((id, index) => (
<div key={id} className="flex aspect-square items-center justify-center rounded-md border border-dashed border-white/15 bg-black/25 text-[9px] text-white/30">
P{index + 1}
</div>
))}
</div>
</div>
</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">
</div>
<div className="space-y-2">
{fusionShots.map((shot, i) => {
const active = i === activeFusionShot
const shotMarker = `${FUSION_PROMPT_MARKER_PREFIX}${shot.id}`
const shotVideos = generatedVideos
.filter((video) => video.frame_idx === f.index && video.prompt.includes(shotMarker))
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
const ready = !!shot.action_text?.trim()
const busy = fusionGenerating === i || fusionGenerating === "all"
const lensStageLabel = PRODUCT_FUSION_LENS_STAGES[i] ?? `镜头 ${i + 1}`
const resultPanel = shotVideos.length > 0 ? (
<div className="rounded-md border border-white/10 bg-black/30 p-1.5">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[8.5px] text-white/42"></span>
<span className="rounded bg-white/8 px-1 py-0.5 text-[8px] font-mono text-white/45">{shotVideos.length}</span>
</div>
<div className="flex max-w-full gap-1.5 overflow-x-auto pb-1">
{shotVideos.map((video, videoIndex) => {
const videoUrl = video.url ? apiAssetUrl(video.url) : ""
const posterUrl = video.poster_url ? apiAssetUrl(video.poster_url) : ""
const completed = video.status === "completed" && !!videoUrl
return (
<div
key={video.id}
className={`group relative h-[124px] w-[74px] shrink-0 overflow-hidden rounded-md border bg-black ${
completed ? "border-emerald-300/55" : video.status === "failed" ? "border-rose-300/65" : "border-amber-300/55"
}`}
onMouseEnter={(e) => setFusionHoverPreview({ kind: "video", id: video.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
onMouseLeave={() => setFusionHoverPreview(null)}
title="鼠标停留放大预览"
>
{completed ? (
<video src={videoUrl} muted loop playsInline preload="metadata" className="h-full w-full object-cover" />
) : posterUrl ? (
<img src={posterUrl} alt="" className="h-full w-full object-cover opacity-75" draggable={false} />
) : (
<div className="flex h-full items-center justify-center bg-black/40">
{video.status === "failed" ? <X className="h-4 w-4 text-rose-200" /> : <Loader2 className="h-4 w-4 animate-spin text-amber-200" />}
</div>
)}
{!completed && posterUrl && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
{video.status === "failed" ? <X className="h-4 w-4 text-rose-200" /> : <Loader2 className="h-4 w-4 animate-spin text-amber-200" />}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/92 to-transparent px-1 py-1">
<div className="truncate text-[8.5px] font-semibold text-white">#{shotVideos.length - videoIndex}</div>
<div className="truncate text-[7.5px] font-mono text-white/65">
{completed ? `${video.duration.toFixed(0)}s` : video.status === "failed" ? "failed" : `${video.progress ?? 0}%`}
</div>
</div>
{onDeleteVideo && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteVideo(video.id)
}}
className="absolute right-1 top-1 inline-flex h-5 w-5 items-center justify-center rounded-full bg-black/65 text-white/65 opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
title="删除此视频"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
)
})}
</div>
</div>
) : (
<div className="flex h-full min-h-[96px] items-center justify-center rounded-md border border-dashed border-white/10 bg-black/20 px-2 text-center text-[9.5px] text-white/32">
</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_minmax(340px,1fr)_78px_minmax(240px,300px)] 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>
<label className="block">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-amber-100/65">{lensStageLabel}</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="写清场景、产品如何佩戴到脖子/后颈,以及人物舒适享受状态。"
className="h-[148px] 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>
{resultPanel}
</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
? isProductTab
? { display: "none" }
: { 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">
/
</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={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>
</div>
<div className="mb-2">
<label className="block 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.asset_role && (
<> · {latestSceneAsset.asset_role === "first_frame" ? "首帧" : latestSceneAsset.asset_role === "last_frame" ? "尾帧" : "场景图"}</>
)}
</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}
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => void handleGenerateSceneAsset("first_frame")}
disabled={!!sceneGenerating || isCleaningCurrentFrame || batchCleaning}
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={`生成后填入产品融合镜头 ${activeFusionShot + 1} 的首帧`}
>
{sceneGenerating === "first_frame" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{sceneGenerating === "first_frame" ? "生成首帧中…" : "生成首帧并填入"}
</button>
<button
type="button"
onClick={() => void handleGenerateSceneAsset("last_frame")}
disabled={!!sceneGenerating || isCleaningCurrentFrame || batchCleaning}
className="w-full rounded-md bg-cyan-500/65 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"
title={`生成后填入产品融合镜头 ${activeFusionShot + 1} 的尾帧`}
>
{sceneGenerating === "last_frame" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{sceneGenerating === "last_frame" ? "生成尾帧中…" : "生成尾帧并填入"}
</button>
</div>
</section>
)}
{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)
}