"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 activeIndex: number | null selected: Set 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 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 = { "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: "gpt-image-2", video_model: "seedance", guide_image: null, })) const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusionShot[] => { const base = createFusionShots() if (!shots?.length) return base return base.map((item, i) => { const shot = shots[i] ?? {} 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>(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(null) const [subjectGenerating, setSubjectGenerating] = useState(null) const [assetSize, setAssetSize] = useState("source") const [sceneStyle, setSceneStyle] = useState("source") const [sceneLocation, setSceneLocation] = useState("modern living room") const [sceneReferenceKeys, setSceneReferenceKeys] = useState(["camera angle and composition", "lighting direction", "spatial layout"]) const [sceneExtraKeywords, setSceneExtraKeywords] = useState("") const [scenePrompt, setScenePrompt] = useState("") const [subjectKinds, setSubjectKinds] = useState>({}) const [subjectBackgrounds, setSubjectBackgrounds] = useState>({}) const [subjectViews, setSubjectViews] = useState>({}) const [activeTab, setActiveTab] = useState("clean") const [fusionShots, setFusionShots] = useState(() => createFusionShots()) const [activeFusionShot, setActiveFusionShot] = useState(0) const [fusionGenerating, setFusionGenerating] = useState(null) const [fusionSaving, setFusionSaving] = useState(false) const [fusionPresetPage, setFusionPresetPage] = useState(0) const [characterLibrary, setCharacterLibrary] = useState([]) const [productLibrary, setProductLibrary] = useState([]) const [selectedCharacterId, setSelectedCharacterId] = useState(DEFAULT_CHARACTER_ID) const [fusionHoverPreview, setFusionHoverPreview] = useState(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([]) const [draftRegion, setDraftRegion] = useState(null) // 当前正在拖的 const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null) const imgWrapRef = useRef(null) const fusionPreviewRootRef = useRef(null) const loadedFusionKey = useRef("") const activeIndexRef = useRef(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, 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) => { 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= 形式(后端写时带) // 这里直接当 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 = (
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 && (
分镜 {String(arrayPos + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")} · {f.timestamp.toFixed(2)}s
)}
{LIGHTBOX_TABS.map((tab) => ( ))}
{hasSubjectAssets ? `统一主体 ${subjectAssetCount} 张` : hasUnifiedSubject ? "统一主体待生成" : "统一主体待选择"} · {latestSceneAsset ? "场景已生成" : "场景待生成"}
{/* 主体 — 左:主图;右:当前页操作 / 状态 / 主体资产 */}
{/* 左侧大图区 */}
{isSubjectTab ? (
主体参考帧
{subjectReferenceLabel}
{subjectReferenceFrames.map((frame) => { const active = frame.index === f.index return ( ) })}
这些参考帧会一起传给模型,用来重绘同一个主体;不是逐张抠图。
) : isSceneTab ? (
首尾帧参考图
{selectedFrameIndices.length > 0 ? `${selectedFrameIndices.length} 已选参考` : "默认当前帧"}
{frames.map((frame) => { const active = frame.index === f.index const checked = selected.has(frame.index) return (
{frame.timestamp.toFixed(2)}s
) })}
左侧显示全部关键帧;点图片设为生成目标,点“选”加入人物/机位参考。未选择时默认只参考当前目标帧。
) : isProductTab ? (
{(() => { if (!fusionHoverPreview) return null if (fusionHoverPreview.kind === "product") { const item = fixedProductItems.find((product) => product.id === fusionHoverPreview.id) if (!item) return null return ( ) } 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 ( ) })()}
产品融合镜头组
{fusionReadyCount}/6 可生成
角色参考 人物身份参考
{(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
{image.label}
))} {!selectedCharacter?.images?.length && (
角色库加载中
)}
真实产品真源 · 固定 4 图 生成时按实物合成,不自由重绘
{fixedProductItems.length > 0 ? fixedProductItems.map((item, index) => (
setFusionHoverPreview({ kind: "product", id: item.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })} onMouseLeave={() => setFusionHoverPreview(null)} title={`真实产品角度 ${index + 1}`} >
{`真实产品角度
P{index + 1}
{item.width}×{item.height}
)) : DESKTOP_PRODUCT_ANGLE_IDS.map((id, index) => (
P{index + 1}
))}
四张产品图是真实产品真源;生成时按外置刚性实物佩戴到后颈,不把产品融进透明身体。每行可反复生成,结果会继续往后追加。
{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 ? (
历史结果 {shotVideos.length}
{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 (
setFusionHoverPreview({ kind: "video", id: video.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })} onMouseLeave={() => setFusionHoverPreview(null)} title="鼠标停留放大预览" > {completed ? (
) })}
) : (
生成后显示在这里
) return (
{ready ? "就绪" : "待补"}