"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: "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<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)
}
