"use client"

import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
  AlertTriangle, BookOpen, Check, ChevronDown, Circle, Download, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
  MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
  type FrameExtractQuality,
  type FrameExtractTarget,
  type FrameObject,
  type GeneratedVideo,
  type ImageRef,
  type AssetLibraryItem,
  type AssetLibraryKind,
  type CharacterLibraryItem,
  type SubjectTemplateItem,
  type Job,
  type KeyElement,
  type KeyFrame,
  type ProductViewAnalysisItem,
  type ProductRefStateItem,
  type QuickStoryboardPlanInput,
  type RefineStoryboardResult,
  type RuntimeModels,
  type StoryboardScriptRewriteSegment,
  type StoryboardScene,
  type SubjectAsset,
  type SubjectImageModelPreference,
  type SubjectModelBundle,
  type SubjectProfilePreference,
  type SubjectKind,
  addElement,
  analyzeJob,
  analyzeSubjectAgent,
  analyzeProductViews,
  apiAssetUrl,
  characterLibraryImageUrl,
  createAssetLibraryItem,
  createPromptLibraryItem,
  cutoutElement,
  deleteSubjectAsset,
  effectiveFrameUrl,
  formatJobError,
  generateSceneAsset,
  generateProductAngleAsset,
  generateStoryboardVideo,
  generateSubjectAssets,
  generatedImageUrl,
  getJob,
  getRuntimeHealth,
  hasCutout,
  listCharacterLibrary,
  listSubjectTemplates,
  representativeCutoutUrl,
  resolveImageRefUrl,
  refineStoryboard,
  quickPlanStoryboard,
  rewriteStoryboardScript,
  saveSubjectTemplate,
  saveProductRefs,
  sendSubjectAgentMessage,
  sourceAudioUrl,
  subjectTemplateImageUrl,
  updateElement,
  updateStoryboard,
  uploadReferenceFrame,
  uploadStoryboardAsset,
  translateText,
  videoUrl,
} from "@/lib/api"
import { type NodeData } from "@/components/nodes"
import { MediaAssetTile } from "@/components/media-asset-tile"
import { AnimatedLoginCharacters } from "@/components/login/animated-login-characters"
import { LibraryDrawer } from "@/components/resource-library/library-drawer"

const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [
  { value: "balanced", label: "综合" },
  { value: "subject", label: "主体" },
  { value: "motion", label: "动作" },
  { value: "expression", label: "表情" },
  { value: "transition", label: "转场" },
  { value: "transparent_human", label: "骨架人" },
]

const QUALITIES: Array<{ value: FrameExtractQuality; label: string }> = [
  { value: "auto", label: "自动" },
  { value: "fast", label: "快速" },
  { value: "accurate", label: "精细" },
  { value: "ultra", label: "极准" },
]

const VIDEO_MODELS = [
  { value: "seedance", label: "Seedance" },
  { value: "kling", label: "Kling" },
  { value: "veo3", label: "Veo" },
] as const

type VideoModel = (typeof VIDEO_MODELS)[number]["value"]
type BoardThemeMode = "dark" | "light"
type AudioStoryboardRole = "hook" | "pain" | "proof" | "solution" | "cta" | "bridge"

const BOARD_THEME_STORAGE_KEY = "skg-board-theme"
const SOURCE_LEFT_COLUMN_WIDTH = 380
const SOURCE_VIDEO_HEIGHT = 500
const SOURCE_TRANSCRIPT_MAX_HEIGHT = 360
const SOURCE_REFERENCE_POOL_WIDTH = 140
const SOURCE_CONVERSION_MAX_HEIGHT = 560
const SOURCE_SUBJECT_EMPTY_HEIGHT = 78

type DraftSegment = {
  id: string
  frameIndex: number | null
  scene: StoryboardScene
}

type AudioFeature = {
  loudness: number
}

type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed"

type FilmstripDensitySeconds = 1 | 2 | 5
type FilmstripStatus = "idle" | "loading" | "ready" | "failed"
type FilmstripPreviewFrame = {
  time: number
  src: string
}
type FilmstripHoverPreview = {
  src: string
  time: number
  left: number
  top: number
  width: number
  height: number
  active: boolean
  selected: boolean
  busy: boolean
}

const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time"
const SOURCE_KEYFRAME_DRAG_TYPE = "application/x-skg-source-keyframe"
const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string; detail: string }> = [
  { value: 5, label: "低", detail: "5s/张" },
  { value: 2, label: "中", detail: "2s/张" },
  { value: 1, label: "高", detail: "1s/张" },
]
const FILMSTRIP_TILT_CLASSES = ["-rotate-[8deg]", "-rotate-[6deg]", "-rotate-[9deg]"]
const FILMSTRIP_VERTICAL_OFFSET_CLASSES = ["translate-y-0", "translate-y-2", "-translate-y-1.5", "translate-y-1", "-translate-y-2"]
const FILMSTRIP_HOVER_SCALE = 4.8
const FILMSTRIP_CACHE_LIMIT = 8
const filmstripPreviewCache = new Map<string, FilmstripPreviewFrame[]>()

function filmstripCacheKey(jobId: string, videoUrl: string, density: FilmstripDensitySeconds, duration: number) {
  return `${jobId}:${videoUrl}:${density}:${Math.round(duration * 10) / 10}`
}

function rememberFilmstripPreview(key: string, frames: FilmstripPreviewFrame[]) {
  filmstripPreviewCache.delete(key)
  filmstripPreviewCache.set(key, frames)
  while (filmstripPreviewCache.size > FILMSTRIP_CACHE_LIMIT) {
    const oldest = filmstripPreviewCache.keys().next().value
    if (!oldest) break
    filmstripPreviewCache.delete(oldest)
  }
}

function isAudioProcessing(job?: Job | null) {
  if (!job) return false
  return job.audio_script?.status === "rewriting" || (job.status === "transcribing" && job.audio_script?.status !== "failed")
}

type AudioStoryboardRow = {
  index: number
  start: number
  end: number
  source: string
  sourceZh: string
  role: AudioStoryboardRole
  visualMode: StoryboardVisualMode
  needsProduct: boolean
  needsSubject: boolean
  subjectDescription: string
  subjectDescriptionZh: string
  skgCopy: string
  skgCopyZh: string
  sceneOneLine: string
  sceneOneLineZh: string
  actionOneLine: string
  actionOneLineZh: string
  visualPlan: string
  visualPlanZh: string
  firstFramePlan: string
  firstFramePlanZh: string
  lastFramePlan: string
  lastFramePlanZh: string
  referencePlan: string
  keyElements: string
  keyElementsZh: string
  productIntegration: string
  productIntegrationZh: string
  productPlacement: string
  productPlacementZh: string
}

type ProductRefItem = ProductRefStateItem
type SubjectPlanningRef = ImageRef & { view: string; roleHint: string; consensusBrief?: string }
type SubjectStyleMode = "transparent_human" | "source_actor" | "cartoon_subject"
type SubjectMode = "template" | "source_similar"
type SubjectViewMode = "all" | "common" | "custom"
type SubjectPipelineViewMode = "all" | "common"
type SubjectReconstructionMode = "realistic" | "cartoon" | "elements" | "custom"
type CartoonReconstructionStyle = "3d_animation" | "designer_toy" | "japanese_clean" | "american_illustration" | "clay_toy" | "flat_minimal"
type SubjectProfileMode = "random" | "manual"
type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood"
type SubjectProfileDraft = Record<SubjectProfileFieldKey, string>
type SubjectProfileOption = { value: string; label: string; prompt: string }
type SubjectProfileCategory = { key: SubjectProfileFieldKey; label: string; options: SubjectProfileOption[] }
type ResolvedSubjectProfile = {
  mode: SubjectProfileMode
  values: SubjectProfileDraft
  summary: string
  promptSummary: string
  payload: SubjectProfilePreference
}
type SubjectAssetPack = {
  key: string
  id: string
  label: string
  mode: SubjectReconstructionMode
  frame: KeyFrame
  element: KeyElement
  createdAt: number
  assets: SubjectAsset[]
  total: number
  completed: number
  failed: number
  running: boolean
}
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "skgCopy" | "skgCopyZh" | "sceneOneLine" | "sceneOneLineZh" | "actionOneLine" | "actionOneLineZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
type WorkflowStepStatus = "blocked" | "pending" | "running" | "ready" | "paused"
type WorkflowStep = {
  id: WorkflowStepId
  no: string
  title: string
  detail: string
  judge: string
  status: WorkflowStepStatus
}

const VISUAL_MODE_OPTIONS: Array<{ value: StoryboardVisualMode; label: string; description: string }> = [
  { value: "person_only", label: "人物/情绪", description: "只拍人物、状态、痛点或口播，不强制露产品。" },
  { value: "person_product", label: "人物+产品", description: "人物佩戴、拿起、调整或使用 SKG 产品。" },
  { value: "product_only", label: "产品特写", description: "只拍产品、包装、功能细节或 hero packshot。" },
  { value: "environment", label: "场景过渡", description: "只做空间、生活方式、转场或情绪氛围。" },
]

const SUBJECT_ASSET_VIEWS = [
  { value: "front", label: "正面" },
  { value: "three_quarter_left", label: "左前45" },
  { value: "left", label: "左侧" },
  { value: "back", label: "背面" },
  { value: "right", label: "右侧" },
  { value: "three_quarter_right", label: "右前45" },
  { value: "bust_front", label: "肩颈正近" },
  { value: "bust_left_45", label: "肩颈左近" },
  { value: "bust_right_45", label: "肩颈右近" },
  { value: "back_neck_detail", label: "后颈肩背" },
] as const

const SUBJECT_VIEW_ORDER = [
  ...SUBJECT_ASSET_VIEWS.map((view) => view.value),
  "bust",
  "back_detail",
]

const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarter_right", "bust_front"]
const RECONSTRUCTION_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "left", "back", "right", "three_quarter_right"]
const RECONSTRUCTION_FRAME_LIMIT = 3
const DEFAULT_RECONSTRUCTION_DIRECTIONS: Record<SubjectReconstructionMode, string> = {
  realistic: "",
  cartoon: "",
  elements: "",
  custom: "",
}
const CARTOON_RECONSTRUCTION_STYLES: Array<{ value: CartoonReconstructionStyle; label: string; prompt: string }> = [
  { value: "3d_animation", label: "3D动画", prompt: "premium 3D animated character, clean commercial toy-like rendering, friendly wellness-ad appeal" },
  { value: "designer_toy", label: "潮玩公仔", prompt: "designer art toy character, collectible figurine proportions, polished playful commercial styling" },
  { value: "japanese_clean", label: "日系清爽", prompt: "clean Japanese animation-inspired character, gentle colors, fresh wellness lifestyle advertising feel" },
  { value: "american_illustration", label: "美式插画", prompt: "American editorial advertising illustration character, confident shapes, expressive but polished" },
  { value: "clay_toy", label: "黏土玩具", prompt: "soft clay toy character, tactile handmade material, charming rounded shapes, clean studio look" },
  { value: "flat_minimal", label: "极简扁平", prompt: "minimal flat vector-like character, simple geometric shapes, restrained premium health-tech palette" },
]
const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: string; subtitle: string; placeholder: string }> = [
  {
    value: "realistic",
    label: "形象锁定",
    subtitle: "参考可见主体和服装，生成同一形象的多视图",
    placeholder: "如：保持透明骨骼男孩、蓝色头带和短裤，人物更大",
  },
  {
    value: "cartoon",
    label: "卡通重构",
    subtitle: "选择风格，把参考转成全新卡通主体 6 视图",
    placeholder: "如：更可爱、科技感强、保留肩颈线条",
  },
  {
    value: "elements",
    label: "创意复刻",
    subtitle: "参考姿态、色块和镜头语言，生成差异化新主体",
    placeholder: "如：保留运动气质，去掉原服装和原脸",
  },
  {
    value: "custom",
    label: "自主描述",
    subtitle: "可不依赖参考帧，直接按描述生成主体 6 视图",
    placeholder: "如：30岁亚洲女性，白色运动背心，高级健康科技广告质感",
  },
]

const SUBJECT_MODEL_BUNDLE_OPTIONS: Array<{ value: SubjectModelBundle; label: string; detail: string }> = [
  { value: "gpt", label: "GPT 套件", detail: "GPT 对话 + gpt-image-2 生图" },
  { value: "gemini", label: "Gemini 套件", detail: "Gemini 对话 + Gemini 生图" },
]
const SUBJECT_PROMPT_MEMORY_KEY = "skg:subject-prompt-memory:v1"
const SUBJECT_PROMPT_MEMORY_LIMIT = 28

const SUBJECT_ASSET_SIZE = "2048" as const

const SUBJECT_PROFILE_CATEGORIES: SubjectProfileCategory[] = [
  {
    key: "gender",
    label: "性别表现",
    options: [
      { value: "random", label: "随机", prompt: "gender presentation selected randomly for this request" },
      { value: "female", label: "女性", prompt: "female-presenting commercial ad subject" },
      { value: "male", label: "男性", prompt: "male-presenting commercial ad subject" },
      { value: "neutral", label: "中性", prompt: "androgynous or gender-neutral commercial ad subject" },
    ],
  },
  {
    key: "age",
    label: "年龄段",
    options: [
      { value: "random", label: "随机", prompt: "age range selected randomly for this request" },
      { value: "young_adult", label: "年轻成人", prompt: "young adult, fresh short-video creator energy" },
      { value: "adult", label: "成熟成人", prompt: "adult, polished and reliable commercial look" },
      { value: "middle_aged", label: "中年", prompt: "middle-aged adult, credible wellness and work-stress context" },
      { value: "senior", label: "银发", prompt: "active senior adult, friendly wellness lifestyle context" },
    ],
  },
  {
    key: "wardrobe",
    label: "着装风格",
    options: [
      { value: "random", label: "随机", prompt: "wardrobe style selected randomly for this request" },
      { value: "athleisure", label: "运动休闲", prompt: "clean athleisure outfit with visible neck and shoulders" },
      { value: "office", label: "通勤职场", prompt: "simple office-casual outfit, clean neckline, no bulky collar" },
      { value: "home_loungewear", label: "居家舒适", prompt: "soft home loungewear, relaxed wellness ad mood" },
      { value: "premium_minimal", label: "高级极简", prompt: "premium minimal styling, refined neutral clothing, clear shoulder line" },
      { value: "street_casual", label: "街头日常", prompt: "modern street-casual styling, creator-ad friendly" },
      { value: "wellness", label: "健康护理", prompt: "wellness-care styling, clean and professional but not medical" },
    ],
  },
  {
    key: "region_ethnicity",
    label: "地域人种",
    options: [
      { value: "random", label: "随机", prompt: "regional and ethnic appearance selected randomly for this request" },
      { value: "east_asian", label: "东亚", prompt: "East Asian appearance cues, contemporary commercial styling" },
      { value: "southeast_asian", label: "东南亚", prompt: "Southeast Asian appearance cues, warm creator-ad styling" },
      { value: "south_asian", label: "南亚", prompt: "South Asian appearance cues, clear commercial readability" },
      { value: "black", label: "黑人/非洲裔", prompt: "Black or African-diaspora appearance cues, polished ad styling" },
      { value: "white", label: "白人/欧美", prompt: "White or European appearance cues, contemporary lifestyle ad styling" },
      { value: "latino", label: "拉丁裔", prompt: "Latino appearance cues, energetic lifestyle ad styling" },
      { value: "middle_eastern", label: "中东", prompt: "Middle Eastern appearance cues, premium lifestyle ad styling" },
      { value: "mixed_global", label: "混合国际化", prompt: "mixed or globally ambiguous appearance cues, international ad campaign feel" },
    ],
  },
  {
    key: "skin_tone",
    label: "肤色",
    options: [
      { value: "random", label: "随机", prompt: "skin tone selected randomly for this request" },
      { value: "fair", label: "白皙", prompt: "fair skin tone" },
      { value: "light", label: "浅肤色", prompt: "light skin tone" },
      { value: "medium", label: "中等肤色", prompt: "medium skin tone" },
      { value: "tan", label: "小麦/棕肤", prompt: "tan or warm brown skin tone" },
      { value: "deep", label: "深肤色", prompt: "deep skin tone" },
    ],
  },
  {
    key: "body",
    label: "体型比例",
    options: [
      { value: "random", label: "随机", prompt: "body proportion selected randomly for this request" },
      { value: "slim", label: "偏瘦", prompt: "slim body proportion with clear neck and shoulder silhouette" },
      { value: "average", label: "自然匀称", prompt: "average natural body proportion, believable short-video creator" },
      { value: "athletic", label: "运动型", prompt: "athletic body proportion, wellness and mobility context" },
      { value: "soft", label: "亲和微胖", prompt: "soft approachable body proportion, friendly lifestyle realism" },
      { value: "broad_shoulder", label: "肩颈明显", prompt: "slightly broader shoulder line, useful for neck-and-shoulder product placement" },
    ],
  },
  {
    key: "hair",
    label: "发型",
    options: [
      { value: "random", label: "随机", prompt: "hair style selected randomly for this request" },
      { value: "short", label: "短发", prompt: "short tidy hair that does not cover the neck" },
      { value: "shoulder_length", label: "齐肩发", prompt: "shoulder-length hair kept away from the neck placement area" },
      { value: "ponytail", label: "马尾/束发", prompt: "ponytail or tied-back hair, neck and shoulders clearly visible" },
      { value: "curly", label: "卷发", prompt: "curly hair controlled away from shoulder product placement area" },
      { value: "buzz", label: "极短发", prompt: "very short hair, clean neck silhouette" },
      { value: "business_neat", label: "利落商务", prompt: "neat business hairstyle, polished creator-ad look" },
    ],
  },
  {
    key: "mood",
    label: "气质场景",
    options: [
      { value: "random", label: "随机", prompt: "commercial mood selected randomly for this request" },
      { value: "energetic", label: "开场钩子", prompt: "energetic short-video hook performance" },
      { value: "premium_calm", label: "高级克制", prompt: "premium calm product-ad presence" },
      { value: "friendly_creator", label: "亲和达人", prompt: "friendly creator speaking-to-camera energy" },
      { value: "wellness_pro", label: "健康专业", prompt: "wellness professional credibility without medical or hospital cues" },
      { value: "urban_commute", label: "通勤疲惫", prompt: "urban commute and office fatigue context" },
      { value: "home_relax", label: "居家放松", prompt: "home relaxation and stress-relief context" },
    ],
  },
]

const DEFAULT_SUBJECT_PROFILE_DRAFT: SubjectProfileDraft = SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => {
  acc[category.key] = "random"
  return acc
}, {} as SubjectProfileDraft)

type ModelTraceSpec = {
  title: string
  model: string
  chain: string[]
  note?: string
}

const PRODUCT_VIEW_SLOTS = [
  { value: "front", label: "正面/外侧", hint: "整体 U 形轮廓、开口宽度、外壳主外观" },
  { value: "left_45", label: "佩戴者左 45", hint: "戴在脖子上时佩戴者左肩一侧的弧度、按钮/结构差异" },
  { value: "right_45", label: "佩戴者右 45", hint: "戴在脖子上时佩戴者右肩一侧的弧度、非对称细节" },
  { value: "side_thickness", label: "侧面厚度", hint: "机身厚度、后颈包裹体积" },
  { value: "inner_contacts", label: "贴颈内侧/触点", hint: "按摩触点、贴颈面、内侧皮肤接触位置" },
  { value: "back_bottom", label: "背面/底部", hint: "底面、背部闭合结构、补缺" },
] as const

const MAX_PRODUCT_REFS_PER_VIDEO = 6
const MAX_PRODUCT_REFS_PER_ENDPOINT = 2
const MAX_SUBJECT_REFS_PER_ENDPOINT = 5

const PRODUCT_BACKGROUND_LABELS: Record<string, string> = {
  white: "白底",
  black: "黑底",
  simple: "纯色/简单",
  complex: "复杂背景",
  unknown: "背景未知",
}

const PRODUCT_USE_TAG_LABELS: Record<string, string> = {
  hero_packshot: "主外观",
  wearing_scale: "佩戴比例",
  inner_contact: "触点",
  side_thickness: "厚度",
  asymmetry: "非对称",
  button_detail: "按键",
  back_bottom: "背底",
  material_texture: "材质",
}

const ROLE_LABELS_ZH: Record<AudioStoryboardRole, string> = {
  hook: "开场钩子",
  pain: "痛点推进",
  proof: "利益证明",
  solution: "方案过渡",
  cta: "转化收口",
  bridge: "节奏承接",
}

const ROLE_LABELS_EN: Record<AudioStoryboardRole, string> = {
  hook: "hook",
  pain: "pain build",
  proof: "benefit proof",
  solution: "solution transition",
  cta: "conversion close",
  bridge: "rhythm bridge",
}

const PRODUCT_VIEW_PROMPT_LABELS: Record<string, string> = {
  front: "front / outer shell",
  left_45: "wearer's left 45-degree view",
  right_45: "wearer's right 45-degree view",
  side_thickness: "side thickness view",
  inner_contacts: "inner neck-contact pads",
  back_bottom: "back / bottom structure",
}

const PRODUCT_BACKGROUND_PROMPT_LABELS: Record<string, string> = {
  white: "white background",
  black: "black background",
  simple: "simple solid background",
  complex: "complex background",
  unknown: "unknown background",
}

const PRODUCT_USE_TAG_PROMPT_LABELS: Record<string, string> = {
  hero_packshot: "hero packshot",
  wearing_scale: "wearing scale",
  inner_contact: "inner contact pads",
  side_thickness: "side thickness",
  asymmetry: "left-right asymmetry",
  button_detail: "button detail",
  back_bottom: "back/bottom structure",
  material_texture: "material texture",
}

const controlClass =
  "h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40"

const fieldClass =
  "w-full resize-y rounded-md border border-white/10 bg-black/35 px-3 py-2 text-[12px] leading-relaxed text-white outline-none transition placeholder:text-white/28 focus:border-cyan-300/60"

const emptyScene = (): StoryboardScene => ({
  duration: 5,
  skg_copy_en: "",
  skg_copy_zh: "",
  scene_one_line_en: "",
  scene_one_line_zh: "",
  action_one_line_en: "",
  action_one_line_zh: "",
  selected_video_id: "",
  subject: "",
  product: "",
  scene: "",
  action: "",
  reference_ids: [],
})

function statusTone(job: Job | null) {
  if (!job) return { label: "等待素材", className: "border-white/10 text-white/50 bg-white/[0.03]" }
  if (job.status === "failed") return { label: "失败", className: "border-rose-400/30 text-rose-200 bg-rose-500/10" }
  if (["created", "downloading", "splitting", "transcribing"].includes(job.status)) {
    return { label: "处理中", className: "border-[#d6b36a]/34 text-[#f5d98e] bg-[#d6b36a]/10" }
  }
  return { label: "可编辑", className: "border-emerald-300/30 text-emerald-100 bg-emerald-400/10" }
}

function shortId(id?: string | null) {
  return id ? id.slice(0, 8) : "-"
}

function containsCjk(text: string) {
  return /[\u3400-\u9fff]/.test(text)
}

type CompactStoryboardFieldKind = "copy" | "scene" | "action"

const STORYBOARD_VIDEO_COUNT_OPTIONS = [1, 2, 4, 6, 8, 12]

function storyboardFieldLabel(kind: CompactStoryboardFieldKind) {
  if (kind === "copy") return "文案"
  if (kind === "scene") return "场景一句话"
  return "人物 + 产品 + 动作"
}

function clampVideoCount(value: number) {
  return Math.round(clampNumber(Number.isFinite(value) ? value : 4, 1, 12))
}

async function ensureEnglishForModel(text: string) {
  const trimmed = text.trim()
  if (!trimmed || !containsCjk(trimmed)) return trimmed
  try {
    return await translateText(trimmed, "en")
  } catch {
    return trimmed
  }
}

function subjectProfileOption(category: SubjectProfileCategory, value: string) {
  return category.options.find((option) => option.value === value) ?? category.options[0]
}

function randomSubjectProfileDraft(): SubjectProfileDraft {
  return SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => {
    const concrete = category.options.filter((option) => option.value !== "random")
    const picked = concrete[Math.floor(Math.random() * concrete.length)] ?? category.options[0]
    acc[category.key] = picked.value
    return acc
  }, {} as SubjectProfileDraft)
}

function resolveSubjectProfile(
  mode: SubjectProfileMode,
  draft: SubjectProfileDraft,
  options: { randomizeRandomValues?: boolean } = {},
): ResolvedSubjectProfile {
  const values = { ...DEFAULT_SUBJECT_PROFILE_DRAFT }
  const labelParts: string[] = []
  const promptParts: string[] = []
  const promptLabelByKey: Record<SubjectProfileFieldKey, string> = {
    gender: "gender presentation",
    age: "age range",
    wardrobe: "wardrobe style",
    region_ethnicity: "regional or ethnic appearance cues",
    skin_tone: "skin tone",
    body: "body proportion",
    hair: "hair style",
    mood: "commercial mood",
  }
  for (const category of SUBJECT_PROFILE_CATEGORIES) {
    const rawValue = draft[category.key] || "random"
    let option = subjectProfileOption(category, rawValue)
    if (option.value === "random" && options.randomizeRandomValues) {
      const concrete = category.options.filter((item) => item.value !== "random")
      option = concrete[Math.floor(Math.random() * concrete.length)] ?? option
    }
    values[category.key] = option.value
    labelParts.push(`${category.label}：${option.label}`)
    promptParts.push(`${promptLabelByKey[category.key]}: ${option.prompt}`)
  }
  const summary = labelParts.join(" / ")
  const promptSummary = promptParts.join("; ")
  return {
    mode,
    values,
    summary,
    promptSummary,
    payload: {
      mode,
      gender: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[0], values.gender).prompt,
      age: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[1], values.age).prompt,
      wardrobe: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[2], values.wardrobe).prompt,
      region_ethnicity: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[3], values.region_ethnicity).prompt,
      skin_tone: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[4], values.skin_tone).prompt,
      body: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[5], values.body).prompt,
      hair: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[6], values.hair).prompt,
      mood: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[7], values.mood).prompt,
      resolved_summary: summary,
      prompt_summary: promptSummary,
    },
  }
}

function emptySubjectPromptMemory(): Record<SubjectReconstructionMode, string[]> {
  return { realistic: [], cartoon: [], elements: [], custom: [] }
}

function subjectScopedStorageKey(baseKey: string, jobId: string) {
  return `${baseKey}:${jobId}`
}

function loadSubjectPromptMemory(jobId: string): Record<SubjectReconstructionMode, string[]> {
  if (typeof window === "undefined") return emptySubjectPromptMemory()
  try {
    const parsed = JSON.parse(window.localStorage.getItem(subjectScopedStorageKey(SUBJECT_PROMPT_MEMORY_KEY, jobId)) || "{}") as Partial<Record<SubjectReconstructionMode, string[]>>
    const next = emptySubjectPromptMemory()
    for (const mode of Object.keys(next) as SubjectReconstructionMode[]) {
      next[mode] = Array.isArray(parsed[mode]) ? parsed[mode]!.filter(Boolean).slice(0, SUBJECT_PROMPT_MEMORY_LIMIT) : []
    }
    return next
  } catch {
    return emptySubjectPromptMemory()
  }
}

function saveSubjectPromptMemory(jobId: string, memory: Record<SubjectReconstructionMode, string[]>) {
  if (typeof window === "undefined") return
  try {
    window.localStorage.setItem(subjectScopedStorageKey(SUBJECT_PROMPT_MEMORY_KEY, jobId), JSON.stringify(memory))
  } catch {
    /* localStorage may be unavailable */
  }
}

function subjectPromptChipsFromText(text: string): string[] {
  const normalized = text.replace(/[，。；;、\n]/g, ",").replace(/\s+/g, " ").trim()
  const rawParts = normalized.split(",").map((item) => item.trim()).filter(Boolean)
  const chips: string[] = []
  const add = (value: string) => {
    const clean = value.replace(/^需要|^保持|^统一|^加上|^加入|^改成|^不要变/g, "").trim()
    if (clean.length < 2 || clean.length > 22) return
    if (!chips.includes(clean)) chips.push(clean)
  }
  for (const part of rawParts) {
    add(part)
    const matches = part.match(/(不要[^,，。；;、]{1,12}|同一套?[^,，。；;、]{1,10}|统一[^,，。；;、]{1,10}|白色[^,，。；;、]{1,10}|黑色[^,，。；;、]{1,10}|运动[^,，。；;、]{1,10}|亚洲|欧美|女性|男性|年轻|中年|短发|长发|马尾|背心|T恤|瑜伽服|运动装|商业广告感|高级感|科技感|可爱|极简)/g)
    matches?.forEach(add)
  }
  return chips.slice(0, 14)
}

function mergeSubjectPromptMemory(current: string[], text: string) {
  const chips = subjectPromptChipsFromText(text)
  return [...chips, ...current.filter((item) => !chips.includes(item))].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT)
}

function formatSeconds(raw?: number) {
  if (!raw || Number.isNaN(raw)) return "0.0s"
  return `${raw.toFixed(1)}s`
}

function clampNumber(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value))
}

async function decodeAudioFeatures(url: string, targetFrames = 640): Promise<AudioFeature[]> {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`audio ${res.status}`)
  const arrayBuffer = await res.arrayBuffer()
  const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
  if (!AudioContextClass) throw new Error("AudioContext unavailable")
  const ctx = new AudioContextClass()
  try {
    const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0))
    const data = buffer.getChannelData(0)
    const bucket = Math.max(1, Math.floor(data.length / targetFrames))
    let maxLoudness = 0.01
    const raw: Array<{ loudness: number }> = []
    for (let i = 0; i < targetFrames; i++) {
      const start = i * bucket
      const end = Math.min(data.length, start + bucket)
      let peak = 0
      let sumSq = 0
      for (let j = start; j < end; j++) {
        const sample = data[j] || 0
        const abs = Math.abs(sample)
        peak = Math.max(peak, abs)
        sumSq += sample * sample
      }
      const size = Math.max(end - start, 1)
      const rms = Math.sqrt(sumSq / size)
      const loudness = Math.max(rms, peak * 0.18)
      raw.push({ loudness })
      maxLoudness = Math.max(maxLoudness, loudness)
    }
    const sorted = raw.map((item) => item.loudness).sort((a, b) => a - b)
    const floor = sorted[Math.floor(sorted.length * 0.12)] ?? 0
    const ceiling = sorted[Math.floor(sorted.length * 0.985)] ?? maxLoudness
    const range = Math.max(ceiling - floor, maxLoudness * 0.08, 0.001)
    return raw.map((item) => ({
      loudness: clampNumber(Math.pow(clampNumber((item.loudness - floor) / range, 0, 1), 1.18), 0.015, 1),
    }))
  } finally {
    void ctx.close().catch(() => {})
  }
}

function waitForMediaEvent(target: HTMLMediaElement, eventName: string, timeoutMs = 12000) {
  return new Promise<void>((resolve, reject) => {
    const timer = window.setTimeout(() => {
      cleanup()
      reject(new Error(`${eventName} timeout`))
    }, timeoutMs)
    const cleanup = () => {
      window.clearTimeout(timer)
      target.removeEventListener(eventName, onReady)
      target.removeEventListener("error", onError)
    }
    const onReady = () => {
      cleanup()
      resolve()
    }
    const onError = () => {
      cleanup()
      reject(new Error("video load failed"))
    }
    target.addEventListener(eventName, onReady, { once: true })
    target.addEventListener("error", onError, { once: true })
  })
}

function waitForVideoSeek(video: HTMLVideoElement, time: number) {
  return new Promise<void>((resolve, reject) => {
    const timer = window.setTimeout(() => {
      cleanup()
      reject(new Error("seek timeout"))
    }, 8000)
    const cleanup = () => {
      window.clearTimeout(timer)
      video.removeEventListener("seeked", onSeeked)
      video.removeEventListener("error", onError)
    }
    const onSeeked = () => {
      cleanup()
      resolve()
    }
    const onError = () => {
      cleanup()
      reject(new Error("video seek failed"))
    }
    video.addEventListener("seeked", onSeeked, { once: true })
    video.addEventListener("error", onError, { once: true })
    video.currentTime = time
  })
}

async function captureVideoFilmstrip(
  url: string,
  duration: number,
  step: FilmstripDensitySeconds,
  shouldCancel: () => boolean,
): Promise<FilmstripPreviewFrame[]> {
  if (!url || duration <= 0) return []
  const video = document.createElement("video")
  video.muted = true
  video.playsInline = true
  video.preload = "auto"
  video.src = url
  video.load()
  if (video.readyState < 1) await waitForMediaEvent(video, "loadedmetadata")
  const sourceDuration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : duration
  const usableDuration = Math.max(Math.min(duration || sourceDuration, sourceDuration), 0.1)
  const times: number[] = []
  for (let time = 0; time < usableDuration; time += step) {
    times.push(clampNumber(time + 0.08, 0, Math.max(usableDuration - 0.05, 0)))
  }
  if (!times.length) times.push(0.05)

  const canvas = document.createElement("canvas")
  canvas.width = 96
  canvas.height = 170
  const ctx = canvas.getContext("2d")
  if (!ctx) throw new Error("canvas unavailable")
  const frames: FilmstripPreviewFrame[] = []
  for (const time of times) {
    if (shouldCancel()) break
    await waitForVideoSeek(video, time)
    if (shouldCancel()) break
    ctx.fillStyle = "#050505"
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    const vw = video.videoWidth || canvas.width
    const vh = video.videoHeight || canvas.height
    const scale = Math.min(canvas.width / vw, canvas.height / vh)
    const dw = vw * scale
    const dh = vh * scale
    ctx.drawImage(video, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh)
    frames.push({ time: Number(time.toFixed(2)), src: canvas.toDataURL("image/jpeg", 0.68) })
  }
  video.removeAttribute("src")
  video.load()
  return frames
}

function frameLabel(frame: KeyFrame, order: number) {
  return `S${String(order + 1).padStart(2, "0")} · ${frame.timestamp.toFixed(1)}s`
}

function videoPoster(job: Job, video: GeneratedVideo) {
  return apiAssetUrl(video.poster_url) || (job.frames[0] ? effectiveFrameUrl(job.id, job.frames[0]) : "")
}

function videoSrc(video: GeneratedVideo) {
  return apiAssetUrl(video.url)
}

function downloadMedia(url: string, filename: string) {
  if (!url || typeof document === "undefined") return
  const link = document.createElement("a")
  link.href = url
  link.download = filename
  link.rel = "noreferrer"
  document.body.appendChild(link)
  link.click()
  link.remove()
}

function audioPreview(job: Job | null) {
  if (!job) return "粘贴 TK 链接或上传视频后，系统会先下载视频；下载完成后自动提取音频文案。"
  const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
  if (source) return source
  if (job.transcript?.length) return job.transcript.slice(0, 5).map((item) => item.en || item.zh).join(" ")
  return "暂无音频文案。下载完成后会自动提取原音频文案、讲话人和背景音。"
}

function orderedFrames(job: Job | null, selectedFrames: KeyFrame[]) {
  if (!job) return []
  if (selectedFrames.length > 0) return selectedFrames
  return [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
}

function countReadySegments(job: Job | null, drafts: DraftSegment[]) {
  const frameStoryboards = job?.frames.filter((frame) => !!frame.storyboard).length ?? 0
  const draftCount = drafts.length
  return frameStoryboards + draftCount
}

function countSubjectAssetViews(job: Job | null) {
  if (!job) return 0
  return job.frames.reduce((sum, frame) =>
    sum + (frame.elements ?? []).reduce((inner, element) => inner + (element.subject_assets?.length ?? 0), 0),
  0)
}

function countEndpointFramePairs(job: Job | null) {
  if (!job) return 0
  return job.frames.filter((frame) => endpointAssetRef(frame, "first_frame") && endpointAssetRef(frame, "last_frame")).length
}

function stepStatus({ ready, running, blocked, paused }: { ready?: boolean; running?: boolean; blocked?: boolean; paused?: boolean }): WorkflowStepStatus {
  if (running) return "running"
  if (ready) return "ready"
  if (paused) return "paused"
  if (blocked) return "blocked"
  return "pending"
}

function buildWorkflowSteps({
  job,
  submitting,
  audioReady,
  audioRunning,
  transcriptCount,
  visualReady,
  visualRunning,
  subjectAssetCount,
  productAssetCount,
  endpointFramePairCount,
  generatedVideoCount,
}: {
  job: Job | null
  submitting: boolean
  audioReady: boolean
  audioRunning: boolean
  transcriptCount: number
  visualReady: boolean
  visualRunning: boolean
  subjectAssetCount: number
  productAssetCount: number
  endpointFramePairCount: number
  generatedVideoCount: number
}): WorkflowStep[] {
  const hasSourceVideo = !!job?.video_url
  const downloading = !!job && ["created", "downloading"].includes(job.status)
  const storyboardReady = transcriptCount > 0
  const endpointTargetCount = Math.max(transcriptCount, 0)
  return [
    {
      id: "input",
      no: "01",
      title: "素材输入",
      detail: job ? `当前 ${shortId(job.id)}` : "待链接/上传",
      judge: "有当前素材任务即通过；输入框只负责创建或切换任务。",
      status: stepStatus({ ready: !!job, running: submitting }),
    },
    {
      id: "source",
      no: "02",
      title: "源视频下载",
      detail: hasSourceVideo ? "源视频已就绪" : downloading ? "下载中" : "待下载",
      judge: "job.video_url 存在即通过；created/downloading 视为运行中。",
      status: stepStatus({ ready: hasSourceVideo, running: downloading, blocked: !job }),
    },
    {
      id: "audio",
      no: "03",
      title: "音频文案",
      detail: audioReady ? `${transcriptCount} 段字幕` : "待转写/分析",
      judge: "audio_script.source_text 有内容，或 transcript 逐句时间轴有内容即通过。",
      status: stepStatus({ ready: audioReady, running: audioRunning, blocked: !hasSourceVideo }),
    },
    {
      id: "visual",
      no: "04",
      title: "抽帧参考",
      detail: visualReady ? `${job?.frames.length ?? 0} 张参考帧` : "待抽帧",
      judge: "job.frames.length 大于 0 即通过；这些帧只做主体重构证据。",
      status: stepStatus({ ready: visualReady, running: visualRunning, blocked: !hasSourceVideo }),
    },
    {
      id: "subject",
      no: "05",
      title: "相似主体",
      detail: subjectAssetCount ? `${subjectAssetCount} 张白底视图` : "待生成主体",
      judge: "关键帧里存在 subject_assets 即通过；生成的是类似创新主体，不复刻原人。",
      status: stepStatus({ ready: subjectAssetCount > 0, blocked: !visualReady }),
    },
    {
      id: "product",
      no: "06",
      title: "产品素材池",
      detail: productAssetCount ? `${productAssetCount} 张产品图` : "待上传/识别",
      judge: "product_refs 有记录即通过；不限量，但每条视频后续最多挑 6 张相关图。",
      status: stepStatus({ ready: productAssetCount > 0, blocked: !job }),
    },
    {
      id: "script",
      no: "07",
      title: "分镜文案",
      detail: storyboardReady ? `${transcriptCount} 条分镜` : "待音频时间轴",
      judge: "逐句时间轴生成后进入分镜；新口播可按单段或整片改写。",
      status: stepStatus({ ready: storyboardReady, running: audioRunning, blocked: !audioReady }),
    },
    {
      id: "scene",
      no: "08",
      title: "三字段规划",
      detail: storyboardReady ? `${transcriptCount} 条紧凑分镜` : "待分镜",
      judge: "客户默认只看文案、场景一句话、人物+产品+动作；首尾帧藏在高级模式和后端内部。",
      status: stepStatus({ ready: storyboardReady, blocked: !storyboardReady }),
    },
    {
      id: "video",
      no: "09",
      title: "视频候选",
      detail: generatedVideoCount ? `${generatedVideoCount} 条候选` : "可生成 4 条",
      judge: "单条默认生成 4 条视频候选；整片一键批量生成后台提交，失败行可单独重试。",
      status: generatedVideoCount > 0 ? "ready" : stepStatus({ ready: false, blocked: !storyboardReady }),
    },
  ]
}

function workflowStepMap(steps: WorkflowStep[]) {
  return steps.reduce((acc, step) => {
    acc[step.id] = step
    return acc
  }, {} as Record<WorkflowStepId, WorkflowStep>)
}

function guessSubjectKind(name: string): SubjectKind {
  return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name)
    ? "living"
    : "object"
}

function closestFrameForTime(frames: KeyFrame[], time: number) {
  if (!frames.length) return null
  const first = frames[0] as KeyFrame
  return frames.reduce((best, frame) =>
    Math.abs(frame.timestamp - time) < Math.abs(best.timestamp - time) ? frame : best,
  first)
}

function isSimilarActorElement(element: KeyElement) {
  const zh = element.name_zh || ""
  const en = (element.name_en || "").toLowerCase()
  const combined = `${zh} ${en}`.toLowerCase()
  const zhSimilarSubject = zh.includes("相似") && (zh.includes("主体") || zh.includes("主角") || zh.includes("人物"))
  const zhReconstructionSubject = zh.includes("重构") && (zh.includes("主体") || zh.includes("主角") || zh.includes("人物"))
  const enSimilarSubject = en.includes("similar") && (en.includes("subject") || en.includes("actor") || en.includes("humanoid") || en.includes("character"))
  const enReconstructionSubject = en.includes("reconstruction") && (en.includes("subject") || en.includes("actor") || en.includes("character"))
  return (
    zhSimilarSubject
    || zhReconstructionSubject
    || enSimilarSubject
    || enReconstructionSubject
    || combined.includes("相似主角")
    || combined.includes("相似主体")
    || combined.includes("重构主体")
    || combined.includes("reconstructed subject")
    || combined.includes("similar ad actor")
    || combined.includes("similar actor")
    || combined.includes("similar subject")
    || combined.includes("transparent skeleton humanoid subject")
  )
}

function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame[]) {
  const pools = [preferredFrames, allFrames]
  const seen = new Set<number>()
  for (const needsAssets of [true, false]) {
    for (const pool of pools) {
      const frames = [...pool].filter((frame) => {
        if (seen.has(frame.index)) return false
        seen.add(frame.index)
        return true
      }).reverse()
      for (const frame of frames) {
        const elements = [...(frame.elements || [])].reverse()
        const element = elements.find((item) => isSimilarActorElement(item) && (!needsAssets || !!item.subject_assets?.length))
        if (element) return { frame, element }
      }
    }
    seen.clear()
  }
  return null
}

type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null

function reconstructionModeConfig(mode: SubjectReconstructionMode) {
  return RECONSTRUCTION_MODES.find((item) => item.value === mode) ?? RECONSTRUCTION_MODES[0]
}

function subjectModelBundleConfig(bundle: SubjectModelBundle) {
  return SUBJECT_MODEL_BUNDLE_OPTIONS.find((item) => item.value === bundle) ?? SUBJECT_MODEL_BUNDLE_OPTIONS[0]
}

function subjectImageModelFromBundle(bundle: SubjectModelBundle): SubjectImageModelPreference {
  return bundle === "gemini" ? "gemini-3-pro-image-preview" : "gpt-image-2"
}

function subjectViewsForQuantity(quantity: number) {
  const count = Math.max(1, Math.min(10, Math.round(quantity || 6)))
  const views = [
    "front",
    "three_quarter_left",
    "left",
    "back",
    "right",
    "three_quarter_right",
    "bust_front",
    "bust_left_45",
    "bust_right_45",
    "back_neck_detail",
  ]
  if (count <= 4) return ["front", "three_quarter_left", "back", "three_quarter_right"].slice(0, count)
  return views.slice(0, count)
}

function cartoonStyleConfig(style: CartoonReconstructionStyle) {
  return CARTOON_RECONSTRUCTION_STYLES.find((item) => item.value === style) ?? CARTOON_RECONSTRUCTION_STYLES[0]
}

function reconstructionModeFromElement(element: KeyElement): SubjectReconstructionMode | null {
  const text = `${element.name_zh || ""} ${element.name_en || ""}`.toLowerCase()
  if (text.includes("真人重构") || text.includes("realistic reconstruction")) return "realistic"
  if (text.includes("卡通重构") || text.includes("cartoon reconstruction")) return "cartoon"
  if (text.includes("元素重构") || text.includes("element reconstruction")) return "elements"
  if (text.includes("自主描述") || text.includes("custom description")) return "custom"
  return null
}

function reconstructionElementName(mode: SubjectReconstructionMode) {
  const config = reconstructionModeConfig(mode)
  return {
    zh: `${config.label}主体`,
    en: `${mode} reconstruction subject`,
  }
}

function reconstructionSubjectStyle(mode: SubjectReconstructionMode): SubjectStyleMode {
  return mode === "cartoon" ? "cartoon_subject" : "source_actor"
}

function buildReconstructionDirection(
  mode: SubjectReconstructionMode,
  direction: string,
  cartoonStyle: CartoonReconstructionStyle,
  viewCount = RECONSTRUCTION_SUBJECT_VIEW_VALUES.length,
) {
  const trimmed = direction.trim()
  const style = cartoonStyleConfig(cartoonStyle)
  const common = [
    "Legal-safe reference reconstruction: use selected reference frames only as non-identifying creative evidence.",
    "Do not copy the original person, face, biometric identity, unique likeness, watermark, platform UI, captions, exact outfit, exact background, exact composition, or source pixels.",
    `Generate exactly ${viewCount} separate views of one newly designed subject.`,
    "Keep the neck, collarbone, shoulders, upper back, and side neck clean and usable for SKG neck-and-shoulder product placement.",
  ]
  if (mode === "realistic") {
    common.push(
      "Direction mode: realistic human reconstruction.",
      "Create a new believable commercial ad actor inspired by broad non-identifying traits from the references: role, body-proportion category, gesture vocabulary, wardrobe category, health-ad energy, and camera readability.",
      "Change the exact identity and personal features clearly enough that this is a new actor, not the source person.",
    )
  } else if (mode === "cartoon") {
    common.push(
      "Direction mode: cartoon reconstruction.",
      trimmed
        ? `Cartoon style: follow the user's requested style from the direction text; if no explicit cartoon style is specified, use ${style.label}; ${style.prompt}.`
        : `Cartoon style: ${style.label}; ${style.prompt}.`,
      "Transform broad pose, emotion, body-readability, and ad energy into a fully original stylized character, not a realistic human and not a traced version of the source.",
    )
  } else if (mode === "elements") {
    common.push(
      "Direction mode: element reconstruction.",
      "Extract only abstract visual logic: pose grammar, silhouette category, color-block relationship, camera angle, motion feeling, and wellness-ad atmosphere.",
      "Create a clearly different new subject with different identity, wardrobe details, face, styling, and visual design while keeping the useful advertising logic.",
    )
  } else {
    common.push(
      "Direction mode: autonomous description.",
      "Use the user's written description as the primary subject bible. Reference frames are optional secondary mood evidence only; if they conflict with the text, follow the text.",
      "Create a fully original subject from the description without depending on source identity.",
    )
  }
  if (trimmed) common.push(`User written direction to understand and apply: ${trimmed}`)
  return common.join(" ")
}

function buildSimilarSubjectPrompt(
  subjectStyle: SubjectStyleMode,
  direction: string,
  selectedTemplate?: SubjectTemplatePromptSource,
  subjectProfile?: ResolvedSubjectProfile | null,
) {
  const base = [
    "Create a new similar but non-identical information-feed ad subject from the selected reference frames.",
    "Treat all selected frames as evidence for ONE same subject, not multiple different subjects.",
    "Default casting rule: inherit the reference frames' broad gender presentation, regional/ethnic appearance category, skin-tone family, body-proportion category, and role energy unless the user explicitly overrides them.",
    "Lock one consistent character bible before generating: same newly designed person or character, same gender presentation, age range, body proportions, face design, hair design, skin tone, material, silhouette, commercial style, and visual identity across the full multi-view set.",
    "Lock one wardrobe bible before generating: same garment type, same color palette, same neckline, same sleeve or strap structure, same fabric/material, same fit, and same visible accessories across every view.",
    "If the user direction asks to change gender, age, or style, apply that single change uniformly to every view; never mix male/female, young/old, or multiple style identities inside one set.",
    "Never change outfit between views. Do not switch clothing category from front to side to back.",
    "Keep the pose vocabulary, camera-readability, creator-ad energy, and commercial clarity, but do not copy the exact source identity, face, watermark, captions, platform UI, or pixels.",
    "This is for SKG neck-and-shoulder wearable massage device videos: keep neck, collarbone, shoulders, side neck, upper back, shoulder blades, and product placement area clean and visible.",
    "Output high-definition assets suitable for downstream video generation.",
  ]
  if (selectedTemplate) {
    base.push(
      `Creative subject template selected: ${selectedTemplate.name} (${selectedTemplate.sourceLabel}).`,
      "Use the template images as planned creative direction only; generate an innovative variation, not a duplicate of that subject pack.",
    )
  }
  if (subjectProfile?.promptSummary) {
    base.push(
      `Locked subject casting and styling profile for this request: ${subjectProfile.promptSummary}.`,
      "Apply this one profile uniformly to every generated view; do not randomize gender, age, region, skin tone, body type, hair, wardrobe, or mood differently between views.",
    )
  }
  if (subjectStyle === "transparent_human") {
    base.push(
      "The subject must be a transparent humanoid: transparent or translucent skin/body shell wrapping a clean visible white skeleton inside the same body.",
      "Keep transparent skin, visible spine, rib cage, pelvis, arm bones, leg bones, and a friendly non-horror wellness advertising look consistent in every view.",
      "Do not generate a normal opaque human, skeleton-only character, medical anatomy, organs, blood, gore, surgery, hospital, or horror imagery.",
    )
  } else if (subjectStyle === "cartoon_subject") {
    base.push(
      "The subject must be an original stylized cartoon or illustrative character, not a photorealistic person and not a transparent skeleton character.",
      "Keep the same stylized character identity, proportions, palette, material language, and commercial wellness-ad personality consistent in every view.",
    )
  } else {
    base.push(
      "The subject must be a normal believable commercial ad actor, not a transparent or skeleton character.",
      "Keep wardrobe category, age range, gender presentation, body proportion, and creator-ad styling consistent in every view.",
    )
  }
  const trimmed = direction.trim()
  if (trimmed) base.push(`User unified subject direction: ${trimmed}`)
  base.push("Output separate pure white background multi-view assets; each image is one view of the same unified subject.")
  return base.join(" ")
}

function buildSourceLockedSubjectPrompt(subjectStyle: SubjectStyleMode) {
  const base = [
    "Source-locked subject replication from the selected reference frames.",
    "Use the attached reference frame(s) as the primary visual source for the same visible subject: preserve gender presentation, regional/ethnic appearance category, skin-tone family, body proportions, hair length/color/silhouette, face structure impression, wardrobe category, outfit colors, fit, and commercial role as closely as the model allows.",
    "Generate separate clean white-background multi-view assets of that same source subject, removing only source video background, platform UI, captions, watermarks, compression artifacts, and accidental occlusions.",
    "Do not invent a different actor, different ethnicity, different gender, different body type, different hair design, or different outfit when the reference evidence is visible.",
    "If multiple frames are supplied, treat them as evidence for one same subject and build one locked subject bible before rendering every view.",
    "Keep the neck, collarbone, shoulders, upper back, and side neck clean and usable for SKG neck-and-shoulder product placement.",
  ]
  if (subjectStyle === "cartoon_subject") {
    base.push("If a cartoon style is requested, convert the same visible source subject into one consistent stylized character while preserving the reference's main appearance and outfit cues.")
  } else {
    base.push("The subject must remain a believable normal commercial ad actor, not a transparent or skeleton character.")
  }
  base.push("Output high-definition assets; each image is one requested view of the same unified subject.")
  return base.join(" ")
}

function subjectAssetUrl(job: Job, asset: SubjectAsset) {
  if (!asset.url && asset.status && asset.status !== "completed") return ""
  return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}

function subjectAssetStatus(asset: SubjectAsset) {
  return asset.status ?? (asset.url ? "completed" : "completed")
}

function subjectAssetIsRunning(asset: SubjectAsset) {
  const status = subjectAssetStatus(asset)
  return status === "queued" || status === "in_progress"
}

function subjectAssetStatusLabel(asset: SubjectAsset) {
  const status = subjectAssetStatus(asset)
  if (status === "queued") return "排队中"
  if (status === "in_progress") return `生成中 ${asset.progress ?? 0}%`
  if (status === "failed") return "失败"
  return asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined
}

function subjectAssetPackKey(frame: KeyFrame, element: KeyElement, asset: SubjectAsset) {
  return `${frame.index}:${element.id}:${asset.pack_id || `legacy-${element.id}`}`
}

function subjectAssetPackSortAssets(assets: SubjectAsset[]) {
  return [...assets].sort((a, b) => {
    const ai = SUBJECT_VIEW_ORDER.indexOf(a.view)
    const bi = SUBJECT_VIEW_ORDER.indexOf(b.view)
    if (ai !== bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
    return (a.created_at || 0) - (b.created_at || 0)
  })
}

function subjectAssetPackSummary(pack: SubjectAssetPack) {
  if (pack.running) return `${pack.completed}/${pack.total} 生成中`
  if (pack.failed) return `${pack.completed}/${pack.total} · 失败 ${pack.failed}`
  return `${pack.completed || pack.total} 张`
}

function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) {
  if (!character?.images?.length) return null
  return character.images.find((image) => image.id === character.primary_image)
    ?? character.images.find((image) => image.view === "front")
    ?? character.images[0]
}

function modelValue(value?: string) {
  return value?.trim() || "待配置"
}

function modelList(values: Array<string | undefined>) {
  return values.map(modelValue).filter((value, index, list) => value && list.indexOf(value) === index).join(" / ")
}

function imageModelChain(models?: RuntimeModels) {
  return modelList([models?.image || "gpt-image-2", ...(models?.image_fallbacks || [])])
}

function subjectImageModelChain(models?: RuntimeModels) {
  return modelList([models?.subject_image || "gpt-image-2", ...(models?.subject_image_fallbacks || [])])
}

function resolveVideoModelLabel(models: RuntimeModels | undefined, model: string) {
  const concrete = models?.video_aliases?.[model] || (model === models?.video ? models.video : "")
  return concrete && concrete !== model ? `${model} -> ${concrete}` : modelValue(concrete || model)
}

function audioModelTrace(models?: RuntimeModels): ModelTraceSpec {
  const remoteState = models?.asr_remote_enabled === false ? "已关闭" : "启用"
  const localState = models?.asr_local_fallback_enabled === false ? "关闭" : "启用"
  const localModel = models?.faster_whisper ? `faster-whisper ${models.faster_whisper}` : modelValue(models?.local_asr)
  return {
    title: "音频解析",
    model: modelList([models?.asr, models?.translate, models?.asr_fallback]),
    chain: [
      `ASR 转写：远端 ${remoteState}，模型 ${modelValue(models?.asr)}，语言 ${models?.asr_language || "auto"}；本机转写 ${localState}，使用 ${localModel} 自动识别中文/多语言；多模态兜底${models?.asr_audio_fallback_enabled === false ? "关闭" : `为 ${modelValue(models?.asr_fallback)}`}，并拒绝假字幕/重复时间轴`,
      `字幕翻译：${modelValue(models?.translate)} 按原语言 ASR 段落输出中文；原文已是中文时保留为中文镜像，失败时保留原文时间轴`,
      `讲话人 / 节奏 / 背景音：${modelValue(models?.asr_fallback)} 读取 audio.wav + 转写时间轴做多模态分析；失败时用本地时长/段落估算兜底`,
    ],
    note: "点击“解析音频”后触发；开始任务下载完成后也会自动走这条链路。",
  }
}

function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
  return {
    title: "产品视角识别 / 补图",
    model: modelList([models?.product_view, models?.image]),
    chain: [
      `批量视角识别：${modelValue(models?.product_view)} 多图读取同一产品素材，标注视角、佩戴者左右、上下、内外侧、用途和风险`,
      "识别兜底：批量失败会按单图重试；仍失败或文件缺失时写入本地默认视角，并在 risk/note 标明兜底原因",
      `缺角度补图：${imageModelChain(models)} 走 /images/edits，最多读取 6 张已上传参考图补齐缺失视角；只有 gpt-image-2 超时、限流或 5xx 上游异常时才自动兜底`,
      "前端只保存标注和 AI 补图结果；后续首尾帧/视频规划每条最多挑 6 张相关产品图",
    ],
    note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
  }
}

function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec {
  const typeLabel = subjectStyle === "transparent_human"
    ? "透明/半透明皮肤包裹可见白色骨架"
    : subjectStyle === "cartoon_subject"
      ? "原创卡通/插画/潮玩主体"
      : "普通商业广告真人"
  return {
    title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : subjectStyle === "cartoon_subject" ? "卡通重构主体" : "相似普通真人主体",
    model: modelList([models?.vision, models?.subject_image]),
    chain: [
      `视觉 brief：${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief；失败时继续用用户方向和模板文字`,
      `主体类型：${typeLabel}`,
      "主体设定：前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
      `图像生成：${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图；gpt-image-2 是主模型，超时、429 或 5xx 时短时熔断并兜底 Gemini；当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
      "身份锁定：整套图必须是同一个主体，性别表现、年龄段、体型、材质和风格保持一致",
    ],
    note: "这是生成类似但创新的主体，不是复制、抠出或复刻源视频人物身份；内置形象也只作为方向参考。",
  }
}

function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec {
  return {
    title: "新口播文案改写",
    model: modelList([models?.audio_rewrite, models?.asr_fallback, models?.translate]),
    chain: [
      `主改写：${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`,
      `模型回退：依次尝试 ${modelValue(models?.asr_fallback)} 和 ${modelValue(models?.translate)}；全部失败时用本地模板保留分镜可编辑`,
      "返回结果会先写入当前分镜文案编辑框；生成候选前会自动同步到分镜数据，无需单独保存视频",
    ],
  }
}

function videoModelTrace(models: RuntimeModels | undefined, model: string): ModelTraceSpec {
  return {
    title: "视频生成",
    model: resolveVideoModelLabel(models, model),
    chain: [
      `前端选择：${model}`,
      `后端解析：${resolveVideoModelLabel(models, model)}`,
      `服务商：${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
      "当前主工作台暂停直接提交视频；旧入口误触也会被页面层保护",
      "开放后输入会包含已确认首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
      "输出为异步候选视频，提交后立即写入当前任务，完成后自动回填 mp4 到对应分镜行；Sora 已停用",
    ],
  }
}

function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene {
  const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
  const duration = Math.max(3.5, Math.min(7.5, Math.max(job.duration || 0, frames.length * 5) / Math.max(frames.length, 1)))
  const audio = job.audio_script?.rewritten_text?.trim()
    || job.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ")
    || "Rewrite the original audio pacing into a new SKG product introduction."
  const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、")
  return {
    duration: Number(duration.toFixed(1)),
    first_image: null,
    last_image: null,
    subject: objects ? `Key element candidates: ${objects}` : "Keep the source video's most important subject motion and composition relationship.",
    scene: `${frame.description?.scene || `Plan SKG information-feed ad scene ${order + 1} from the audio segment.`}\nAudio pacing reference: ${audio.slice(0, 220)}`,
    product: "Convert the source product or pain-point context into SKG neck-and-shoulder massager expression. Use the uploaded SKG product angles as product truth.",
    action: frame.description?.style
      ? `Keep the source speaking rhythm, action beats, and ${frame.description.style}; show tension before use and relaxed comfort after use.`
      : "Keep the source speaking rhythm and action beats; show tension before use and relaxed comfort after use.",
    reference_ids: [],
  }
}

function classifyAudioRole(text: string, index: number, total: number): AudioStoryboardRole {
  const lower = text.toLowerCase()
  if (index === 0) return "hook"
  if (index >= total - 2 || /discount|code|shipping|link|limited|sold out|grab|recommend|tiktok/.test(lower)) return "cta"
  if (/can't|dont|don't|if |when |tired|stress|pain|crave|bloated|puffy|ready/.test(lower)) return "pain"
  if (/help|can |reduce|improve|relax|lower|stabilize|clear|less/.test(lower)) return "proof"
  if (/use|try|apple|product|bottle|one month/.test(lower)) return "solution"
  return "bridge"
}

function buildSkgCopy(role: AudioStoryboardRole, index: number) {
  const variants: Record<AudioStoryboardRole, string[]> = {
    hook: [
      "If you spend hours looking down at your phone or working at a desk, your neck and shoulders may already be carrying that tension.",
      "A few hours on screens can make your neck and shoulders feel tired faster than you expect.",
    ],
    pain: [
      "That tight neck, heavy shoulder feeling, and uncomfortable head lift are signs you should not wait to deal with it.",
      "Commuting, desk work, parenting, and phone scrolling can keep your neck and shoulders tense all day.",
    ],
    proof: [
      "The SKG neck-and-shoulder massager sits around the back of your neck and shoulders, bringing warmth and kneading-like comfort right where you feel tight.",
      "Wear it hands-free between work, at home, or before bed to settle into a calmer relaxation rhythm.",
    ],
    solution: [
      "This beat turns the source explanation into a clear SKG routine: pick it up, wear it, adjust the fit, and relax.",
      "Let the product enter naturally, and show the change from neck tension to a more relaxed state.",
    ],
    cta: [
      "If you want neck-and-shoulder relaxation to become part of your daily routine, this SKG massager is an easy place to start.",
      "Close with a clear product detail and a relaxed expression so viewers know exactly what to try next.",
    ],
    bridge: [
      "Keep the source video's short, fast rhythm, but anchor each line in a specific neck-and-shoulder moment or product action.",
      "Use this line as a bridge from the pain point into the SKG routine without slowing the pace.",
    ],
  }
  const list = variants[role] ?? variants.bridge
  return list[index % list.length]
}

function buildSkgCopyZh(role: AudioStoryboardRole, index: number) {
  const variants: Record<AudioStoryboardRole, string[]> = {
    hook: [
      "如果你也经常低头刷手机、久坐办公，肩颈紧绷可能已经在悄悄影响状态。",
      "每天盯屏几个小时，脖子和肩膀的疲惫会比你想得更早出现。",
    ],
    pain: [
      "脖子发紧、肩膀沉、抬头不舒服，不一定要等到很难受才处理。",
      "通勤、办公、带娃、刷手机叠在一起，肩颈很容易一直处在紧绷状态。",
    ],
    proof: [
      "SKG 颈部按摩仪贴合后颈和肩颈两侧，把热敷感和揉按感带到真正紧的位置。",
      "戴上后不用占手，工作间隙、居家放松、睡前都能快速进入舒缓节奏。",
    ],
    solution: [
      "这一镜把原片的讲解节奏换成 SKG 使用步骤：拿起、佩戴、贴合、放松。",
      "让产品自然进入画面，重点不是硬推，而是把肩颈紧绷到放松的变化拍清楚。",
    ],
    cta: [
      "如果你也想把肩颈放松变成日常习惯，可以先从这台 SKG 开始。",
      "最后用清晰产品特写和轻松状态收住，让用户知道现在就可以入手。",
    ],
    bridge: [
      "延续原片短句快节奏，把每一句都落到一个具体肩颈场景或产品动作。",
      "这一句作为过渡，画面从痛点切到产品，让节奏继续往下走。",
    ],
  }
  const list = variants[role] ?? variants.bridge
  return list[index % list.length]
}

function buildVisualPlan(role: AudioStoryboardRole) {
  if (role === "hook") return "Vertical close-up creator opening. The subject gently rubs the neck or rotates the shoulders to establish fatigue immediately."
  if (role === "pain") return "Keep the source expression, gesture rhythm, and fast pacing while emphasizing phone posture, desk sitting, and neck-and-shoulder tension."
  if (role === "proof") return "Bring the product into frame and place it around the back of the neck, then cut to fit, button, warmth, and kneading-comfort details."
  if (role === "cta") return "End with a clean product detail plus a relaxed expression, keeping the quick action feeling of a feed ad."
  return "Keep the source-style composition and camera movement, but replace the content with an SKG neck-and-shoulder relaxation scene."
}

function buildVisualPlanZh(role: AudioStoryboardRole) {
  if (role === "hook") return "竖屏近景口播开场，人物轻揉脖子或转动肩颈，直接建立疲惫感。"
  if (role === "pain") return "沿用原视频的表情、手势和节奏，画面强调低头、久坐、肩颈紧绷。"
  if (role === "proof") return "产品进入画面并佩戴到后颈，切到肩颈贴合、按键、热敷/揉按感的细节。"
  if (role === "cta") return "产品清晰特写 + 人物放松表情收尾，保留信息流广告的快速行动感。"
  return "保持原片同类构图和运镜，把画面内容替换成 SKG 肩颈放松场景。"
}

function visualModeDefaults(mode: StoryboardVisualMode, language: "en" | "zh" = "en") {
  if (mode === "person_only") {
    return {
      needsProduct: false,
      needsSubject: true,
      productPlacement: language === "zh"
        ? "本条不出现产品，只用人物状态、痛点或口播承接节奏；不要硬插 SKG 产品。"
        : "Do not show the product in this beat. Use the subject's state, pain point, or voice-over performance to carry the rhythm; do not force in the SKG product.",
    }
  }
  if (mode === "product_only") {
    return {
      needsProduct: true,
      needsSubject: false,
      productPlacement: language === "zh"
        ? "只展示 SKG 肩颈按摩仪本体、佩戴角度或功能细节；不要强行加入人物。"
        : "Show only the SKG neck-and-shoulder massager, wearing angle, or functional detail; do not force a main character into this beat.",
    }
  }
  if (mode === "environment") {
    return {
      needsProduct: false,
      needsSubject: false,
      productPlacement: language === "zh"
        ? "本条作为场景/情绪/节奏过渡，不出现产品和人物主体；只保留空间、光线和运动节奏。"
        : "Use this beat as a scene, mood, or pacing transition. Do not show the product or main subject; keep only space, light, and motion rhythm.",
    }
  }
  return {
    needsProduct: true,
    needsSubject: true,
    productPlacement: language === "zh"
      ? "SKG 肩颈按摩仪作为外置佩戴产品出现，围绕拿起、佩戴、调整、按键或放松状态展开。"
      : "Show the SKG neck-and-shoulder massager as an external wearable product, built around picking it up, wearing it, adjusting it, pressing controls, or relaxing with it.",
  }
}

function visualModeForRole(role: AudioStoryboardRole): StoryboardVisualMode {
  if (role === "hook" || role === "pain") return "person_only"
  if (role === "cta") return "product_only"
  if (role === "bridge") return "environment"
  return "person_product"
}

function buildFirstFramePlan(role: AudioStoryboardRole) {
  if (role === "hook") return "Close-up subject looking at camera or working with head down, one hand lightly touching the back of the neck, with no product visible yet."
  if (role === "pain") return "Preserve the source action rhythm while making neck tension, looking down, neck rubbing, or desk-sitting posture clear."
  if (role === "proof") return "The subject picks up or prepares to wear the SKG neck-and-shoulder massager; product position is clear but the action has just started."
  if (role === "solution") return "Move from the pain state into picking up the product or bringing it toward the neck and shoulders, ready to begin use."
  if (role === "cta") return "Clean product close-up or stable worn-product frame, leaving a strong visual focus for the conversion close."
  return "Start from the current source sentence's composition to carry the rhythm without forcing a subject change."
}

function buildFirstFramePlanZh(role: AudioStoryboardRole) {
  if (role === "hook") return "人物近景看向镜头或低头办公，手轻扶后颈，画面先不露产品。"
  if (role === "pain") return "保留原片人物动作节奏，肩颈紧绷、低头、揉脖子或久坐状态明确。"
  if (role === "proof") return "人物拿起或准备佩戴 SKG 肩颈按摩仪，产品位置清晰但动作刚开始。"
  if (role === "solution") return "人物从痛点状态切到拿起产品/靠近肩颈，准备进入使用动作。"
  if (role === "cta") return "产品干净特写或佩戴完成后的稳定画面，留出转化收口的视觉焦点。"
  return "按原视频当前句的构图启动，先承接节奏，不强行改变镜头主体。"
}

function buildLastFramePlan(role: AudioStoryboardRole) {
  if (role === "hook") return "The subject lifts the head or becomes more focused, leaving room for the product or solution to enter in the next beat."
  if (role === "pain") return "Amplify the tense state into a clear stopping point, ready to cut into the product solution."
  if (role === "proof") return "The product is correctly worn around the back of the neck and shoulders, the subject looks more relaxed, and product scale is stable."
  if (role === "solution") return "The product fits against the neck and shoulders, hand adjustment is complete, and the frame can move into functional detail or relaxation."
  if (role === "cta") return "Hold a stable product or worn-product frame with clean composition, ready for purchase or action-call continuation."
  return "Advance the action slightly and hold a stable endpoint that connects naturally to the next sentence."
}

function buildLastFramePlanZh(role: AudioStoryboardRole) {
  if (role === "hook") return "人物抬头或表情更集中，给下一镜产品或方案进入留出空间。"
  if (role === "pain") return "紧绷状态被放大到一个明确停点，准备切入产品解决方案。"
  if (role === "proof") return "产品已正确佩戴在后颈/肩颈位置，人物放松，产品比例稳定。"
  if (role === "solution") return "产品贴合肩颈，手部调整完成，画面自然进入功能细节或放松状态。"
  if (role === "cta") return "产品或佩戴状态稳定收住，画面干净，适合后续接购买/行动号召。"
  return "动作小幅推进并稳定停住，保留与下一句衔接的方向感。"
}

function buildSubjectDescription(role: AudioStoryboardRole, visualMode: StoryboardVisualMode) {
  if (visualMode === "product_only" || visualMode === "environment") return ""
  const base = "Consistent similar subject: a friendly transparent or semi-transparent humanoid with visible clean white skeleton inside, commercial not horror, with neck, collarbone, and upper-back areas clear for wearing a neck-and-shoulder massager."
  if (role === "hook") return `${base} Front or upper-body creator speaking state, with a pain-point or curious expression that grabs attention quickly.`
  if (role === "pain") return `${base} Neck-and-shoulder tension, looking down, desk posture, or rubbing the neck; make the neck line, shoulders, and upper back readable.`
  if (role === "proof") return `${base} Relaxed state while wearing or about to wear the product, prioritizing neck-and-shoulder close-up, side, and back-neck angles.`
  if (role === "solution") return `${base} Hands adjust the product or show wearable fit naturally; product placement must not hide important anatomy or device structure.`
  if (role === "cta") return `${base} Stable, relaxed, clean ending state using front, three-quarter, or stable worn-product framing.`
  return `${base} Keep one consistent subject identity, material, body type, gender presentation, and commercial mood across the whole video.`
}

function buildSubjectDescriptionZh(role: AudioStoryboardRole, visualMode: StoryboardVisualMode) {
  if (visualMode === "product_only" || visualMode === "environment") return ""
  const base = "统一相似主体：透明或半透明皮肤包裹可见白色骨架的人形，广告感、非恐怖、肩颈/锁骨/上背区域清晰，适合佩戴肩颈按摩仪。"
  if (role === "hook") return `${base} 正面或半身口播状态，表情有痛点或好奇感，能快速抓住注意。`
  if (role === "pain") return `${base} 肩颈紧绷、低头久坐或按揉脖子的状态，重点看清脖子、肩线和上背。`
  if (role === "proof") return `${base} 产品佩戴或即将佩戴的放松状态，优先肩颈近景、侧面和后颈肩背角度。`
  if (role === "solution") return `${base} 手部调整产品或展示佩戴贴合感，人物姿态自然，产品位置不能挡住关键结构。`
  if (role === "cta") return `${base} 状态稳定、放松、干净收尾，可用正面/三分之二视角或产品佩戴后的稳定状态。`
  return `${base} 保持与整片一致的主体身份、材质、体型、性别表现和广告气质。`
}

function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
  if (!job?.transcript.length) return []
  return job.transcript.map((segment, index) => {
    const source = segment.en?.trim() || segment.zh?.trim() || "Source audio script pending."
    const sourceZh = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充"
    const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length)
    const visualMode = visualModeForRole(role)
    const defaults = visualModeDefaults(visualMode)
    const defaultsZh = visualModeDefaults(visualMode, "zh")
    const keyElements = role === "proof"
      ? "wearing action, product position, hand pressing the control, relaxed expression"
      : "creator framing, subject gesture, facial rhythm, scene lighting"
    const keyElementsZh = role === "proof"
      ? "佩戴动作、产品位置、手部按键、放松表情"
      : "口播构图、人物动作、表情节奏、场景光线"
    return {
      index: segment.index,
      start: segment.start,
      end: segment.end,
      source,
      sourceZh,
      role,
      visualMode,
      needsProduct: defaults.needsProduct,
      needsSubject: defaults.needsSubject,
      subjectDescription: buildSubjectDescription(role, visualMode),
      subjectDescriptionZh: buildSubjectDescriptionZh(role, visualMode),
      skgCopy: buildSkgCopy(role, index),
      skgCopyZh: buildSkgCopyZh(role, index),
      sceneOneLine: buildVisualPlan(role),
      sceneOneLineZh: buildVisualPlanZh(role),
      actionOneLine: `${buildSubjectDescription(role, visualMode) || "Product-forward SKG short-video beat."} ${defaults.productPlacement}`,
      actionOneLineZh: `${buildSubjectDescriptionZh(role, visualMode) || "以 SKG 产品为主的短视频镜头。"}${defaultsZh.productPlacement ? ` ${defaultsZh.productPlacement}` : ""}`,
      visualPlan: buildVisualPlan(role),
      visualPlanZh: buildVisualPlanZh(role),
      firstFramePlan: buildFirstFramePlan(role),
      firstFramePlanZh: buildFirstFramePlanZh(role),
      lastFramePlan: buildLastFramePlan(role),
      lastFramePlanZh: buildLastFramePlanZh(role),
      referencePlan: `Extract 1-2 targeted reference frames from source video ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s.`,
      keyElements,
      keyElementsZh,
      productIntegration: "Replace the source product or prop context with the SKG white U-shaped neck-and-shoulder massager. The product must be worn externally around the neck and shoulders.",
      productIntegrationZh: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪，产品必须外置佩戴在肩颈位置。",
      productPlacement: defaults.productPlacement,
      productPlacementZh: defaultsZh.productPlacement,
    }
  })
}

function productRefKey(ref: ImageRef, index: number) {
  return `${ref.kind}:${ref.frame_idx}:${ref.element_id ?? ""}:${ref.cutout_id ?? ""}:${index}`
}

function sameImageRef(a: ImageRef, b: ImageRef) {
  return (
    a.kind === b.kind &&
    a.frame_idx === b.frame_idx &&
    (a.element_id ?? "") === (b.element_id ?? "") &&
    (a.cutout_id ?? "") === (b.cutout_id ?? "")
  )
}

function productViewLabel(view: string) {
  return PRODUCT_VIEW_SLOTS.find((slot) => slot.value === view)?.label ?? view
}

function productBackgroundLabel(background: string) {
  return PRODUCT_BACKGROUND_LABELS[background] ?? PRODUCT_BACKGROUND_LABELS.unknown
}

function formatProductAssetSize(meta?: ImageRef["asset_meta"]) {
  if (!meta?.width || !meta?.height) return "AI工作图"
  return `${meta.width}x${meta.height}`
}

function defaultProductUseTags(view: string) {
  const defaults: Record<string, string[]> = {
    front: ["hero_packshot", "asymmetry"],
    left_45: ["hero_packshot", "asymmetry", "button_detail"],
    right_45: ["hero_packshot", "asymmetry", "button_detail"],
    side_thickness: ["side_thickness", "wearing_scale"],
    inner_contacts: ["inner_contact", "wearing_scale"],
    back_bottom: ["back_bottom", "material_texture"],
  }
  return defaults[view] ?? ["hero_packshot"]
}

function normalizeProductUseTags(tags: string[] | undefined, view: string) {
  const result: string[] = []
  for (const tag of [...(tags ?? []), ...defaultProductUseTags(view)]) {
    if (PRODUCT_USE_TAG_LABELS[tag] && !result.includes(tag)) result.push(tag)
  }
  return result.slice(0, 4)
}

function defaultProductLandmarks(view: string) {
  const defaults: Record<string, string[]> = {
    front: ["U形开口", "外壳主轮廓", "左右臂"],
    left_45: ["佩戴者左侧臂", "侧边弧度", "按键/结构差异"],
    right_45: ["佩戴者右侧臂", "侧边弧度", "按键/结构差异"],
    side_thickness: ["机身厚度", "侧边轮廓", "佩戴比例"],
    inner_contacts: ["贴颈内侧", "按摩触点", "皮肤接触面"],
    back_bottom: ["背面/底部", "接口/底面", "材质细节"],
  }
  return defaults[view] ?? ["U形挂脖轮廓"]
}

function normalizeProductLandmarks(landmarks: string[] | undefined, view: string) {
  const result: string[] = []
  for (const item of [...(landmarks ?? []), ...defaultProductLandmarks(view)]) {
    const text = item.trim()
    if (text && !result.includes(text)) result.push(text)
  }
  return result.slice(0, 8)
}

function formatProductOrientation(orientation?: ProductViewAnalysisItem["orientation"]) {
  if (!orientation) return ""
  const parts = [
    orientation.product_left ? `左=${orientation.product_left}` : "",
    orientation.product_right ? `右=${orientation.product_right}` : "",
    orientation.top ? `上=${orientation.top}` : "",
    orientation.bottom ? `下=${orientation.bottom}` : "",
    orientation.inner_side ? `内=${orientation.inner_side}` : "",
    orientation.opening_direction ? `开口=${orientation.opening_direction}` : "",
  ].filter(Boolean)
  return parts.join("；")
}

function createProductRefItem(
  ref: ImageRef,
  index: number,
  source: ProductRefItem["source"] = "upload",
  view?: string,
  note?: string,
  background = "unknown",
  useTags?: string[],
  orientation?: ProductViewAnalysisItem["orientation"],
  landmarks?: string[],
  risk = "",
  confidence?: number,
): ProductRefItem {
  const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
  const targetSlot = PRODUCT_VIEW_SLOTS.find((item) => item.value === view) ?? slot
  return {
    id: productRefKey(ref, index),
    ref,
    view: view ?? targetSlot.value,
    background,
    useTags: normalizeProductUseTags(useTags, view ?? targetSlot.value),
    orientation,
    landmarks: normalizeProductLandmarks(landmarks, view ?? targetSlot.value),
    note: note ?? targetSlot.hint,
    risk,
    source,
    assetMeta: ref.asset_meta,
    confidence,
  }
}

const PRODUCT_ANGLE_REFERENCE_PRIORITY: Record<string, string[]> = {
  front: ["front", "left_45", "right_45", "side_thickness", "inner_contacts", "back_bottom"],
  left_45: ["left_45", "front", "side_thickness", "right_45", "inner_contacts", "back_bottom"],
  right_45: ["right_45", "front", "side_thickness", "left_45", "inner_contacts", "back_bottom"],
  side_thickness: ["side_thickness", "left_45", "right_45", "front", "inner_contacts", "back_bottom"],
  inner_contacts: ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"],
  back_bottom: ["back_bottom", "side_thickness", "inner_contacts", "left_45", "right_45", "front"],
}

function productAngleReferenceScore(item: ProductRefItem, targetView: string) {
  const priority = PRODUCT_ANGLE_REFERENCE_PRIORITY[targetView] ?? PRODUCT_VIEW_SLOTS.map((slot) => slot.value)
  const rank = priority.indexOf(item.view)
  let score = rank === -1 ? 0 : 90 - rank * 12
  if (item.source === "upload" || item.source === "library") score += 28
  if (item.source === "ai") score -= 18
  if (item.confidence) score += Math.round(item.confidence * 14)
  if (item.useTags.includes("asymmetry")) score += 8
  if (targetView === "side_thickness" && item.useTags.includes("side_thickness")) score += 16
  if (targetView === "inner_contacts" && item.useTags.includes("inner_contact")) score += 16
  if (targetView === "back_bottom" && item.useTags.includes("back_bottom")) score += 16
  if (item.risk) score -= 10
  return score
}

function selectProductAngleReferenceItems(items: ProductRefItem[], targetView: string) {
  const unique = new Map<string, ProductRefItem>()
  for (const item of items) {
    if (!unique.has(item.id)) unique.set(item.id, item)
  }
  return [...unique.values()]
    .sort((a, b) => productAngleReferenceScore(b, targetView) - productAngleReferenceScore(a, targetView))
    .slice(0, 6)
}

function productAngleSourceNotes(items: ProductRefItem[]) {
  return items.map((item, index) => {
    const parts = [
      `ref${index + 1}`,
      `view=${productViewLabel(item.view)}`,
      `source=${item.source}`,
      item.note ? `note=${item.note}` : "",
      formatProductOrientation(item.orientation),
      item.landmarks?.length ? `landmarks=${item.landmarks.join("/")}` : "",
      item.risk ? `risk=${item.risk}` : "",
    ].filter(Boolean)
    return parts.join("；")
  })
}

function normalizeStoredProductItem(item: ProductRefItem, index: number): ProductRefItem {
  const ref = { ...item.ref, asset_meta: item.ref.asset_meta ?? item.assetMeta }
  const restored = createProductRefItem(
    ref,
    index,
    item.source ?? "upload",
    item.view,
    item.note,
    item.background ?? "unknown",
    item.useTags,
    item.orientation,
    item.landmarks,
    item.risk ?? "",
    item.confidence,
  )
  return {
    ...restored,
    id: item.id || restored.id,
    assetMeta: item.assetMeta ?? restored.assetMeta,
  }
}

function productReferenceNotes(items: ProductRefItem[]) {
  if (!items.length) return ""
  return items
    .map((item, index) => {
      const tags = item.useTags.map((tag) => PRODUCT_USE_TAG_PROMPT_LABELS[tag] ?? tag).filter(Boolean).join(", ")
      const orientation = formatProductOrientation(item.orientation)
      const direction = orientation ? `; orientation: ${orientation}` : ""
      const landmarks = item.landmarks.length ? `; structural landmarks: ${item.landmarks.join(", ")}` : ""
      const risk = item.risk ? `; risk: ${item.risk}` : ""
      return `${index + 1}. ${PRODUCT_VIEW_PROMPT_LABELS[item.view] ?? item.view} | ${PRODUCT_BACKGROUND_PROMPT_LABELS[item.background] ?? item.background} | ${tags || "general product reference"}: ${item.note || "no extra note"}${direction}${landmarks}${risk}`
    })
    .join("; ")
}

function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch {
  if (!scene) return {}
  return {
    visualMode: scene.visual_mode,
    needsProduct: scene.needs_product,
    needsSubject: scene.needs_subject,
    skgCopy: scene.skg_copy_en,
    skgCopyZh: scene.skg_copy_zh,
    sceneOneLine: scene.scene_one_line_en,
    sceneOneLineZh: scene.scene_one_line_zh,
    actionOneLine: scene.action_one_line_en,
    actionOneLineZh: scene.action_one_line_zh,
    subjectDescription: scene.subject?.split("\n").find((line) => line.trim() && !line.startsWith("Subject source") && !line.startsWith("No main subject") && !line.startsWith("主体真源") && !line.startsWith("本条不需要"))?.trim(),
    visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !line.startsWith("Visual mode") && !line.startsWith("First-frame plan") && !line.startsWith("Last-frame plan") && !line.startsWith("Source audio reference") && !line.startsWith("镜头类型") && !line.startsWith("首帧规划") && !line.startsWith("尾帧规划") && !line.startsWith("原音频依据"))?.trim(),
    firstFramePlan: scene.first_frame_plan,
    lastFramePlan: scene.last_frame_plan,
    productIntegration: scene.product?.split("\n").find((line) => line.trim() && !line.startsWith("Product requirement") && !line.startsWith("Product placement") && !line.startsWith("Product reference pool") && !line.startsWith("No product") && !line.startsWith("This beat") && !line.startsWith("产品需求") && !line.startsWith("产品出现方式") && !line.startsWith("产品素材池") && !line.startsWith("未上传产品图") && !line.startsWith("本条规划"))?.trim(),
    productPlacement: scene.product_placement,
  }
}

function storyboardSceneBelongsToRow(scene: StoryboardScene | null | undefined, rowIndex: number, legacyRowIndex?: number | null) {
  if (!scene) return false
  if (typeof scene.storyboard_row_idx === "number") return scene.storyboard_row_idx === rowIndex
  return legacyRowIndex != null && legacyRowIndex === rowIndex
}

function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioStoryboardRow {
  if (!patch) return row
  return {
    ...row,
    visualMode: patch.visualMode ?? row.visualMode,
    needsProduct: patch.needsProduct ?? row.needsProduct,
    needsSubject: patch.needsSubject ?? row.needsSubject,
    skgCopy: patch.skgCopy ?? row.skgCopy,
    skgCopyZh: patch.skgCopyZh ?? row.skgCopyZh,
    sceneOneLine: patch.sceneOneLine ?? row.sceneOneLine,
    sceneOneLineZh: patch.sceneOneLineZh ?? row.sceneOneLineZh,
    actionOneLine: patch.actionOneLine ?? row.actionOneLine,
    actionOneLineZh: patch.actionOneLineZh ?? row.actionOneLineZh,
    subjectDescription: patch.subjectDescription ?? row.subjectDescription,
    subjectDescriptionZh: patch.subjectDescriptionZh ?? row.subjectDescriptionZh,
    visualPlan: patch.visualPlan ?? row.visualPlan,
    visualPlanZh: patch.visualPlanZh ?? row.visualPlanZh,
    firstFramePlan: patch.firstFramePlan ?? row.firstFramePlan,
    firstFramePlanZh: patch.firstFramePlanZh ?? row.firstFramePlanZh,
    lastFramePlan: patch.lastFramePlan ?? row.lastFramePlan,
    lastFramePlanZh: patch.lastFramePlanZh ?? row.lastFramePlanZh,
    productIntegration: patch.productIntegration ?? row.productIntegration,
    productIntegrationZh: patch.productIntegrationZh ?? row.productIntegrationZh,
    productPlacement: patch.productPlacement ?? row.productPlacement,
    productPlacementZh: patch.productPlacementZh ?? row.productPlacementZh,
  }
}

function productPriorityForRow(row: AudioStoryboardRow) {
  const viewPriorityByRole: Record<AudioStoryboardRole, string[]> = {
    hook: ["front", "left_45", "right_45", "side_thickness"],
    pain: ["front", "side_thickness", "left_45", "right_45"],
    proof: ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"],
    solution: ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
    cta: ["front", "back_bottom", "left_45", "right_45", "inner_contacts"],
    bridge: ["front", "left_45", "right_45", "side_thickness"],
  }
  const tagPriorityByRole: Record<AudioStoryboardRole, string[]> = {
    hook: ["hero_packshot", "asymmetry", "side_thickness"],
    pain: ["wearing_scale", "side_thickness", "hero_packshot"],
    proof: ["inner_contact", "wearing_scale", "button_detail", "side_thickness"],
    solution: ["wearing_scale", "hero_packshot", "inner_contact"],
    cta: ["hero_packshot", "back_bottom", "asymmetry", "material_texture"],
    bridge: ["hero_packshot", "asymmetry", "side_thickness"],
  }
  return {
    views: viewPriorityByRole[row.role] ?? viewPriorityByRole.bridge,
    tags: tagPriorityByRole[row.role] ?? tagPriorityByRole.bridge,
  }
}

function endpointProductPriority(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") {
  const text = `${row.role} ${row.visualMode} ${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productIntegration} ${row.productPlacement} ${role ?? ""}`.toLowerCase()
  const views = ["front"]
  const tags = ["hero_packshot", "wearing_scale"]
  const add = (view: string, tag?: string) => {
    if (!views.includes(view)) views.push(view)
    if (tag && !tags.includes(tag)) tags.push(tag)
  }
  if (/back neck|neck back|upper back|back view|back side|shoulder blade|last frame|worn|wearing complete|fit complete|后颈|肩背|背面|背部|后背|上背|尾帧|佩戴完成|贴合完成/.test(text)) add("back_bottom", "back_bottom")
  if (/side|profile|thickness|volume|left side|right side|45|adjust|pick up|bring.*neck|toward.*shoulder|侧面|侧身|厚度|侧厚|体积|左侧|右侧|调整|拿起|靠近肩颈/.test(text)) add("side_thickness", "side_thickness")
  if (/inner|contact pad|massage head|touching skin|neck contact|skin contact|内侧|触点|按摩头|贴颈|接触|皮肤接触/.test(text)) add("inner_contacts", "inner_contact")
  if (/wearing scale|upper body|worn on human|neck|shoulder|collarbone|佩戴比例|上身|真人佩戴|脖子|肩颈|锁骨/.test(text)) add("left_45", "wearing_scale")
  if (/button|control|switch|logo|按键|按钮|控制|开关/.test(text)) add("right_45", "button_detail")
  return { views, tags }
}

function endpointProductMaxForRow(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") {
  const text = `${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productIntegration} ${row.productPlacement} ${role ?? ""}`.toLowerCase()
  return /side|profile|thickness|back neck|upper back|back view|inner|contact pad|massage head|neck contact|close-up|closeup|button|control|worn|wearing complete|侧面|侧身|厚度|侧厚|后颈|肩背|背面|背部|内侧|触点|按摩头|贴颈|特写|近景|按键|按钮|佩戴完成|上背/.test(text)
    ? MAX_PRODUCT_REFS_PER_ENDPOINT
    : 1
}

function scoreProductItem(row: AudioStoryboardRow, item: ProductRefItem, index: number, priority: { views: string[]; tags: string[] }) {
  const viewRank = priority.views.indexOf(item.view)
  const tagScore = item.useTags.reduce((sum, tag) => {
    const rank = priority.tags.indexOf(tag)
    return sum + (rank >= 0 ? 18 - rank * 3 : 0)
  }, 0)
  const backgroundScore = item.background === "complex" ? -8 : item.background === "unknown" ? -3 : 0
  const riskScore = item.risk ? -10 : 0
  const confidenceScore = Math.round((item.confidence ?? 0.5) * 10)
  const rotationScore = -Math.abs((row.index % Math.max(1, index + 1)) - (index % 3))
  return (viewRank >= 0 ? 30 - viewRank * 4 : 0) + tagScore + backgroundScore + riskScore + confidenceScore + rotationScore
}

function selectProductItemsForRow(
  row: AudioStoryboardRow,
  items: ProductRefItem[],
  mode: "video" | "endpoint" = "video",
  role?: "first_frame" | "last_frame",
) {
  if (!items.length) return []
  const picked: ProductRefItem[] = []
  const pickedIds = new Set<string>()
  const maxItems = mode === "endpoint" ? endpointProductMaxForRow(row, role) : MAX_PRODUCT_REFS_PER_VIDEO
  const priority = mode === "endpoint" ? endpointProductPriority(row, role) : productPriorityForRow(row)
  const add = (item?: ProductRefItem) => {
    if (!item || pickedIds.has(item.id) || picked.length >= maxItems) return
    picked.push(item)
    pickedIds.add(item.id)
  }

  for (const view of priority.views) {
    const matches = items
      .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) }))
      .filter(({ item }) => item.view === view)
      .sort((a, b) => b.score - a.score)
    add(matches[0]?.item)
  }

  for (const tag of priority.tags) {
    const matches = items
      .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) }))
      .filter(({ item }) => item.useTags.includes(tag))
      .sort((a, b) => b.score - a.score)
    add(matches[0]?.item)
  }

  const ranked = items
    .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) }))
    .sort((a, b) => b.score - a.score)
  for (const { item } of ranked) {
    add(item)
  }

  return picked
}

function subjectViewLabel(view: string) {
  const labels: Record<string, string> = {
    top: "正投影俯视图",
    bottom: "正投影仰视图",
  }
  if (labels[view]) return labels[view]
  return SUBJECT_ASSET_VIEWS.find((item) => item.value === view)?.label ?? view
}

function subjectViewRoleHint(view: string) {
  const hints: Record<string, string> = {
    front: "正面口播、开场、情绪表达、转化收口",
    three_quarter_left: "左前45度、口播、佩戴前动作、自然转身",
    three_quarter_right: "右前45度、口播、佩戴前动作、自然转身",
    left: "左侧、肩颈侧面、佩戴动作、产品厚度与位置",
    right: "右侧、肩颈侧面、佩戴动作、产品厚度与位置",
    back: "背面、后颈肩背、产品佩戴落位",
    bust_front: "肩颈正面近景、痛点表情、佩戴比例",
    bust_left_45: "肩颈左前近景、手部调整、佩戴贴合",
    bust_right_45: "肩颈右前近景、手部调整、佩戴贴合",
    back_neck_detail: "后颈肩背特写、触点位置、产品贴合",
    top: "正投影俯视、无透视、无倾斜、产品顶部轮廓",
    bottom: "正投影仰视、无透视、无倾斜、产品底部结构",
  }
  return hints[view] ?? "主体参考视角"
}

function subjectViewPromptHint(view: string) {
  const hints: Record<string, string> = {
    front: "front speaking shot, opening hook, expression, conversion close",
    three_quarter_left: "left three-quarter angle, talking, pre-wear motion, natural turn",
    three_quarter_right: "right three-quarter angle, talking, pre-wear motion, natural turn",
    left: "left side, neck-and-shoulder side profile, wearing action, product thickness and position",
    right: "right side, neck-and-shoulder side profile, wearing action, product thickness and position",
    back: "back view, back neck and upper shoulders, product placement landing",
    bust_front: "front neck-and-shoulder close-up, pain-point expression, wearing scale",
    bust_left_45: "left three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit",
    bust_right_45: "right three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit",
    back_neck_detail: "back-neck and upper-back detail, contact-pad position, product fit",
    top: "orthographic top view, straight down from above, no perspective, no tilt, no three-quarter angle",
    bottom: "orthographic bottom view, straight up at underside, no perspective, no tilt, no three-quarter angle",
  }
  return hints[view] ?? "subject reference view"
}

function subjectDescriptionForRow(row: AudioStoryboardRow, subjectRefs: SubjectPlanningRef[]) {
  const trimmed = row.subjectDescription.trim()
  if (trimmed) return trimmed
  const labels = subjectRefs.slice(0, 4).map((ref) => ref.label || subjectViewLabel(ref.view)).join(", ")
  return [
    "Consistent similar subject: use the generated subject view pack as the character truth, maintaining one identity, body proportion, material, age range, gender presentation, and commercial mood.",
    labels ? `Available subject views: ${labels}.` : "",
    "If this beat needs a subject but lacks a specific description, default to a friendly transparent skin shell with visible white skeleton, non-horror, with clear neck and shoulder area for wearable product placement.",
  ].filter(Boolean).join("")
}

function subjectPriorityForRow(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") {
  const text = `${row.role} ${row.visualMode} ${row.subjectDescription} ${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productPlacement}`.toLowerCase()
  if (/back neck|upper back|shoulder blade|back view|fit|worn|wearing complete|correctly worn|后颈|肩背|上背|背面|背部|贴合|佩戴完成|已正确佩戴/.test(text)) {
    return ["back_neck_detail", "back", "bust_left_45", "bust_right_45", "left", "right", "bust_front", "three_quarter_left", "three_quarter_right", "front"]
  }
  if (/side|left|right|45|adjust|pick up|prepare to wear|toward.*neck|hand|侧面|左侧|右侧|调整|拿起|准备佩戴|靠近肩颈|手部/.test(text)) {
    return ["bust_left_45", "bust_right_45", "left", "right", "three_quarter_left", "three_quarter_right", "bust_front", "front", "back_neck_detail", "back"]
  }
  if (/close-up|closeup|upper-body|bust|neck|shoulder|collarbone|rubbing.*neck|looking down|tense|tension|近景|半身|肩颈|锁骨|脖子|揉脖子|低头|紧绷/.test(text)) {
    return ["bust_front", "bust_left_45", "bust_right_45", "front", "three_quarter_left", "three_quarter_right", "left", "right", "back_neck_detail", "back"]
  }
  if (role === "last_frame" && row.needsProduct) {
    return ["back_neck_detail", "back", "bust_left_45", "bust_right_45", "bust_front", "left", "right", "front", "three_quarter_left", "three_quarter_right"]
  }
  return ["front", "three_quarter_left", "three_quarter_right", "bust_front", "left", "right", "bust_left_45", "bust_right_45", "back_neck_detail", "back"]
}

function selectSubjectRefsForRow(row: AudioStoryboardRow, refs: SubjectPlanningRef[], role?: "first_frame" | "last_frame") {
  if (!row.needsSubject || !refs.length) return []
  const priority = subjectPriorityForRow(row, role)
  return refs
    .map((ref, index) => {
      const rank = priority.indexOf(ref.view)
      const labelText = `${ref.label || ""} ${ref.roleHint}`.toLowerCase()
      const closeupScore = /neck|shoulder|back neck|close-up|closeup|fit|wear|佩戴|肩颈|后颈|近景|贴合/.test(row.visualPlan + row.firstFramePlan + row.lastFramePlan + row.productPlacement)
        && /bust|neck|close-up|closeup|近景|肩颈|后颈/.test(`${ref.view} ${labelText}`)
        ? 12
        : 0
      return { ref, score: (rank >= 0 ? 100 - rank * 8 : 0) + closeupScore - index }
    })
    .sort((a, b) => b.score - a.score)
    .slice(0, MAX_SUBJECT_REFS_PER_ENDPOINT)
    .map((item) => item.ref)
}

function subjectReferenceNotes(refs: SubjectPlanningRef[]) {
  return refs.map((ref, index) => `${index + 1}. ${ref.label || subjectViewLabel(ref.view)} | ${subjectViewPromptHint(ref.view)}`).join("; ")
}

function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElement } | null): SubjectPlanningRef[] {
  if (!source) return []
  return (source.element.subject_assets ?? []).slice(0, 10).map((asset) => ({
    kind: "asset",
    frame_idx: source.frame.index,
    element_id: asset.id,
    cutout_id: asset.id,
    label: asset.label || asset.view || "相似主体视图",
    view: asset.view,
    roleHint: subjectViewRoleHint(asset.view),
    consensusBrief: source.element.subject_consensus_brief || "",
  }))
}

function subjectBriefForEndpoint(row: AudioStoryboardRow, refs: SubjectPlanningRef[]) {
  const storedBrief = refs.find((ref) => ref.consensusBrief?.trim())?.consensusBrief?.trim()
  if (storedBrief) return storedBrief
  const manualBrief = row.subjectDescription.trim()
  if (manualBrief) return manualBrief
  if (row.needsSubject) return subjectDescriptionForRow(row, refs)
  return ""
}

function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null {
  if (!frame) return null
  const key = role === "first_frame" ? "first_image" : "last_image"
  if (frame.storyboard && Object.prototype.hasOwnProperty.call(frame.storyboard, key)) {
    const saved = role === "first_frame" ? frame.storyboard.first_image : frame.storyboard.last_image
    return saved && saved.kind !== "keyframe" ? saved : null
  }
  const asset = [...(frame.scene_assets ?? [])].reverse().find((item) => item.asset_role === role)
  if (!asset) return null
  return {
    kind: "asset",
    frame_idx: frame.index,
    element_id: asset.id,
    cutout_id: asset.id,
    label: asset.label || (role === "first_frame" ? "首帧" : "尾帧"),
  }
}

function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectBrief: string) {
  const target = role === "first_frame" ? row.firstFramePlan : row.lastFramePlan
  const opposite = role === "first_frame" ? row.lastFramePlan : row.firstFramePlan
  const productNotes = selectedProductItems.length ? productReferenceNotes(selectedProductItems) : ""
  return [
    `Storyboard beat ${row.index + 1}, ${role === "first_frame" ? "first frame" : "last frame"}.`,
    `New English voice-over line: ${row.skgCopy}`,
    `Narrative role: ${ROLE_LABELS_EN[row.role]}.`,
    `Visual mode: ${row.visualMode}.`,
    `Target endpoint frame to generate now: ${target}`,
    `Opposite endpoint continuity reference: ${opposite}`,
    `Overall visual plan: ${row.visualPlan}`,
    row.needsSubject
      ? `Subject identity brief: ${subjectBrief || "Subject brief is missing. Keep one unified commercial ad subject with clear neck-and-shoulder area for product placement."}. Use only this text identity brief; no subject reference image is uploaded. The subject may freely change pose, framing, expression, gesture, and environment for this shot, but must not become a different character. Do not copy the original source-video person or keyframe.`
      : "This beat does not need a main character. If people appear, they should only be partial hands, back-view background figures, or environmental figures; do not generate the transparent skeleton main subject.",
    row.needsProduct
      ? `Product integration: ${row.productPlacement}. ${row.productIntegration}. This request provides ${selectedProductItems.length} rigid reference image(s) of the same SKG neck-and-shoulder massager: ${productNotes}. The product is a U-shaped wearable device worn around the neck and shoulders. Preserve realistic wearable scale, left-right asymmetry, button placement, contact pads, side thickness, and neck-contact position.`
      : "Do not show the product in this beat. Do not force-generate an SKG product, package, white-background product image, or random merchandise.",
    "Output one single 9:16 high-definition endpoint frame. No contact sheet, no multiple views, no subtitles, no platform UI, no watermark. The image must work as a clear first/last frame for downstream video generation.",
  ].join("\n")
}

function buildStoryboardSceneFromAudioRow(
  row: AudioStoryboardRow,
  frame: KeyFrame,
  productItems: ProductRefItem[] = [],
  subjectRefs: SubjectPlanningRef[] = [],
  endpointRefs: { firstImage?: ImageRef | null; lastImage?: ImageRef | null } = {},
): StoryboardScene {
  const selectedProductItems = row.needsProduct ? selectProductItemsForRow(row, productItems) : []
  const productRefs = selectedProductItems.map((item) => item.ref)
  const notes = productReferenceNotes(selectedProductItems)
  const subjectDescription = subjectDescriptionForRow(row, subjectRefs)
  const subjectNotes = subjectReferenceNotes(subjectRefs)
  const subjectBrief = subjectBriefForEndpoint(row, subjectRefs)
  const productGuidance = !row.needsProduct
    ? "This beat is planned without product visibility or without product as the visual subject. Do not force-insert an SKG product, package, white-background product render, or incorrect merchandise during video generation."
    : productItems.length
    ? `The product pool has ${productItems.length} image(s); this beat selects only the ${selectedProductItems.length} most relevant reference image(s). Do not mix unselected assets into this shot. Rigid product definition: this is a U-shaped neck-and-shoulder wearable massager, not headphones, a headset, or a neck pillow. Coordinate rule: left/right refer to the wearer's body, not the image; top means closer to chin/face/upper neck, bottom means closer to collarbone/shoulders; inner means skin-contact side and massage pads, outer means shell/buttons/logo. Selected images are only product structure, angle, scale, and detail references; do not copy the white/black/studio background. View notes: ${notes}. Preserve left-right asymmetry; do not mirror the two sides. The shoulder-neck product size must match realistic wearing scale, not earphone-small and not neck-pillow-large.`
    : "No product images are uploaded. Use the default SKG product concept only if needed, and preferably establish a same-product pool before generation to lock left-right differences, thickness, and wearing scale."
  return {
    duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
    first_image: endpointRefs.firstImage ?? null,
    last_image: endpointRefs.lastImage ?? null,
    visual_mode: row.visualMode,
    needs_product: row.needsProduct,
    needs_subject: row.needsSubject,
    storyboard_row_idx: row.index,
    subject_brief: row.needsSubject ? subjectBrief : "",
    skg_copy_en: row.skgCopy,
    skg_copy_zh: row.skgCopyZh,
    scene_one_line_en: row.sceneOneLine,
    scene_one_line_zh: row.sceneOneLineZh,
    action_one_line_en: row.actionOneLine,
    action_one_line_zh: row.actionOneLineZh,
    selected_video_id: frame.storyboard?.storyboard_row_idx === row.index ? frame.storyboard?.selected_video_id ?? "" : "",
    first_frame_plan: row.firstFramePlan,
    last_frame_plan: row.lastFramePlan,
    product_placement: row.productPlacement,
    product_images: productRefs,
    product_image: productRefs[0] ?? null,
    subject_images: row.needsSubject ? subjectRefs : [],
    subject_image: row.needsSubject ? subjectRefs[0] ?? null : null,
    subject: row.needsSubject
      ? `${subjectDescription}\nSubject action and visual elements: ${row.keyElements}\nSubject source: select ${subjectRefs.length} generated similar-subject view(s) according to this shot's need; ${subjectNotes}. Source keyframes are only used for upstream subject extraction and must not be used as direct endpoint-frame references.`
      : "No main character or similar-subject reference is needed for this beat. If people appear, they should be background or partial-body context, not the main subject.",
    scene: `Visual mode: ${row.visualMode}\n${row.visualPlan}\nFirst-frame plan: ${row.firstFramePlan}\nLast-frame plan: ${row.lastFramePlan}\nSource audio reference: ${row.source}`,
    product: `Product requirement: ${row.needsProduct ? "product reference required" : "no product required for this beat"}\nProduct placement: ${row.productPlacement}\n${row.needsProduct ? row.productIntegration : "This beat focuses on emotion, subject state, space, or pacing transition and should not show the product."}\n${productGuidance}`,
    action: `${row.skgCopy}\nContinuity action: transition naturally from the first-frame plan to the last-frame plan. The visual mode and product/subject requirements must not change mid-clip.`,
    reference_ids: [],
  }
}

export function AdRecreationBoard({
  data,
  onGenerateVideo,
}: {
  data: NodeData
  onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
  const { job, jobs, activeJobId } = data
  const [url, setUrl] = useState("")
  const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
  const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
  const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
  const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
  const [generatingAll, setGeneratingAll] = useState(false)
  const [runtimeModels, setRuntimeModels] = useState<RuntimeModels | undefined>()
  const [boardTheme, setBoardTheme] = useState<BoardThemeMode>("dark")
  const [libraryOpen, setLibraryOpen] = useState(false)
  const [materialRailOpen, setMaterialRailOpen] = useState(false)
  const fileRef = useRef<HTMLInputElement | null>(null)
  const selectedFrames = job
    ? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
    : []
  const framesForSegments = orderedFrames(job, selectedFrames)
  const generatedVideos = job?.generated_videos ?? []
  const audioReady = !!job?.audio_script?.source_text?.trim() || !!job?.transcript?.length
  const readySegments = countReadySegments(job, draftSegments)
  const transcriptCount = job?.transcript.length ?? 0
  const backgroundReady = !!job?.audio_script?.background_audio_profile?.trim()
  const audioRunning = isAudioProcessing(job)
  const visualRunning = job?.status === "splitting"
  const visualReady = (job?.frames.length ?? 0) > 0
  const subjectAssetCount = countSubjectAssetViews(job)
  const productAssetCount = job?.product_refs?.length ?? 0
  const endpointFramePairCount = countEndpointFramePairs(job)
  const workflowSteps = buildWorkflowSteps({
    job,
    submitting: data.submitting,
    audioReady,
    audioRunning,
    transcriptCount,
    visualReady,
    visualRunning,
    subjectAssetCount,
    productAssetCount,
    endpointFramePairCount,
    generatedVideoCount: generatedVideos.length,
  })
  const workflow = workflowStepMap(workflowSteps)
  const statusMessage = job?.message?.startsWith("视频生成已提交")
    ? "视频候选已提交；当前默认按紧凑三字段生成候选，首尾帧细节自动处理。"
    : job?.message

  useEffect(() => {
    setDraftSegments([])
    setSelectedVideoIds(new Set())
  }, [activeJobId])

  useEffect(() => {
    try {
      const saved = window.localStorage.getItem(BOARD_THEME_STORAGE_KEY)
      if (saved === "light" || saved === "dark") setBoardTheme(saved)
    } catch {
      // Ignore storage failures; dark mode remains the product default.
    }
  }, [])

  useEffect(() => {
    let cancelled = false
    getRuntimeHealth()
      .then((health) => {
        if (!cancelled) setRuntimeModels(health.models)
      })
      .catch((error) => {
        console.warn("模型配置读取失败", error)
      })
    return () => {
      cancelled = true
    }
  }, [])

  const submitUrl = () => {
    const trimmed = url.trim()
    if (!trimmed) return
    data.onSubmitUrl(trimmed)
    setUrl("")
  }

  const startProduction = () => {
    const trimmed = url.trim()
    data.onStartProduction?.(trimmed || undefined)
    if (trimmed) setUrl("")
  }

  const toggleBoardTheme = () => {
    setBoardTheme((current) => {
      const next: BoardThemeMode = current === "dark" ? "light" : "dark"
      try {
        window.localStorage.setItem(BOARD_THEME_STORAGE_KEY, next)
      } catch {
        // Ignore storage failures; the in-memory theme still switches.
      }
      return next
    })
  }

  const selectAllFrames = () => {
    if (!job) return
    for (const frame of job.frames) {
      if (!data.selectedFrames.has(frame.index)) data.onToggleFrame(frame.index)
    }
  }

  const clearFrameSelection = () => {
    if (!job) return
    for (const frame of job.frames) {
      if (data.selectedFrames.has(frame.index)) data.onToggleFrame(frame.index)
    }
  }

  const applyLibraryAsset = async (
    kind: AssetLibraryKind,
    ref: ImageRef,
    target: "copy_only" | "product_pool",
    item: AssetLibraryItem,
  ) => {
    if (!job) return
    if (target === "product_pool" && kind === "products") {
      const existing = job.product_refs ?? []
      const next = [
        ...existing,
        createProductRefItem(ref, existing.length, "library", "front", item.note || item.name || "素材库产品图"),
      ]
      const updated = await saveProductRefs(job.id, next)
      data.onJobUpdate(updated)
      return
    }
    toast.success("素材已复制到当前 job；需要入产品池时请选择“应用到产品素材池”。")
  }

  const addDraftSegment = () => {
    setDraftSegments((prev) => [
      ...prev,
      {
        id: `draft-${Date.now()}-${prev.length}`,
        frameIndex: null,
        scene: emptyScene(),
      },
    ])
  }

  const updateDraftSegment = (id: string, patch: Partial<DraftSegment>) => {
    setDraftSegments((prev) => prev.map((draft) => draft.id === id ? { ...draft, ...patch } : draft))
  }

  const removeDraftSegment = (id: string) => {
    setDraftSegments((prev) => prev.filter((draft) => draft.id !== id))
  }

  const toggleVideo = (videoId: string) => {
    setSelectedVideoIds((prev) => {
      const next = new Set(prev)
      if (next.has(videoId)) next.delete(videoId)
      else next.add(videoId)
      return next
    })
  }

  const generateElementForFrame = async (frame: KeyFrame, candidate?: FrameObject, withSixViews = true) => {
    if (!job) return
    setElementBusyFrame(frame.index)
    const candidateName = candidate?.name?.trim()
    try {
      let workingJob = job
      let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame
      const existing = workingFrame.elements?.find((item) =>
        candidateName
          ? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName)
          : true,
      )
      const sourceObject = candidate ?? workingFrame.description?.objects?.[0]
      const name = candidateName || sourceObject?.name?.trim() || existing?.name_zh || existing?.name_en || "主体"
      let element = existing

      if (!element) {
        workingJob = await addElement(job.id, frame.index, {
          name_zh: name,
          name_en: name,
          position: sourceObject?.position,
          source: "manual",
        })
        data.onJobUpdate(workingJob)
        workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
        element = workingFrame.elements?.[workingFrame.elements.length - 1]
      }

      if (!element) {
        toast.success(`已登记元素：${name}`)
        return
      }

      if (!hasCutout(element)) {
        workingJob = await cutoutElement(job.id, frame.index, element.id)
        data.onJobUpdate(workingJob)
        workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
        element = workingFrame.elements?.find((item) => item.id === element?.id) ?? element
      }

      if (withSixViews && !element.subject_assets?.length) {
        setSixViewBusyKey(`${frame.index}:${element.id}`)
        workingJob = await generateSubjectAssets(job.id, frame.index, element.id, {
          subject_kind: guessSubjectKind(name),
          background: "white",
          size: SUBJECT_ASSET_SIZE,
          source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
        })
        data.onJobUpdate(workingJob)
      }

      toast.success(`已准备关键元素：${name}`)
    } catch (e) {
      toast.error("元素生成失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setElementBusyFrame(null)
      setSixViewBusyKey(null)
    }
  }

  const generateSixViewsForElement = async (frame: KeyFrame, element: KeyElement) => {
    if (!job) return
    setSixViewBusyKey(`${frame.index}:${element.id}`)
    try {
      const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
        subject_kind: guessSubjectKind(element.name_zh || element.name_en || "主体"),
        background: "white",
        size: SUBJECT_ASSET_SIZE,
        source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
      })
      data.onJobUpdate(updated)
      toast.success(`高清视图已生成：${element.name_zh || element.name_en}`)
    } catch (e) {
      toast.error("高清视图生成失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSixViewBusyKey(null)
    }
  }

  const generateAllVideos = async () => {
    if (!job || framesForSegments.length === 0) return
    setGeneratingAll(true)
    try {
      for (let order = 0; order < framesForSegments.length; order += 1) {
        const frame = framesForSegments[order]
        const scene = frame.storyboard ?? buildFallbackScene(job, frame, order)
        if (!frame.storyboard) {
          const updated = await updateStoryboard(job.id, frame.index, scene)
          data.onJobUpdate(updated)
        }
        await onGenerateVideo(frame.index, scene, "seedance")
      }
      toast.success(`已提交 ${framesForSegments.length} 条分镜视频`)
    } catch (e) {
      toast.error("批量生成失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setGeneratingAll(false)
    }
  }

  return (
    <section className={`skg-board-theme ${boardTheme === "light" ? "skg-board-theme--light" : ""} relative z-20 min-h-screen w-full overflow-auto bg-black text-white`}>
      <div className="skg-board-ambient pointer-events-none fixed inset-0" />
      <div className="relative z-10 mx-auto min-h-screen w-full min-w-[1280px] max-w-[1920px] px-4 py-4 xl:px-5">
        <div className="skg-board-shell flex gap-4 p-4">
          <WorkbenchRail
            jobsCount={jobs.length}
            materialOpen={materialRailOpen}
            onToggleMaterial={() => setMaterialRailOpen((open) => !open)}
            onOpenLibrary={() => setLibraryOpen(true)}
            onToggleTheme={toggleBoardTheme}
            boardTheme={boardTheme}
            materialPanel={
              <MaterialColumn
                data={data}
                step={workflow.input}
                jobs={jobs}
                job={job}
                activeJobId={activeJobId}
                url={url}
                setUrl={setUrl}
                fileRef={fileRef}
                onSubmitUrl={submitUrl}
                onStartProduction={startProduction}
              />
            }
          />

          <div className="flex min-w-0 flex-1 flex-col gap-3">
            <header className="skg-board-topbar flex items-center justify-between gap-4 rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3">
              <div className="min-w-0">
                <div className="flex items-center gap-3">
                  <span className="skg-status-orb shrink-0 text-[10px] font-semibold">SKG</span>
                  <div className="min-w-0">
                    <div className="text-[11px] font-medium tracking-[0.18em] text-white/40">未来健康 · 营销内容工作台</div>
                    <h1 className="mt-1 truncate text-[22px] font-semibold leading-tight text-white">营销内容工作台</h1>
                  </div>
                </div>
                <p className="mt-2 truncate text-[12px] text-white/42">信息流广告复刻生产</p>
              </div>
              <div className="grid min-w-[440px] grid-cols-5 gap-2 text-[11px]">
                <Metric label="素材" value={`${jobs.length}`} variant="violet" />
                <Metric label="当前" value={shortId(activeJobId)} variant="lime" />
                <Metric label="视频" value={job?.video_url ? "ready" : "-"} variant="gold" />
                <Metric label="文案段" value={`${transcriptCount}`} variant="teal" />
                <Metric label="背景音" value={backgroundReady ? "ready" : "-"} variant="green" />
              </div>
            </header>

            <section className="skg-board-panel flex min-h-0 flex-1 flex-col rounded-[20px] border border-white/10 bg-white/[0.035] shadow-2xl">
              <header className="shrink-0 border-b border-white/10 p-3">
                <div className="flex items-center justify-between gap-4">
                  <div className="min-w-0">
                    <div className="flex items-center gap-2">
                      <span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/12 bg-white/[0.07] text-[#c8cd19]"><Mic className="h-4 w-4" /></span>
                      <WorkflowStepBadge step={workflow.source} compact />
                      <h2 className="text-[15px] font-semibold leading-tight text-white">视频拆解</h2>
                    </div>
                    <div className="mt-1 truncate text-[11px] text-white/38" title={statusMessage}>
                      {statusMessage || "下载源视频后解析音频，再抽参考帧并生成相似主体。"}
                    </div>
                  </div>
                  <div className="flex shrink-0 items-center gap-2">
                    <ModelTrace trace={audioModelTrace(runtimeModels)} compact />
                    <ActionButton disabled={!job?.video_url || audioRunning} onClick={() => data.onTranscribeAudio?.(job?.id)}>
                      <Mic className="h-3.5 w-3.5" />
                      解析音频
                    </ActionButton>
                  </div>
                </div>
              </header>

              <div className="min-h-0 flex-1 overflow-y-auto p-4">
                <AudioIntakePanel
                  job={job}
                  selectedFrames={data.selectedFrames}
                  onToggleFrame={data.onToggleFrame}
                  onJobUpdate={data.onJobUpdate}
                  onAddFrame={data.onAddManualFrameForJob}
                  onDeleteFrame={data.onDeleteFrameForJob}
                  runtimeModels={runtimeModels}
                />
                <AudioStoryboardPlanPanel
                  job={job}
                  selectedFrames={data.selectedFrames}
                  onJobUpdate={data.onJobUpdate}
                  onDeleteVideo={data.onDeleteVideo}
                  runtimeModels={runtimeModels}
                  productStep={workflow.product}
                  scriptStep={workflow.script}
                  sceneStep={workflow.scene}
                  videoStep={workflow.video}
                />
              </div>
            </section>
          </div>
        </div>
      </div>
      <LibraryDrawer
        open={libraryOpen}
        currentJobId={job?.id}
        onClose={() => setLibraryOpen(false)}
        onApplyAsset={applyLibraryAsset}
      />
    </section>
  )
}

function WorkbenchRail({
  jobsCount,
  materialOpen,
  onToggleMaterial,
  onOpenLibrary,
  onToggleTheme,
  boardTheme,
  materialPanel,
}: {
  jobsCount: number
  materialOpen: boolean
  onToggleMaterial: () => void
  onOpenLibrary: () => void
  onToggleTheme: () => void
  boardTheme: BoardThemeMode
  materialPanel: ReactNode
}) {
  const [hoverOpen, setHoverOpen] = useState(false)
  const railOpen = materialOpen || hoverOpen

  return (
    <aside
      className={`skg-board-rail sticky flex shrink-0 items-stretch ${railOpen ? "is-open" : ""}`}
      aria-label="工作台工具栏"
      onMouseEnter={() => setHoverOpen(true)}
      onMouseLeave={() => setHoverOpen(false)}
      onFocus={() => setHoverOpen(true)}
      onBlur={(event) => {
        const nextTarget = event.relatedTarget
        if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) setHoverOpen(false)
      }}
    >
      <div className="skg-board-rail__strip flex shrink-0 items-stretch gap-3" aria-label="工作台导航与素材输入">
        <div className="skg-board-rail__iconbar flex shrink-0 flex-col items-center py-5" aria-label="工作台导航">
          <div className="skg-board-rail__logo mb-8 flex h-9 w-9 items-center justify-center rounded-full text-[12px] font-semibold" title="SKG Marketing Studio">
            S
          </div>
          <nav className="flex flex-col items-center gap-4">
            <button
              type="button"
              onClick={onToggleMaterial}
              className={`skg-board-rail__button inline-flex h-9 w-9 items-center justify-center rounded-full ${railOpen ? "is-active" : ""}`}
              title={jobsCount ? `素材任务 · ${jobsCount}` : "素材任务"}
              aria-label="素材任务"
              aria-expanded={railOpen}
              aria-controls="skg-material-rail-panel"
            >
              <Link2 className="h-[18px] w-[18px]" />
            </button>
            <button
              type="button"
              onClick={onOpenLibrary}
              className="skg-board-rail__button inline-flex h-9 w-9 items-center justify-center rounded-full"
              title="资源库"
              aria-label="打开资源库"
            >
              <BookOpen className="h-[18px] w-[18px]" />
            </button>
            <button
              type="button"
              onClick={onToggleTheme}
              className="skg-board-rail__button inline-flex h-9 w-9 items-center justify-center rounded-full"
              title={boardTheme === "dark" ? "切换到明亮模式" : "切换到暗色模式"}
              aria-label={boardTheme === "dark" ? "切换到明亮模式" : "切换到暗色模式"}
            >
              {boardTheme === "dark" ? <Sun className="h-[18px] w-[18px]" /> : <Moon className="h-[18px] w-[18px]" />}
            </button>
          </nav>
        </div>

        {railOpen ? (
          <div id="skg-material-rail-panel" className="skg-board-rail__drawer w-[320px] shrink-0">
            {materialPanel}
          </div>
        ) : null}
      </div>
    </aside>
  )
}

function MaterialColumn({
  data,
  step,
  jobs,
  job,
  activeJobId,
  url,
  setUrl,
  fileRef,
  onSubmitUrl,
  onStartProduction,
}: {
  data: NodeData
  step: WorkflowStep
  jobs: Job[]
  job: Job | null
  activeJobId: string | null
  url: string
  setUrl: (value: string) => void
  fileRef: RefObject<HTMLInputElement | null>
  onSubmitUrl: () => void
  onStartProduction: () => void
}) {
  const actionLabel = !url.trim() && job?.status === "failed"
    ? job.video_url ? "重新解析" : "重新下载"
    : "开始分析"
  return (
    <section className="skg-board-panel flex h-full min-h-[514px] flex-col gap-3 rounded-[20px] border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
      <header className="shrink-0 border-b border-white/10 pb-3">
        <div className="mb-2 flex items-center gap-2">
          <span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/12 bg-white/[0.07] text-[#c8cd19]"><Plus className="h-4 w-4" /></span>
          <WorkflowStepBadge step={step} compact />
        </div>
        <h2 className="text-[15px] font-semibold leading-tight text-white">素材输入</h2>
        <p className="mt-1 text-[12px] leading-snug text-white/42">一个素材就是一次文件任务</p>
      </header>

      <div className="flex gap-2">
        <input
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") onSubmitUrl() }}
          placeholder="粘贴 TK / 信息流视频链接"
          className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-[#a2c638]/60"
        />
        <button
          type="button"
          onClick={onStartProduction}
          disabled={data.submitting || (!url.trim() && !job)}
          className="skg-primary-action inline-flex h-10 items-center justify-center px-3 text-[13px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-45"
        >
          {actionLabel}
        </button>
        <button
          type="button"
          onClick={() => fileRef.current?.click()}
          className="skg-secondary-action inline-flex h-10 w-10 items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-45"
          aria-label="上传视频"
          title="上传视频"
        >
          <Upload className="h-4 w-4" />
        </button>
        <input
          ref={fileRef}
          type="file"
          accept="video/*"
          className="hidden"
          onChange={(e) => {
            const file = e.target.files?.[0]
            if (file) data.onUploadFile(file)
            e.currentTarget.value = ""
          }}
        />
      </div>

      <div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
        {jobs.length ? jobs.map((item, index) => (
          <MaterialCard
            key={item.id}
            job={item}
            index={index}
            active={item.id === activeJobId}
            onClick={() => data.onSwitchJob(item.id)}
            onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined}
          />
        )) : (
          <EmptyState text="还没有素材。每导入一个链接或上传一个文件，就会新增一个素材任务。" />
        )}
      </div>
    </section>
  )
}

function AudioIntakePanel({
  job,
  selectedFrames,
  onToggleFrame,
  onJobUpdate,
  onAddFrame,
  onDeleteFrame,
  runtimeModels,
}: {
  job: Job | null
  selectedFrames: Set<number>
  onToggleFrame: (idx: number) => void
  onJobUpdate: (job: Job) => void
  onAddFrame?: (jobId: string, t: number) => Promise<Job | void> | Job | void
  onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
  runtimeModels?: RuntimeModels
}) {
  const [currentTime, setCurrentTime] = useState(0)
  const [mediaDuration, setMediaDuration] = useState(0)
  const [audioFeatures, setAudioFeatures] = useState<AudioFeature[]>([])
  const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
  const [manualBusy, setManualBusy] = useState(false)
  const [extracting, setExtracting] = useState(false)
  const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
  const [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
  const [filmstripDensity, setFilmstripDensity] = useState<FilmstripDensitySeconds>(2)
  const [filmstripPreviews, setFilmstripPreviews] = useState<FilmstripPreviewFrame[]>([])
  const [filmstripStatus, setFilmstripStatus] = useState<FilmstripStatus>("idle")
  const [filmstripDragTime, setFilmstripDragTime] = useState<number | null>(null)
  const [filmstripBusyTime, setFilmstripBusyTime] = useState<number | null>(null)
  const videoRef = useRef<HTMLVideoElement | null>(null)
  const transcriptScrollRef = useRef<HTMLDivElement | null>(null)
  const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
  const syncFrameRef = useRef<number | null>(null)
  const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : ""
  const videoSrcUrl = job ? apiAssetUrl(job.video_url) || videoUrl(job.id) : ""
  const processing = isAudioProcessing(job)
  const timelineDuration = useMemo(() => {
    if (!job) return 1
    const lastTranscriptEnd = job.transcript.reduce((max, segment) => Math.max(max, segment.end || 0), 0)
    return Math.max(
      mediaDuration,
      job.duration ?? 0,
      lastTranscriptEnd,
      1,
    )
  }, [job, mediaDuration])
  const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2))
  const frames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
  const waveTimeHint = waveHoverTime !== null
    ? `指针停点 ${waveHoverTime.toFixed(1)}s`
    : activeSegment
      ? `当前句 ${activeSegment.start.toFixed(1)}-${activeSegment.end.toFixed(1)}s`
      : "指针 -"

  useEffect(() => {
    if (!job?.id || !audioSrcUrl) {
      setAudioFeatures([])
      setAudioFeatureStatus("idle")
      return
    }
    setCurrentTime(0)
    setMediaDuration(0)
    setAudioFeatures([])
    setAudioFeatureStatus("loading")
    let cancelled = false
    decodeAudioFeatures(audioSrcUrl)
      .then((next) => {
        if (!cancelled) {
          setAudioFeatures(next)
          setAudioFeatureStatus("ready")
        }
      })
      .catch(() => {
        if (!cancelled) {
          setAudioFeatures([])
          setAudioFeatureStatus("failed")
        }
      })
    return () => { cancelled = true }
  }, [audioSrcUrl, job?.id])

  useEffect(() => {
    const container = transcriptScrollRef.current
    const row = activeSegment ? rowRefs.current[activeSegment.index] : null
    if (!container || !row) return
    const containerRect = container.getBoundingClientRect()
    const rowRect = row.getBoundingClientRect()
    if (rowRect.top < containerRect.top) {
      container.scrollTop -= containerRect.top - rowRect.top
    } else if (rowRect.bottom > containerRect.bottom) {
      container.scrollTop += rowRect.bottom - containerRect.bottom
    }
  }, [activeSegment?.index])

  useEffect(() => {
    if (!job?.id || !job.video_url || !videoSrcUrl || timelineDuration <= 0) {
      setFilmstripPreviews([])
      setFilmstripStatus("idle")
      return
    }
    const cacheKey = filmstripCacheKey(job.id, videoSrcUrl, filmstripDensity, timelineDuration)
    const cached = filmstripPreviewCache.get(cacheKey)
    if (cached) {
      setFilmstripPreviews(cached)
      setFilmstripStatus(cached.length ? "ready" : "idle")
      return
    }
    let cancelled = false
    setFilmstripPreviews([])
    setFilmstripStatus("loading")
    captureVideoFilmstrip(videoSrcUrl, timelineDuration, filmstripDensity, () => cancelled)
      .then((frames) => {
        if (!cancelled) {
          rememberFilmstripPreview(cacheKey, frames)
          setFilmstripPreviews(frames)
          setFilmstripStatus(frames.length ? "ready" : "idle")
        }
      })
      .catch(() => {
        if (!cancelled) {
          setFilmstripPreviews([])
          setFilmstripStatus("failed")
        }
      })
    return () => { cancelled = true }
  }, [filmstripDensity, job?.id, job?.video_url, timelineDuration, videoSrcUrl])

  useEffect(() => {
    return () => {
      if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
    }
  }, [])

  const stopFrameSync = () => {
    if (syncFrameRef.current !== null) {
      cancelAnimationFrame(syncFrameRef.current)
      syncFrameRef.current = null
    }
    if (videoRef.current) setCurrentTime(videoRef.current.currentTime)
  }

  const startFrameSync = () => {
    if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
    const tick = () => {
      const video = videoRef.current
      if (!video || video.paused || video.ended) {
        stopFrameSync()
        return
      }
      setCurrentTime(video.currentTime)
      syncFrameRef.current = requestAnimationFrame(tick)
    }
    syncFrameRef.current = requestAnimationFrame(tick)
  }

  const seekTo = (time: number) => {
    const next = clampNumber(time, 0, timelineDuration)
    if (videoRef.current) videoRef.current.currentTime = next
    setCurrentTime(next)
  }

  const addFrameAtCurrentTime = async () => {
    if (!job || !onAddFrame) return
    const next = clampNumber(currentTime, 0, timelineDuration)
    setManualBusy(true)
    try {
      await onAddFrame(job.id, next)
    } finally {
      setManualBusy(false)
    }
  }

  const addFilmstripFrame = async (time: number) => {
    if (!job || !onAddFrame) return null
    const next = clampNumber(time, 0, timelineDuration)
    const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45)
    if (duplicate) {
      toast.warning(`附近已有关键帧：${duplicate.timestamp.toFixed(1)}s`)
      return duplicate
    }
    setFilmstripBusyTime(next)
    try {
      const known = new Set(frames.map((frame) => frame.index))
      const updated = await onAddFrame(job.id, next)
      toast.success(`已加入关键帧：${next.toFixed(1)}s`)
      const updatedJob = updated && typeof updated === "object" && "frames" in updated ? updated : null
      const added = updatedJob?.frames.find((frame) => !known.has(frame.index) && Math.abs(frame.timestamp - next) < 0.45) ?? null
      return added
    } finally {
      setFilmstripBusyTime(null)
      setFilmstripDragTime(null)
    }
  }

  const extractKeyframes = async () => {
    if (!job) return
    setExtracting(true)
    try {
      for (const frame of job.frames) {
        if (selectedFrames.has(frame.index)) onToggleFrame(frame.index)
      }
      const updated = await analyzeJob(job.id, 12, "motion", "replace", "accurate")
      onJobUpdate(updated)
      toast.info("已按动作峰值逻辑重新抽取 12 张参考帧，完成后在时间轴左侧选择主角参考。")
    } catch (e) {
      toast.error("12 张关键帧抽取失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setExtracting(false)
    }
  }

  const deleteReferenceFrame = async (idx: number) => {
    if (!job || !onDeleteFrame) return
    setDeletingFrame(idx)
    try {
      await onDeleteFrame(job.id, idx)
    } finally {
      setDeletingFrame(null)
    }
  }

  if (!job) {
    return <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后，会先下载视频，再自动解析原音频文案、讲话人节奏和背景音。" />
  }

  return (
    <section className="skg-glass-card border border-white/10 p-3">
      <div className="mb-2 flex items-center justify-between gap-3">
        <SectionTitle icon={<Film className="h-4 w-4" />} title="源视频工作区" />
        <div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
          <span>{job.transcript.length} 段</span>
          <span>{formatSeconds(job.duration)}</span>
        </div>
      </div>

      <div className="grid gap-2 border-t border-white/8 pt-2">
        <div className="grid gap-2">
          <div
            className="grid gap-3"
            style={{ gridTemplateColumns: `${SOURCE_LEFT_COLUMN_WIDTH}px minmax(0,1fr)` }}
          >
            <div className="min-w-0 space-y-2">
              <div className="mb-2 flex items-center justify-between gap-3">
                <SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
                <span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s</span>
              </div>
              <div
                className="relative mx-auto aspect-[9/16] overflow-hidden rounded-[20px] border border-white/10 bg-black shadow-[10px_10px_10px_rgba(0,0,0,0.3)]"
                style={{ height: SOURCE_VIDEO_HEIGHT }}
              >
                {job.video_url ? (
                  <video
                    ref={videoRef}
                    controls
                    playsInline
                    className="h-full w-full bg-black object-contain"
                    src={videoSrcUrl}
                    onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
                    onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
                    onPlay={startFrameSync}
                    onPlaying={startFrameSync}
                    onPause={stopFrameSync}
                    onEnded={stopFrameSync}
                    onLoadedMetadata={(event) => {
                      setMediaDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)
                      setCurrentTime(event.currentTarget.currentTime)
                    }}
                  />
                ) : (
                  <div className="flex h-full items-center justify-center text-[12px] text-white/38">等待原视频</div>
                )}
                <button
                  type="button"
                  onClick={() => void addFrameAtCurrentTime()}
                  disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
                  title={`按当前播放位置手动抽帧：${currentTime.toFixed(1)}s`}
                  className="absolute right-2 top-2 inline-flex h-7 items-center justify-center gap-1 rounded-md border border-[#a2c638]/35 bg-black/78 px-2 text-[10.5px] font-semibold text-[#e0f5a0] shadow-lg backdrop-blur transition hover:border-[#a2c638]/70 hover:bg-[#a2c638]/18 disabled:cursor-not-allowed disabled:opacity-35"
                >
                  {manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
                  当前点抽帧
                </button>
              </div>
              <TranscriptTimelinePanel
                job={job}
                processing={processing}
                activeSegmentIndex={activeSegment?.index ?? null}
                scrollRef={transcriptScrollRef}
                rowRefs={rowRefs}
                onSeek={seekTo}
              />
            </div>

            <div className="min-w-0 space-y-2">
              <div className="skg-glass-card--flat relative z-40 overflow-visible border border-white/10 p-2">
                <div className="mb-1 flex items-center justify-end gap-3 text-[10px] text-white/40">
                  <FilmstripDensityControls
                    density={filmstripDensity}
                    onDensityChange={setFilmstripDensity}
                  />
                  <div className="flex items-center gap-2 font-mono">
                    <span>当前 {currentTime.toFixed(1)}s</span>
                    <span>总 {formatSeconds(timelineDuration)}</span>
                    <span>{waveTimeHint}</span>
                  </div>
                </div>
                <AudioWaveform
                  features={audioFeatures}
                  status={audioFeatureStatus}
                  currentTime={currentTime}
                  hoverTime={waveHoverTime}
                  duration={timelineDuration}
                  segments={job.transcript}
                  onSeek={seekTo}
                  onHoverTimeChange={setWaveHoverTime}
                />
                <TimelineFilmstrip
                  frames={filmstripPreviews}
                  status={filmstripStatus}
                  density={filmstripDensity}
                  duration={timelineDuration}
                  currentTime={currentTime}
                  hoverTime={waveHoverTime}
                  selectedTimes={frames.map((frame) => frame.timestamp)}
                  busyTime={filmstripBusyTime}
                  onSeek={seekTo}
                  onAddFrame={(time) => void addFilmstripFrame(time)}
                  onDragStart={setFilmstripDragTime}
                  onDragEnd={() => setFilmstripDragTime(null)}
                />
              </div>

              <div className="min-w-0">
                <SourceSubjectPipeline
                  job={job}
                  frames={frames}
                  selectedFrames={selectedFrames}
                  extracting={extracting}
                  deletingFrame={deletingFrame}
                  onToggleFrame={onToggleFrame}
                  onExtract={() => void extractKeyframes()}
                  onDeleteFrame={onDeleteFrame ? (idx) => void deleteReferenceFrame(idx) : undefined}
                  onJobUpdate={onJobUpdate}
                  runtimeModels={runtimeModels}
                  filmstripDragging={filmstripDragTime !== null}
                  onDropFilmstripFrame={(time) => addFilmstripFrame(time)}
                />
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  )
}

function TranscriptTimelinePanel({
  job,
  processing,
  activeSegmentIndex,
  scrollRef,
  rowRefs,
  onSeek,
}: {
  job: Job
  processing: boolean
  activeSegmentIndex: number | null
  scrollRef: RefObject<HTMLDivElement | null>
  rowRefs: { current: Record<number, HTMLDivElement | null> }
  onSeek: (time: number) => void
}) {
  return (
    <div className="min-w-0">
      <div className="mb-2 flex items-center justify-between gap-3">
        <SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
        <span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} 段</span>
      </div>
      {job.transcript.length ? (
        <div className="overflow-hidden rounded-md border border-white/10">
          <div className="grid grid-cols-[68px_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-[11px] font-semibold text-white/50">
            <div>时间</div>
            <div>原文 / 中文</div>
          </div>
          <div ref={scrollRef} className="overflow-y-auto" style={{ maxHeight: SOURCE_TRANSCRIPT_MAX_HEIGHT }}>
            {job.transcript.map((segment) => {
              const active = activeSegmentIndex === segment.index
              return (
                <div
                  key={segment.index}
                  ref={(node) => { rowRefs.current[segment.index] = node }}
                  onClick={() => onSeek(segment.start)}
                  className={`grid cursor-pointer grid-cols-[68px_minmax(0,1fr)] items-start gap-2 border-b px-2.5 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
                    active
                      ? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
                      : "border-white/8 text-white/64 hover:bg-white/[0.045]"
                  }`}
                >
                  <div className={`pt-0.5 font-mono text-[10px] leading-tight ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
                  <div className="min-w-0">
                    <div className="line-clamp-2 break-words" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
                    <div className={`mt-0.5 line-clamp-2 break-words text-[11px] ${active ? "text-emerald-50/80" : "text-white/42"}`} title={segment.zh}>{segment.zh || <span className="text-white/30">翻译中</span>}</div>
                  </div>
                </div>
              )
            })}
          </div>
        </div>
      ) : (
        <EmptyState text={processing ? "音频解析中，完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频；也可以点击右上角“解析音频”手动重试。"} />
      )}
    </div>
  )
}

function TimelineFilmstrip({
  frames,
  status,
  density,
  duration,
  currentTime,
  hoverTime,
  selectedTimes,
  busyTime,
  onSeek,
  onAddFrame,
  onDragStart,
  onDragEnd,
}: {
  frames: FilmstripPreviewFrame[]
  status: FilmstripStatus
  density: FilmstripDensitySeconds
  duration: number
  currentTime: number
  hoverTime: number | null
  selectedTimes: number[]
  busyTime: number | null
  onSeek: (time: number) => void
  onAddFrame: (time: number) => void
  onDragStart: (time: number) => void
  onDragEnd: () => void
}) {
  const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100)
  const hoverPct = hoverTime === null ? null : clampNumber((hoverTime / Math.max(duration, 1)) * 100, 0, 100)
  const [hoverPreview, setHoverPreview] = useState<FilmstripHoverPreview | null>(null)

  const showHoverPreview = (
    event: ReactMouseEvent<HTMLDivElement>,
    frame: FilmstripPreviewFrame,
    active: boolean,
    selected: boolean,
    busy: boolean,
  ) => {
    const tile = event.currentTarget.querySelector("[data-filmstrip-tile]")
    const rect = (tile instanceof HTMLElement ? tile : event.currentTarget).getBoundingClientRect()
    const width = rect.width * FILMSTRIP_HOVER_SCALE
    const height = rect.height * FILMSTRIP_HOVER_SCALE
    const margin = 10
    const left = clampNumber(rect.left + rect.width / 2 - width / 2, margin, Math.max(margin, window.innerWidth - width - margin))
    const top = clampNumber(rect.bottom - height - 12, margin, Math.max(margin, window.innerHeight - height - margin))
    setHoverPreview({
      src: frame.src,
      time: frame.time,
      left,
      top,
      width,
      height,
      active,
      selected,
      busy,
    })
  }

  useEffect(() => {
    if (!frames.length) setHoverPreview(null)
  }, [frames.length])

  return (
    <div className="relative z-[80] mt-1 overflow-visible pt-0.5">
      <div className="relative h-[172px] overflow-visible">
        {status === "loading" ? (
          <div className="absolute inset-x-0 top-12 flex h-[72px] items-center justify-center gap-2 rounded-md border border-dashed border-white/12 text-[11px] text-white/40">
            <Loader2 className="h-3.5 w-3.5 animate-spin" />
            正在生成临时胶片
          </div>
        ) : status === "failed" ? (
          <div className="absolute inset-x-0 top-12 flex h-[72px] items-center justify-center rounded-md border border-dashed border-rose-200/20 text-[11px] text-rose-100/68">
            胶片预览生成失败，可继续用当前点抽帧。
          </div>
        ) : frames.length ? (
          <div className="absolute bottom-7 left-0 right-0 top-1 overflow-visible">
            {hoverPct !== null && (
              <div
                className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-cyan-100/55"
                style={{ left: `${hoverPct}%` }}
              />
            )}
            <div
              className="pointer-events-none absolute bottom-0 top-0 z-10 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.8)]"
              style={{ left: `${pointerPct}%` }}
            />
            {frames.map((frame, index) => {
              const selected = selectedTimes.some((time) => Math.abs(time - frame.time) < 0.45)
              const active = Math.abs(currentTime - frame.time) <= Math.max(density * 0.45, 0.45)
              const busy = busyTime !== null && Math.abs(busyTime - frame.time) < 0.45
              const tiltClass = FILMSTRIP_TILT_CLASSES[index % FILMSTRIP_TILT_CLASSES.length]
              const verticalClass = FILMSTRIP_VERTICAL_OFFSET_CLASSES[index % FILMSTRIP_VERTICAL_OFFSET_CLASSES.length]
              const framePct = clampNumber((frame.time / Math.max(duration, 1)) * 100, 0, 100)
              return (
                <div
                  key={`${frame.time}-${index}`}
                  draggable={!busy}
                  onMouseEnter={(event) => showHoverPreview(event, frame, active, selected, busy)}
                  onMouseMove={(event) => showHoverPreview(event, frame, active, selected, busy)}
                  onMouseLeave={() => setHoverPreview(null)}
                  onDoubleClick={(event) => {
                    event.preventDefault()
                    if (!busy) onAddFrame(frame.time)
                  }}
                  onDragStart={(event) => {
                    setHoverPreview(null)
                    event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2))
                    event.dataTransfer.effectAllowed = "copy"
                    onDragStart(frame.time)
                  }}
                  onDragEnd={onDragEnd}
                  className={`absolute bottom-[58px] z-20 -translate-x-1/2 ${verticalClass} ${tiltClass} origin-bottom cursor-grab transition-transform duration-150 will-change-transform hover:z-[90] hover:-translate-y-1 hover:rotate-0 active:cursor-grabbing`}
                  style={{ left: `${framePct}%` }}
                  title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`}
                >
                  <div className="absolute left-1/2 top-full h-4 w-px -translate-x-1/2 bg-white/18" />
                  <div data-filmstrip-tile className="h-[72px] w-[42px]">
                    <MediaAssetTile
                      src={frame.src}
                      alt={`胶片 ${frame.time.toFixed(1)}s`}
                      label="临时胶片"
                      meta={`${frame.time.toFixed(1)}s`}
                      className={`h-full w-full rounded-md shadow-[0_10px_26px_rgba(0,0,0,0.36)] ${
                        active ? "ring-1 ring-[#d6b36a]/75" : ""
                      }`}
                      mediaClassName="bg-black"
                      objectFit="contain"
                      disablePreview
                      selected={selected}
                      onClick={() => onSeek(frame.time)}
                      title="单击跳到该时间点，双击或拖入参考帧池才正式选取"
                      topLeft={selected ? <span className="rounded bg-emerald-500/85 px-1 text-[8.5px] font-semibold text-black">已添加</span> : undefined}
                      topRight={busy ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : undefined}
                      bottom={<span className={`block rounded px-1 py-0.5 text-center font-mono text-[9px] ${selected ? "bg-emerald-400/82 text-black" : "bg-black/74 text-white/68"}`}>{selected ? "已添加" : `${frame.time.toFixed(1)}s`}</span>}
                    />
                  </div>
                </div>
              )
            })}
          </div>
        ) : (
          <div className="absolute inset-x-0 top-12 flex h-[72px] items-center justify-center rounded-md border border-dashed border-white/12 text-[11px] text-white/34">
            等待原视频生成临时胶片
          </div>
        )}
        <div className="pointer-events-none absolute bottom-2 left-0 right-0 flex items-center justify-between font-mono text-[9.5px] text-white/30">
          <span>0s</span>
          <span>{formatSeconds(duration)}</span>
        </div>
      </div>
      {hoverPreview && typeof document !== "undefined"
        ? createPortal(
            <div
              className="pointer-events-none fixed z-[10000]"
              style={{
                left: hoverPreview.left,
                top: hoverPreview.top,
                width: hoverPreview.width,
                height: hoverPreview.height,
              }}
            >
              <MediaAssetTile
                src={hoverPreview.src}
                alt={`胶片 ${hoverPreview.time.toFixed(1)}s`}
                label="临时胶片"
                meta={`${hoverPreview.time.toFixed(1)}s`}
                className={`h-full w-full rounded-xl shadow-[0_24px_70px_rgba(0,0,0,0.45)] ${
                  hoverPreview.active ? "ring-1 ring-[#d6b36a]/80" : ""
                }`}
                mediaClassName="bg-black"
                objectFit="contain"
                disablePreview
                selected={hoverPreview.selected}
                topLeft={hoverPreview.selected ? <span className="rounded-md bg-emerald-500/88 px-2 py-1 text-[22px] font-semibold leading-none text-black">已添加</span> : undefined}
                topRight={hoverPreview.busy ? <Loader2 className="h-6 w-6 animate-spin text-cyan-100" /> : hoverPreview.selected ? <Check className="h-6 w-6 text-emerald-200" /> : undefined}
                bottom={<span className={`block rounded-md px-2 py-1 text-center font-mono text-[42px] leading-none ${hoverPreview.selected ? "bg-emerald-400/86 text-black" : "bg-black/74 text-white/68"}`}>{hoverPreview.selected ? `已添加 · ${hoverPreview.time.toFixed(1)}s` : `${hoverPreview.time.toFixed(1)}s`}</span>}
              />
            </div>,
            document.body,
          )
        : null}
    </div>
  )
}

function FilmstripDensityControls({
  density,
  onDensityChange,
}: {
  density: FilmstripDensitySeconds
  onDensityChange: (density: FilmstripDensitySeconds) => void
}) {
  return (
    <div className="flex shrink-0 items-center gap-1">
      {FILMSTRIP_DENSITIES.map((item) => (
        <button
          key={item.value}
          type="button"
          onClick={() => onDensityChange(item.value)}
          aria-label={`胶片密度：${item.detail}`}
          className={`h-6 rounded-md border px-2 text-[10.5px] font-semibold transition ${
            density === item.value
              ? "border-[#d6b36a]/70 bg-[#d6b36a]/18 text-[#f7df9a]"
              : "border-white/10 bg-white/[0.035] text-white/48 hover:border-white/22 hover:text-white/72"
          }`}
          title={item.detail}
        >
          {item.label}
        </button>
      ))}
    </div>
  )
}

function SourceSubjectPipeline({
  job,
  frames,
  selectedFrames,
  extracting,
  deletingFrame,
  onToggleFrame,
  onExtract,
  onDeleteFrame,
  onJobUpdate,
  runtimeModels,
  filmstripDragging,
  onDropFilmstripFrame,
}: {
  job: Job
  frames: KeyFrame[]
  selectedFrames: Set<number>
  extracting: boolean
  deletingFrame: number | null
  onToggleFrame: (idx: number) => void
  onExtract: () => void
  onDeleteFrame?: (idx: number) => void
  onJobUpdate: (job: Job) => void
  runtimeModels?: RuntimeModels
  filmstripDragging?: boolean
  onDropFilmstripFrame?: (time: number) => Promise<KeyFrame | null> | KeyFrame | null | void
}) {
  const [referenceDropActive, setReferenceDropActive] = useState(false)
  const [agentDropActive, setAgentDropActive] = useState(false)
  const [referenceFrameDragging, setReferenceFrameDragging] = useState(false)
  const [agentReferenceUploadBusy, setAgentReferenceUploadBusy] = useState(false)
  const [reconstructionDirections, setReconstructionDirections] = useState<Record<SubjectReconstructionMode, string>>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS }))
  const [subjectModelBundle, setSubjectModelBundle] = useState<SubjectModelBundle>(() => job.subject_agent?.model_bundle ?? "gpt")
  const [agentReferenceFrameIndices, setAgentReferenceFrameIndices] = useState<number[]>(() => job.subject_agent?.source_frame_indices ?? [])
  const [agentMode, setAgentMode] = useState<SubjectReconstructionMode>(() => job.subject_agent?.selected_mode ?? "custom")
  const [agentQuantity, setAgentQuantity] = useState(() => job.subject_agent?.quantity ?? 6)
  const [agentRequirement, setAgentRequirement] = useState(() => job.subject_agent?.requirements_zh ?? "")
  const [agentPrompt, setAgentPrompt] = useState(() => job.subject_agent?.generation_prompt_en ?? "")
  const [agentSelectedTraits, setAgentSelectedTraits] = useState<string[]>(() => job.subject_agent?.selected_traits ?? [])
  const [agentInput, setAgentInput] = useState("")
  const [subjectAgentBusy, setSubjectAgentBusy] = useState<"analyze" | "message" | null>(null)
  const [promptMemoryByMode, setPromptMemoryByMode] = useState<Record<SubjectReconstructionMode, string[]>>(() => loadSubjectPromptMemory(job.id))
  const [cartoonStyle] = useState<CartoonReconstructionStyle>("3d_animation")
  const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string; modelLabel: string } | null>(null)
  const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
  const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState<string | null>(null)
  const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
  const subjectBusy = !!subjectBusyFor
  const selectedSubjectViews = useMemo(() => subjectViewsForQuantity(agentQuantity), [agentQuantity])
  const allConversionFrameIndices = useMemo(
    () => new Set(agentReferenceFrameIndices),
    [agentReferenceFrameIndices],
  )
  const agentReferenceFrames = useMemo(
    () => agentReferenceFrameIndices
      .map((index) => frames.find((frame) => frame.index === index))
      .filter((frame): frame is KeyFrame => !!frame),
    [agentReferenceFrameIndices, frames],
  )
  const persistedAgentSelectedTraits = job.subject_agent?.selected_traits ?? []
  const agentSelectedTraitsDirty = agentSelectedTraits.length !== persistedAgentSelectedTraits.length
    || agentSelectedTraits.some((trait) => !persistedAgentSelectedTraits.includes(trait))
    || persistedAgentSelectedTraits.some((trait) => !agentSelectedTraits.includes(trait))
  const actorSources = useMemo(() => {
    const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode }> = []
    for (const frame of frames) {
      for (const element of frame.elements || []) {
        const mode = reconstructionModeFromElement(element) ?? (isSimilarActorElement(element) ? "realistic" : null)
        if (mode && element.subject_assets?.length) items.push({ frame, element, mode })
      }
    }
    return items
  }, [frames])
  const subjectAssetPacks = useMemo<SubjectAssetPack[]>(() => {
    const packs = new Map<string, SubjectAssetPack>()
    for (const source of actorSources) {
      for (const asset of source.element.subject_assets ?? []) {
        const key = subjectAssetPackKey(source.frame, source.element, asset)
        const rawMode = asset.pack_mode as SubjectReconstructionMode | undefined
        const packMode = rawMode && RECONSTRUCTION_MODES.some((item) => item.value === rawMode) ? rawMode : source.mode
        const createdAt = asset.pack_created_at || asset.created_at || 0
        const existing = packs.get(key)
        if (existing) {
          existing.assets.push(asset)
          existing.createdAt = Math.min(existing.createdAt || createdAt, createdAt)
        } else {
          packs.set(key, {
            key,
            id: asset.pack_id || key,
            label: asset.pack_label || `${reconstructionModeConfig(packMode).label}套图`,
            mode: packMode,
            frame: source.frame,
            element: source.element,
            createdAt,
            assets: [asset],
            total: 0,
            completed: 0,
            failed: 0,
            running: false,
          })
        }
      }
    }
    return [...packs.values()].map((pack) => {
      const latestByView = new Map<string, SubjectAsset>()
      for (const asset of pack.assets) {
        const current = latestByView.get(asset.view)
        if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
      }
      const assets = subjectAssetPackSortAssets([...latestByView.values()])
      const completed = assets.filter((asset) => subjectAssetStatus(asset) === "completed").length
      const failed = assets.filter((asset) => subjectAssetStatus(asset) === "failed").length
      const running = assets.some(subjectAssetIsRunning)
      return { ...pack, assets, total: assets.length, completed, failed, running }
    }).sort((a, b) => {
      const mi = RECONSTRUCTION_MODES.findIndex((item) => item.value === a.mode)
      const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
      if (mi !== mj) return mi - mj
      return (b.createdAt || 0) - (a.createdAt || 0)
    })
  }, [actorSources])
  const activeSubjectPack = useMemo(
    () => subjectAssetPacks.find((pack) => pack.key === expandedSubjectPackKey) ?? subjectAssetPacks[0] ?? null,
    [expandedSubjectPackKey, subjectAssetPacks],
  )
  const runningActorModes = useMemo(() => {
    const next = new Set<SubjectReconstructionMode>()
    for (const pack of subjectAssetPacks) {
      if (pack.running) next.add(pack.mode)
    }
    return next
  }, [subjectAssetPacks])

  useEffect(() => {
    setReconstructionDirections({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS })
    setSubjectModelBundle(job.subject_agent?.model_bundle ?? "gpt")
    setAgentReferenceFrameIndices(job.subject_agent?.source_frame_indices ?? [])
    setAgentMode(job.subject_agent?.selected_mode ?? "custom")
    setAgentQuantity(job.subject_agent?.quantity ?? 6)
    setAgentRequirement(job.subject_agent?.requirements_zh ?? "")
    setAgentPrompt(job.subject_agent?.generation_prompt_en ?? "")
    setAgentSelectedTraits(job.subject_agent?.selected_traits ?? [])
    setAgentInput("")
    setSubjectAgentBusy(null)
    setPromptMemoryByMode(loadSubjectPromptMemory(job.id))
    setLastSubjectProfile(null)
    setSubjectBusyFor(null)
    setSubjectAssetBusy(null)
    setExpandedSubjectPackKey(null)
  }, [job.id])

  useEffect(() => {
    const agent = job.subject_agent
    setSubjectModelBundle(agent?.model_bundle ?? "gpt")
    setAgentReferenceFrameIndices(agent?.source_frame_indices ?? [])
    setAgentMode(agent?.selected_mode ?? "custom")
    setAgentQuantity(agent?.quantity ?? 6)
    setAgentRequirement(agent?.requirements_zh ?? "")
    setAgentPrompt(agent?.generation_prompt_en ?? "")
    setAgentSelectedTraits(agent?.selected_traits ?? [])
  }, [job.id, job.subject_agent?.updated_at])

  useEffect(() => {
    saveSubjectPromptMemory(job.id, promptMemoryByMode)
  }, [job.id, promptMemoryByMode])

  useEffect(() => {
    if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
      setExpandedSubjectPackKey(null)
    }
  }, [expandedSubjectPackKey, subjectAssetPacks])

  useEffect(() => {
    setAgentReferenceFrameIndices((current) => current.filter((index) => frames.some((frame) => frame.index === index)))
  }, [frames])

  const buildSubjectProfileForRequest = () => {
    const resolved = resolveSubjectProfile("random", randomSubjectProfileDraft())
    setLastSubjectProfile(resolved)
    return resolved
  }

  const rememberPromptForMode = (mode: SubjectReconstructionMode, text = reconstructionDirections[mode]) => {
    setPromptMemoryByMode((current) => ({
      ...current,
      [mode]: mergeSubjectPromptMemory(current[mode] || [], text),
    }))
  }

  const subjectModelLabel = (value: SubjectModelBundle) => subjectModelBundleConfig(value).label

  const generateSubjectPack = async (
    mode: SubjectReconstructionMode,
    sourceIndices = agentReferenceFrameIndices,
    views = selectedSubjectViews,
  ) => {
    if (subjectBusyFor) {
      toast.warning("主体套图正在生成中，完成后再重生。")
      return
    }
    if (runningActorModes.has(mode)) {
      toast.warning(`${reconstructionModeConfig(mode).label}还有主体图正在逐张生成。`)
      return
    }
    const sourceFrames = sourceIndices
      .map((index) => frames.find((frame) => frame.index === index))
      .filter((frame): frame is KeyFrame => !!frame)
    const agentPrompt = (job.subject_agent?.generation_prompt_en || agentRequirement || "").trim()
    const rawDirection = (agentPrompt || reconstructionDirections[mode]).trim()
    const sourceLockedReplication = mode === "realistic" || (mode === "custom" && !rawDirection)
    if (!sourceFrames.length && mode !== "custom") {
      toast.warning(`先把参考帧拖到${reconstructionModeConfig(mode).label}。`)
      return
    }
    if (!sourceFrames.length && sourceLockedReplication) {
      toast.warning("自主描述没有文字时，需要先拖入参考帧用于形象复刻。")
      return
    }
    const baseFrame = sourceFrames[0] ?? frames[0]
    if (!baseFrame) {
      toast.warning("先完成抽帧，或从胶片加入至少一张参考帧。")
      return
    }
    const requestJobId = job.id
    const requestProfile = sourceLockedReplication || (mode === "custom" && rawDirection)
      ? null
      : buildSubjectProfileForRequest()
    const subjectStyle = reconstructionSubjectStyle(mode)
    const userDirection = buildReconstructionDirection(mode, rawDirection, cartoonStyle, views.length)
    rememberPromptForMode(mode, rawDirection)
    const modeName = reconstructionElementName(mode)
    setSubjectBusyFor({
      jobId: requestJobId,
      jobLabel: shortId(requestJobId),
      mode,
      viewCount: views.length,
      sourceCount: sourceFrames.length,
      profileLabel: requestProfile?.summary ?? "按自主描述",
      modelLabel: subjectModelLabel(subjectModelBundle),
    })
    try {
      let workingJob = job
      let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
      let element = workingFrame.elements?.find((item) => reconstructionModeFromElement(item) === mode)
      if (!element) {
        workingJob = await addElement(requestJobId, baseFrame.index, {
          name_zh: modeName.zh,
          name_en: modeName.en,
          position: `${reconstructionModeConfig(mode).label} · generated from conversion layer references`,
          source: "manual",
        })
        onJobUpdate(workingJob)
        workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? workingFrame
        element = workingFrame.elements?.find((item) => reconstructionModeFromElement(item) === mode)
          ?? workingFrame.elements?.[workingFrame.elements.length - 1]
      }
      if (!element) throw new Error("subject element missing")

      const updated = await generateSubjectAssets(requestJobId, baseFrame.index, element.id, {
        subject_kind: "living",
        subject_style: subjectStyle,
        reconstruction_mode: sourceLockedReplication ? "same" : "similar",
        background: "white",
        size: SUBJECT_ASSET_SIZE,
        source_frame_indices: sourceFrames.slice(0, RECONSTRUCTION_FRAME_LIMIT).map((frame) => frame.index),
        views,
        subject_profile: requestProfile?.payload ?? null,
        prompt: sourceLockedReplication
          ? `${buildSourceLockedSubjectPrompt(subjectStyle)} ${userDirection}`
          : buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
        image_model_preference: subjectImageModelFromBundle(subjectModelBundle),
        replace_views: false,
        pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
        pack_mode: mode,
      })
      onJobUpdate(updated)
      const updatedFrame = updated.frames.find((frame) => frame.index === baseFrame.index)
      const updatedElement = updatedFrame?.elements?.find((item) => item.id === element.id)
      const newestAsset = [...(updatedElement?.subject_assets ?? [])].sort((a, b) => (b.pack_created_at || b.created_at || 0) - (a.pack_created_at || a.created_at || 0))[0]
      if (updatedFrame && updatedElement && newestAsset) {
        setExpandedSubjectPackKey(subjectAssetPackKey(updatedFrame, updatedElement, newestAsset))
      }
      toast.success(`${reconstructionModeConfig(mode).label}已提交：${views.length} 张会逐张出来`)
    } catch (e) {
      try {
        onJobUpdate(await getJob(requestJobId))
      } catch { /* keep original error visible */ }
      toast.error(`${reconstructionModeConfig(mode).label}生成失败：` + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectBusyFor(null)
    }
  }

  const regenerateSubjectAsset = async (item: { frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }) => {
    const { frame, element, mode, asset } = item
    const sourceIndices = asset.source_frame_indices?.length
      ? asset.source_frame_indices
      : agentReferenceFrameIndices
    const agentPrompt = (job.subject_agent?.generation_prompt_en || agentRequirement || "").trim()
    const rawDirection = (agentPrompt || reconstructionDirections[mode]).trim()
    const sourceLockedReplication = mode === "realistic" || (mode === "custom" && !rawDirection)
    if (!sourceIndices.length && mode !== "custom") {
      toast.warning("转换层没有参考帧，不能重生。")
      return
    }
    if (!sourceIndices.length && sourceLockedReplication) {
      toast.warning("自主描述没有文字时，需要参考帧才能复刻重生。")
      return
    }
    setSubjectAssetBusy(`regen:${asset.id}`)
    try {
      const requestProfile = sourceLockedReplication || (mode === "custom" && rawDirection)
        ? null
        : lastSubjectProfile ?? buildSubjectProfileForRequest()
      const subjectStyle = reconstructionSubjectStyle(mode)
      rememberPromptForMode(mode, rawDirection)
      const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
        subject_kind: "living",
        subject_style: subjectStyle,
        reconstruction_mode: sourceLockedReplication ? "same" : "similar",
        background: asset.background || "white",
        size: SUBJECT_ASSET_SIZE,
        source_frame_indices: sourceIndices.slice(0, RECONSTRUCTION_FRAME_LIMIT),
        views: [asset.view],
        subject_profile: requestProfile?.payload ?? null,
        prompt: sourceLockedReplication
          ? `${buildSourceLockedSubjectPrompt(subjectStyle)} ${buildReconstructionDirection(mode, rawDirection, cartoonStyle, 1)}`
          : buildSimilarSubjectPrompt(
            subjectStyle,
            buildReconstructionDirection(mode, rawDirection, cartoonStyle, 1),
            null,
            requestProfile,
          ),
        image_model_preference: subjectImageModelFromBundle(subjectModelBundle),
        replace_views: true,
        pack_id: asset.pack_id ?? "",
        pack_label: asset.pack_label ?? "",
        pack_mode: asset.pack_mode ?? mode,
        pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
      })
      onJobUpdate(updated)
      toast.success("已提交重生，这张主体元素会生成完成后替换")
    } catch (e) {
      toast.error("主体元素重生失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectAssetBusy(null)
    }
  }

  const deleteActorAsset = async (item: { frame: KeyFrame; element: KeyElement; asset: SubjectAsset }) => {
    const { frame, element, asset } = item
    setSubjectAssetBusy(`delete:${asset.id}`)
    try {
      const updated = await deleteSubjectAsset(job.id, frame.index, element.id, asset.id)
      onJobUpdate(updated)
      toast.success("主体元素已删除")
    } catch (e) {
      toast.error("主体元素删除失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectAssetBusy(null)
    }
  }

  const mergeAgentReferenceIndices = (current: number[], incoming: number[]) => {
    let replaced = false
    const next = [...current]
    for (const index of incoming) {
      const numericIndex = Number(index)
      if (!Number.isFinite(numericIndex) || next.includes(numericIndex)) continue
      next.push(numericIndex)
      while (next.length > RECONSTRUCTION_FRAME_LIMIT) {
        next.shift()
        replaced = true
      }
    }
    return { next, replaced }
  }

  const addAgentReferenceFrame = (frame: KeyFrame) => {
    setAgentReferenceFrameIndices((current) => {
      if (current.includes(frame.index)) {
        toast.info("这张参考帧已经在转换层里。")
        return current
      }
      const { next, replaced } = mergeAgentReferenceIndices(current, [frame.index])
      if (replaced) {
        toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图，已替换为最近拖入的组合。`)
      } else {
        toast.info(`已加入转换层：${frame.timestamp.toFixed(1)}s。`)
      }
      return next
    })
  }

  const addAgentReferenceIndices = (indices: number[], notice = "已加入转换层") => {
    if (!indices.length) return
    setAgentReferenceFrameIndices((current) => {
      const { next, replaced } = mergeAgentReferenceIndices(current, indices)
      if (next.length === current.length && next.every((item, idx) => item === current[idx])) {
        toast.info("这些参考图已经在转换层里。")
        return current
      }
      if (replaced) {
        toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图，已保留最近加入的组合。`)
      } else {
        toast.success(`${notice}：${indices.length} 张。`)
      }
      return next
    })
  }

  const removeAgentReferenceFrame = (frameIndex: number) => {
    setAgentReferenceFrameIndices((current) => current.filter((index) => index !== frameIndex))
  }

  const transferHasAgentReference = (transfer: DataTransfer) => {
    const types = Array.from(transfer.types || [])
    return (
      types.includes(SOURCE_KEYFRAME_DRAG_TYPE) ||
      types.includes(FILMSTRIP_DRAG_TYPE) ||
      types.includes("Files")
    )
  }

  const handleAgentReferenceDragEnter = (event: ReactDragEvent<HTMLElement>) => {
    if (!transferHasAgentReference(event.dataTransfer)) return
    event.preventDefault()
    setAgentDropActive(true)
  }

  const handleAgentReferenceDragOver = (event: ReactDragEvent<HTMLElement>) => {
    if (!transferHasAgentReference(event.dataTransfer)) return
    event.preventDefault()
    event.dataTransfer.dropEffect = "copy"
    setAgentDropActive(true)
  }

  const handleAgentReferenceDragLeave = (event: ReactDragEvent<HTMLElement>) => {
    const next = event.relatedTarget as Node | null
    if (next && event.currentTarget.contains(next)) return
    setAgentDropActive(false)
  }

  const uploadAgentReferenceFiles = async (files: File[]) => {
    const imageFiles = files.filter((file) => {
      const name = file.name.toLowerCase()
      return file.type.startsWith("image/") || /\.(jpe?g|png|webp|bmp)$/i.test(name)
    }).slice(0, RECONSTRUCTION_FRAME_LIMIT)
    if (!imageFiles.length) {
      toast.warning("只支持拖入图片文件。")
      return
    }
    setAgentReferenceUploadBusy(true)
    try {
      let workingJob = job
      const known = new Set(job.frames.map((frame) => frame.index))
      const addedIndices: number[] = []
      for (const file of imageFiles) {
        const updated = await uploadReferenceFrame(workingJob.id, file)
        workingJob = updated
        onJobUpdate(updated)
        const added = updated.frames.filter((frame) => !known.has(frame.index))
        added.forEach((frame) => {
          known.add(frame.index)
          addedIndices.push(frame.index)
        })
      }
      addAgentReferenceIndices(addedIndices, "已上传并加入转换层")
    } catch (e) {
      toast.error("参考图上传失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setAgentReferenceUploadBusy(false)
    }
  }

  const handleAgentReferenceDrop = async (event: ReactDragEvent<HTMLElement>) => {
    if (!transferHasAgentReference(event.dataTransfer)) return
    event.preventDefault()
    setAgentDropActive(false)
    const files = Array.from(event.dataTransfer.files || [])
    if (files.length) {
      await uploadAgentReferenceFiles(files)
      return
    }
    const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
    if (Number.isFinite(frameIndex)) {
      const frame = frames.find((item) => item.index === frameIndex)
      if (frame) addAgentReferenceFrame(frame)
      return
    }
    const filmstripTime = Number(event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE))
    if (Number.isFinite(filmstripTime) && onDropFilmstripFrame) {
      const addedFrame = await onDropFilmstripFrame(filmstripTime)
      if (addedFrame) addAgentReferenceFrame(addedFrame)
    }
  }

  const runSubjectAgentAnalyze = async () => {
    if (!agentReferenceFrameIndices.length) {
      toast.warning("先从左侧拖入 1-3 张参考帧，再开始分析。")
      return
    }
    setSubjectAgentBusy("analyze")
    try {
      const updated = await analyzeSubjectAgent(job.id, {
        model_bundle: subjectModelBundle,
        source_frame_indices: agentReferenceFrameIndices,
      })
      onJobUpdate(updated)
      setAgentSelectedTraits(updated.subject_agent?.selected_traits ?? [])
      toast.success("转换层分析完成")
    } catch (e) {
      toast.error("转换层分析失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectAgentBusy(null)
    }
  }

  const sendSubjectAgentRequirement = async (message = agentInput) => {
    const text = message.trim()
    if (!text && !agentRequirement.trim() && !agentSelectedTraits.length && !agentSelectedTraitsDirty) {
      toast.warning("先写一句要怎么生成，或者选择要保留的识别元素。")
      return
    }
    setSubjectAgentBusy("message")
    try {
      const updated = await sendSubjectAgentMessage(job.id, {
        model_bundle: subjectModelBundle,
        source_frame_indices: agentReferenceFrameIndices,
        selected_mode: agentMode,
        selected_traits: agentSelectedTraits,
        requirements_zh: agentRequirement,
        message: text,
        quantity: agentQuantity,
      })
      onJobUpdate(updated)
      const nextAgent = updated.subject_agent
      if (nextAgent) {
        setAgentMode(nextAgent.selected_mode)
        setAgentQuantity(nextAgent.quantity)
        setAgentRequirement(nextAgent.requirements_zh)
        setAgentPrompt(nextAgent.generation_prompt_en)
        setAgentReferenceFrameIndices(nextAgent.source_frame_indices)
        setAgentSelectedTraits(nextAgent.selected_traits ?? [])
      }
      setAgentInput("")
      toast.success("出图提示词已生成，可以直接确认生成。")
    } catch (e) {
      toast.error("生图要求更新失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectAgentBusy(null)
    }
  }

  const toggleSubjectAgentTrait = (trait: string) => {
    setAgentSelectedTraits((current) => {
      if (current.includes(trait)) return current.filter((item) => item !== trait)
      return [...current, trait].slice(0, 24)
    })
  }

  const subjectAgent = job.subject_agent
  const agentAnalysis = subjectAgent?.analysis ?? null
  const agentTraits = agentAnalysis?.trait_chips ?? []
  const selectedAgentTraits = agentSelectedTraits
  const effectiveAgentMode = subjectAgent?.selected_mode ?? agentMode
  const effectiveAgentQuantity = agentQuantity
  const effectiveAgentViews = subjectViewsForQuantity(effectiveAgentQuantity)
  const effectivePrompt = (agentPrompt || subjectAgent?.generation_prompt_en || "").trim()
  const effectiveRequirement = (subjectAgent?.requirements_zh || agentRequirement).trim()
  const agentInputText = agentInput.trim()
  const agentInputConfirmsGeneration = /^(确认)?(生成|出图|开始生成|确认出图)(\s*\d+\s*张?)?$/.test(agentInputText)
  const canGenerateAgentPack = effectiveAgentMode === "custom"
    ? Boolean(effectiveRequirement || agentReferenceFrames.length)
    : agentReferenceFrames.length > 0
  const agentModeRunning = runningActorModes.has(effectiveAgentMode)
  const showInlineGenerateAction = Boolean(effectivePrompt) && (!agentInputText || agentInputConfirmsGeneration) && !agentSelectedTraitsDirty
  const sendDisabled = !!subjectAgentBusy || (!agentInputText && !agentRequirement.trim() && !selectedAgentTraits.length && !agentSelectedTraitsDirty)
  const generateDisabled = !canGenerateAgentPack || subjectBusy || agentModeRunning
  const confirmSubjectGeneration = () => {
    setAgentInput("")
    void generateSubjectPack(effectiveAgentMode, agentReferenceFrameIndices, effectiveAgentViews)
  }

  return (
    <>
    <div className="space-y-2">
      <div
        className="grid gap-2"
        style={{ gridTemplateColumns: `${SOURCE_REFERENCE_POOL_WIDTH}px minmax(0,1fr)` }}
      >
      <div className="min-w-0">
        <div className="mb-2 flex items-center justify-between gap-2">
          <SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="参考帧池" />
          <button
            type="button"
            onClick={onExtract}
            disabled={!job.video_url || extracting || job.status === "splitting"}
            title="自动按动作峰值抽 12 张参考帧"
            className="skg-primary-action inline-flex h-7 items-center justify-center gap-1 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
          >
            {extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
            12
          </button>
        </div>
        <div
	          className={`skg-glass-card--flat border p-1.5 transition ${
	            filmstripDragging
	              ? referenceDropActive
	                ? "border-[#a2c638]/80 bg-[#a2c638]/12 ring-1 ring-[#a2c638]/45"
	                : "border-[#a2c638]/45 bg-[#a2c638]/[0.065]"
	              : "border-white/10 bg-black/28"
	          }`}
          onDragEnter={(event) => {
            if (!onDropFilmstripFrame) return
            event.preventDefault()
            setReferenceDropActive(true)
          }}
          onDragOver={(event) => {
            if (!onDropFilmstripFrame) return
            event.preventDefault()
            event.dataTransfer.dropEffect = "copy"
          }}
          onDragLeave={(event) => {
            const next = event.relatedTarget as Node | null
            if (next && event.currentTarget.contains(next)) return
            setReferenceDropActive(false)
          }}
          onDrop={(event) => {
            if (!onDropFilmstripFrame) return
            event.preventDefault()
            setReferenceDropActive(false)
            const raw = event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE)
            const time = Number(raw)
            if (Number.isFinite(time)) onDropFilmstripFrame(time)
          }}
        >
          <div className="mb-1 flex items-center justify-between gap-2 text-[9.5px] text-white/30">
            <span>{frames.length} 张</span>
            <span>{filmstripDragging ? "松手加入" : "点击选择"}</span>
          </div>
          <div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: SOURCE_CONVERSION_MAX_HEIGHT }}>
            {frames.map((frame, index) => {
              const selected = selectedFrames.has(frame.index)
              return (
                <div
                  key={frame.index}
                  draggable
                  onDragStart={(event) => {
                    event.dataTransfer.setData(SOURCE_KEYFRAME_DRAG_TYPE, String(frame.index))
                    event.dataTransfer.effectAllowed = "copy"
                    setReferenceFrameDragging(true)
                  }}
                  onDragEnd={() => setReferenceFrameDragging(false)}
                  className="relative cursor-grab active:cursor-grabbing"
                  title="拖到转换层作为生图参考"
                >
                  <MediaAssetTile
                    src={effectiveFrameUrl(job.id, frame)}
                    alt={`参考帧 ${index + 1}`}
                    label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
                    meta={`${frame.timestamp.toFixed(1)}s`}
                    className="mx-auto aspect-[9/16] w-[80px]"
                    objectFit="contain"
                    previewObjectFit="contain"
                    previewPlacement="left"
                    previewMaxWidth={320}
                    previewClassName="p-2"
                    selected={selected}
                    title={selected ? "已选 · 点击取消" : "点击选择"}
                    onClick={() => onToggleFrame(frame.index)}
                    actions={[
                      {
                        key: "send-to-conversion",
                        label: allConversionFrameIndices.has(frame.index) ? "已在转换层" : "送入转换层",
                        icon: allConversionFrameIndices.has(frame.index) ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />,
                        onClick: () => addAgentReferenceFrame(frame),
                        disabled: allConversionFrameIndices.has(frame.index),
                        tone: "cyan",
                      },
                    ]}
                    topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
                    topRight={<span className="rounded-full bg-black/72 p-0.5">{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>}
                    onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined}
                    deleting={deletingFrame === frame.index}
                    deleteLabel={`删除参考帧 ${index + 1}`}
                  />
                </div>
              )
            })}
            {!frames.length && (
              <div className="flex h-28 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10.5px] leading-snug text-white/34">
                自动抽帧，或从上方胶片拖入。
              </div>
            )}
          </div>
        </div>
      </div>

      <div className="min-w-0">
        <div className="mb-2 flex items-center justify-between gap-2">
          <SectionTitle icon={<Wand2 className="h-4 w-4" />} title="转换层" />
          <span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
            {agentReferenceFrames.length ? `${agentReferenceFrames.length}/${RECONSTRUCTION_FRAME_LIMIT} 图` : "待选图"}
          </span>
        </div>
        <div
	          className="skg-glass-card--flat flex flex-col gap-2 overflow-y-auto border border-white/10 p-2"
          style={{ maxHeight: SOURCE_CONVERSION_MAX_HEIGHT }}
        >
          <div className="grid shrink-0 grid-cols-2 gap-1.5">
            {SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
              <button
                key={option.value}
                type="button"
                onClick={() => setSubjectModelBundle(option.value)}
                className={`rounded-md border px-2 py-1.5 text-left transition ${
                  subjectModelBundle === option.value
	                    ? "border-[#a2c638]/65 bg-[#a2c638]/12 text-white"
	                    : "border-white/10 bg-black/24 text-white/52 hover:border-white/24 hover:text-white/76"
                }`}
                title={option.detail}
              >
                <span className="block text-[10px] font-semibold">{option.label}</span>
                <span className="mt-0.5 block truncate text-[8.5px] opacity-65">{option.detail}</span>
              </button>
            ))}
          </div>

          <div
            className={`shrink-0 rounded-md border p-2 transition ${
	              agentDropActive || referenceFrameDragging || filmstripDragging || agentReferenceUploadBusy
	                ? "border-[#a2c638]/65 bg-[#a2c638]/[0.08] ring-1 ring-[#a2c638]/25"
	                : "border-white/10 bg-black/22"
            }`}
            onDragEnter={handleAgentReferenceDragEnter}
            onDragOver={handleAgentReferenceDragOver}
            onDragLeave={handleAgentReferenceDragLeave}
            onDrop={(event) => void handleAgentReferenceDrop(event)}
          >
            <div className="mb-1.5 flex items-center justify-between gap-2">
              <span className="text-[10px] font-semibold text-white/72">参考输入</span>
              <span className="text-[9px] text-white/34">{agentReferenceFrames.length}/{RECONSTRUCTION_FRAME_LIMIT}</span>
            </div>
            {agentReferenceFrames.length ? (
              <div className="flex min-h-[78px] items-center justify-center gap-1.5 overflow-x-auto rounded border border-white/8 bg-black/18 px-1.5 py-1">
                {agentReferenceFrames.map((frame, index) => (
                  <MediaAssetTile
                    key={frame.index}
                    src={effectiveFrameUrl(job.id, frame)}
                    alt={`转换参考 ${index + 1}`}
                    label={`参考 ${index + 1}`}
                    meta={`${frame.timestamp.toFixed(1)}s`}
                    className="aspect-[9/16] w-[50px] shrink-0 bg-black"
                    objectFit="contain"
                    previewObjectFit="contain"
                    previewPlacement="left"
                    previewMaxWidth={300}
                    topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
                    onDelete={() => removeAgentReferenceFrame(frame.index)}
                    deleteLabel="移出转换层"
                  />
                ))}
              </div>
            ) : (
              <div className="flex h-[72px] flex-col items-center justify-center rounded border border-dashed border-white/15 px-3 text-center text-[10px] leading-snug text-white/34">
	                {agentReferenceUploadBusy ? <Loader2 className="mb-1.5 h-4 w-4 animate-spin text-[#d7efbc]" /> : <Upload className="mb-1.5 h-4 w-4 text-[#d7efbc]/70" />}
                <span className="font-semibold text-white/50">拖入参考帧或本地图片</span>
                <span className="mt-0.5 text-white/28">也可点左侧缩略图上的 +</span>
              </div>
            )}
            <div className="mt-2 flex items-center gap-1.5">
              <button
                type="button"
                onClick={() => void runSubjectAgentAnalyze()}
                disabled={!agentReferenceFrames.length || !!subjectAgentBusy || agentReferenceUploadBusy}
                className="skg-secondary-action inline-flex h-7 flex-1 items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
              >
                {subjectAgentBusy === "analyze" || agentReferenceUploadBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
                分析参考图
              </button>
              <span className="shrink-0 rounded border border-white/10 bg-black/24 px-1.5 py-1 text-[9px] text-white/35">
                图片区
              </span>
            </div>
          </div>

          {agentAnalysis ? (
	            <div className="shrink-0 rounded-md border border-[#a2c638]/24 bg-[#a2c638]/[0.07] p-2">
              <div className="flex items-center justify-between gap-2">
	                <span className="text-[10px] font-semibold text-[#d7efbc]">识别结果</span>
                <span className="text-[9px] text-white/34">
                  点亮=保留 · 再点取消
                </span>
              </div>
              <p className="mt-1 max-h-16 overflow-auto text-[9.5px] leading-snug text-white/58">{agentAnalysis.summary_zh}</p>
              {agentTraits.length ? (
                <>
                  <div className="mt-2 flex items-center justify-between gap-2 text-[9px]">
	                    <span className={agentSelectedTraitsDirty ? "text-[#d7efbc]/70" : "text-white/34"}>
                      保留元素 {selectedAgentTraits.length} 个{agentSelectedTraitsDirty ? " · 待发送" : ""}
                    </span>
                    {selectedAgentTraits.length ? (
                      <button
                        type="button"
                        onClick={() => setAgentSelectedTraits([])}
                        className="rounded border border-white/10 bg-black/24 px-1.5 py-0.5 text-white/42 transition hover:border-white/24 hover:text-white/70"
                      >
                        清空
                      </button>
                    ) : null}
                  </div>
                  <div className="mt-1.5 flex max-h-[72px] flex-wrap gap-1 overflow-auto">
                    {agentTraits.slice(0, 12).map((trait) => {
                      const active = selectedAgentTraits.includes(trait)
                      return (
                        <button
                          key={trait}
                          type="button"
                          onClick={() => toggleSubjectAgentTrait(trait)}
                          aria-pressed={active}
                          title={active ? "已作为保留元素，再点取消" : "点一下加入保留元素"}
                          className={`inline-flex min-h-[22px] cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-[9px] transition ${
                            active
	                              ? "border-[#a2c638]/65 bg-[#a2c638]/16 text-[#e9f6b8] shadow-[0_0_0_1px_rgba(162,198,56,0.12)]"
                              : "border-white/10 bg-black/26 text-white/46 hover:border-white/22 hover:text-white/70"
                          }`}
                        >
                          {active ? <Check className="h-2.5 w-2.5" /> : null}
                          {trait}
                        </button>
                      )
                    })}
                  </div>
                </>
              ) : null}
            </div>
          ) : null}

	          <div className="flex shrink-0 flex-col rounded-md border border-white/10 bg-black/22 p-2">
            <div className="mb-1.5 flex items-center justify-between gap-2">
              <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-white/72">
	                <MessageSquare className="h-3.5 w-3.5 text-[#d7efbc]/70" />
                生成要求
              </span>
              {effectivePrompt ? (
	                <span className="inline-flex h-6 items-center gap-1 rounded-md border border-[#a2c638]/28 bg-[#a2c638]/[0.08] px-2 text-[9px] font-semibold text-[#d7efbc]">
                  <Check className="h-3 w-3" />
                  提示词就绪 · {effectiveAgentViews.length} 张
                </span>
              ) : (
                <span className="text-[9px] text-white/34">
                  {reconstructionModeConfig(effectiveAgentMode).label} · {effectiveAgentQuantity} 张
                </span>
              )}
            </div>
            <div className="rounded-md border border-white/10 bg-black/35 p-2">
              <textarea
                value={agentInput}
                onChange={(event) => setAgentInput(event.target.value)}
                placeholder="直接写要怎么生成，或补充要改什么。"
	                className="h-32 w-full resize-none rounded border border-transparent bg-transparent px-2 py-2 text-[11px] leading-relaxed text-white outline-none transition placeholder:text-white/24 focus:border-[#a2c638]/45"
              />
              <div className="mt-2 flex items-center gap-2">
                <div className="flex h-10 shrink-0 items-center overflow-hidden rounded-md border border-white/10 bg-black/35">
                  <button
                    type="button"
                    onClick={() => setAgentQuantity((current) => clampNumber(current - 1, 1, 10))}
                    disabled={agentQuantity <= 1 || !!subjectAgentBusy}
                    className="inline-flex h-full w-9 items-center justify-center text-white/48 transition hover:bg-white/[0.06] hover:text-white/78 disabled:cursor-not-allowed disabled:opacity-30"
                    title="减少生成数量"
                    aria-label="减少生成数量"
                  >
                    <Minus className="h-4 w-4" />
                  </button>
                  <label className="flex h-full items-center gap-1.5 border-x border-white/10 px-2 text-[10px] text-white/42">
                    <span>张数</span>
                    <input
                      type="number"
                      min={1}
                      max={10}
                      value={agentQuantity}
                      aria-label="生成张数"
                      onChange={(event) => {
                        const next = Number.parseInt(event.target.value, 10)
                        setAgentQuantity(Number.isFinite(next) ? clampNumber(next, 1, 10) : 6)
                      }}
                      disabled={!!subjectAgentBusy}
                      className="h-8 w-10 bg-transparent text-center text-[11px] font-semibold text-white outline-none disabled:opacity-45"
                    />
                  </label>
                  <button
                    type="button"
                    onClick={() => setAgentQuantity((current) => clampNumber(current + 1, 1, 10))}
                    disabled={agentQuantity >= 10 || !!subjectAgentBusy}
                    className="inline-flex h-full w-9 items-center justify-center text-white/48 transition hover:bg-white/[0.06] hover:text-white/78 disabled:cursor-not-allowed disabled:opacity-30"
                    title="增加生成数量"
                    aria-label="增加生成数量"
                  >
                    <Plus className="h-4 w-4" />
                  </button>
                </div>
                <button
                  type="button"
                  onClick={showInlineGenerateAction ? confirmSubjectGeneration : () => void sendSubjectAgentRequirement()}
                  disabled={showInlineGenerateAction ? generateDisabled : sendDisabled}
                  className="skg-primary-action inline-flex h-10 flex-1 items-center justify-center gap-2 px-3 text-[11.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
                >
                  {subjectAgentBusy === "message" ? (
                    <Loader2 className="h-4 w-4 animate-spin" />
                  ) : showInlineGenerateAction ? (
                    <Sparkles className="h-4 w-4" />
                  ) : (
                    <Send className="h-4 w-4" />
                  )}
                  {showInlineGenerateAction ? `确认生成 ${effectiveAgentViews.length} 张` : "发送消息"}
                </button>
              </div>
            </div>
          </div>

        </div>
      </div>
      </div>

      <div className="min-w-0">
        <div className="mb-2 flex items-center justify-between gap-2">
          <SectionTitle icon={<Sparkles className="h-4 w-4" />} title="主体元素" />
          <span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
            {subjectAssetPacks.length ? `${subjectAssetPacks.length} 套` : "待生成"}
          </span>
        </div>
	        <div className="skg-glass-card--flat border border-white/10 p-2">
          {subjectBusyFor ? (
            <div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
              正在生成{reconstructionModeConfig(subjectBusyFor.mode).label} {subjectBusyFor.viewCount} 张；参考 {subjectBusyFor.sourceCount || "自主描述"}。
              <span className="mt-1 block text-cyan-50/58">主体设定：{subjectBusyFor.profileLabel}</span>
              <span className="mt-1 block text-cyan-50/50">生图模型：{subjectBusyFor.modelLabel}</span>
            </div>
          ) : null}
          {subjectAssetPacks.length ? (
            <div className="grid grid-cols-[minmax(0,1fr)_260px] gap-2">
              {activeSubjectPack ? (
	                <div className="rounded-md border border-[#a2c638]/28 bg-[#a2c638]/[0.07] p-2">
                  <div className="mb-2 flex items-center justify-between gap-2">
                    <div className="min-w-0">
                      <div className="truncate text-[11px] font-semibold text-white">{activeSubjectPack.label}</div>
                      <div className="mt-0.5 text-[9.5px] text-white/42">
                        {reconstructionModeConfig(activeSubjectPack.mode).label} · {subjectAssetPackSummary(activeSubjectPack)}
                      </div>
                    </div>
                    <span className="shrink-0 rounded-full border border-white/10 bg-black/35 px-2 py-0.5 text-[9px] text-white/46">
                      {activeSubjectPack.assets.length} 张
                    </span>
                  </div>
                  <div className="flex max-h-[190px] gap-2 overflow-x-auto pb-1">
                    {activeSubjectPack.assets.map((asset) => {
                      const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
                      const status = subjectAssetStatus(asset)
                      const running = subjectAssetIsRunning(asset)
                      const failed = status === "failed"
                      const mediaUrl = subjectAssetUrl(job, asset)
                      const item = { frame: activeSubjectPack.frame, element: activeSubjectPack.element, mode: activeSubjectPack.mode, asset }
                      return (
                        <MediaAssetTile
                          key={asset.id}
                          src={mediaUrl || undefined}
                          href={mediaUrl || undefined}
                          alt={asset.label || asset.view}
                          label={asset.label || subjectViewLabel(asset.view)}
                          meta={subjectAssetStatusLabel(asset)}
                          className={`aspect-[9/16] w-[86px] shrink-0 ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
                          objectFit="contain"
                          busy={running}
                          emptyText={failed ? "失败" : running ? "生成中" : undefined}
                          title={asset.label || subjectViewLabel(asset.view)}
                          actions={[{
                            key: "regen",
                            label: "重新生成这一张",
                            icon: <RefreshCw className="h-3 w-3" />,
                            tone: "cyan",
                            busy: busyMode === "regen",
                            disabled: !!subjectAssetBusy || subjectBusy || running,
                            onClick: () => void regenerateSubjectAsset(item),
                          }]}
                          onDelete={() => void deleteActorAsset(item)}
                          deleting={busyMode === "delete"}
                          deleteDisabled={!!subjectAssetBusy || subjectBusy || running}
                          deleteLabel="删除这一张"
                        />
                      )
                    })}
                  </div>
                </div>
              ) : null}
              <div className="max-h-[244px] overflow-auto rounded-md border border-white/10 bg-black/24 p-1.5">
                <div className="grid grid-cols-1 gap-1.5">
                  {subjectAssetPacks.map((pack, index) => {
                    const active = activeSubjectPack?.key === pack.key
                    return (
                      <button
                        key={pack.key}
                        type="button"
                        onClick={() => setExpandedSubjectPackKey(pack.key)}
                        className={`min-w-0 rounded-md border px-2 py-1.5 text-left transition ${
                          active
                            ? "border-[#d6b36a]/70 bg-[#d6b36a]/14 text-white"
                            : "border-white/10 bg-black/28 text-white/58 hover:border-white/22 hover:text-white"
                        }`}
                        title={pack.label}
                      >
                        <div className="flex items-center gap-1.5">
                          <Package className="h-3.5 w-3.5 shrink-0" />
                          <span className="min-w-0 truncate text-[10px] font-semibold">{pack.label || `套图 ${index + 1}`}</span>
                        </div>
                        <div className="mt-1 flex items-center justify-between gap-2 text-[9px] text-white/38">
                          <span>{reconstructionModeConfig(pack.mode).label}</span>
                          <span className="font-mono">{subjectAssetPackSummary(pack)}</span>
                        </div>
                      </button>
                    )
                  })}
                </div>
              </div>
            </div>
          ) : (
            <div
              className="flex items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34"
              style={{ minHeight: SOURCE_SUBJECT_EMPTY_HEIGHT }}
            >
              转换层生成完成后，这里会横向展示主体套图。
            </div>
          )}
        </div>
      </div>
    </div>
    </>
  )
}

function SourceKeyframePicker({
  job,
  frames,
  selectedFrames,
  selectedReferenceFrames,
  extracting,
  deletingFrame,
  onToggleFrame,
  onExtract,
  onDeleteFrame,
  filmstripDragging,
  onDropFilmstripFrame,
}: {
  job: Job
  frames: KeyFrame[]
  selectedFrames: Set<number>
  selectedReferenceFrames: KeyFrame[]
  extracting: boolean
  deletingFrame: number | null
  onToggleFrame: (idx: number) => void
  onExtract: () => void
  onDeleteFrame?: (idx: number) => void
  filmstripDragging?: boolean
  onDropFilmstripFrame?: (time: number) => void
}) {
  const [dropActive, setDropActive] = useState(false)
  return (
    <div className="min-w-0">
      <div className="mb-2 flex items-center justify-between gap-2">
        <SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧" />
        <div className="flex items-center gap-1.5">
          <span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
            已选 {selectedReferenceFrames.length || "全部"}
          </span>
          <button
            type="button"
            onClick={onExtract}
            disabled={!job.video_url || extracting || job.status === "splitting"}
            title="自动按动作峰值抽 12 张参考帧，更偏向手势、表情变化、节奏点和镜头变化"
            className="skg-primary-action inline-flex h-7 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
          >
            {extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
            自动 12 张
          </button>
        </div>
      </div>

      <div
        className={`min-h-[260px] rounded-md border p-1.5 transition ${
          filmstripDragging
            ? dropActive
              ? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45"
              : "border-[#d6b36a]/45 bg-[#d6b36a]/[0.065]"
            : "border-white/10 bg-black/32"
        }`}
        onDragEnter={(event) => {
          if (!onDropFilmstripFrame) return
          event.preventDefault()
          setDropActive(true)
        }}
        onDragOver={(event) => {
          if (!onDropFilmstripFrame) return
          event.preventDefault()
          event.dataTransfer.dropEffect = "copy"
        }}
        onDragLeave={(event) => {
          const next = event.relatedTarget as Node | null
          if (next && event.currentTarget.contains(next)) return
          setDropActive(false)
        }}
        onDrop={(event) => {
          if (!onDropFilmstripFrame) return
          event.preventDefault()
          setDropActive(false)
          const raw = event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE)
          const time = Number(raw)
          if (Number.isFinite(time)) onDropFilmstripFrame(time)
        }}
      >
        <div className="mb-1 flex items-center justify-between gap-2">
          <span className="text-[10px] text-white/34">参考帧池</span>
          <span className="text-[9.5px] text-white/28">{filmstripDragging ? "松手加入关键帧" : "拖入胶片选帧，悬停放大"}</span>
        </div>
        <div className="grid max-h-[232px] grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1 overflow-y-auto pr-0.5">
          {frames.map((frame, index) => {
            const selected = selectedFrames.has(frame.index)
            return (
              <MediaAssetTile
                key={frame.index}
                src={effectiveFrameUrl(job.id, frame)}
                alt={`关键帧 ${index + 1}`}
                label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
                meta={`${frame.timestamp.toFixed(1)}s`}
                className="aspect-[9/16]"
                objectFit="contain"
                selected={selected}
                title={`${selected ? "已选 · 点击取消" : "点击选择"} · 关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
                onClick={() => onToggleFrame(frame.index)}
                topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
                topRight={<span className="rounded-full bg-black/72 p-0.5">{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>}
                onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined}
                deleting={deletingFrame === frame.index}
                deleteLabel={`删除关键帧 ${index + 1}`}
              />
            )
          })}
          {!frames.length && (
            <div className="col-span-full flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10.5px] leading-snug text-white/34">
              自动抽帧或在原版视频上用当前点抽帧。
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

function SourceReferenceBuildPanel({
  job,
  selectedFrames,
  onJobUpdate,
  runtimeModels,
}: {
  job: Job
  selectedFrames: Set<number>
  onJobUpdate: (job: Job) => void
  runtimeModels?: RuntimeModels
}) {
  const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; profileLabel: string } | null>(null)
  const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
  const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
  const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
  const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all")
  const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES)
  const [subjectDirection, setSubjectDirection] = useState("")
  const [subjectProfileMode, setSubjectProfileMode] = useState<SubjectProfileMode>("random")
  const [subjectProfileDraft, setSubjectProfileDraft] = useState<SubjectProfileDraft>({ ...DEFAULT_SUBJECT_PROFILE_DRAFT })
  const [randomProfileDraft, setRandomProfileDraft] = useState<SubjectProfileDraft>(() => randomSubjectProfileDraft())
  const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
  const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
  const [selectedCharacterId, setSelectedCharacterId] = useState("")
  const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
  const [selectedSubjectTemplateId, setSelectedSubjectTemplateId] = useState("")
  const [templateLibraryBusy, setTemplateLibraryBusy] = useState(false)
  const [templateSaveBusy, setTemplateSaveBusy] = useState(false)
  const [templateDraftName, setTemplateDraftName] = useState("")
  const [templateDraftNote, setTemplateDraftNote] = useState("")
  const [subjectBriefDraft, setSubjectBriefDraft] = useState("")
  const [subjectBriefBusy, setSubjectBriefBusy] = useState(false)
  const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
  const selectedReferenceFrames = useMemo(
    () => frames.filter((frame) => selectedFrames.has(frame.index)),
    [frames, selectedFrames],
  )
  const subjectReferenceFrames = useMemo(
    () => selectedReferenceFrames.length ? selectedReferenceFrames : frames,
    [frames, selectedReferenceFrames],
  )
  const actorSource = useMemo(() => {
    return findSimilarActorSource(subjectReferenceFrames, frames)
  }, [frames, subjectReferenceFrames])
  const actorAssets = actorSource?.element.subject_assets ?? []
  const selectedCharacter = useMemo(
    () => characterLibrary.find((character) => character.id === selectedCharacterId) ?? null,
    [characterLibrary, selectedCharacterId],
  )
  const selectedSubjectTemplate = useMemo(
    () => subjectTemplateLibrary.find((template) => template.id === selectedSubjectTemplateId) ?? null,
    [subjectTemplateLibrary, selectedSubjectTemplateId],
  )
  const selectedTemplatePrompt = subjectMode === "template" && selectedSubjectTemplate
    ? { name: selectedSubjectTemplate.name, sourceLabel: "数据库主体模板" }
    : subjectMode === "template" && selectedCharacter
      ? { name: selectedCharacter.name, sourceLabel: "内置策划形象" }
      : null
  const selectedSubjectViews = useMemo(() => {
    if (subjectViewMode === "common") return COMMON_SUBJECT_VIEW_VALUES
    if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES
    return SUBJECT_ASSET_VIEWS.map((view) => view.value)
  }, [customSubjectViews, subjectViewMode])
  const subjectProfilePreview = useMemo(() => {
    return subjectProfileMode === "random"
      ? resolveSubjectProfile("random", randomProfileDraft)
      : resolveSubjectProfile("manual", subjectProfileDraft)
  }, [randomProfileDraft, subjectProfileDraft, subjectProfileMode])
  const visibleActorAssets = useMemo(() => {
    const latestByView = new Map<string, SubjectAsset>()
    for (const asset of actorAssets) {
      const current = latestByView.get(asset.view)
      if (!current || (asset.created_at || 0) >= (current.created_at || 0)) {
        latestByView.set(asset.view, asset)
      }
    }
    return [...latestByView.values()].sort((a, b) => {
      const ai = SUBJECT_VIEW_ORDER.indexOf(a.view)
      const bi = SUBJECT_VIEW_ORDER.indexOf(b.view)
      return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
    })
  }, [actorAssets])
  const referenceCountLabel = selectedReferenceFrames.length
    ? `已选 ${selectedReferenceFrames.length} 张参考帧`
    : frames.length
      ? `默认使用全部 ${frames.length} 张参考帧`
      : "待抽帧"
  const templateSaveHint = visibleActorAssets.length
    ? templateDraftName.trim()
      ? "保存后会进入主体模板库，后续任务可直接复用"
      : "先给这套主体命名，再保存到主体模板库"
    : "先生成本次主体视图，再决定是否入库"
  const templateSourceLabel = subjectMode === "template" && selectedSubjectTemplate
    ? `${selectedSubjectTemplate.name} · 数据库模板`
    : subjectMode === "template" && selectedCharacter
    ? `${selectedCharacter.name} · 模板参考`
    : "源视频关键帧 · 相似创新"
  const templateRequired = subjectMode === "template" && !selectedSubjectTemplate && !selectedCharacter
  const subjectBusy = !!subjectBusyFor
  const generationCtaLabel = subjectMode === "template"
    ? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
    : `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图`
  const currentSubjectBrief = actorSource?.element.subject_consensus_brief?.trim()
    || selectedSubjectTemplate?.prompt_brief?.trim()
    || selectedCharacter?.prompt_brief?.trim()
    || ""

  const buildSubjectProfileForRequest = () => {
    if (subjectProfileMode === "random") {
      const randomized = randomSubjectProfileDraft()
      setRandomProfileDraft(randomized)
      const resolved = resolveSubjectProfile("random", randomized)
      setLastSubjectProfile(resolved)
      return resolved
    }
    const resolved = resolveSubjectProfile("manual", subjectProfileDraft, { randomizeRandomValues: true })
    setLastSubjectProfile(resolved)
    return resolved
  }

  const loadSubjectTemplateLibrary = async (silent = false) => {
    setTemplateLibraryBusy(true)
    try {
      const items = await listSubjectTemplates()
      setSubjectTemplateLibrary(items)
    } catch (e) {
      if (!silent) toast.error("主体模板库读取失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setTemplateLibraryBusy(false)
    }
  }

  useEffect(() => {
    let cancelled = false
    Promise.allSettled([listCharacterLibrary(), listSubjectTemplates()])
      .then(([characters, templates]) => {
        if (cancelled) return
        if (characters.status === "fulfilled") setCharacterLibrary(characters.value)
        else toast.error("内置形象读取失败：" + (characters.reason instanceof Error ? characters.reason.message : String(characters.reason)))
        if (templates.status === "fulfilled") setSubjectTemplateLibrary(templates.value)
        else toast.error("主体模板库读取失败：" + (templates.reason instanceof Error ? templates.reason.message : String(templates.reason)))
      })
    return () => { cancelled = true }
  }, [])

  useEffect(() => {
    setTemplateDraftName("")
    setTemplateDraftNote("")
    setLastSubjectProfile(null)
  }, [job.id])

  useEffect(() => {
    setSubjectBriefDraft(currentSubjectBrief)
  }, [actorSource?.element.id, currentSubjectBrief])

  const generateSimilarActor = async () => {
    if (!frames.length) {
      toast.warning("请先自动抽帧 12 张，或在原版视频上手动补帧。")
      return
    }
    if (templateRequired) {
      toast.warning("请先选择一个内置或数据库主体模板。")
      return
    }
    const baseFrame = subjectReferenceFrames[0]
    if (!baseFrame) return
    const requestJobId = job.id
    const requestProfile = buildSubjectProfileForRequest()
    setSubjectBusyFor({
      jobId: requestJobId,
      jobLabel: shortId(requestJobId),
      viewCount: selectedSubjectViews.length,
      profileLabel: requestProfile.summary,
    })
    try {
      let workingJob = job
      let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
      let element = workingFrame.elements?.find(isSimilarActorElement)
      if (!element) {
        workingJob = await addElement(requestJobId, baseFrame.index, {
          name_zh: selectedTemplatePrompt
            ? `相似透明骨架主体 · ${selectedTemplatePrompt.name}`
            : subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
          name_en: selectedTemplatePrompt
            ? `similar innovative transparent skeleton humanoid subject based on ${selectedTemplatePrompt.name}`
            : subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor",
          position: "source-video main subject selected from global keyframes",
          source: "manual",
        })
        onJobUpdate(workingJob)
        workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? workingFrame
        element = workingFrame.elements?.find(isSimilarActorElement)
          ?? workingFrame.elements?.[workingFrame.elements.length - 1]
      }
      if (!element) throw new Error("similar subject element missing")

      const updated = await generateSubjectAssets(requestJobId, baseFrame.index, element.id, {
        subject_kind: "living",
        subject_style: subjectStyle,
        reconstruction_mode: "similar",
        background: "white",
        size: SUBJECT_ASSET_SIZE,
        source_frame_indices: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index),
        views: selectedSubjectViews,
        character_id: subjectMode === "template" ? selectedCharacterId : "",
        subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
        subject_profile: requestProfile.payload,
        prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
        replace_views: true,
        pack_label: `主体视图 ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
        pack_mode: "realistic",
      })
      onJobUpdate(updated)
      toast.success(`相似主体已提交：${selectedSubjectViews.length} 张会逐张出来`)
    } catch (e) {
      try {
        onJobUpdate(await getJob(requestJobId))
      } catch { /* keep original error visible */ }
      toast.error("相似主体重构失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectBusyFor(null)
    }
  }

  const regenerateSubjectAsset = async (asset: SubjectAsset) => {
    if (!actorSource) return
    setSubjectAssetBusy(`regen:${asset.id}`)
    try {
      const requestProfile = lastSubjectProfile ?? resolveSubjectProfile(
        subjectProfileMode,
        subjectProfileMode === "random" ? randomProfileDraft : subjectProfileDraft,
        { randomizeRandomValues: subjectProfileMode === "manual" },
      )
      const sourceIndices = asset.source_frame_indices?.length
        ? asset.source_frame_indices
        : subjectReferenceFrames.slice(0, 12).map((frame) => frame.index)
      const updated = await generateSubjectAssets(job.id, actorSource.frame.index, actorSource.element.id, {
        subject_kind: "living",
        subject_style: subjectStyle,
        reconstruction_mode: "similar",
        background: asset.background || "white",
        size: SUBJECT_ASSET_SIZE,
        source_frame_indices: sourceIndices,
        views: [asset.view],
        character_id: subjectMode === "template" ? selectedCharacterId : "",
        subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
        subject_profile: requestProfile.payload,
        prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
        replace_views: true,
        pack_id: asset.pack_id ?? "",
        pack_label: asset.pack_label ?? "",
        pack_mode: asset.pack_mode ?? "realistic",
        pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
      })
      onJobUpdate(updated)
      toast.success("已提交重生，这张主体视图会生成完成后替换")
    } catch (e) {
      toast.error("单张主体视图重生失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectAssetBusy(null)
    }
  }

  const deleteActorAsset = async (asset: SubjectAsset) => {
    if (!actorSource) return
    setSubjectAssetBusy(`delete:${asset.id}`)
    try {
      const updated = await deleteSubjectAsset(job.id, actorSource.frame.index, actorSource.element.id, asset.id)
      onJobUpdate(updated)
      toast.success("主体视图已删除")
    } catch (e) {
      toast.error("主体视图删除失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectAssetBusy(null)
    }
  }

  const saveSubjectBriefDraft = async () => {
    if (!actorSource) {
      toast.warning("先生成本次主体视图，才能把 brief 绑定到主体元素。")
      return
    }
    setSubjectBriefBusy(true)
    try {
      const updated = await updateElement(job.id, actorSource.frame.index, actorSource.element.id, {
        subject_consensus_brief: subjectBriefDraft.trim(),
      })
      onJobUpdate(updated)
      toast.success("主体 brief 已保存，后续首尾帧会使用这段文字依据")
    } catch (e) {
      toast.error("主体 brief 保存失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectBriefBusy(false)
    }
  }

  const saveGeneratedSubjectTemplate = async () => {
    if (!actorSource || !visibleActorAssets.length) {
      toast.warning("请先生成相似主体视图。")
      return
    }
    const name = templateDraftName.trim()
    if (!name) {
      toast.warning("请先给这套主体模板命名。")
      return
    }
    setTemplateSaveBusy(true)
    try {
      const item = await saveSubjectTemplate(job.id, {
        name,
        note: templateDraftNote.trim(),
        frame_idx: actorSource.frame.index,
        element_id: actorSource.element.id,
        asset_ids: visibleActorAssets.map((asset) => asset.id),
        subject_style: subjectStyle,
      })
      setSubjectTemplateLibrary((items) => [item, ...items.filter((template) => template.id !== item.id)])
      setSelectedSubjectTemplateId(item.id)
      setSelectedCharacterId("")
      setTemplateDraftName("")
      setTemplateDraftNote("")
      toast.success("已保存到主体模板库")
    } catch (e) {
      toast.error("保存主体模板失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setTemplateSaveBusy(false)
    }
  }

  return (
    <div className="min-w-0">
      <div className="mb-2 flex items-center justify-between gap-3">
        <SectionTitle icon={<Sparkles className="h-4 w-4" />} title="相似主体 / 主体模板" />
        <div className="flex items-center gap-2">
          <span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
            {referenceCountLabel}
          </span>
        </div>
      </div>
      <div className="rounded-md border border-white/10 bg-black/32 p-2">
        <div className="rounded-md border border-white/10 bg-black/28 p-2.5">
          <div className="mb-2 flex flex-wrap items-start justify-between gap-2">
            <div>
              <div className="text-[11px] font-semibold text-white/72">主体模板库</div>
              <div className="mt-0.5 text-[9.5px] text-white/34">先决定是否用模板，再选择内置或数据库主体；源视频相似不再混在模板网格里。</div>
            </div>
            <button
              type="button"
              onClick={() => void loadSubjectTemplateLibrary()}
              disabled={templateLibraryBusy}
              className="skg-secondary-action inline-flex h-7 items-center gap-1 px-2 text-[10px] font-semibold transition disabled:cursor-wait disabled:opacity-50"
            >
              {templateLibraryBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
              数据库 {subjectTemplateLibrary.length} 套
            </button>
          </div>

          <div className="mb-2 grid grid-cols-2 gap-1.5">
            {[
              { value: "template" as const, label: "用模板生成", desc: "从内置形象或数据库模板延展新主体" },
              { value: "source_similar" as const, label: "不用模板（从源视频关键帧创新）", desc: "只读取源视频角色文字特征，不上传参考图做复制" },
            ].map((item) => (
              <button
                key={item.value}
                type="button"
                onClick={() => setSubjectMode(item.value)}
                className={`flex min-h-[48px] items-start gap-2 rounded-md border px-2.5 py-2 text-left transition ${
                  subjectMode === item.value
                    ? "border-[#d6b36a]/65 bg-[#d6b36a]/12 text-[#f5d98e]"
                    : "border-white/10 bg-black/24 text-white/50 hover:border-[#d6b36a]/35 hover:text-white/78"
                }`}
              >
                <span className="mt-0.5 shrink-0">{subjectMode === item.value ? <Check className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}</span>
                <span className="min-w-0">
                  <span className="block text-[11px] font-semibold">{item.label}</span>
                  <span className="mt-0.5 block text-[9.5px] leading-snug opacity-65">{item.desc}</span>
                </span>
              </button>
            ))}
          </div>

          {subjectMode === "template" ? (
          <div>
            <div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2">
              {subjectTemplateLibrary.map((template) => {
                const preview = characterPreviewImage(template)
                const active = subjectMode === "template" && selectedSubjectTemplateId === template.id
                return (
                  <button
                    key={template.id}
                    type="button"
                    onClick={() => {
                      setSubjectMode("template")
                      setSelectedSubjectTemplateId(template.id)
                      setSelectedCharacterId("")
                      setSubjectStyle(template.subject_style || "transparent_human")
                    }}
                    className={`group relative rounded-md border p-1.5 text-left transition ${
                      active ? "border-[#d6b36a]/75 bg-[#d6b36a]/12 text-[#f5d98e]" : "border-white/10 bg-black/24 text-white/58 hover:border-[#d6b36a]/35 hover:text-white/82"
                    }`}
                  >
                    {active ? <span className="absolute right-2 top-2 z-10 rounded-full bg-[#d6b36a] p-0.5 text-black"><Check className="h-3 w-3" /></span> : null}
                    <span className="block aspect-[4/5] overflow-hidden rounded border border-white/10 bg-white">
                      {preview ? <img src={subjectTemplateImageUrl(preview.filename)} alt={template.name} className="h-full w-full object-cover" /> : null}
                    </span>
                    <span className="mt-1 block truncate text-[10.5px] font-semibold">{template.name}</span>
                    <span className="mt-0.5 block truncate text-[9px] opacity-58">数据库 · {template.images.length} 图</span>
                  </button>
                )
              })}
              {characterLibrary.map((character) => {
                const preview = characterPreviewImage(character)
                const active = subjectMode === "template" && selectedCharacterId === character.id
                return (
                  <button
                    key={character.id}
                    type="button"
                    onClick={() => {
                      setSubjectMode("template")
                      setSelectedCharacterId(character.id)
                      setSelectedSubjectTemplateId("")
                      setSubjectStyle("transparent_human")
                    }}
                    className={`group relative rounded-md border p-1.5 text-left transition ${
                      active ? "border-[#d6b36a]/75 bg-[#d6b36a]/12 text-[#f5d98e]" : "border-white/10 bg-black/24 text-white/58 hover:border-[#d6b36a]/35 hover:text-white/82"
                    }`}
                  >
                    {active ? <span className="absolute right-2 top-2 z-10 rounded-full bg-[#d6b36a] p-0.5 text-black"><Check className="h-3 w-3" /></span> : null}
                    <span className="block aspect-[4/5] overflow-hidden rounded border border-white/10 bg-white">
                      {preview ? <img src={characterLibraryImageUrl(preview.filename)} alt={character.name} className="h-full w-full object-cover" /> : null}
                    </span>
                    <span className="mt-1 block truncate text-[10.5px] font-semibold">{character.name}</span>
                    <span className="mt-0.5 block truncate text-[9px] opacity-58">内置 · {character.images.length} 图</span>
                  </button>
                )
              })}
            </div>
          </div>
          ) : (
            <div className="rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62">
              当前跳过模板库：本次只用源视频关键帧的文字化主体特征生成创新主体。模板卡已收起，避免占用生成结果区域。
            </div>
          )}

          {subjectMode === "template" && (selectedSubjectTemplate?.images?.length || selectedCharacter?.images?.length) ? (
            <div className="mt-2 flex gap-1.5 overflow-x-auto pb-0.5">
              {(selectedSubjectTemplate?.images ?? selectedCharacter?.images ?? []).slice(0, 10).map((image) => (
                <div key={image.id} className="h-16 w-12 shrink-0 overflow-hidden rounded border border-white/10 bg-white" title={image.label}>
                  <img
                    src={selectedSubjectTemplate ? subjectTemplateImageUrl(image.filename) : characterLibraryImageUrl(image.filename)}
                    alt={image.label}
                    className="h-full w-full object-cover"
                  />
                </div>
              ))}
            </div>
          ) : null}

          <div className="my-2 h-px bg-white/10" />
          <div className="grid grid-cols-[1fr_1.6fr_auto] gap-2">
            <input
              value={templateDraftName}
              onChange={(event) => setTemplateDraftName(event.target.value)}
              placeholder={visibleActorAssets.length ? "模板名称" : "生成主体视图后可命名保存"}
              className="h-8 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
            />
            <input
              value={templateDraftNote}
              onChange={(event) => setTemplateDraftNote(event.target.value)}
              placeholder="保存为主体模板备注：适用广告、人物风格、禁用点"
              className="h-8 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
            />
            <button
              type="button"
              onClick={() => void saveGeneratedSubjectTemplate()}
              disabled={!visibleActorAssets.length || !templateDraftName.trim() || templateSaveBusy}
              title={!visibleActorAssets.length ? "先生成主体视图" : !templateDraftName.trim() ? "先填写模板名称" : "保存到主体模板库"}
              className="skg-secondary-action inline-flex h-8 items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-35"
            >
              {templateSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
              保存到主体库
            </button>
          </div>
          <div className="mt-1 text-[9px] text-white/32">{templateSaveHint}</div>
          <div className="mt-2 rounded-md border border-white/10 bg-black/24 p-2">
            <div className="mb-1 flex items-center justify-between gap-2">
              <span className="text-[9.5px] font-semibold text-white/48">主体 brief 预览 / 首尾帧文字依据</span>
              <button
                type="button"
                onClick={() => void saveSubjectBriefDraft()}
                disabled={!actorSource || subjectBriefBusy || !subjectBriefDraft.trim()}
                className="inline-flex h-6 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-2 text-[9.5px] font-semibold text-white/52 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
              >
                {subjectBriefBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
                保存 brief
              </button>
            </div>
            <textarea
              value={subjectBriefDraft}
              onChange={(event) => setSubjectBriefDraft(event.target.value)}
              placeholder="生成主体视图后，后端会用视觉模型反推出主体 brief；这里也会显示所选模板的 prompt_brief。"
              className="min-h-[58px] w-full resize-y rounded border border-white/10 bg-black/35 px-2 py-1.5 text-[10px] leading-snug text-white/68 outline-none placeholder:text-white/25 focus:border-cyan-300/45"
            />
            <div className="mt-1 text-[9px] text-white/30">
              首尾帧后续只使用这段主体 brief，不再把 5 张主体图拼成参考图上传；产品图仍作为结构硬参考。
            </div>
          </div>
        </div>

        <div className="mt-2 rounded-md border border-white/10 bg-black/28 p-2.5">
          <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
            <div>
              <div className="text-[11px] font-semibold text-white/72">生成主体视图</div>
              <div className="mt-0.5 text-[9.5px] text-white/34">{templateSourceLabel}</div>
            </div>
            <ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
          </div>
          {subjectBusyFor ? (
            <div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
              正在为素材 {subjectBusyFor.jobLabel} 生成 {subjectBusyFor.viewCount} 张主体视图；本次请求已锁定素材、参考帧、模式和视图数量，切换其他模块不会改变生成目标，完成后会回写到该素材。
              <span className="mt-1 block text-cyan-50/58">主体设定：{subjectBusyFor.profileLabel}</span>
            </div>
          ) : null}

          {visibleActorAssets.length ? (
            <div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
              {visibleActorAssets.map((asset) => {
                const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
                const status = subjectAssetStatus(asset)
                const running = subjectAssetIsRunning(asset)
                const failed = status === "failed"
                const mediaUrl = subjectAssetUrl(job, asset)
                return (
                  <MediaAssetTile
                    key={asset.id}
                    src={mediaUrl || undefined}
                    href={mediaUrl || undefined}
                    alt={asset.label || asset.view}
                    label={asset.label || asset.view || "主体视图预览"}
                    meta={subjectAssetStatusLabel(asset)}
                    className={`aspect-[9/16] w-24 ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
                    objectFit="contain"
                    busy={running}
                    emptyText={failed ? "失败" : running ? "生成中" : undefined}
                    title={asset.label || asset.view}
                    actions={[{
                      key: "regen",
                      label: "重新生成这一张",
                      icon: <RefreshCw className="h-3 w-3" />,
                      tone: "cyan",
                      busy: busyMode === "regen",
                      disabled: !!subjectAssetBusy || running,
                      onClick: () => void regenerateSubjectAsset(asset),
                    }]}
                    onDelete={() => void deleteActorAsset(asset)}
                    deleting={busyMode === "delete"}
                    deleteDisabled={!!subjectAssetBusy || running}
                    deleteLabel="删除这一张"
                  />
                )
              })}
            </div>
          ) : null}

          <div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2">
            <div className="mb-2 flex flex-wrap items-start justify-between gap-2">
              <div>
                <div className="text-[10.5px] font-semibold text-white/70">主体设定</div>
                <div className="mt-0.5 max-w-3xl text-[9.5px] leading-snug text-white/34">
                  默认随机组合，点击生成时会锁定一套性别、年龄、着装、地域人种、肤色、体型、发型和气质，保证整组视图是同一个人设。
                </div>
              </div>
              <div className="flex shrink-0 items-center gap-1.5">
                <div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
                  {[
                    { value: "random" as const, label: "随机组合" },
                    { value: "manual" as const, label: "手动指定" },
                  ].map((item) => (
                    <button
                      key={item.value}
                      type="button"
                      onClick={() => setSubjectProfileMode(item.value)}
                      className={`h-7 rounded px-2 text-[10px] font-semibold transition ${
                        subjectProfileMode === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
                      }`}
                    >
                      {item.label}
                    </button>
                  ))}
                </div>
                {subjectProfileMode === "random" ? (
                  <button
                    type="button"
                    onClick={() => setRandomProfileDraft(randomSubjectProfileDraft())}
                    className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10px] font-semibold text-white/56 transition hover:border-cyan-300/35 hover:text-cyan-100"
                  >
                    <RefreshCw className="h-3 w-3" />
                    换一组
                  </button>
                ) : null}
              </div>
            </div>

            <div className="rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/64">
              {subjectProfileMode === "random" ? "当前随机预览：" : "当前手动设定："}{subjectProfilePreview.summary}
              {lastSubjectProfile ? (
                <span className="mt-1 block text-cyan-50/44">上次生成锁定：{lastSubjectProfile.summary}</span>
              ) : null}
            </div>

            {subjectProfileMode === "manual" ? (
              <div className="mt-2 grid grid-cols-4 gap-1.5">
                {SUBJECT_PROFILE_CATEGORIES.map((category) => (
                  <label key={category.key} className="min-w-0">
                    <span className="mb-1 block text-[9px] font-semibold text-white/40">{category.label}</span>
                    <select
                      value={subjectProfileDraft[category.key]}
                      onChange={(event) => {
                        const value = event.target.value
                        setSubjectProfileDraft((current) => ({ ...current, [category.key]: value }))
                      }}
                      className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white outline-none transition focus:border-cyan-300/50"
                    >
                      {category.options.map((option) => (
                        <option key={option.value} value={option.value}>{option.label}</option>
                      ))}
                    </select>
                  </label>
                ))}
              </div>
            ) : null}
          </div>

          <div className="grid grid-cols-[auto_auto_minmax(220px,1fr)_auto] items-start gap-2">
            <div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
              {[
                { value: "transparent_human" as const, label: "透明骨架" },
                { value: "source_actor" as const, label: "真人" },
              ].map((item) => (
                <button
                  key={item.value}
                  type="button"
                  onClick={() => setSubjectStyle(item.value)}
                  className={`h-8 rounded px-2.5 text-[10.5px] font-semibold transition ${
                    subjectStyle === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
                  }`}
                >
                  {item.label}
                </button>
              ))}
            </div>
            <div className="flex flex-wrap rounded-md border border-white/10 bg-black/28 p-0.5">
              {[
                { value: "all" as const, label: `全部 ${SUBJECT_ASSET_VIEWS.length}` },
                { value: "common" as const, label: `常用 ${COMMON_SUBJECT_VIEW_VALUES.length}` },
                { value: "custom" as const, label: "自定义" },
              ].map((item) => (
                <button
                  key={item.value}
                  type="button"
                  onClick={() => setSubjectViewMode(item.value)}
                  className={`h-8 rounded px-2.5 text-[10.5px] font-semibold transition ${
                    subjectViewMode === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
                  }`}
                >
                  {item.label}
                </button>
              ))}
            </div>
            <input
              value={subjectDirection}
              onChange={(event) => setSubjectDirection(event.target.value)}
              placeholder="统一方向：如年轻女性 / 更运动 / 更高级"
              className="h-9 rounded-md border border-white/10 bg-black/35 px-2.5 text-[11px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
            />
            <button
              type="button"
              onClick={() => void generateSimilarActor()}
              disabled={!frames.length || subjectBusy || templateRequired || !selectedSubjectViews.length}
              className="skg-primary-action inline-flex h-9 min-w-[170px] items-center justify-center gap-1 px-3 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
            >
              {subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
              {subjectBusyFor ? `生成中 · ${subjectBusyFor.jobLabel}` : generationCtaLabel}
            </button>
          </div>

          {subjectViewMode === "custom" ? (
            <div className="mt-2 flex flex-wrap gap-1.5">
              {SUBJECT_ASSET_VIEWS.map((view) => {
                const checked = customSubjectViews.includes(view.value)
                return (
                  <button
                    key={view.value}
                    type="button"
                    onClick={() => setCustomSubjectViews((current) =>
                      current.includes(view.value)
                        ? current.filter((item) => item !== view.value)
                        : [...current, view.value],
                    )}
                    className={`h-7 rounded-md border px-2 text-[10px] font-semibold transition ${
                      checked ? "border-cyan-200/60 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/24 text-white/45 hover:border-cyan-200/28 hover:text-white/75"
                    }`}
                  >
                    {checked ? "✓ " : ""}{view.label}
                  </button>
                )
              })}
            </div>
          ) : null}

          {!visibleActorAssets.length ? (
            <div className="mt-2 rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
              {subjectMode === "template"
                ? "先选主体模板，再生成新主体视图；模板只作为文字化创意方向，不再作为强参考图复制。"
                : "直接使用关键帧的文字化主体特征生成创新主体；后端不会上传源图给生图端点。"}
            </div>
          ) : null}
        </div>
      </div>
    </div>
  )
}

function AudioStoryboardPlanPanel({
  job,
  selectedFrames,
  onJobUpdate,
  onDeleteVideo,
  runtimeModels,
  productStep,
  scriptStep,
  sceneStep,
  videoStep,
}: {
  job: Job | null
  selectedFrames: Set<number>
  onJobUpdate?: (job: Job) => void
  onDeleteVideo?: (videoId: string) => void
  runtimeModels?: RuntimeModels
  productStep: WorkflowStep
  scriptStep: WorkflowStep
  sceneStep: WorkflowStep
  videoStep: WorkflowStep
}) {
  const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState<number | null>(null)
  const [batchStoryboardSaveBusy, setBatchStoryboardSaveBusy] = useState(false)
  const [endpointFrameBusy, setEndpointFrameBusy] = useState<string | null>(null)
  const [productItems, setProductItems] = useState<ProductRefItem[]>([])
  const [productUploading, setProductUploading] = useState(false)
  const [productAnalyzing, setProductAnalyzing] = useState(false)
  const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
  const [copyOverrides, setCopyOverrides] = useState<Record<number, string>>({})
  const [copyZhOverrides, setCopyZhOverrides] = useState<Record<number, string>>({})
  const [planOverrides, setPlanOverrides] = useState<Record<number, RowPlanPatch>>({})
  const [authorIntent, setAuthorIntent] = useState("")
  const [showChineseMirror, setShowChineseMirror] = useState(true)
  const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
  const [quickVideoBusyRow, setQuickVideoBusyRow] = useState<number | null>(null)
  const [batchCardBusy, setBatchCardBusy] = useState(false)
  const [advancedRows, setAdvancedRows] = useState<Set<number>>(new Set())
  const [refineDialog, setRefineDialog] = useState<{ rowIndex: number; frameIndex: number | null } | null>(null)
  const [refineFeedback, setRefineFeedback] = useState("")
  const [refineBusy, setRefineBusy] = useState(false)
  const [refinePreview, setRefinePreview] = useState<RefineStoryboardResult["items"] | null>(null)
  const [panelOpen, setPanelOpen] = useState<Record<"product" | "batch", boolean>>({ product: true, batch: true })
  const [rowSectionOpen, setRowSectionOpen] = useState<Record<string, boolean>>({})
  const [rowVideoCounts, setRowVideoCounts] = useState<Record<number, number>>({})
  const [batchVideoCount, setBatchVideoCount] = useState(4)
  const [autoOptimizingField, setAutoOptimizingField] = useState<string | null>(null)
  const [autoOptimizedChinese, setAutoOptimizedChinese] = useState<Record<string, string>>({})
  const productFileRef = useRef<HTMLInputElement | null>(null)
  const productPersistSeq = useRef(0)
  const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
  const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
  const selectedReferenceFrames = useMemo(
    () => orderedFrames.filter((frame) => selectedFrames.has(frame.index)),
    [orderedFrames, selectedFrames],
  )
  const rowReferencePool = selectedReferenceFrames.length ? selectedReferenceFrames : orderedFrames
  const similarActorSource = useMemo(
    () => findSimilarActorSource(selectedReferenceFrames, orderedFrames),
    [selectedReferenceFrames, orderedFrames],
  )
  const subjectRefs = useMemo(() => subjectAssetRefsForPlanning(similarActorSource), [similarActorSource])

  const rowSectionKey = (rowIndex: number, section: "fields" | "videos") => `${rowIndex}:${section}`
  const isRowSectionOpen = (rowIndex: number, section: "fields" | "videos", defaultOpen: boolean) =>
    rowSectionOpen[rowSectionKey(rowIndex, section)] ?? defaultOpen
  const toggleRowSection = (rowIndex: number, section: "fields" | "videos", defaultOpen: boolean) => {
    const key = rowSectionKey(rowIndex, section)
    setRowSectionOpen((prev) => ({ ...prev, [key]: !(prev[key] ?? defaultOpen) }))
  }

  useEffect(() => {
    setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
    setCopyOverrides({})
    setPlanOverrides({})
    setAuthorIntent("")
    setScriptRewriteBusy(null)
    setQuickVideoBusyRow(null)
    setBatchCardBusy(false)
    setAdvancedRows(new Set())
    setRefineDialog(null)
    setRefineFeedback("")
    setRefinePreview(null)
    setPanelOpen({ product: true, batch: true })
    setRowSectionOpen({})
    setRowVideoCounts({})
    setBatchVideoCount(4)
    setAutoOptimizingField(null)
    setAutoOptimizedChinese({})
  }, [job?.id])

  const persistProductItems = async (items: ProductRefItem[]) => {
    if (!job) return
    const seq = ++productPersistSeq.current
    try {
      const updated = await saveProductRefs(job.id, items)
      if (seq === productPersistSeq.current) onJobUpdate?.(updated)
    } catch (e) {
      console.warn("产品素材池保存失败", e)
    }
  }

  const setAndPersistProductItems = (items: ProductRefItem[]) => {
    setProductItems(items)
    void persistProductItems(items)
  }

  const copyForRow = (row: AudioStoryboardRow) => copyOverrides[row.index] ?? row.skgCopy
  const copyZhForRow = (row: AudioStoryboardRow) => copyZhOverrides[row.index] ?? row.skgCopyZh

  const patchRowCopy = (rowIndex: number, value: string) => {
    setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
  }

  const patchRowCopyZh = (rowIndex: number, value: string) => {
    setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: value }))
  }

  const patchRowPlan = (rowIndex: number, patch: RowPlanPatch) => {
    setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } }))
  }

  const videoCountForRow = (rowIndex: number) => clampVideoCount(rowVideoCounts[rowIndex] ?? batchVideoCount)

  const patchRowVideoCount = (rowIndex: number, value: number) => {
    setRowVideoCounts((prev) => ({ ...prev, [rowIndex]: clampVideoCount(value) }))
  }

  const savePromptToLibrary = async (
    category: "scene_desc" | "video_desc" | "subject_desc" | "skg_script" | "product_angle",
    name: string,
    promptEn: string,
    promptZh = "",
  ) => {
    if (!job) return
    const text = promptEn.trim()
    if (!text) {
      toast.warning("没有可保存的提示词内容。")
      return
    }
    try {
      await createPromptLibraryItem({
        category,
        name,
        prompt_en: text,
        prompt_zh: promptZh.trim(),
        tags: ["工作台保存"],
        source_job_id: job.id,
      })
      toast.success("提示词已保存到资源库")
    } catch (error) {
      toast.error("提示词保存失败：" + (error instanceof Error ? error.message : String(error)))
    }
  }

  const applyVisualMode = (rowIndex: number, mode: StoryboardVisualMode) => {
    const defaults = visualModeDefaults(mode)
    const row = rows.find((item) => item.index === rowIndex)
    patchRowPlan(rowIndex, {
      visualMode: mode,
      needsProduct: defaults.needsProduct,
      needsSubject: defaults.needsSubject,
      subjectDescription: row ? buildSubjectDescription(row.role, mode) : "",
      subjectDescriptionZh: row ? buildSubjectDescriptionZh(row.role, mode) : "",
      productPlacement: defaults.productPlacement,
      productPlacementZh: visualModeDefaults(mode, "zh").productPlacement,
    })
  }

  const referenceFrameForRow = (row: AudioStoryboardRow) =>
    closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))

  const legacyRowIndexForFrame = (frameIndex: number) => {
    for (const item of rows) {
      if (referenceFrameForRow(item)?.index === frameIndex) return item.index
    }
    return null
  }

  const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
    const legacyRowIndex = frame ? legacyRowIndexForFrame(frame.index) : null
    const savedPatch = storyboardSceneBelongsToRow(frame?.storyboard, row.index, legacyRowIndex)
      ? savedScenePatch(frame?.storyboard)
      : {}
    return applyPlanPatch(applyPlanPatch(row, savedPatch), planOverrides[row.index])
  }

  const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({
    index: row.index,
    start: row.start,
    end: row.end,
    role: row.role,
    source: row.source,
    current_text: copyForRow(row),
  })

  const videosForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
    if (!frame) return []
    const legacyRowIndex = legacyRowIndexForFrame(frame.index)
    return (job?.generated_videos ?? []).filter((video) => {
      if (video.frame_idx !== frame.index) return false
      if (typeof video.storyboard_row_idx === "number") return video.storyboard_row_idx === row.index
      return legacyRowIndex === row.index
    })
  }

  const selectedVideoIdForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
    if (!frame?.storyboard) return ""
    const legacyRowIndex = legacyRowIndexForFrame(frame.index)
    return storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
      ? frame.storyboard.selected_video_id ?? ""
      : ""
  }

  const quickInputForRow = (row: AudioStoryboardRow, frame: KeyFrame | null): QuickStoryboardPlanInput => ({
    skg_copy_en: row.skgCopy,
    skg_copy_zh: row.skgCopyZh,
    scene_one_line_en: row.sceneOneLine,
    scene_one_line_zh: row.sceneOneLineZh,
    action_one_line_en: row.actionOneLine,
    action_one_line_zh: row.actionOneLineZh,
    subject_brief: row.needsSubject ? subjectBriefForEndpoint(row, subjectRefs) : "",
    duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || frame?.storyboard?.duration || 4.5)).toFixed(1)),
    visual_mode: row.visualMode,
    needs_product: row.needsProduct,
    needs_subject: row.needsSubject,
  })

  const buildSceneForPlannedRow = (
    row: AudioStoryboardRow,
    frame: KeyFrame,
    quickPlan?: StoryboardScene | null,
    selectedVideoId?: string,
  ): StoryboardScene => {
    const selectedSubjectRefs = row.needsSubject ? selectSubjectRefsForRow(row, subjectRefs) : []
    const legacyRowIndex = legacyRowIndexForFrame(frame.index)
    const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
      ? frame.storyboard
      : null
    const base = buildStoryboardSceneFromAudioRow(row, frame, productItems, selectedSubjectRefs, {
      firstImage: savedSceneForRow?.first_image ?? endpointAssetRef(frame, "first_frame"),
      lastImage: savedSceneForRow?.last_image ?? endpointAssetRef(frame, "last_frame"),
    })
    const savedSelectedVideoId = selectedVideoIdForRow(row, frame)
    if (!quickPlan) {
      return { ...base, selected_video_id: selectedVideoId ?? savedSelectedVideoId ?? base.selected_video_id ?? "" }
    }
    return {
      ...base,
      duration: quickPlan.duration || base.duration,
      visual_mode: quickPlan.visual_mode ?? base.visual_mode,
      needs_product: quickPlan.needs_product ?? base.needs_product,
      needs_subject: quickPlan.needs_subject ?? base.needs_subject,
      subject_brief: quickPlan.subject_brief || base.subject_brief,
      skg_copy_en: quickPlan.skg_copy_en || base.skg_copy_en,
      skg_copy_zh: quickPlan.skg_copy_zh || base.skg_copy_zh,
      scene_one_line_en: quickPlan.scene_one_line_en || base.scene_one_line_en,
      scene_one_line_zh: quickPlan.scene_one_line_zh || base.scene_one_line_zh,
      action_one_line_en: quickPlan.action_one_line_en || base.action_one_line_en,
      action_one_line_zh: quickPlan.action_one_line_zh || base.action_one_line_zh,
      first_frame_plan: quickPlan.first_frame_plan || base.first_frame_plan,
      last_frame_plan: quickPlan.last_frame_plan || base.last_frame_plan,
      product_placement: quickPlan.product_placement || base.product_placement,
      subject: quickPlan.subject || base.subject,
      scene: quickPlan.scene || base.scene,
      product: quickPlan.product || base.product,
      action: quickPlan.action || base.action,
      selected_video_id: selectedVideoId ?? savedSelectedVideoId ?? base.selected_video_id ?? "",
    }
  }

  const promptForStoryboardScene = (scene: StoryboardScene) => [
    "Create one vertical 9:16 short-form SKG ad video clip.",
    `English voice-over line: ${scene.skg_copy_en || scene.action || ""}`,
    `Scene: ${scene.scene_one_line_en || scene.scene || ""}`,
    `Subject + product + action: ${scene.action_one_line_en || scene.action || ""}`,
    `First frame intent: ${scene.first_frame_plan || ""}`,
    `Last frame intent: ${scene.last_frame_plan || ""}`,
    `Product placement: ${scene.product_placement || scene.product || ""}`,
    `Subject brief: ${scene.subject_brief || scene.subject || ""}`,
    "Keep motion natural, creator-ad style, premium wellness lighting, no subtitles, no platform UI, no watermark, no medical treatment claims.",
  ].filter((line) => line.trim()).join("\n")

  const drawVideosForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, count = 4, quiet = false) => {
    if (!job || !frame) {
      if (!quiet) toast.warning("这条分镜还没有参考帧，先完成抽帧。")
      return false
    }
    const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
    setQuickVideoBusyRow(row.index)
    try {
      const expandedPlan = await quickPlanStoryboard(job.id, frame.index, quickInputForRow(plannedRow, frame))
      const scene = buildSceneForPlannedRow(plannedRow, frame, expandedPlan)
      const saved = await updateStoryboard(job.id, frame.index, scene)
      onJobUpdate?.(saved)
      const updated = await generateStoryboardVideo(job.id, frame.index, {
        prompt: promptForStoryboardScene(scene),
        duration: scene.duration || 4,
        count,
        storyboard_row_idx: row.index,
        first_image: scene.first_image ?? null,
        last_image: scene.last_image ?? null,
        product_images: scene.product_images ?? [],
        subject_images: scene.subject_images ?? [],
        subject_image: scene.subject_image ?? null,
        scene_image: scene.scene_image ?? null,
        product_image: scene.product_image ?? null,
        action_image: scene.action_image ?? null,
        model: "seedance",
        size: "720x1280",
      })
      onJobUpdate?.(updated)
      if (!quiet) toast.success(`分镜 ${row.index + 1} 已提交 ${count} 条视频候选`)
      return true
    } catch (e) {
      if (!quiet) toast.error("视频候选生成失败：" + (e instanceof Error ? e.message : String(e)))
      return false
    } finally {
      setQuickVideoBusyRow(null)
    }
  }

  const clearVideosForRow = (videos: GeneratedVideo[]) => {
    if (!videos.length) return
    for (const video of videos) onDeleteVideo?.(video.id)
    toast.success(`已清空 ${videos.length} 个候选`)
  }

  const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"

  const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = [], startIndex = 0) => refs.map((ref, index) => {
    const analysis = analysisItems.find((item) => item.index === index)
    const validView = analysis && PRODUCT_VIEW_SLOTS.some((slot) => slot.value === analysis.view) ? analysis.view : undefined
    return createProductRefItem(
      ref,
      startIndex + index,
      itemSourceForRef(ref),
      validView,
      analysis?.note,
      analysis?.background ?? "unknown",
      analysis?.use_tags,
      analysis?.orientation,
      analysis?.landmarks,
      analysis?.risk ?? "",
      analysis?.confidence,
    )
  })

  const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
    if (!job || !seedItems.length) return seedItems
    let working = seedItems
    const failures: string[] = []
    const missing = PRODUCT_VIEW_SLOTS
      .filter((slot) => !working.some((item) => item.view === slot.value))

    for (const slot of missing) {
      setProductAngleBusy(slot.value)
      try {
        const references = selectProductAngleReferenceItems(working, slot.value)
        const ref = await generateProductAngleAsset(job.id, {
          source_ref: references[0].ref,
          source_refs: references.map((item) => item.ref),
          source_notes: productAngleSourceNotes(references),
          target_view: slot.label,
          note: `${slot.hint}；请综合这些同产品参考图补目标视角，不要只照抄某一张。`,
        })
        working = [
          ...working,
          createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐：${slot.hint}`, "white", undefined, undefined, undefined, "", 1),
        ]
        setAndPersistProductItems(working)
      } catch (e) {
        failures.push(`${slot.label}：${e instanceof Error ? e.message : String(e)}`)
      }
    }

    setProductAngleBusy(null)
    if (failures.length) {
      toast.warning(`部分产品视角自动补图失败：${failures.map((item) => item.split("：")[0]).join("、")}`)
    }
    return working
  }

  const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
    if (!job || !refs.length) return
    setProductAnalyzing(true)
    const pending = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角..."))
    setAndPersistProductItems(pending)
    try {
      const analysis = await analyzeProductViews(job.id, refs)
      const analyzed = buildAnalyzedProductItems(refs, analysis.items)
      setAndPersistProductItems(analyzed)
      const completed = await completeMissingProductAngles(analyzed)
      toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
    } catch (e) {
      const fallback = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
      setAndPersistProductItems(fallback)
      await completeMissingProductAngles(fallback)
      toast.warning("产品视角识别失败，已按默认顺序标注并尝试自动补图：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setProductAnalyzing(false)
      setProductAngleBusy(null)
    }
  }

  const analyzeUploadedProductRefs = async (refs: ImageRef[]) => {
    if (!job || !refs.length) return
    const baseItems = productItems
    const startIndex = baseItems.length
    setProductAnalyzing(true)
    setAndPersistProductItems([
      ...baseItems,
      ...refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload", undefined, "正在自动识别视角...")),
    ])
    try {
      const analysis = await analyzeProductViews(job.id, refs)
      const newItems = buildAnalyzedProductItems(refs, analysis.items, startIndex)
      const combined = [...baseItems, ...newItems]
      setAndPersistProductItems(combined)
      const completed = await completeMissingProductAngles(combined)
      toast.success(completed.length > combined.length ? "新产品图已识别，并已补齐缺失角度" : "新产品图已自动识别")
    } catch (e) {
      const fallback = refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload"))
      const combined = [...baseItems, ...fallback]
      setAndPersistProductItems(combined)
      await completeMissingProductAngles(combined)
      toast.warning("新产品图识别失败，已按默认顺序标注并尝试自动补图：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setProductAnalyzing(false)
      setProductAngleBusy(null)
    }
  }

  const uploadProductImages = async (files: FileList | null) => {
    if (!job || !files?.length) return
    const selected = Array.from(files)
    setProductUploading(true)
    try {
      const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
      await analyzeUploadedProductRefs(refs)
      toast.success(`已上传 ${refs.length} 张产品图`)
    } catch (e) {
      toast.error("产品图上传失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setProductUploading(false)
    }
  }

  const patchProductItem = (id: string, patch: Partial<ProductRefItem>) => {
    setProductItems((prev) => {
      const next = prev.map((item) => item.id === id ? { ...item, ...patch } : item)
      void persistProductItems(next)
      return next
    })
  }

  const removeProductItem = (id: string) => {
    setProductItems((prev) => {
      const next = prev.filter((item) => item.id !== id)
      void persistProductItems(next)
      return next
    })
  }

  const reanalyzeProductViews = async () => {
    if (!productItems.length) return
    await analyzeAndCompleteProductViews(productItems.map((item) => item.ref))
  }

  const applyScriptRewriteItems = (items: Array<{ index: number; text: string; text_zh?: string }>) => {
    if (!items.length) return
    setCopyOverrides((prev) => {
      const next = { ...prev }
      for (const item of items) {
        if (item.text?.trim()) next[item.index] = item.text.trim()
      }
      return next
    })
    setCopyZhOverrides((prev) => {
      const next = { ...prev }
      for (const item of items) {
        if (item.text_zh?.trim()) next[item.index] = item.text_zh.trim()
      }
      return next
    })
  }

  const rewriteSingleRow = async (row: AudioStoryboardRow) => {
    if (!job) return
    setScriptRewriteBusy(row.index)
    try {
      const result = await rewriteStoryboardScript(job.id, {
        mode: "segment",
        author_intent: authorIntent,
        segments: [rewriteSegmentForRow(row)],
      })
      applyScriptRewriteItems(result.items)
      toast.success(`分镜 ${row.index + 1} 已改写`)
    } catch (e) {
      toast.error("单段改写失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setScriptRewriteBusy(null)
    }
  }

  const rewriteAllRows = async () => {
    if (!job || !rows.length) return
    setScriptRewriteBusy("all")
    try {
      const result = await rewriteStoryboardScript(job.id, {
        mode: "all",
        author_intent: authorIntent,
        segments: rows.map(rewriteSegmentForRow),
      })
      applyScriptRewriteItems(result.items)
      toast.success("整片新口播文案已改写")
    } catch (e) {
      toast.error("整片改写失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setScriptRewriteBusy(null)
    }
  }

  const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
    if (!job || !productItems.length) return
    const references = selectProductAngleReferenceItems(productItems, slot.value)
    setProductAngleBusy(slot.value)
    try {
      const ref = await generateProductAngleAsset(job.id, {
        source_ref: references[0].ref,
        source_refs: references.map((item) => item.ref),
        source_notes: productAngleSourceNotes(references),
        target_view: slot.label,
        note: `${slot.hint}；请综合这些同产品参考图补目标视角，不要只照抄某一张。`,
      })
      setProductItems((prev) => {
        const next = [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐：${slot.hint}`, "white", undefined, undefined, undefined, "", 1)]
        void persistProductItems(next)
        return next
      })
      toast.success(`AI 已补全产品视角：${slot.label}`)
    } catch (e) {
      toast.error("AI 补角度失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setProductAngleBusy(null)
    }
  }

  const saveRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame) => {
    if (!job) return
    const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
    const scene = buildSceneForPlannedRow(plannedRow, frame, frame.storyboard)
    const updated = await updateStoryboard(job.id, frame.index, scene)
    onJobUpdate?.(updated)
  }

  const generateEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => {
    if (!job || !frame) return
    const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
    const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs, role) : []
    const subjectBrief = plannedRow.needsSubject ? subjectBriefForEndpoint(plannedRow, subjectRefs) : ""
    if (plannedRow.needsProduct && !productItems.length) {
      toast.warning("本条需要产品，请先上传并识别产品素材池")
      return
    }
    const selectedProductItems = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems, "endpoint", role) : []
    const busyKey = `${row.index}:${role}`
    setEndpointFrameBusy(busyKey)
    try {
      await saveRowStoryboardDraft(plannedRow, frame)
      const rawPrompt = buildEndpointFramePrompt(plannedRow, role, selectedProductItems, subjectBrief)
      const prompt = await ensureEnglishForModel(rawPrompt)
      const englishSubjectBrief = await ensureEnglishForModel(subjectBrief)
      const updated = await generateSceneAsset(job.id, frame.index, {
        size: SUBJECT_ASSET_SIZE,
        scene_mode: "similar",
        scene_style: "premium_product",
        asset_role: role,
        prompt,
        subject_brief: englishSubjectBrief,
        product_images: selectedProductItems.map((item) => item.ref),
        source_frame_indices: [],
      })
      const updatedFrame = updated.frames.find((item) => item.index === frame.index) ?? frame
      const generatedRef = endpointAssetRef(updatedFrame, role)
      const scene = buildStoryboardSceneFromAudioRow(plannedRow, updatedFrame, productItems, selectedSubjectRefs, {
        firstImage: role === "first_frame" ? generatedRef : endpointAssetRef(updatedFrame, "first_frame"),
        lastImage: role === "last_frame" ? generatedRef : endpointAssetRef(updatedFrame, "last_frame"),
      })
      const saved = await updateStoryboard(job.id, frame.index, scene)
      onJobUpdate?.(saved)
      toast.success(`分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}已生成`)
    } catch (e) {
      toast.error(`${role === "first_frame" ? "首帧" : "尾帧"}生成失败：` + (e instanceof Error ? e.message : String(e)))
    } finally {
      setEndpointFrameBusy(null)
    }
  }

  const clearEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => {
    if (!job || !frame) return
    const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
    const busyKey = `${row.index}:clear_${role}`
    setEndpointFrameBusy(busyKey)
    try {
      const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs, role) : []
      const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, {
        firstImage: role === "first_frame" ? null : endpointAssetRef(frame, "first_frame"),
        lastImage: role === "last_frame" ? null : endpointAssetRef(frame, "last_frame"),
      })
      const updated = await updateStoryboard(job.id, frame.index, scene)
      onJobUpdate?.(updated)
      toast.success(`${role === "first_frame" ? "首帧" : "尾帧"}已从本条规划移除`)
    } catch (e) {
      toast.error("移除首尾帧失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setEndpointFrameBusy(null)
    }
  }

  const saveSingleRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
    if (!job || !frame) return
    setStoryboardSaveBusyRow(row.index)
    try {
      await saveRowStoryboardDraft(row, frame)
      toast.success("本条三字段规划已同步")
    } catch (e) {
      toast.error("同步本条规划失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setStoryboardSaveBusyRow(null)
    }
  }

  const saveAllStoryboardDrafts = async (quiet = false) => {
    if (!job || !rows.length) return { ok: 0, failed: 0 }
    const jobsToSubmit = rows
      .map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) }))
      .filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame)
    if (!jobsToSubmit.length) {
      if (!quiet) toast.warning("先完成前置抽帧，让每条分镜有可同步的承载位置")
      return { ok: 0, failed: rows.length }
    }
    setBatchStoryboardSaveBusy(true)
    let ok = 0
    let failed = 0
    try {
      for (const item of jobsToSubmit) {
        setStoryboardSaveBusyRow(item.row.index)
        try {
          await saveRowStoryboardDraft(item.row, item.frame)
          ok += 1
        } catch (e) {
          failed += 1
          console.warn("批量同步分镜规划失败", item.row.index, e)
        }
      }
      if (!quiet) {
        if (failed) toast.warning(`已同步 ${ok} 条规划，${failed} 条失败`)
        else toast.success(`已同步全部 ${ok} 条分镜规划`)
      }
    } finally {
      setStoryboardSaveBusyRow(null)
      setBatchStoryboardSaveBusy(false)
    }
    return { ok, failed }
  }

  const batchDrawAllRows = async () => {
    if (!job || !rows.length) return
    const count = clampVideoCount(batchVideoCount)
    setBatchCardBusy(true)
    let submitted = 0
    let failed = 0
    try {
      for (const row of rows) {
        const frame = referenceFrameForRow(row)
        const ok = await drawVideosForRow(row, frame, count, true)
        if (ok) submitted += 1
        else failed += 1
      }
      if (failed) toast.warning(`整片已排队 ${submitted} 条分镜，${failed} 条失败或缺少参考帧`)
      else toast.success(`整片视频候选已按行排队：${submitted} 条分镜 × 每条 ${count} 个候选`)
    } catch (e) {
      toast.error("整片视频候选生成失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setBatchCardBusy(false)
    }
  }

  const openRefineForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
    setRefineDialog({ rowIndex: row.index, frameIndex: frame?.index ?? null })
    setRefineFeedback("")
    setRefinePreview(null)
  }

  const applyRefineItems = (rowIndex: number, items: RefineStoryboardResult["items"]) => {
    setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en }))
    setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh }))
    patchRowPlan(rowIndex, {
      skgCopy: items.skg_copy_en,
      skgCopyZh: items.skg_copy_zh,
      sceneOneLine: items.scene_one_line_en,
      sceneOneLineZh: items.scene_one_line_zh,
      actionOneLine: items.action_one_line_en,
      actionOneLineZh: items.action_one_line_zh,
    })
  }

  const applyFieldRefineItems = (rowIndex: number, field: CompactStoryboardFieldKind, items: RefineStoryboardResult["items"]) => {
    if (field === "copy") {
      setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en }))
      setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh }))
      patchRowPlan(rowIndex, { skgCopy: items.skg_copy_en, skgCopyZh: items.skg_copy_zh })
      return
    }
    if (field === "scene") {
      patchRowPlan(rowIndex, {
        sceneOneLine: items.scene_one_line_en,
        sceneOneLineZh: items.scene_one_line_zh,
        visualPlan: items.scene_one_line_en,
        visualPlanZh: items.scene_one_line_zh,
      })
      return
    }
    patchRowPlan(rowIndex, {
      actionOneLine: items.action_one_line_en,
      actionOneLineZh: items.action_one_line_zh,
      subjectDescription: items.action_one_line_en,
      subjectDescriptionZh: items.action_one_line_zh,
    })
  }

  const patchTranslatedFieldFallback = (rowIndex: number, field: CompactStoryboardFieldKind, zhValue: string, english: string) => {
    if (field === "copy") {
      setCopyOverrides((prev) => ({ ...prev, [rowIndex]: english }))
      setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: zhValue }))
      patchRowPlan(rowIndex, { skgCopy: english, skgCopyZh: zhValue })
      return
    }
    if (field === "scene") {
      patchRowPlan(rowIndex, { sceneOneLine: english, sceneOneLineZh: zhValue, visualPlan: english, visualPlanZh: zhValue })
      return
    }
    patchRowPlan(rowIndex, { actionOneLine: english, actionOneLineZh: zhValue, subjectDescription: english, subjectDescriptionZh: zhValue })
  }

  const optimizeEnglishFromChinese = async (
    row: AudioStoryboardRow,
    frame: KeyFrame | null,
    field: CompactStoryboardFieldKind,
    zhValue: string,
  ) => {
    if (!job || !frame) return
    const trimmedZh = zhValue.trim()
    if (!trimmedZh || !containsCjk(trimmedZh)) return
    const cacheKey = `${row.index}:${field}`
    if (autoOptimizedChinese[cacheKey] === trimmedZh) return
    const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
    const currentPlan = quickInputForRow(plannedRow, frame)
    if (field === "copy") currentPlan.skg_copy_zh = trimmedZh
    if (field === "scene") currentPlan.scene_one_line_zh = trimmedZh
    if (field === "action") currentPlan.action_one_line_zh = trimmedZh
    setAutoOptimizingField(cacheKey)
    try {
      const result = await refineStoryboard(job.id, frame.index, {
        current_plan: currentPlan,
        user_feedback: `用户刚修改了「${storyboardFieldLabel(field)}」的中文字段。请把这个中文作为该字段的最新意思，优化对应英文主字段，让它适合英文视频生成 prompt 和短视频口播/动作描述。其他两个字段只做必要的轻微润色，不要改变语义。`,
      })
      applyFieldRefineItems(row.index, field, result.items)
      setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh }))
      if (result.error) toast.warning(`中文已更新，英文使用兜底优化：${result.error}`)
      else toast.success(`${storyboardFieldLabel(field)}已按中文优化英文`)
    } catch (e) {
      try {
        const english = await translateText(trimmedZh, "en")
        patchTranslatedFieldFallback(row.index, field, trimmedZh, english)
        setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh }))
        toast.success(`${storyboardFieldLabel(field)}已翻译成英文`)
      } catch {
        toast.error("中文优化英文失败：" + (e instanceof Error ? e.message : String(e)))
      }
    } finally {
      setAutoOptimizingField(null)
    }
  }

  const submitRefine = async () => {
    if (!job || !refineDialog) return
    const row = rows.find((item) => item.index === refineDialog.rowIndex)
    const frame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null
    if (!row || !frame) return
    const feedback = refineFeedback.trim()
    if (!feedback) {
      toast.warning("先写一句你想怎么改。")
      return
    }
    const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
    const currentPlan = refinePreview
      ? {
          ...quickInputForRow(plannedRow, frame),
          skg_copy_en: refinePreview.skg_copy_en,
          skg_copy_zh: refinePreview.skg_copy_zh,
          scene_one_line_en: refinePreview.scene_one_line_en,
          scene_one_line_zh: refinePreview.scene_one_line_zh,
          action_one_line_en: refinePreview.action_one_line_en,
          action_one_line_zh: refinePreview.action_one_line_zh,
        }
      : quickInputForRow(plannedRow, frame)
    setRefineBusy(true)
    try {
      const result = await refineStoryboard(job.id, frame.index, {
        current_plan: currentPlan,
        user_feedback: feedback,
      })
      setRefinePreview(result.items)
      if (result.error) toast.warning(`AI 改写已回退：${result.error}`)
    } catch (e) {
      toast.error("AI 改写失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setRefineBusy(false)
    }
  }

  const closeRefineDialog = () => {
    setRefineDialog(null)
    setRefineFeedback("")
    setRefinePreview(null)
    setRefineBusy(false)
  }

  if (!job) return null

  return (
    <section className="mt-3 rounded-lg border border-white/10 bg-black/28 p-2.5">
      <div className="mb-2 flex items-start justify-between gap-3">
        <div>
          <div className="flex flex-wrap items-center gap-2">
            <SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
            <WorkflowStepBadge step={scriptStep} compact />
          </div>
          <p className="mt-1 text-[11px] leading-snug text-white/42">每条分镜纵向排列；行内完成原内容、新文案、画面/产品和视频候选。关键帧选择与相似主体重构在源视频工作区下方统一处理。</p>
        </div>
        <div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
          <Requirement label="分镜" ready={rows.length > 0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
          <Requirement label="参考帧" ready={orderedFrames.length > 0} detail={orderedFrames.length ? `${orderedFrames.length} 张` : "待抽帧"} />
          <Requirement label="候选" ready={(job.generated_videos?.length ?? 0) > 0} detail={`${job.generated_videos?.length ?? 0} 条历史`} />
        </div>
      </div>

      <div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2.5">
        <div className="mb-2 flex flex-wrap items-start justify-between gap-2">
          <div className="min-w-0">
            <div className="flex items-center gap-2">
              <SectionTitle icon={<Package className="h-4 w-4" />} title="同一产品素材池 / 视角标注" />
              <WorkflowStepBadge step={productStep} compact />
              <ModelTrace trace={productModelTrace(runtimeModels)} compact />
              <span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
              {(productAnalyzing || productAngleBusy) && (
                <span className="inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75">
                  <Loader2 className="h-3 w-3 animate-spin" />
                  {productAnalyzing ? "识别视角中" : `补图中：${productViewLabel(productAngleBusy ?? "")}`}
                </span>
              )}
            </div>
            {panelOpen.product ? (
              <p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
                产品固定按“套在脖子上的 U 形肩颈按摩仪”识别；左/右按佩戴者身体左右，上/下按佩戴方向，不按图片左右。推荐原图长边 1200-2000px、短边至少 600px；系统会生成最长边 1600px、JPEG 92 的 AI 工作副本。每条视频只挑最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图。
              </p>
            ) : (
              <p className="mt-1 text-[11px] text-white/34">已折叠 · {productItems.length ? `${productItems.length} 张产品素材` : "还没有产品素材"}</p>
            )}
          </div>
          <div className="flex shrink-0 items-center gap-2">
            <button
              type="button"
              onClick={() => setPanelOpen((prev) => ({ ...prev, product: !prev.product }))}
              className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition"
            >
              <ChevronDown className={`h-3.5 w-3.5 transition ${panelOpen.product ? "rotate-180" : ""}`} />
              {panelOpen.product ? "收起" : "展开"}
            </button>
            <button
              type="button"
              onClick={() => void reanalyzeProductViews()}
              disabled={!productItems.length || productAnalyzing || !!productAngleBusy}
              className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
            >
              {productAnalyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
              重新识别并补图
            </button>
            <button
              type="button"
              onClick={() => productFileRef.current?.click()}
              disabled={!job || productUploading || productAnalyzing || !!productAngleBusy}
              className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
            >
              {productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
              上传产品图
            </button>
            <input
              ref={productFileRef}
              type="file"
              accept="image/*"
              multiple
              className="hidden"
              onChange={(event) => {
                void uploadProductImages(event.currentTarget.files)
                event.currentTarget.value = ""
              }}
            />
          </div>
        </div>

        {panelOpen.product ? (
          <div className="grid max-h-[360px] grid-cols-3 gap-2 overflow-y-auto pr-1">
            {productItems.map((item) => (
              <ProductReferenceCard
                key={item.id}
                job={job}
                item={item}
                onPatch={(patch) => patchProductItem(item.id, patch)}
                onRemove={() => removeProductItem(item.id)}
              />
            ))}
            {PRODUCT_VIEW_SLOTS.filter((slot) => !productItems.some((item) => item.view === slot.value)).map((slot) => (
              <MissingProductViewSlot
                key={slot.value}
                slot={slot}
                canGenerate={!!productItems.length}
                busy={productAngleBusy === slot.value}
                blocked={productAnalyzing || !!productAngleBusy}
                onGenerate={() => generateMissingProductAngle(slot)}
              />
            ))}
          </div>
        ) : null}
      </div>

      {rows.length ? (
        <>
        <div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2">
          <div className="flex flex-wrap items-center justify-between gap-2">
            <div className="flex min-w-0 items-center gap-2">
              <SectionTitle icon={<Wand2 className="h-4 w-4" />} title="批量控制 / 作者想法" />
              <ModelTrace trace={scriptRewriteModelTrace(runtimeModels)} compact />
              {!panelOpen.batch ? <span className="truncate text-[11px] text-white/34">{authorIntent.trim() || "已折叠"}</span> : null}
            </div>
            <button
              type="button"
              onClick={() => setPanelOpen((prev) => ({ ...prev, batch: !prev.batch }))}
              className="skg-secondary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[11px] font-semibold transition"
            >
              <ChevronDown className={`h-3.5 w-3.5 transition ${panelOpen.batch ? "rotate-180" : ""}`} />
              {panelOpen.batch ? "收起" : "展开"}
            </button>
          </div>
          {panelOpen.batch ? (
            <div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2">
              <textarea
                value={authorIntent}
                onChange={(event) => setAuthorIntent(event.target.value)}
                placeholder="作者想法：比如更像真实达人、前半段强调久坐低头、结尾弱化优惠、语气更轻松..."
                className="min-h-[42px] resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[11px] leading-snug text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
              />
              <div className="flex flex-wrap items-center justify-end gap-2">
                <button
                  type="button"
                  onClick={() => void savePromptToLibrary("skg_script", "作者想法", authorIntent)}
                  disabled={!authorIntent.trim()}
                  className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-35"
                  title="保存作者想法到提示词库"
                >
                  <BookOpen className="h-3.5 w-3.5" />
                  想法入库
                </button>
                <button
                  type="button"
                  onClick={() => setShowChineseMirror((value) => !value)}
                  className="skg-secondary-action inline-flex h-9 items-center justify-center px-2.5 text-[11px] font-semibold transition"
                >
                  {showChineseMirror ? "收起中文" : "显示中文"}
                </button>
                <button
                  type="button"
                  onClick={() => void rewriteAllRows()}
                  disabled={scriptRewriteBusy !== null || !rows.length}
                  className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
                >
                  {scriptRewriteBusy === "all" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
                  整片改写
                </button>
                <label className="inline-flex h-9 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] font-semibold text-white/52">
                  每行
                  <select
                    value={batchVideoCount}
                    onChange={(event) => setBatchVideoCount(clampVideoCount(Number(event.target.value)))}
                    className="h-6 rounded border border-white/10 bg-black/45 px-1 text-center font-mono text-[11px] text-white/78 outline-none focus:border-cyan-300/45"
                  >
                    {STORYBOARD_VIDEO_COUNT_OPTIONS.map((count) => (
                      <option key={count} value={count}>{count}</option>
                    ))}
                  </select>
                  条
                </label>
                <button
                  type="button"
                  onClick={() => void batchDrawAllRows()}
                  disabled={batchCardBusy || batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
                  className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
                >
                  {batchCardBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
                  整片排队生成（{rows.length}×{batchVideoCount}条）
                </button>
                <button
                  type="button"
                  onClick={() => {
                    setCopyOverrides({})
                    setCopyZhOverrides({})
                  }}
                  disabled={scriptRewriteBusy !== null || !Object.keys(copyOverrides).length}
                  className="skg-secondary-action inline-flex h-9 items-center justify-center px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-35"
                >
                  还原初稿
                </button>
                <button
                  type="button"
                  onClick={() => void saveAllStoryboardDrafts()}
                  disabled={batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
                  title="只同步三字段规划；生成的视频会自动保存到当前任务"
                  className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
                >
                  {batchStoryboardSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
                  同步规划
                </button>
              </div>
            </div>
          ) : null}
        </div>
        <div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
          {rows.map((row) => {
            const referenceFrame = referenceFrameForRow(row)
            const plannedRow = planForRow(row, referenceFrame)
            const rowVideos = videosForRow(row, referenceFrame)
            const savingStoryboard = storyboardSaveBusyRow === row.index
            const copyText = copyForRow(row)
            const copyZhText = copyZhForRow(row)
            const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems, "endpoint").length : 0
            const endpointSubjectBrief = plannedRow.needsSubject ? subjectBriefForEndpoint(plannedRow, subjectRefs) : ""
            const fieldsOpen = isRowSectionOpen(row.index, "fields", true)
            const videosOpen = isRowSectionOpen(row.index, "videos", false)
            const rowVideoCount = videoCountForRow(row.index)
            return (
              <article
                key={row.index}
                className="overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64"
              >
                <div className="border-b border-white/8 p-2.5">
                  <div className="flex flex-wrap items-center justify-between gap-2">
                    <div className="min-w-0">
                      <div className="flex flex-wrap items-center gap-2">
                        <span className="font-semibold text-white/82">分镜 {row.index + 1}</span>
                        <span className="font-mono text-[10.5px] text-white/42">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</span>
                        <span className="rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
                          {ROLE_LABELS_ZH[row.role]}
                        </span>
                        {referenceFrame ? (
                          <span className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-white/38">帧 {referenceFrame.index + 1}</span>
                        ) : (
                          <span className="rounded-md border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70">待抽帧</span>
                        )}
                      </div>
                      <p className="mt-1 line-clamp-1 text-[10.5px] text-white/32" title={row.source}>{row.source}</p>
                    </div>
                    <div className="flex flex-wrap items-center justify-end gap-1.5">
                      <button
                        type="button"
                        onClick={() => openRefineForRow(plannedRow, referenceFrame)}
                        disabled={!referenceFrame}
                        className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-violet-300/18 bg-violet-300/[0.07] px-2 text-[10.5px] font-semibold text-violet-100/75 transition hover:border-violet-300/45 hover:text-violet-50 disabled:cursor-not-allowed disabled:opacity-35"
                      >
                        <Wand2 className="h-3.5 w-3.5" />
                        AI 改写
                      </button>
                      <button
                        type="button"
                        onClick={() => patchRowVideoCount(row.index, Math.max(1, rowVideoCount - 1))}
                        disabled={rowVideoCount <= 1}
                        className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] text-white/58 transition hover:border-white/25 hover:text-white/82 disabled:cursor-not-allowed disabled:opacity-30"
                        aria-label="减少本行生成数量"
                      >
                        -
                      </button>
                      <label className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] font-semibold text-white/52">
                        本行
                        <input
                          type="number"
                          min={1}
                          max={12}
                          value={rowVideoCount}
                          onChange={(event) => patchRowVideoCount(row.index, Number(event.target.value) || 1)}
                          className="h-5 w-10 rounded border border-white/10 bg-black/45 text-center font-mono text-[11px] text-white/82 outline-none focus:border-cyan-300/45"
                        />
                        条
                      </label>
                      <button
                        type="button"
                        onClick={() => patchRowVideoCount(row.index, Math.min(12, rowVideoCount + 1))}
                        disabled={rowVideoCount >= 12}
                        className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] text-white/58 transition hover:border-white/25 hover:text-white/82 disabled:cursor-not-allowed disabled:opacity-30"
                        aria-label="增加本行生成数量"
                      >
                        <Plus className="h-3.5 w-3.5" />
                      </button>
                      <button
                        type="button"
                        onClick={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
                        disabled={!referenceFrame || quickVideoBusyRow !== null}
                        className="skg-primary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
                      >
                        {quickVideoBusyRow === row.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
                        生成 {rowVideoCount} 条
                      </button>
                      <button
                        type="button"
                        onClick={() => setAdvancedRows((prev) => {
                          const next = new Set(prev)
                          if (next.has(row.index)) next.delete(row.index)
                          else next.add(row.index)
                          return next
                        })}
                        className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/25 hover:text-white/82"
                      >
                        高级
                        <ChevronDown className={`h-3.5 w-3.5 transition ${advancedRows.has(row.index) ? "rotate-180" : ""}`} />
                      </button>
                      <button
                        type="button"
                        onClick={() => toggleRowSection(row.index, "fields", true)}
                        className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/25 hover:text-white/82"
                      >
                        {fieldsOpen ? "收起" : "展开"}
                        <ChevronDown className={`h-3.5 w-3.5 transition ${fieldsOpen ? "rotate-180" : ""}`} />
                      </button>
                    </div>
                  </div>

                  {fieldsOpen ? (
                  <div className="mt-2 grid grid-cols-[minmax(280px,0.82fr)_minmax(420px,1.18fr)] gap-3">
                    <div className="grid min-w-0 gap-1.5">
                      <CompactStoryboardField
                        label="文案"
                        value={copyText}
                        zhValue={copyZhText}
                        showChinese={showChineseMirror}
                        optimizing={autoOptimizingField === `${row.index}:copy`}
                        onChange={(value) => patchRowCopy(row.index, value)}
                        onChangeZh={(value) => patchRowCopyZh(row.index, value)}
                        onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "copy", value)}
                        onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)}
                        onPick={() => toast.info("从右侧资源库选用提示词后，可粘贴到当前字段。")}
                      />
                      <CompactStoryboardField
                        label="场景一句话"
                        value={plannedRow.sceneOneLine}
                        zhValue={plannedRow.sceneOneLineZh}
                        showChinese={showChineseMirror}
                        optimizing={autoOptimizingField === `${row.index}:scene`}
                        onChange={(value) => patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })}
                        onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })}
                        onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "scene", value)}
                        onSave={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 场景一句话`, plannedRow.sceneOneLine, plannedRow.sceneOneLineZh)}
                        onPick={() => toast.info("从右侧资源库选用提示词后，可粘贴到当前字段。")}
                      />
                      <CompactStoryboardField
                        label="人物 + 产品 + 动作"
                        value={plannedRow.actionOneLine}
                        zhValue={plannedRow.actionOneLineZh}
                        showChinese={showChineseMirror}
                        optimizing={autoOptimizingField === `${row.index}:action`}
                        onChange={(value) => patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })}
                        onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })}
                        onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "action", value)}
                        onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)}
                        onPick={() => toast.info("从右侧资源库选用提示词后，可粘贴到当前字段。")}
                      />
                    </div>
                    <StoryboardVideoSlots
                      job={job}
                      videos={rowVideos}
                      enabled={!!referenceFrame}
                      busy={quickVideoBusyRow === row.index}
                      count={rowVideoCount}
                      onCountChange={(count) => patchRowVideoCount(row.index, count)}
                      onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
                      onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
                      onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
                      onClear={() => clearVideosForRow(rowVideos)}
                      onDeleteVideo={onDeleteVideo}
                    />
                  </div>
                  ) : null}
                </div>

                {advancedRows.has(row.index) ? (
                <div className="grid grid-cols-[56px_140px_280px_minmax(560px,1fr)_420px]">
                <StoryboardPlanCell label="分镜">
                  <div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
                  <div className="mt-1.5 inline-flex max-w-full rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
                    {ROLE_LABELS_ZH[row.role]}
                  </div>
                </StoryboardPlanCell>

                <StoryboardPlanCell label="原内容">
                  <p className="line-clamp-2 text-[10.5px] leading-snug" title={row.source}>{row.source}</p>
                  {showChineseMirror && row.sourceZh ? (
                    <p className="mt-1 line-clamp-2 text-[10px] leading-snug text-white/34" title={row.sourceZh}>中：{row.sourceZh}</p>
                  ) : null}
                </StoryboardPlanCell>

                <StoryboardPlanCell label={`${scriptStep.no} 新口播文案`}>
                  <textarea
                    value={copyText}
                    onChange={(event) => patchRowCopy(row.index, event.target.value)}
                    className="min-h-[64px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
                  />
                  {showChineseMirror && copyZhText ? (
                    <p className="mt-1 line-clamp-2 text-[10px] leading-snug text-white/34" title={copyZhText}>中：{copyZhText}</p>
                  ) : null}
                  <button
                    type="button"
                    onClick={() => void rewriteSingleRow(row)}
                    disabled={scriptRewriteBusy !== null}
                    className="mt-1 inline-flex h-6 w-full items-center justify-center gap-1 rounded border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
                  >
                    {scriptRewriteBusy === row.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
                    单段改写
                  </button>
                  <button
                    type="button"
                    onClick={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 新口播`, copyText, copyZhText)}
                    disabled={!copyText.trim()}
                    className="mt-1 inline-flex h-6 w-full items-center justify-center gap-1 rounded border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2 text-[10.5px] font-semibold text-[#f1d78e]/72 transition hover:border-[#d6b36a]/42 hover:text-[#f1d78e] disabled:cursor-not-allowed disabled:opacity-35"
                  >
                    <BookOpen className="h-3 w-3" />
                    文案入库
                  </button>
                </StoryboardPlanCell>

                <StoryboardPlanCell label={`${sceneStep.no} 画面规划 / 产品融入`}>
                  <div className="grid gap-1.5">
                    <div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-1.5">
                      <select
                        value={plannedRow.visualMode}
                        onChange={(event) => applyVisualMode(row.index, event.target.value as StoryboardVisualMode)}
                        className="h-7 min-w-0 rounded border border-white/10 bg-black/45 px-1.5 text-[10.5px] text-white/76 outline-none focus:border-cyan-300/50"
                        title={VISUAL_MODE_OPTIONS.find((item) => item.value === plannedRow.visualMode)?.description}
                      >
                        {VISUAL_MODE_OPTIONS.map((option) => (
                          <option key={option.value} value={option.value}>{option.label}</option>
                        ))}
                      </select>
                      <label className="inline-flex h-7 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-1.5 text-[10px] text-white/55">
                        <input
                          type="checkbox"
                          checked={plannedRow.needsSubject}
                          onChange={(event) => patchRowPlan(row.index, { needsSubject: event.target.checked })}
                          className="h-3 w-3 accent-cyan-300"
                        />
                        人物
                      </label>
                      <label className="inline-flex h-7 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-1.5 text-[10px] text-white/55">
                        <input
                          type="checkbox"
                          checked={plannedRow.needsProduct}
                          onChange={(event) => patchRowPlan(row.index, { needsProduct: event.target.checked })}
                          className="h-3 w-3 accent-cyan-300"
                        />
                        产品
                      </label>
                    </div>
                    <textarea
                      value={plannedRow.visualPlan}
                      onChange={(event) => patchRowPlan(row.index, { visualPlan: event.target.value })}
                      placeholder="画面规划"
                      className="min-h-[42px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/76 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
                    />
                    {showChineseMirror && plannedRow.visualPlanZh ? (
                      <p className="-mt-1 line-clamp-2 text-[10px] leading-snug text-white/32" title={plannedRow.visualPlanZh}>中：{plannedRow.visualPlanZh}</p>
                    ) : null}
                    {plannedRow.needsSubject && (
                      <>
                        <textarea
                          value={plannedRow.subjectDescription}
                          onChange={(event) => patchRowPlan(row.index, { subjectDescription: event.target.value })}
                          placeholder="人物描述：主体身份、姿态、情绪、需要用哪些视角"
                          className="min-h-[42px] w-full resize-y rounded border border-[#d6b36a]/14 bg-[#d6b36a]/[0.045] px-2 py-1.5 text-[10.5px] leading-snug text-white/78 outline-none placeholder:text-white/25 focus:border-[#d6b36a]/50"
                        />
                        {showChineseMirror && plannedRow.subjectDescriptionZh ? (
                          <p className="-mt-1 line-clamp-2 text-[10px] leading-snug text-white/34" title={plannedRow.subjectDescriptionZh}>中：{plannedRow.subjectDescriptionZh}</p>
                        ) : null}
                      </>
                    )}
                    <div className="grid grid-cols-2 gap-1">
                      <textarea
                        value={plannedRow.firstFramePlan}
                        onChange={(event) => patchRowPlan(row.index, { firstFramePlan: event.target.value })}
                        placeholder="首帧：视频开始画面"
                        className="min-h-[48px] w-full resize-y rounded border border-emerald-300/12 bg-emerald-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-emerald-50/78 outline-none placeholder:text-white/25 focus:border-emerald-300/50"
                      />
                      <textarea
                        value={plannedRow.lastFramePlan}
                        onChange={(event) => patchRowPlan(row.index, { lastFramePlan: event.target.value })}
                        placeholder="尾帧：视频结束画面"
                        className="min-h-[48px] w-full resize-y rounded border border-cyan-300/12 bg-cyan-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-cyan-50/78 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
                      />
                    </div>
                    {showChineseMirror && (plannedRow.firstFramePlanZh || plannedRow.lastFramePlanZh) ? (
                      <div className="-mt-1 grid grid-cols-2 gap-1">
                        <p className="line-clamp-2 text-[10px] leading-snug text-emerald-100/34" title={plannedRow.firstFramePlanZh}>中：{plannedRow.firstFramePlanZh}</p>
                        <p className="line-clamp-2 text-[10px] leading-snug text-cyan-100/34" title={plannedRow.lastFramePlanZh}>中：{plannedRow.lastFramePlanZh}</p>
                      </div>
                    ) : null}
                    <textarea
                      value={plannedRow.productPlacement}
                      onChange={(event) => patchRowPlan(row.index, { productPlacement: event.target.value })}
                      placeholder="产品出现方式：不出现 / 首帧出现 / 尾帧出现 / 全程佩戴 / 产品特写"
                      className="min-h-[38px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/68 outline-none placeholder:text-white/25 focus:border-rose-300/45"
                    />
                    {showChineseMirror && plannedRow.productPlacementZh ? (
                      <p className="-mt-1 line-clamp-2 text-[10px] leading-snug text-white/32" title={plannedRow.productPlacementZh}>中：{plannedRow.productPlacementZh}</p>
                    ) : null}
                    <div className="grid grid-cols-3 gap-1">
                      <button
                        type="button"
                        onClick={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 画面规划`, plannedRow.visualPlan, plannedRow.visualPlanZh)}
                        className="h-6 rounded border border-white/10 bg-white/[0.04] px-1 text-[10px] text-white/52 transition hover:border-[#d6b36a]/32 hover:text-[#f1d78e]"
                      >
                        画面入库
                      </button>
                      <button
                        type="button"
                        onClick={() => void savePromptToLibrary("subject_desc", `分镜 ${row.index + 1} 主体描述`, plannedRow.subjectDescription, plannedRow.subjectDescriptionZh)}
                        disabled={!plannedRow.needsSubject}
                        className="h-6 rounded border border-white/10 bg-white/[0.04] px-1 text-[10px] text-white/52 transition hover:border-[#d6b36a]/32 hover:text-[#f1d78e] disabled:opacity-35"
                      >
                        主体入库
                      </button>
                      <button
                        type="button"
                        onClick={() => void savePromptToLibrary("product_angle", `分镜 ${row.index + 1} 产品出现`, plannedRow.productPlacement, plannedRow.productPlacementZh)}
                        disabled={!plannedRow.needsProduct}
                        className="h-6 rounded border border-white/10 bg-white/[0.04] px-1 text-[10px] text-white/52 transition hover:border-[#d6b36a]/32 hover:text-[#f1d78e] disabled:opacity-35"
                      >
                        产品入库
                      </button>
                    </div>
                    <div className="grid grid-cols-[minmax(0,1fr)_88px_88px] gap-1.5">
                      <div className="rounded border border-white/10 bg-black/24 px-2 py-1.5 text-[10px] leading-snug text-white/42">
                        <div className="mb-1 flex items-center justify-between gap-2">
                          <span className="text-white/54">{sceneStep.no} 首尾帧闸门</span>
                          <span className={endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "text-emerald-100/75" : "text-amber-100/72"}>
                            {endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "可进入视频候选" : "先看图再生视频"}
                          </span>
                        </div>
                        <p>
                          关键帧只用于前置主体重构；这里用相似主体视图{plannedRow.needsProduct ? " + 产品素材池" : ""}生成首尾帧。
                        </p>
                      </div>
                      <EndpointFrameSlot
                        job={job}
                        frame={referenceFrame}
                        role="first_frame"
                        subjectBrief={endpointSubjectBrief}
                        busy={endpointFrameBusy === `${row.index}:first_frame`}
                        deleting={endpointFrameBusy === `${row.index}:clear_first_frame`}
                        disabled={!referenceFrame || (plannedRow.needsProduct && !productItems.length)}
                        onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
                        onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
                      />
                      <EndpointFrameSlot
                        job={job}
                        frame={referenceFrame}
                        role="last_frame"
                        subjectBrief={endpointSubjectBrief}
                        busy={endpointFrameBusy === `${row.index}:last_frame`}
                        deleting={endpointFrameBusy === `${row.index}:clear_last_frame`}
                        disabled={!referenceFrame || (plannedRow.needsProduct && !productItems.length)}
                        onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
                        onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
                      />
                    </div>
                    <div className="flex items-center justify-between gap-2 text-[10px] text-white/34">
                      <span title={endpointSubjectBrief || "本条不传主体 brief"}>
                        {plannedRow.needsSubject ? "依据：主体 brief" : "本条不传主体"} · {plannedRow.needsProduct ? `${selectedProductCount || 0} 张产品参考` : "本条不传产品图"}
                      </span>
                      <button
                        type="button"
                        onClick={() => patchRowPlan(row.index, {
                          ...visualModeDefaults(plannedRow.visualMode),
                          subjectDescription: buildSubjectDescription(plannedRow.role, plannedRow.visualMode),
                          subjectDescriptionZh: buildSubjectDescriptionZh(plannedRow.role, plannedRow.visualMode),
                          productPlacementZh: visualModeDefaults(plannedRow.visualMode, "zh").productPlacement,
                        })}
                        className="rounded border border-white/10 px-1.5 py-0.5 text-white/42 transition hover:border-white/25 hover:text-white/72"
                      >
                        重置类型
                      </button>
                    </div>
                  </div>
                </StoryboardPlanCell>

                <StoryboardPlanCell label={`${videoStep.no} 视频候选 / 待生成`} className="border-r-0">
                  <StoryboardVideoSlots
                    job={job}
                    videos={rowVideos}
                    enabled={!!referenceFrame}
                    expanded={videosOpen}
                    busy={quickVideoBusyRow === row.index}
                    count={rowVideoCount}
                    onCountChange={(count) => patchRowVideoCount(row.index, count)}
                    onToggleExpanded={() => toggleRowSection(row.index, "videos", false)}
                    onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
                    onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
                    onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
                    onClear={() => clearVideosForRow(rowVideos)}
                    onDeleteVideo={onDeleteVideo}
                  />
                  <div className="mt-1 flex items-center justify-between gap-2">
                    <span className="text-[10px] text-white/34">高级首尾帧</span>
                    <span className="rounded border border-emerald-300/18 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[10px] text-emerald-100/70">视频接口自动兜底</span>
                  </div>
                  <div className="mt-1 rounded border border-cyan-300/12 bg-cyan-300/[0.045] px-2 py-1 text-[10px] leading-snug text-cyan-100/62">
                    不需要客户先生成首尾帧；这里保留给老手手动调首帧/尾帧 prompt 或资产。
                  </div>
                  <button
                    type="button"
                    onClick={() => void saveSingleRowStoryboardDraft(plannedRow, referenceFrame)}
                    disabled={!referenceFrame || savingStoryboard}
                    title="只同步本条三字段规划；生成的视频会自动保存到当前任务"
                    className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[11px] font-semibold text-white/58 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
                  >
                    {savingStoryboard ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
                    同步本条规划
                  </button>
                </StoryboardPlanCell>
                </div>
                ) : null}
              </article>
            )
          })}
        </div>
        {refineDialog ? (() => {
          const dialogRow = rows.find((item) => item.index === refineDialog.rowIndex)
          const dialogFrame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null
          const plannedDialogRow = dialogRow && dialogFrame ? { ...planForRow(dialogRow, dialogFrame), skgCopy: copyForRow(dialogRow), skgCopyZh: copyZhForRow(dialogRow) } : null
          if (!dialogRow || !dialogFrame || !plannedDialogRow) return null
          const quickButtons = ["更兴奋", "更舒缓", "产品再露", "主体更近", "镜头更动", "背景更暗", "节奏更快"]
          return (
            <div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/72 p-4">
              <div className="w-full max-w-3xl rounded-lg border border-white/14 bg-[#080b0f] p-3 shadow-[0_26px_90px_rgba(0,0,0,0.72)]">
                <div className="mb-2 flex items-center justify-between gap-3">
                  <div>
                    <div className="text-[14px] font-semibold text-white">让 AI 帮你改这条分镜</div>
                    <div className="mt-0.5 text-[11px] text-white/42">分镜 {dialogRow.index + 1} · 先预览，不会直接覆盖。</div>
                  </div>
                  <button type="button" onClick={closeRefineDialog} className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/48 transition hover:text-white">
                    关闭
                  </button>
                </div>
                <textarea
                  value={refineFeedback}
                  onChange={(event) => setRefineFeedback(event.target.value)}
                  placeholder="你想怎么改？例如：让这条更兴奋一点，但产品露出更自然。"
                  className="min-h-[72px] w-full resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
                />
                <div className="mt-2 flex flex-wrap gap-1.5">
                  {quickButtons.map((item) => (
                    <button
                      key={item}
                      type="button"
                      onClick={() => setRefineFeedback((prev) => prev ? `${prev}，${item}` : item)}
                      className="rounded-md border border-white/10 bg-white/[0.045] px-2 py-1 text-[10.5px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100"
                    >
                      {item}
                    </button>
                  ))}
                </div>

                {refinePreview ? (
                  <div className="mt-3 grid grid-cols-2 gap-2">
                    <div className="rounded-md border border-white/10 bg-black/24 p-2">
                      <div className="mb-1 text-[11px] font-semibold text-white/58">改前</div>
                      <p className="text-[11px] leading-snug text-white/60">文案：{plannedDialogRow.skgCopy}</p>
                      <p className="mt-1 text-[11px] leading-snug text-white/44">场景：{plannedDialogRow.sceneOneLine}</p>
                      <p className="mt-1 text-[11px] leading-snug text-white/44">动作：{plannedDialogRow.actionOneLine}</p>
                    </div>
                    <div className="rounded-md border border-emerald-300/18 bg-emerald-300/[0.06] p-2">
                      <div className="mb-1 text-[11px] font-semibold text-emerald-100/72">改后预览</div>
                      <p className="text-[11px] leading-snug text-white/76">文案：{refinePreview.skg_copy_en}</p>
                      <p className="mt-1 text-[11px] leading-snug text-white/56">场景：{refinePreview.scene_one_line_en}</p>
                      <p className="mt-1 text-[11px] leading-snug text-white/56">动作：{refinePreview.action_one_line_en}</p>
                    </div>
                  </div>
                ) : null}

                <div className="mt-3 flex items-center justify-end gap-2">
                  <button type="button" onClick={closeRefineDialog} className="skg-secondary-action inline-flex h-8 items-center px-3 text-[11px] font-semibold">
                    取消
                  </button>
                  {refinePreview ? (
                    <>
                      <button
                        type="button"
                        onClick={() => void submitRefine()}
                        disabled={refineBusy}
                        className="skg-secondary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
                      >
                        {refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
                        再改一次
                      </button>
                      <button
                        type="button"
                        onClick={() => {
                          applyRefineItems(dialogRow.index, refinePreview)
                          closeRefineDialog()
                        }}
                        className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold"
                      >
                        <Check className="h-3.5 w-3.5" />
                        应用
                      </button>
                    </>
                  ) : (
                    <button
                      type="button"
                      onClick={() => void submitRefine()}
                      disabled={refineBusy}
                      className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
                    >
                      {refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
                      让 AI 改
                    </button>
                  )}
                </div>
              </div>
            </div>
          )
        })() : null}
        </>
      ) : (
        <EmptyState text="音频解析完成后，这里会按逐句时间轴生成三字段分镜，并支持单条或整片批量生成 4 条视频候选。" />
      )}
    </section>
  )
}

function ProductReferenceCard({
  job,
  item,
  onPatch,
  onRemove,
}: {
  job: Job
  item: ProductRefItem
  onPatch: (patch: Partial<ProductRefItem>) => void
  onRemove: () => void
}) {
  const src = resolveImageRefUrl(job.id, item.ref)
  const tagLabels = item.useTags.map((tag) => PRODUCT_USE_TAG_LABELS[tag]).filter(Boolean)
  const assetWarnings = item.assetMeta?.warnings ?? []
  const assetActions = item.assetMeta?.actions ?? []
  const orientationText = formatProductOrientation(item.orientation)
  const saveProductToLibrary = async () => {
    if (!src) return
    try {
      const response = await fetch(src)
      if (!response.ok) throw new Error(`fetch ${response.status}`)
      const blob = await response.blob()
      const file = new File([blob], `${item.view || "product"}.jpg`, { type: blob.type || "image/jpeg" })
      await createAssetLibraryItem("products", {
        name: item.note || productViewLabel(item.view),
        name_zh: item.note || productViewLabel(item.view),
        note: item.note,
        tags: ["产品素材池", productViewLabel(item.view)],
        source_job_id: job.id,
        product_type: "neck_and_shoulder_massager",
        views: [item.view],
      }, [file])
      toast.success("产品图已保存到素材库")
    } catch (error) {
      toast.error("保存到素材库失败：" + (error instanceof Error ? error.message : String(error)))
    }
  }
  const previewDetail = (
    <>
      {productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ") || "用途待标注"}
      <br />
      {item.note || "无备注"}
      {orientationText ? <><br />方向：{orientationText}</> : null}
      {item.landmarks.length ? <><br />结构：{item.landmarks.join(" / ")}</> : null}
      {item.risk ? <><br />风险：{item.risk}</> : null}
      {assetWarnings.length ? <><br />规格：{assetWarnings.join("；")}</> : null}
    </>
  )

  return (
    <div className="grid min-w-0 grid-cols-[74px_minmax(0,1fr)_28px] gap-2 rounded-md border border-white/10 bg-black/26 p-2">
      <MediaAssetTile
        src={src}
        alt={productViewLabel(item.view)}
        label={productViewLabel(item.view)}
        meta={formatProductAssetSize(item.assetMeta)}
        previewDetail={previewDetail}
        className="h-[74px] w-[74px] bg-white"
        objectFit="contain"
        topLeft={<span className="rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>}
      />
      <div className="min-w-0">
        <div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white/78">
          <span className="truncate font-semibold">{productViewLabel(item.view)}</span>
          <span className="shrink-0 text-[10px] text-white/34">
            {item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
          </span>
        </div>
        <div className="mt-1 flex min-h-5 flex-wrap gap-1 overflow-hidden">
          <span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{formatProductAssetSize(item.assetMeta)}</span>
          <span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{productBackgroundLabel(item.background)}</span>
          {tagLabels.slice(0, 3).map((tag) => (
            <span key={tag} className="rounded border border-cyan-300/14 bg-cyan-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-cyan-100/58">{tag}</span>
          ))}
          {orientationText ? <span className="rounded border border-violet-300/14 bg-violet-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-violet-100/60">方向已识别</span> : null}
          {item.landmarks.slice(0, 2).map((landmark) => (
            <span key={landmark} className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{landmark}</span>
          ))}
          {item.risk || assetWarnings.length ? <span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[9.5px] leading-none text-amber-100/68">需留意</span> : null}
          {assetActions.length ? <span className="rounded border border-emerald-300/14 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-emerald-100/58">已标准化</span> : null}
        </div>
        <input
          value={item.note}
          onChange={(event) => onPatch({ note: event.target.value })}
          placeholder="检查备注：佩戴者左/右、上/下、触点、尺寸比例"
          className="mt-1 h-8 w-full rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
        />
        <div className="mt-1 truncate text-[10px] text-white/32">{item.ref.label || "产品参考图"}</div>
      </div>
      <button
        type="button"
        onClick={onRemove}
        className="h-7 w-7 rounded-md border border-white/10 text-white/40 transition hover:border-rose-300/40 hover:text-rose-200"
        aria-label="移除产品图"
      >
        <Trash2 className="mx-auto h-3.5 w-3.5" />
      </button>
      <button
        type="button"
        onClick={() => void saveProductToLibrary()}
        className="col-start-3 h-7 w-7 rounded-md border border-[#d6b36a]/22 text-[#f1d78e]/70 transition hover:border-[#d6b36a]/55 hover:text-[#f1d78e]"
        aria-label="保存产品图到素材库"
        title="保存到素材库"
      >
        <BookOpen className="mx-auto h-3.5 w-3.5" />
      </button>
    </div>
  )
}

function MissingProductViewSlot({
  slot,
  canGenerate,
  busy,
  blocked,
  onGenerate,
}: {
  slot: typeof PRODUCT_VIEW_SLOTS[number]
  canGenerate: boolean
  busy: boolean
  blocked: boolean
  onGenerate: () => void
}) {
  return (
    <div className="min-h-[94px] rounded-md border border-dashed border-white/12 bg-black/20 p-2">
      <div className="flex items-center justify-between gap-2">
        <div className="text-[12px] font-semibold text-white/58">{slot.label}</div>
        <span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[10px] text-amber-100/72">缺视角</span>
      </div>
      <p className="mt-1 line-clamp-2 text-[10.5px] leading-snug text-white/34">{slot.hint}</p>
      <p className="mt-1 text-[10px] leading-snug text-white/28">{canGenerate ? "自动补图失败时可重试。" : "上传后会自动识别并补齐。"}</p>
      <button
        type="button"
        onClick={onGenerate}
        disabled={!canGenerate || busy || blocked}
        className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
      >
        {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
        重试补图
      </button>
    </div>
  )
}

function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
  return (
    <div className={`min-w-0 border-r border-white/8 p-2 ${className}`}>
      <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/32">{label}</div>
      {children}
    </div>
  )
}

function CompactStoryboardField({
  label,
  value,
  zhValue,
  showChinese,
  onChange,
  onChangeZh,
  onChineseCommit,
  onSave,
  onPick,
  optimizing = false,
}: {
  label: string
  value: string
  zhValue?: string
  showChinese: boolean
  onChange: (value: string) => void
  onChangeZh?: (value: string) => void
  onChineseCommit?: (value: string) => void
  onSave?: () => void
  onPick?: () => void
  optimizing?: boolean
}) {
  return (
    <div className="min-w-0 rounded-md border border-white/10 bg-black/28 p-1.5">
      <div className="mb-1 flex items-center justify-between gap-2">
        <span className="text-[11px] font-semibold text-white/68">{label}</span>
        <span className="flex items-center gap-1">
          {optimizing ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100/80" /> : null}
          <button
            type="button"
            onClick={onSave}
            disabled={!value.trim()}
            className="inline-flex h-6 w-6 items-center justify-center rounded border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] text-[#f1d78e]/70 transition hover:border-[#d6b36a]/45 hover:text-[#f1d78e] disabled:cursor-not-allowed disabled:opacity-30"
            title="保存到提示词库"
            aria-label="保存到提示词库"
          >
            <BookOpen className="h-3 w-3" />
          </button>
          <button
            type="button"
            onClick={onPick}
            className="inline-flex h-6 w-6 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45 transition hover:border-cyan-300/35 hover:text-cyan-100"
            title="从提示词库选用"
            aria-label="从提示词库选用"
          >
            <PanelRight className="h-3 w-3" />
          </button>
        </span>
      </div>
      <textarea
        value={value}
        onChange={(event) => onChange(event.target.value)}
        className="min-h-[40px] w-full resize-y rounded border border-white/10 bg-black/34 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
      />
      {showChinese ? (
        <textarea
          value={zhValue ?? ""}
          onChange={(event) => onChangeZh?.(event.target.value)}
          onBlur={(event) => onChineseCommit?.(event.currentTarget.value)}
          placeholder="改中文后自动优化英文"
          className="mt-1 min-h-[28px] w-full resize-none rounded border border-white/8 bg-black/22 px-2 py-1 text-[10px] leading-snug text-white/46 outline-none placeholder:text-white/22 focus:border-cyan-300/35"
        />
      ) : null}
    </div>
  )
}

function StoryboardVideoSlots({
  job,
  videos,
  enabled,
  busy = false,
  count = 4,
  onCountChange,
  onDraw,
  onReroll,
  onRegenerate,
  onClear,
  onDeleteVideo,
}: {
  job: Job
  videos: GeneratedVideo[]
  enabled: boolean
  expanded?: boolean
  busy?: boolean
  count?: number
  onCountChange?: (count: number) => void
  onToggleExpanded?: () => void
  onDraw?: () => void
  onReroll?: () => void
  onRegenerate?: () => void
  onClear?: () => void
  onDeleteVideo?: (videoId: string) => void
}) {
  const visible = videos
  const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
  const targetCount = clampVideoCount(count)
  const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
  return (
    <div className="min-w-0 rounded-md border border-white/10 bg-black/24 p-2">
      <div className="flex flex-wrap items-center justify-between gap-2">
        <div className="flex min-w-0 flex-1 items-center gap-2">
          <Film className="h-3.5 w-3.5 text-cyan-100/65" />
          <span className="text-[11px] font-semibold text-white/66">视频候选横向轨</span>
          <span className="shrink-0 text-[10px] text-white/34">
            {videos.length ? `${videos.length} 条${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
          </span>
          {videos.length ? (
            <span className="rounded border border-emerald-300/18 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[10px] text-emerald-100/70">自动保存</span>
          ) : null}
        </div>
        <div className="flex flex-wrap items-center gap-1.5">
          <label className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/36 px-1.5 text-[10px] font-semibold text-white/48">
            生成
            <select
              value={targetCount}
              onChange={(event) => onCountChange?.(Number(event.target.value))}
              disabled={!onCountChange}
              className="h-5 rounded border border-white/10 bg-black/45 px-1 text-center font-mono text-[10.5px] text-white/78 outline-none disabled:opacity-45"
            >
              {STORYBOARD_VIDEO_COUNT_OPTIONS.map((option) => (
                <option key={option} value={option}>{option}</option>
              ))}
            </select>
            条
          </label>
          <button
            type="button"
            onClick={videos.length ? onReroll : onDraw}
            disabled={!enabled || busy}
            className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.07] px-2 text-[10px] font-semibold text-cyan-100/70 transition hover:border-cyan-300/45 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
          >
            {busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
            {videos.length ? `再生成 ${targetCount} 条` : `生成 ${targetCount} 条`}
          </button>
          <button
            type="button"
            onClick={onClear}
            disabled={!videos.length}
            className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[10px] font-semibold text-white/46 transition hover:border-rose-300/35 hover:text-rose-100 disabled:cursor-not-allowed disabled:opacity-30"
          >
            <Trash2 className="h-3 w-3" />
            清空
          </button>
        </div>
      </div>
      <div className="mt-2 flex min-h-[170px] items-stretch gap-2 overflow-x-auto pb-1">
        {visible.map((video) => (
          <StoryboardVideoPreview
            key={video.id}
            job={job}
            video={video}
            className="h-[168px] w-[94px]"
            onRegenerate={onRegenerate}
            onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
          />
        ))}
        {Array.from({ length: emptyCount }).map((_, index) => (
          <div key={`empty-video-${index}`} className="flex h-[168px] w-[94px] shrink-0 items-center justify-center rounded-md border border-dashed border-white/12 bg-black/25 px-2 text-center text-[9.5px] leading-tight text-white/26">
            {enabled ? `待生成 ${index + 1}` : "待抽帧"}
          </div>
        ))}
        <button
          type="button"
          onClick={videos.length ? onReroll : onDraw}
          disabled={!enabled || busy}
          className="flex h-[168px] w-[94px] shrink-0 flex-col items-center justify-center gap-2 rounded-md border border-cyan-300/18 bg-cyan-300/[0.055] px-2 text-center text-[10.5px] font-semibold text-cyan-100/72 transition hover:border-cyan-300/48 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
        >
          {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
          {videos.length ? `追加 ${targetCount} 条` : `生成 ${targetCount} 条`}
        </button>
      </div>
    </div>
  )
}

function EndpointFrameSlot({
  job,
  frame,
  role,
  subjectBrief,
  busy,
  deleting,
  disabled,
  onGenerate,
  onDelete,
}: {
  job: Job
  frame: KeyFrame | null
  role: "first_frame" | "last_frame"
  subjectBrief?: string
  busy: boolean
  deleting?: boolean
  disabled: boolean
  onGenerate: () => void
  onDelete?: () => void
}) {
  const ref = endpointAssetRef(frame, role)
  const src = ref ? resolveImageRefUrl(job.id, ref) : ""
  const label = role === "first_frame" ? "首帧" : "尾帧"
  const saveEndpointFrameToLibrary = async () => {
    if (!src) return
    try {
      const response = await fetch(src)
      if (!response.ok) throw new Error(`fetch ${response.status}`)
      const blob = await response.blob()
      const file = new File([blob], `${role}.jpg`, { type: blob.type || "image/jpeg" })
      await createAssetLibraryItem("scenes", {
        name: `${label} · ${shortId(job.id)}`,
        name_zh: `${label} · ${shortId(job.id)}`,
        note: subjectBrief || `${label}首尾帧资产`,
        tags: [label, "首尾帧"],
        source_job_id: job.id,
        asset_role: role,
        aspect_ratio: "9:16",
      }, [file])
      toast.success(`${label}已保存到素材库`)
    } catch (error) {
      toast.error("保存首尾帧到素材库失败：" + (error instanceof Error ? error.message : String(error)))
    }
  }
  return (
    <div className="overflow-hidden rounded border border-white/10 bg-black/32">
      <div className="flex h-6 items-center justify-between gap-1 border-b border-white/10 px-1.5 text-[9.5px] text-white/42">
        <span>{label}</span>
        <span className="flex items-center gap-1">
          {src ? (
            <button type="button" onClick={() => void saveEndpointFrameToLibrary()} className="inline-flex h-4 w-4 items-center justify-center rounded border border-[#d6b36a]/20 bg-[#d6b36a]/8 text-[#f1d78e]/72" title="保存到素材库">
              <BookOpen className="h-3 w-3" />
            </button>
          ) : null}
          <span
            title={subjectBrief?.trim() ? subjectBrief : "本条没有主体 brief，生成时只按画面规划和产品参考执行。"}
            className="inline-flex h-4 w-4 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45"
          >
            <Info className="h-3 w-3" />
          </span>
        </span>
      </div>
      <MediaAssetTile
        src={src}
        href={src || undefined}
        alt={`${label}资产`}
        label={`${label}资产`}
        className="aspect-[9/16] min-h-[112px] border-0"
        objectFit="contain"
        busy={busy}
        emptyText={`先生成${label}`}
        onDelete={src && onDelete ? onDelete : undefined}
        deleting={deleting}
        deleteLabel={`移除${label}`}
      />
      <button
        type="button"
        onClick={onGenerate}
        disabled={disabled || busy}
        className="flex h-7 w-full items-center justify-center gap-1 border-t border-white/10 bg-white/[0.045] px-1 text-[10px] font-semibold text-white/62 transition hover:bg-white/[0.09] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
      >
        {busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
        {src ? `重生${label}` : `生成${label}`}
      </button>
    </div>
  )
}

function StoryboardVideoPreview({
  job,
  video,
  className = "h-20 w-12",
  onRegenerate,
  onDelete,
}: {
  job: Job
  video: GeneratedVideo
  className?: string
  onRegenerate?: () => void
  onDelete?: () => void
}) {
  const src = videoSrc(video)
  const playableSrc = src && video.status === "completed" ? src : ""
  const poster = videoPoster(job, video)
  const running = video.status === "queued" || video.status === "in_progress"
  return (
    <MediaAssetTile
      kind="video"
      src={playableSrc || undefined}
      poster={poster}
      href={playableSrc || undefined}
      alt={`片段 ${shortId(video.id)}`}
      label={`${shortId(video.id)} · ${video.model}`}
      meta={video.status}
      className={`shrink-0 bg-black/45 ${className}`}
      objectFit="cover"
      title={playableSrc ? "点击打开视频预览" : `${video.model} · ${video.status}`}
      bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
      topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
      actions={[
        ...(playableSrc ? [{
          key: "download",
          label: "下载视频",
          icon: <Download className="h-3 w-3" />,
          onClick: () => downloadMedia(playableSrc, `skg-storyboard-${shortId(video.id)}.mp4`),
          tone: "cyan" as const,
        }] : []),
        ...(onRegenerate ? [{
          key: "regen",
          label: "重生一个候选",
          icon: <RefreshCw className="h-3 w-3" />,
          onClick: onRegenerate,
          tone: "neutral" as const,
        }] : []),
      ]}
      actionsAlwaysVisible={!!playableSrc}
      onDelete={onDelete}
      deleteLabel="删除这个视频候选"
    />
  )
}

function AudioWaveform({
  features,
  status,
  currentTime,
  hoverTime,
  duration,
  segments,
  onSeek,
  onHoverTimeChange,
}: {
  features: AudioFeature[]
  status: AudioFeatureStatus
  currentTime: number
  hoverTime: number | null
  duration: number
  segments: Array<{ start: number; end: number }>
  onSeek: (time: number) => void
  onHoverTimeChange?: (time: number | null) => void
}) {
  const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100)
  const hoverPct = hoverTime === null ? null : clampNumber((hoverTime / Math.max(duration, 1)) * 100, 0, 100)
  const hasFeatures = features.length > 0
  const { topPoints, bottomPoints, envelopePoints } = useMemo(() => {
    const top = features.map((feature, index) => {
      const x = features.length <= 1 ? 0 : (index / (features.length - 1)) * 100
      return `${x.toFixed(2)},${(50 - feature.loudness * 32).toFixed(2)}`
    }).join(" ")
    const bottom = [...features].reverse().map((feature, index) => {
      const originalIndex = features.length - 1 - index
      const x = features.length <= 1 ? 0 : (originalIndex / (features.length - 1)) * 100
      return `${x.toFixed(2)},${(50 + feature.loudness * 32).toFixed(2)}`
    }).join(" ")
    return { topPoints: top, bottomPoints: bottom, envelopePoints: `${top} ${bottom}` }
  }, [features])
  return (
    <div
      className="skg-audio-waveform relative h-24 cursor-pointer overflow-hidden rounded-md border px-2"
      aria-label="音频响度波形"
      onClick={(event) => {
        const rect = event.currentTarget.getBoundingClientRect()
        onSeek(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration)
      }}
      onMouseMove={(event) => {
        const rect = event.currentTarget.getBoundingClientRect()
        onHoverTimeChange?.(clampNumber(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration, 0, duration))
      }}
      onMouseLeave={() => onHoverTimeChange?.(null)}
    >
      <div className="absolute inset-y-2 left-2 right-2">
        {!hasFeatures && (
          <div className="absolute inset-0 flex items-center justify-center text-[11px] text-white/35">
            {status === "failed" ? "audio.wav 解码失败" : status === "loading" ? "正在解码 audio.wav" : "等待音频文件"}
          </div>
        )}
        <div className="skg-audio-waveform__center absolute inset-x-0 top-1/2 h-px" />
        {hasFeatures && (
          <svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
            <polygon
              points={envelopePoints}
              fill="var(--skg-wave-fill)"
            />
            <polyline
              points={topPoints}
              fill="none"
              stroke="var(--skg-wave-stroke-1)"
              strokeWidth="0.55"
              vectorEffect="non-scaling-stroke"
            />
            <polyline
              points={bottomPoints}
              fill="none"
              stroke="var(--skg-wave-stroke-2)"
              strokeWidth="0.55"
              vectorEffect="non-scaling-stroke"
            />
          </svg>
        )}
      </div>
      {segments.map((segment, index) => (
        <div
          key={`${segment.start}-${index}`}
          className="skg-audio-waveform__segment absolute inset-y-0 w-px"
          style={{ left: `${clampNumber((segment.start / Math.max(duration, 1)) * 100, 0, 100)}%` }}
        />
      ))}
      {hoverPct !== null && (
        <div
          className="skg-audio-waveform__hover pointer-events-none absolute inset-y-0 w-px"
          style={{ left: `${hoverPct}%` }}
        />
      )}
      <div
        className="skg-audio-waveform__playhead pointer-events-none absolute inset-y-0 w-[2px] will-change-[left]"
        style={{ left: `${pointerPct}%` }}
      />
    </div>
  )
}

function ModelTrace({ trace, compact = false }: { trace: ModelTraceSpec; compact?: boolean }) {
  const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
  const buttonRef = useRef<HTMLButtonElement | null>(null)

  const toggle = () => {
    if (position) {
      setPosition(null)
      return
    }
    const rect = buttonRef.current?.getBoundingClientRect()
    if (!rect) return
    const width = Math.min(380, window.innerWidth - 32)
    const height = 260
    let left = rect.right - width
    let top = rect.bottom + 8
    if (left < 16) left = 16
    if (left + width > window.innerWidth - 16) left = window.innerWidth - width - 16
    if (top + height > window.innerHeight - 16) top = Math.max(16, rect.top - height - 8)
    setPosition({ left, top })
  }

  const popover = position && typeof document !== "undefined"
    ? createPortal(
        <div
          className="fixed z-[10000] w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/95 p-3 text-white shadow-[0_24px_80px_rgba(0,0,0,0.75)]"
          style={{ left: position.left, top: position.top }}
        >
          <div className="mb-2 flex items-start justify-between gap-3">
            <div className="min-w-0">
              <div className="text-[12px] font-semibold text-white">{trace.title}</div>
              <div className="mt-1 truncate font-mono text-[11px] text-cyan-100/80" title={trace.model}>{trace.model}</div>
            </div>
            <button
              type="button"
              onClick={() => setPosition(null)}
              className="h-6 w-6 rounded-md border border-white/10 text-white/45 transition hover:border-white/25 hover:text-white"
              aria-label="关闭模型链路"
            >
              ×
            </button>
          </div>
          <ol className="space-y-1.5">
            {trace.chain.map((item, index) => (
              <li key={`${trace.title}-${index}`} className="grid grid-cols-[18px_minmax(0,1fr)] gap-2 text-[11px] leading-snug text-white/66">
                <span className="flex h-[18px] w-[18px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] font-mono text-[9px] text-white/42">{index + 1}</span>
                <span>{item}</span>
              </li>
            ))}
          </ol>
          {trace.note ? <div className="mt-2 rounded-md border border-white/10 bg-white/[0.035] px-2 py-1.5 text-[10.5px] leading-snug text-white/42">{trace.note}</div> : null}
        </div>,
        document.body,
      )
    : null

  return (
    <>
      <button
        ref={buttonRef}
        type="button"
        onClick={toggle}
        className={`inline-flex min-w-0 items-center justify-center gap-1 rounded-md border border-cyan-300/18 bg-cyan-300/[0.065] text-cyan-50/76 transition hover:border-cyan-200/45 hover:bg-cyan-300/[0.12] ${compact ? "h-7 max-w-[260px] px-2 text-[10px]" : "h-8 max-w-[320px] px-2.5 text-[11px]"}`}
        title={`${trace.title} · ${trace.model}`}
      >
        <Info className="h-3.5 w-3.5 shrink-0" />
        <span className="shrink-0">模型</span>
        <span className="min-w-0 truncate font-mono">{trace.model}</span>
      </button>
      {popover}
    </>
  )
}

function FrameExtractControls({
  job,
  data,
  selectedFramesCount,
  onSelectAllFrames,
  onClearFrameSelection,
}: {
  job: Job | null
  data: NodeData
  selectedFramesCount: number
  onSelectAllFrames: () => void
  onClearFrameSelection: () => void
}) {
  return (
    <div className="rounded-lg border border-white/10 bg-black/32 p-3">
      <div className="mb-2 flex items-center justify-between gap-2">
        <SectionTitle icon={<Scissors className="h-4 w-4" />} title="抽帧供分镜使用" />
        <StatusPill ready={!!job?.frames.length} running={data.analyzing || job?.status === "splitting"} />
      </div>
      <div className="grid grid-cols-[1fr_1fr_72px] gap-2">
        <select
          value={job ? data.frameTargets[job.id] ?? "transparent_human" : "balanced"}
          onChange={(e) => job && data.onFrameTargetChange(job.id, e.target.value as FrameExtractTarget)}
          disabled={!job}
          className={controlClass}
        >
          {TARGETS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
        </select>
        <select
          value={job ? data.frameQualities[job.id] ?? "auto" : "auto"}
          onChange={(e) => job && data.onFrameQualityChange(job.id, e.target.value as FrameExtractQuality)}
          disabled={!job}
          className={controlClass}
        >
          {QUALITIES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
        </select>
        <input
          type="number"
          min={1}
          max={20}
          value={job ? data.frameCounts[job.id] ?? 12 : 12}
          onChange={(e) => job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)}
          disabled={!job}
          className={`${controlClass} text-center`}
        />
      </div>
      <div className="mt-2 flex flex-wrap gap-2">
        <ActionButton disabled={!job || data.analyzing} onClick={() => data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}>
          {data.analyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
          {job?.frames.length ? "追加抽帧" : "开始抽帧"}
        </ActionButton>
        <ActionButton disabled={!job?.frames.length} variant="ghost" onClick={onSelectAllFrames}>全选分镜</ActionButton>
        <ActionButton disabled={!selectedFramesCount} variant="ghost" onClick={onClearFrameSelection}>清空选择</ActionButton>
      </div>
    </div>
  )
}

function StoryboardSegmentCard({
  job,
  frame,
  order,
  selected,
  selectedVideoIds,
  videos,
  busy,
  sixViewBusyKey,
  onToggleFrame,
  onJobUpdate,
  onGenerateElement,
  onGenerateSixViews,
  onGenerateVideo,
  onToggleVideo,
  onDeleteVideo,
}: {
  job: Job
  frame: KeyFrame
  order: number
  selected: boolean
  selectedVideoIds: Set<string>
  videos: GeneratedVideo[]
  busy: boolean
  sixViewBusyKey: string | null
  onToggleFrame: () => void
  onJobUpdate: (job: Job) => void
  onGenerateElement: (candidate?: FrameObject) => void
  onGenerateSixViews: (element: KeyElement) => void
  onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
  onToggleVideo: (videoId: string) => void
  onDeleteVideo?: (videoId: string) => void
}) {
  const [scene, setScene] = useState<StoryboardScene>(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) }))
  const [model, setModel] = useState<VideoModel>("seedance")
  const [saving, setSaving] = useState(false)
  const [generatingVideo, setGeneratingVideo] = useState(false)
  const elements = frame.elements ?? []
  const generatedImages = frame.generated_images ?? []
  const objectCandidates = frame.description?.objects?.slice(0, 8).filter((item) => item.name?.trim()) ?? []
  const objectNames = objectCandidates.map((item) => item.name)
  const elementPreviews = elements
    .map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
    .filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)

  useEffect(() => {
    setScene({ ...emptyScene(), ...(frame.storyboard ?? {}) })
  }, [frame.index, frame.storyboard])

  const patch = (next: Partial<StoryboardScene>) => setScene((prev) => ({ ...prev, ...next }))

  const save = async () => {
    setSaving(true)
    try {
      const updated = await updateStoryboard(job.id, frame.index, scene)
      onJobUpdate(updated)
      toast.success(`分镜 ${order + 1} 已保存`)
    } catch (e) {
      toast.error("保存失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSaving(false)
    }
  }

  const generateVideo = async () => {
    setGeneratingVideo(true)
    try {
      await save()
      await onGenerateVideo(frame.index, scene, model)
    } finally {
      setGeneratingVideo(false)
    }
  }

  return (
    <article className={`rounded-lg border p-3 transition ${selected ? "border-rose-400/60 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
      <div className="mb-3 flex items-start justify-between gap-3">
        <div className="flex items-start gap-3">
          <button
            type="button"
            onClick={onToggleFrame}
            className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black"
            aria-label={selected ? "取消选中分镜" : "选中分镜"}
          >
            <img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover" />
            <span className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">
              {selected ? <Check className="h-3.5 w-3.5 text-rose-200" /> : <Circle className="h-3.5 w-3.5 text-white/55" />}
            </span>
          </button>
          <div>
            <div className="font-mono text-[12px] text-white/48">分镜 {String(order + 1).padStart(2, "0")}</div>
            <h3 className="mt-1 text-[15px] font-semibold text-white">{frameLabel(frame, order)}</h3>
            <p className="mt-1 max-w-[520px] line-clamp-2 text-[12px] leading-relaxed text-white/42">{frame.description?.scene || "等待生成新的分镜文字"}</p>
          </div>
        </div>
        <label className="flex h-9 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
          秒
          <input
            type="number"
            min={1}
            step={0.5}
            value={scene.duration || 5}
            onChange={(e) => patch({ duration: Number(e.target.value) || 5 })}
            className="w-14 bg-transparent text-center font-mono text-white outline-none"
          />
        </label>
      </div>

      <div className="grid gap-3">
        <SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
          <div className="grid grid-cols-3 gap-2">
            <textarea
              value={scene.scene ?? ""}
              onChange={(e) => patch({ scene: e.target.value })}
              placeholder="新剧情：根据原音频和产品内容，这一镜要讲什么"
              className={`${fieldClass} min-h-[72px]`}
            />
            <textarea
              value={scene.product ?? ""}
              onChange={(e) => patch({ product: e.target.value })}
              placeholder="产品融入：SKG 产品在哪里出现，怎么被使用"
              className={`${fieldClass} min-h-[72px]`}
            />
            <textarea
              value={scene.action ?? ""}
              onChange={(e) => patch({ action: e.target.value })}
              placeholder="动作 / 镜头：首尾变化、手部动作、运镜节奏"
              className={`${fieldClass} min-h-[72px]`}
            />
          </div>
          <div className="mt-2 flex justify-end">
            <ActionButton variant="ghost" disabled={saving} onClick={save}>
              {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
              保存分镜文字
            </ActionButton>
          </div>
        </SegmentBand>

        <SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
          <div className="grid grid-cols-[170px_minmax(0,1fr)_auto] gap-3">
            <img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="aspect-video w-full rounded-md border border-white/10 bg-black object-cover" />
            <div className="min-w-0">
              <div className="mb-2 flex items-center gap-2 text-[11px] text-white/44">
                <ImageIcon className="h-3.5 w-3.5" />
                <span>{elements.filter(hasCutout).length}/{elements.length || objectNames.length || 0} 元素</span>
                <span>{generatedImages.length} 张生成图</span>
              </div>
              <div className="mb-2 flex flex-wrap gap-1">
                {objectCandidates.length > 0 && objectCandidates.map((candidate) => (
                  <button
                    key={`${candidate.name}:${candidate.position ?? ""}`}
                    type="button"
                    onClick={() => onGenerateElement(candidate)}
                    disabled={busy}
                    className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
                    title="选择该元素并生成提取图 + 高清视图"
                  >
                    {candidate.name}
                  </button>
                ))}
                {!objectCandidates.length && !elements.length && <span className="text-[11px] text-white/32">暂无识别元素</span>}
              </div>
              {elements.length > 0 && (
                <div className="mb-2 grid gap-1">
                  {elements.slice(0, 5).map((element) => {
                    const busySix = sixViewBusyKey === `${frame.index}:${element.id}`
                    return (
                      <div key={element.id} className="flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
                        <span className="min-w-0 truncate text-[11px] text-white/62">{element.name_zh || element.name_en}</span>
                        <button
                          type="button"
                          onClick={() => onGenerateSixViews(element)}
                          disabled={busySix}
                          className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
                        >
                          {busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
                          {element.subject_assets?.length ? `${element.subject_assets.length}视图` : "高清视图"}
                        </button>
                      </div>
                    )
                  })}
                </div>
              )}
              {(elementPreviews.length > 0 || generatedImages.length > 0) && (
                <div className="flex gap-1 overflow-x-auto pb-1">
                  {elementPreviews.slice(0, 6).map(({ element, src }) => (
                    <img key={element.id} src={src} alt={element.name_zh || element.name_en} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
                  ))}
                  {generatedImages.slice(0, 6).map((image) => (
                    <img key={image.id} src={generatedImageUrl(job.id, frame.index, image.id)} alt={image.prompt || "生成图"} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
                  ))}
                </div>
              )}
            </div>
            <ActionButton disabled={busy} variant="ghost" onClick={() => onGenerateElement()}>
              {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
              提取+高清视图
            </ActionButton>
          </div>
        </SegmentBand>

        <SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
          <div className="grid grid-cols-[220px_minmax(0,1fr)] gap-3">
            <div className="grid gap-2">
              <select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
                {VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
              </select>
              <ActionButton disabled={generatingVideo} onClick={generateVideo}>
                {generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
                生成本分镜视频
              </ActionButton>
            </div>
            <div className="grid grid-cols-3 gap-2">
              {videos.length > 0 ? videos.map((video) => (
                <VideoCandidate
                  key={video.id}
                  job={job}
                  video={video}
                  selected={selectedVideoIds.has(video.id)}
                  onToggle={() => onToggleVideo(video.id)}
                  onDelete={() => onDeleteVideo?.(video.id)}
                />
              )) : (
                <EmptyState text="这里会出现本分镜生成出的候选视频。" />
              )}
            </div>
          </div>
        </SegmentBand>
      </div>
    </article>
  )
}

function DraftSegmentCard({
  draft,
  order,
  job,
  onPatch,
  onRemove,
  onJobUpdate,
  onGenerateVideo,
}: {
  draft: DraftSegment
  order: number
  job: Job | null
  onPatch: (patch: Partial<DraftSegment>) => void
  onRemove: () => void
  onJobUpdate: (job: Job) => void
  onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
  const [model, setModel] = useState<VideoModel>("seedance")
  const [saving, setSaving] = useState(false)
  const [generatingVideo, setGeneratingVideo] = useState(false)
  const boundFrame = draft.frameIndex !== null ? job?.frames.find((frame) => frame.index === draft.frameIndex) ?? null : null

  const patchScene = (next: Partial<StoryboardScene>) => {
    onPatch({ scene: { ...draft.scene, ...next } })
  }

  const save = async () => {
    if (!job || draft.frameIndex === null) {
      toast.info("先给草稿分镜绑定一个关键帧，再保存。")
      return
    }
    setSaving(true)
    try {
      const updated = await updateStoryboard(job.id, draft.frameIndex, draft.scene)
      onJobUpdate(updated)
      toast.success(`草稿分镜 ${order + 1} 已保存到关键帧`)
    } catch (e) {
      toast.error("保存失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSaving(false)
    }
  }

  const generateVideo = async () => {
    if (!job || draft.frameIndex === null) {
      toast.info("先绑定关键帧，视频生成需要首帧参考。")
      return
    }
    setGeneratingVideo(true)
    try {
      await save()
      await onGenerateVideo(draft.frameIndex, draft.scene, model)
    } finally {
      setGeneratingVideo(false)
    }
  }

  return (
    <article className="rounded-lg border border-dashed border-cyan-300/22 bg-cyan-500/[0.045] p-3">
      <div className="mb-3 flex items-start justify-between gap-3">
        <div>
          <div className="font-mono text-[12px] text-cyan-100/50">草稿分镜 {String(order + 1).padStart(2, "0")}</div>
          <h3 className="mt-1 text-[15px] font-semibold text-white">追加音频分镜</h3>
          <p className="mt-1 text-[12px] text-white/42">可先写分镜，后面再绑定关键帧和生成视频。</p>
        </div>
        <button type="button" onClick={onRemove} className="h-9 w-9 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除草稿分镜">
          <Trash2 className="mx-auto h-4 w-4" />
        </button>
      </div>

      <div className="grid gap-3">
        <SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
          <div className="grid grid-cols-3 gap-2">
            <textarea value={draft.scene.scene ?? ""} onChange={(e) => patchScene({ scene: e.target.value })} placeholder="新剧情" className={`${fieldClass} min-h-[72px]`} />
            <textarea value={draft.scene.product ?? ""} onChange={(e) => patchScene({ product: e.target.value })} placeholder="产品融入" className={`${fieldClass} min-h-[72px]`} />
            <textarea value={draft.scene.action ?? ""} onChange={(e) => patchScene({ action: e.target.value })} placeholder="动作 / 镜头" className={`${fieldClass} min-h-[72px]`} />
          </div>
          <div className="mt-2 flex justify-between gap-2">
            <label className="flex h-10 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
              秒
              <input type="number" min={1} step={0.5} value={draft.scene.duration || 5} onChange={(e) => patchScene({ duration: Number(e.target.value) || 5 })} className="w-14 bg-transparent text-center font-mono text-white outline-none" />
            </label>
            <ActionButton variant="ghost" disabled={saving} onClick={save}>
              {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
              保存到关键帧
            </ActionButton>
          </div>
        </SegmentBand>

        <SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
          <div className="grid grid-cols-[220px_minmax(0,1fr)] gap-3">
            <select
              value={draft.frameIndex ?? ""}
              onChange={(e) => onPatch({ frameIndex: e.target.value ? Number(e.target.value) : null })}
              disabled={!job?.frames.length}
              className={controlClass}
            >
              <option value="">绑定关键帧</option>
              {job?.frames.map((frame, index) => <option key={frame.index} value={frame.index}>{frameLabel(frame, index)}</option>)}
            </select>
            {boundFrame ? (
              <div className="flex items-center gap-3 rounded-md border border-white/10 bg-black/28 p-2">
                <img src={effectiveFrameUrl(job!.id, boundFrame)} alt="绑定关键帧" className="h-16 w-24 rounded-md object-cover" />
                <span className="text-[12px] text-white/52">{boundFrame.description?.scene || "已绑定关键帧，可保存并生成视频。"}</span>
              </div>
            ) : (
              <div className="rounded-md border border-dashed border-white/12 bg-black/25 p-3 text-[12px] text-white/38">暂未绑定关键帧，关键元素和视频生成会在绑定后可用。</div>
            )}
          </div>
        </SegmentBand>

        <SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
          <div className="grid grid-cols-[220px_220px] gap-2">
            <select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
              {VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
            </select>
            <ActionButton disabled={generatingVideo || draft.frameIndex === null} onClick={generateVideo}>
              {generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
              生成本分镜视频
            </ActionButton>
          </div>
        </SegmentBand>
      </div>
    </article>
  )
}

function SegmentBand({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
  return (
    <section className="rounded-lg border border-white/10 bg-black/24 p-3">
      <div className="mb-2">
        <SectionTitle icon={icon} title={title} />
      </div>
      {children}
    </section>
  )
}

function ComposeSummary({
  audioReady,
  selectedVideoCount,
  generatedVideoCount,
}: {
  audioReady: boolean
  selectedVideoCount: number
  generatedVideoCount: number
}) {
  return (
    <div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2">
      <div className="flex items-center gap-2">
        <PanelRight className="h-4 w-4 text-rose-200" />
        <div>
          <div className="text-[13px] font-semibold text-white">完整视频合成</div>
          <div className="text-[11px] text-white/40">音频和已生成候选视频将合成完整广告，接口待接入。</div>
        </div>
      </div>
      <div className="flex items-center gap-2 text-[11px] text-white/52">
        <Requirement label="音频" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
        <Requirement label="候选" ready={generatedVideoCount > 0} detail={`${generatedVideoCount}`} />
        <Requirement label="自动存" ready={generatedVideoCount > 0} detail={generatedVideoCount > 0 ? "已保存" : "待候选"} />
        <button type="button" disabled className="inline-flex h-10 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 text-[12px] font-semibold text-white/34">
          <Film className="h-4 w-4" />
          合成完整视频
        </button>
      </div>
    </div>
  )
}

function MaterialCard({
  job,
  index,
  active,
  onClick,
  onDelete,
}: {
  job: Job
  index: number
  active: boolean
  onClick: () => void
  onDelete?: () => void
}) {
  const tone = statusTone(job)
  const errorText = formatJobError(job.error)
  return (
    <button
      type="button"
      onClick={onClick}
      className={`group skg-glass-card--flat w-full border p-3 text-left transition ${active ? "border-[#a2c638]/55 bg-[#a2c638]/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" : "border-white/10 bg-black/24 hover:border-[#a2c638]/30 hover:bg-white/[0.055]"}`}
    >
      <div className="flex items-start justify-between gap-2">
        <div className="min-w-0">
          <div className="font-mono text-[12px] text-white/78">素材 {String(index + 1).padStart(2, "0")}</div>
          <div className="mt-1 flex items-center gap-1.5 text-[11px] text-white/42">
            <Link2 className="h-3 w-3" />
            <span className="truncate">{job.url || shortId(job.id)}</span>
          </div>
        </div>
        <span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] ${tone.className}`}>{tone.label}</span>
      </div>
      <div className="mt-3 grid grid-cols-3 gap-2 text-[11px] text-white/44">
        <Metric label="视频" value={job.video_url ? "ready" : "-"} compact />
        <Metric label="文案" value={job.audio_script?.source_text || job.transcript.length ? "ready" : "-"} compact />
        <Metric label="段落" value={`${job.transcript.length}`} compact />
      </div>
      {job.status === "failed" && errorText && (
        <div className="mt-2 flex gap-1.5 rounded-md border border-rose-300/18 bg-rose-500/[0.08] px-2 py-1.5 text-[11px] leading-snug text-rose-100/82">
          <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
          <span className="line-clamp-3">{errorText}</span>
        </div>
      )}
      {onDelete && (
        <span
          role="button"
          tabIndex={0}
          onClick={(event) => { event.stopPropagation(); onDelete() }}
          onKeyDown={(event) => {
            if (event.key === "Enter" || event.key === " ") {
              event.preventDefault()
              event.stopPropagation()
              onDelete()
            }
          }}
          className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-[#a2c638]/40 hover:text-[#c8cd19] group-hover:flex"
        >
          <Trash2 className="h-3.5 w-3.5" />
          删除素材
        </span>
      )}
    </button>
  )
}

type MetricVariant = "violet" | "lime" | "gold" | "teal" | "green"

function Metric({ label, value, compact, variant }: { label: string; value: string; compact?: boolean; variant?: MetricVariant }) {
  return (
    <div className={`skg-stat-card ${variant ? `skg-stat-card--${variant}` : ""} ${compact ? "px-2 py-1" : "px-2.5 py-1.5"}`}>
      <div className="skg-stat-card__label">{label}</div>
      <div className="skg-stat-card__value mt-0.5 truncate font-mono text-[13px] font-semibold">{value}</div>
    </div>
  )
}

const workflowStatusLabels: Record<WorkflowStepStatus, string> = {
  blocked: "未解锁",
  pending: "待处理",
  running: "运行中",
  ready: "已通过",
  paused: "已暂停",
}

function workflowStatusClass(status: WorkflowStepStatus) {
  if (status === "ready") return "border-[#8fb071]/32 bg-[#8fb071]/12 text-[#d7efbc]"
  if (status === "running") return "border-[#d6b36a]/34 bg-[#d6b36a]/12 text-[#f5d98e]"
  if (status === "paused") return "border-[#d6b36a]/25 bg-[#d6b36a]/08 text-[#e8c77a]/78"
  if (status === "blocked") return "border-white/8 bg-white/[0.025] text-white/32"
  return "border-white/10 bg-white/[0.035] text-white/45"
}

function workflowStatusIcon(status: WorkflowStepStatus) {
  if (status === "running") return <Loader2 className="h-3 w-3 animate-spin" />
  if (status === "ready") return <Check className="h-3 w-3" />
  if (status === "paused") return <AlertTriangle className="h-3 w-3" />
  return <Circle className="h-2.5 w-2.5" />
}

function WorkflowOrderBar({ steps }: { steps: WorkflowStep[] }) {
  return (
    <div className="skg-board-panel mb-3 shrink-0 rounded-lg border border-white/10 bg-white/[0.035] p-2">
      <div className="mb-1.5 flex items-center justify-between gap-3">
        <div className="flex items-center gap-2 text-[11px] font-semibold text-white/66">
          <PanelRight className="h-3.5 w-3.5 text-[#f2d58a]" />
          流程顺序
        </div>
        <div className="truncate text-[10.5px] text-white/36">编号按解锁条件判定，悬停每步可查看完整判断逻辑。</div>
      </div>
      <div className="grid grid-cols-[repeat(9,minmax(116px,1fr))] gap-1.5 overflow-x-auto">
        {steps.map((step) => (
          <div
            key={step.id}
            title={`判定：${step.judge}`}
            className={`min-h-[58px] rounded-md border px-2 py-1.5 ${workflowStatusClass(step.status)}`}
          >
            <div className="flex items-center justify-between gap-1">
              <div className="min-w-0 truncate">
                <span className="mr-1 font-mono text-[11px]">{step.no}</span>
                <span className="text-[11px] font-semibold">{step.title}</span>
              </div>
              {workflowStatusIcon(step.status)}
            </div>
            <div className="mt-1 truncate text-[10px] opacity-80">{step.detail}</div>
            <div className="mt-0.5 truncate text-[9.5px] opacity-60">判定：{step.judge}</div>
          </div>
        ))}
      </div>
    </div>
  )
}

function WorkflowStepBadge({ step, compact }: { step: WorkflowStep; compact?: boolean }) {
  return (
    <span
      title={`判定：${step.judge}`}
      className={`inline-flex shrink-0 items-center gap-1 rounded-md border font-mono ${workflowStatusClass(step.status)} ${compact ? "px-1.5 py-0.5 text-[10.5px]" : "px-2 py-1 text-[11px]"}`}
    >
      <span>{step.no}</span>
      {!compact && <span className="font-sans">{step.title}</span>}
    </span>
  )
}

function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
  return (
    <h3 className="flex items-center gap-2 text-[13px] font-semibold text-white">
      <span className="text-[#f2d58a]">{icon}</span>
      {title}
    </h3>
  )
}

function WorkflowStatusPill({ status }: { status: WorkflowStepStatus }) {
  return (
    <span className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] ${workflowStatusClass(status)}`}>
      {workflowStatusIcon(status)}
      {workflowStatusLabels[status]}
    </span>
  )
}

function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
  return <WorkflowStatusPill status={stepStatus({ ready, running })} />
}

function ActionButton({
  children,
  disabled,
  onClick,
  variant = "solid",
}: {
  children: ReactNode
  disabled?: boolean
  onClick?: () => void
  variant?: "solid" | "ghost"
}) {
  return (
    <button
      type="button"
      disabled={disabled}
      onClick={onClick}
      className={`inline-flex h-10 cursor-pointer items-center justify-center gap-1.5 px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "skg-primary-action" : "skg-secondary-action"}`}
    >
      {children}
    </button>
  )
}

function EmptyState({ text }: { text: string }) {
  return (
    <div className="skg-empty-state px-3 py-8 text-center text-[12px]">
      <div className="skg-empty-character" aria-hidden="true">
        <AnimatedLoginCharacters mood="idle" eyeOffset={{ x: 0, y: 0 }} />
      </div>
      <div>{text}</div>
    </div>
  )
}

function Requirement({ label, ready, detail }: { label: string; ready: boolean; detail: string }) {
  return (
    <div className="flex h-7 min-w-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/24 px-2">
      {ready ? <Check className="h-3 w-3 shrink-0 text-emerald-200" /> : <Circle className="h-3 w-3 shrink-0 text-white/38" />}
      <span className="shrink-0 whitespace-nowrap">{label}</span>
      <span className="min-w-0 truncate font-mono text-[10.5px] text-white/42">{detail}</span>
    </div>
  )
}

function PipelineLane({ step }: { step: WorkflowStep }) {
  return (
    <div className="flex min-h-9 items-center justify-between gap-2 rounded-md border border-white/10 bg-black/24 px-2.5 py-1.5">
      <div className="min-w-0">
        <div className="truncate text-[11px] font-semibold text-white/64">
          <span className="mr-1 font-mono text-white/38">{step.no}</span>
          {step.title}
        </div>
        <div className="mt-0.5 truncate text-[10px] text-white/34" title={`判定：${step.judge}`}>{step.detail}</div>
      </div>
      <WorkflowStatusPill status={step.status} />
    </div>
  )
}

function VideoCandidate({
  job,
  video,
  selected,
  onToggle,
  onDelete,
}: {
  job: Job
  video: GeneratedVideo
  selected: boolean
  onToggle: () => void
  onDelete?: () => void
}) {
  const src = videoSrc(video)
  const poster = videoPoster(job, video)
  const running = video.status === "queued" || video.status === "in_progress"
  const playableSrc = src && video.status === "completed" ? src : ""
  const thumb = (
    <>
      {playableSrc ? (
        <video src={playableSrc} poster={poster} muted playsInline className="h-full w-full object-cover" />
      ) : poster ? (
        <img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
      ) : (
        <div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
      )}
      <div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
    </>
  )
  return (
    <div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
      <div className="flex gap-2">
        {playableSrc ? (
          <a href={playableSrc} target="_blank" rel="noreferrer" className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black" title="打开视频预览">
            {thumb}
          </a>
        ) : (
          <div className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
            {thumb}
          </div>
        )}
        <div className="min-w-0 flex-1">
          <div className="flex items-center justify-between gap-2">
            <div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
            <button type="button" onClick={onDelete} className="h-8 w-8 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除片段">
              <Trash2 className="mx-auto h-3.5 w-3.5" />
            </button>
          </div>
          <div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
            {running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-200" /> : video.status === "failed" ? <AlertTriangle className="h-3 w-3 text-rose-200" /> : <Film className="h-3 w-3" />}
            <span>{video.status}</span>
            <span>{formatSeconds(video.duration)}</span>
            <span>{video.progress}%</span>
          </div>
          {video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
          {playableSrc && (
            <div className="mt-2 flex flex-wrap items-center gap-2">
              <a href={playableSrc} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
                <Play className="h-3 w-3" />
                预览片段
              </a>
              <a href={playableSrc} download={`skg-storyboard-${shortId(video.id)}.mp4`} className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-200 hover:text-emerald-100">
                <Download className="h-3 w-3" />
                下载
              </a>
            </div>
          )}
        </div>
      </div>
    </div>
  )
}
