2355 lines
128 KiB
TypeScript
2355 lines
128 KiB
TypeScript
"use client"
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { createPortal } from "react-dom"
|
||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, 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)
|
||
}
|