"use client"

import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
  AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
  Mic, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Sparkles, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
  type FrameExtractQuality,
  type FrameExtractTarget,
  type FrameObject,
  type GeneratedVideo,
  type ImageRef,
  type CharacterLibraryItem,
  type Job,
  type KeyElement,
  type KeyFrame,
  type ProductViewAnalysisItem,
  type ProductRefStateItem,
  type RuntimeModels,
  type StoryboardScriptRewriteSegment,
  type StoryboardScene,
  type SubjectAsset,
  type SubjectKind,
  addElement,
  analyzeJob,
  analyzeProductViews,
  apiAssetUrl,
  characterLibraryImageUrl,
  cutoutElement,
  deleteSubjectAsset,
  effectiveFrameUrl,
  generateSceneAsset,
  generateProductAngleAsset,
  generateSubjectAssets,
  generatedImageUrl,
  getRuntimeHealth,
  hasCutout,
  listCharacterLibrary,
  representativeCutoutUrl,
  resolveImageRefUrl,
  rewriteStoryboardScript,
  saveProductRefs,
  sourceAudioUrl,
  updateStoryboard,
  uploadStoryboardAsset,
  videoUrl,
} from "@/lib/api"
import { type NodeData } from "@/components/nodes"
import { MediaAssetTile } from "@/components/media-asset-tile"

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 DraftSegment = {
  id: string
  frameIndex: number | null
  scene: StoryboardScene
}

type AudioFeature = {
  loudness: number
}

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

type AudioStoryboardRow = {
  index: number
  start: number
  end: number
  source: string
  role: string
  visualMode: StoryboardVisualMode
  needsProduct: boolean
  needsSubject: boolean
  subjectDescription: string
  skgCopy: string
  visualPlan: string
  firstFramePlan: string
  lastFramePlan: string
  referencePlan: string
  keyElements: string
  productIntegration: string
  productPlacement: string
}

type ProductRefItem = ProductRefStateItem
type SubjectPlanningRef = ImageRef & { view: string; roleHint: string }
type SubjectStyleMode = "transparent_human" | "source_actor"
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">>
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 SUBJECT_ASSET_SIZE = "2048" as const

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_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 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,
  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-cyan-300/30 text-cyan-100 bg-cyan-400/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 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 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 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: endpointTargetCount ? `${endpointFramePairCount}/${endpointTargetCount} 组首尾帧` : "待分镜",
      judge: "每条分镜先确定场景+人+产品+动作，再生成 asset 类型首帧/尾帧；keyframe 不算通过。",
      status: stepStatus({ ready: endpointTargetCount > 0 && endpointFramePairCount >= endpointTargetCount, blocked: !storyboardReady }),
    },
    {
      id: "video",
      no: "09",
      title: "视频候选",
      detail: generatedVideoCount ? `${generatedVideoCount} 条历史` : "生成入口暂停",
      judge: "当前不直接调视频模型；首尾帧审核后才开放单条或批量提交。",
      status: generatedVideoCount > 0 ? "ready" : "paused",
    },
  ]
}

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 enSimilarSubject = en.includes("similar") && (en.includes("subject") || en.includes("actor") || en.includes("humanoid") || en.includes("character"))
  return (
    zhSimilarSubject
    || enSimilarSubject
    || combined.includes("相似主角")
    || combined.includes("相似主体")
    || 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
}

function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedCharacter?: CharacterLibraryItem | 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.",
    "Lock one consistent character bible before generating: same gender presentation, age range, body proportions, head shape, material, silhouette, commercial style, and visual identity across the full multi-view set.",
    "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.",
    "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 (selectedCharacter) {
    base.push(
      `Built-in creative character selected: ${selectedCharacter.name}.`,
      "Use the built-in images as planned creative direction only; generate an innovative variation, not a duplicate of that character pack.",
    )
  }
  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 {
    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 subjectAssetUrl(job: Job, asset: SubjectAsset) {
  return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}

function characterPreviewImage(character?: CharacterLibraryItem | 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"])
}

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

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 {
  return {
    title: "音频解析",
    model: modelList([models?.asr, models?.translate, models?.asr_fallback]),
    chain: [
      `ASR 转写：优先 ${modelValue(models?.asr)}，失败后尝试本机 ${modelValue(models?.local_asr)}，再回退 ${modelValue(models?.asr_fallback)}`,
      `字幕翻译：${modelValue(models?.translate)} 输出中文逐句时间轴`,
      `讲话人 / 节奏 / 背景音：${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)} 一次读取同一产品多张图，标注视角、左右、上下、用途和风险`,
      `缺角度补图：${imageModelChain(models)} 读取最相关的多张已上传参考图，按同一肩颈按摩仪结构补齐缺失视角`,
      "前端只保存标注和 AI 补图结果；后续生成视频时每条最多挑 6 张相关产品图",
    ],
    note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
  }
}

function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec {
  return {
    title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体",
    model: subjectImageModelChain(models),
    chain: [
      "参考策略：未勾选关键帧时使用全部关键帧，勾选后只使用已选关键帧；也可叠加内置形象作为创意参考",
      `主体类型：${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
      `图像生成：${subjectImageModelChain(models)} 逐张生成 10 张高清图，包含全身多视角和肩颈/后背特写`,
      "身份锁定：整套图必须是同一个主体，性别表现、年龄段、体型、材质和风格保持一致",
    ],
    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)}`,
      "返回结果只写入当前分镜文案编辑框；生成视频时再把当前文案写入分镜 action",
    ],
  }
}

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)}`,
      "输入：已确认的首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
      "输出：异步候选视频，完成后回填到对应分镜行",
    ],
  }
}

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(" ")
    || "按原音频说话节奏改写为 SKG 产品介绍。"
  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 ? `关键元素候选：${objects}` : "保留原视频最重要的主体动作和构图关系。",
    scene: `${frame.description?.scene || `按第 ${order + 1} 段音频规划 SKG 信息流广告分镜。`}\n音频节奏依据：${audio.slice(0, 220)}`,
    product: "把原素材里的产品/痛点转成 SKG 颈部/肩颈按摩仪表达，默认使用 SKG 四张产品角度图做产品真源。",
    action: frame.description?.style
      ? `沿用原画面的讲话节奏、动作节点和 ${frame.description.style}，突出使用前紧绷、使用后放松。`
      : "沿用原视频的讲话节奏和动作节点，突出使用前紧绷、使用后放松。",
    reference_ids: [],
  }
}

function classifyAudioRole(text: string, index: number, total: number) {
  const lower = text.toLowerCase()
  if (index === 0) return "开场钩子"
  if (index >= total - 2 || /discount|code|shipping|link|limited|sold out|grab|recommend|tiktok/.test(lower)) return "转化收口"
  if (/can't|dont|don't|if |when |tired|stress|pain|crave|bloated|puffy|ready/.test(lower)) return "痛点推进"
  if (/help|can |reduce|improve|relax|lower|stabilize|clear|less/.test(lower)) return "利益证明"
  if (/use|try|apple|product|bottle|one month/.test(lower)) return "方案过渡"
  return "节奏承接"
}

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

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

function visualModeDefaults(mode: StoryboardVisualMode) {
  if (mode === "person_only") {
    return {
      needsProduct: false,
      needsSubject: true,
      productPlacement: "本条不出现产品，只用人物状态、痛点或口播承接节奏；不要硬插 SKG 产品。",
    }
  }
  if (mode === "product_only") {
    return {
      needsProduct: true,
      needsSubject: false,
      productPlacement: "只展示 SKG 肩颈按摩仪本体、佩戴角度或功能细节；不要强行加入人物。",
    }
  }
  if (mode === "environment") {
    return {
      needsProduct: false,
      needsSubject: false,
      productPlacement: "本条作为场景/情绪/节奏过渡，不出现产品和人物主体；只保留空间、光线和运动节奏。",
    }
  }
  return {
    needsProduct: true,
    needsSubject: true,
    productPlacement: "SKG 肩颈按摩仪作为外置佩戴产品出现，围绕拿起、佩戴、调整、按键或放松状态展开。",
  }
}

function visualModeForRole(role: string): StoryboardVisualMode {
  if (role === "开场钩子" || role === "痛点推进") return "person_only"
  if (role === "转化收口") return "product_only"
  if (role === "节奏承接") return "environment"
  return "person_product"
}

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

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

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

function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
  if (!job?.transcript.length) return []
  return job.transcript.map((segment, index) => {
    const source = 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)
    return {
      index: segment.index,
      start: segment.start,
      end: segment.end,
      source,
      role,
      visualMode,
      needsProduct: defaults.needsProduct,
      needsSubject: defaults.needsSubject,
      subjectDescription: buildSubjectDescription(role, visualMode),
      skgCopy: buildSkgCopy(role, index),
      visualPlan: buildVisualPlan(role),
      firstFramePlan: buildFirstFramePlan(role),
      lastFramePlan: buildLastFramePlan(role),
      referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`,
      keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线",
      productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪，产品必须外置佩戴在肩颈位置。",
      productPlacement: defaults.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_LABELS[tag]).filter(Boolean).join("/")
      const orientation = formatProductOrientation(item.orientation)
      const direction = orientation ? `；方向：${orientation}` : ""
      const landmarks = item.landmarks.length ? `；结构：${item.landmarks.join("/")}` : ""
      const risk = item.risk ? `；风险：${item.risk}` : ""
      return `${index + 1}. ${productViewLabel(item.view)}｜${productBackgroundLabel(item.background)}｜${tags}：${item.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,
    subjectDescription: scene.subject?.split("\n").find((line) => line.trim() && !line.startsWith("主体真源") && !line.startsWith("本条不需要"))?.trim(),
    visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !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("产品需求") && !line.startsWith("产品出现方式") && !line.startsWith("产品素材池") && !line.startsWith("未上传产品图") && !line.startsWith("本条规划"))?.trim(),
    productPlacement: scene.product_placement,
  }
}

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,
    subjectDescription: patch.subjectDescription ?? row.subjectDescription,
    visualPlan: patch.visualPlan ?? row.visualPlan,
    firstFramePlan: patch.firstFramePlan ?? row.firstFramePlan,
    lastFramePlan: patch.lastFramePlan ?? row.lastFramePlan,
    productIntegration: patch.productIntegration ?? row.productIntegration,
    productPlacement: patch.productPlacement ?? row.productPlacement,
  }
}

function productPriorityForRow(row: AudioStoryboardRow) {
  const viewPriorityByRole: Record<string, string[]> = {
    "开场钩子": ["front", "left_45", "right_45", "side_thickness"],
    "痛点推进": ["front", "side_thickness", "left_45", "right_45"],
    "利益证明": ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"],
    "方案过渡": ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
    "转化收口": ["front", "back_bottom", "left_45", "right_45", "inner_contacts"],
    "节奏承接": ["front", "left_45", "right_45", "side_thickness"],
  }
  const tagPriorityByRole: Record<string, string[]> = {
    "开场钩子": ["hero_packshot", "asymmetry", "side_thickness"],
    "痛点推进": ["wearing_scale", "side_thickness", "hero_packshot"],
    "利益证明": ["inner_contact", "wearing_scale", "button_detail", "side_thickness"],
    "方案过渡": ["wearing_scale", "hero_packshot", "inner_contact"],
    "转化收口": ["hero_packshot", "back_bottom", "asymmetry", "material_texture"],
    "节奏承接": ["hero_packshot", "asymmetry", "side_thickness"],
  }
  return {
    views: viewPriorityByRole[row.role] ?? viewPriorityByRole["节奏承接"],
    tags: tagPriorityByRole[row.role] ?? tagPriorityByRole["节奏承接"],
  }
}

function scoreProductItemForRow(row: AudioStoryboardRow, item: ProductRefItem, index: number) {
  const priority = productPriorityForRow(row)
  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[]) {
  if (!items.length) return []
  const picked: ProductRefItem[] = []
  const pickedIds = new Set<string>()
  const add = (item?: ProductRefItem) => {
    if (!item || pickedIds.has(item.id) || picked.length >= MAX_PRODUCT_REFS_PER_VIDEO) return
    picked.push(item)
    pickedIds.add(item.id)
  }

  const priority = productPriorityForRow(row)
  for (const view of priority.views) {
    const matches = items
      .map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
      .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: scoreProductItemForRow(row, item, index) }))
      .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: scoreProductItemForRow(row, item, index) }))
    .sort((a, b) => b.score - a.score)
  for (const { item } of ranked) {
    add(item)
  }

  return picked
}

function subjectViewLabel(view: string) {
  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: "后颈肩背特写、触点位置、产品贴合",
  }
  return hints[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 [
    "统一相似主体：使用已生成的主体视图作为人物真源，保持同一人物身份、体型、材质、年龄段、性别表现和广告气质。",
    labels ? `可用主体视角：${labels}。` : "",
    "如果本条需要人物但缺少更具体描述，默认保持透明皮肤包裹白色骨架、非恐怖、肩颈区域清晰可佩戴产品。",
  ].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 (/后颈|肩背|上背|背面|背部|贴合|佩戴完成|已正确佩戴/.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 (/侧面|左侧|右侧|45|调整|拿起|准备佩戴|靠近肩颈|手部/.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 (/近景|半身|肩颈|锁骨|脖子|揉脖子|低头|紧绷/.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 = /肩颈|后颈|近景|贴合|佩戴/.test(row.visualPlan + row.firstFramePlan + row.lastFramePlan + row.productPlacement)
        && /bust|neck|近景|肩颈|后颈/.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)}｜${ref.roleHint}`).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),
  }))
}

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[], subjectRefs: SubjectPlanningRef[]) {
  const target = role === "first_frame" ? row.firstFramePlan : row.lastFramePlan
  const opposite = role === "first_frame" ? row.lastFramePlan : row.firstFramePlan
  const productNotes = selectedProductItems.length ? productReferenceNotes(selectedProductItems) : ""
  const subjectNotes = subjectRefs.length ? subjectReferenceNotes(subjectRefs) : ""
  const subjectDescription = subjectDescriptionForRow(row, subjectRefs)
  return [
    `分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}。`,
    `新口播文案：${row.skgCopy}`,
    `镜头类型：${VISUAL_MODE_OPTIONS.find((item) => item.value === row.visualMode)?.label ?? row.visualMode}。`,
    `当前要生成的画面：${target}`,
    `另一端画面用于连续性参考：${opposite}`,
    `画面规划：${row.visualPlan}`,
    row.needsSubject
      ? `人物主体：${subjectDescription} 必须使用已生成的相似主体白底视图作为人物真源；本次只选择 ${subjectRefs.length} 张最符合镜头需求的主体视角：${subjectNotes}。不要回到原视频关键帧复刻人物。`
      : "本条不需要主角人物；如出现人物，只能是局部手部、背影或环境人物，不要生成透明骨架主角。",
    row.needsProduct
      ? `产品融入：${row.productPlacement}。${row.productIntegration}。已提供 ${selectedProductItems.length} 张同一 SKG 肩颈按摩仪产品参考；${productNotes}。产品是套在脖子上的 U 形肩颈按摩仪，必须保持真实佩戴大小、左右非对称和贴颈位置。`
      : "本条不露出产品，不要强行生成 SKG 产品、包装、白底图或随机商品。",
    "输出一张单独的 9:16 高清首/尾帧，不要拼图，不要字幕，不要平台 UI，不要水印。画面要能作为后续视频生成的明确起止帧。",
  ].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 productGuidance = !row.needsProduct
    ? "本条规划为不露出产品或不把产品作为画面主体；视频生成时不要硬插 SKG 产品、包装、白底图或错误商品。"
    : productItems.length
    ? `产品素材池共有 ${productItems.length} 张，本条只选用 ${selectedProductItems.length} 张最相关参考图，不要把未选素材混入本条画面。产品硬定义：这是套在脖子上的 U 形肩颈按摩仪，不是耳机、头戴设备或护颈枕。坐标系硬规则：左/右按佩戴者身体左右，不能按图片左右；上=靠近下巴/脸/颈部上沿，下=靠近锁骨/肩部下沿；内侧=贴颈皮肤/按摩触点，外侧=外壳/按键/Logo。所选图片只作为产品结构、角度、比例和细节参考，不要照搬参考图的白底/黑底/棚拍背景。视角标注：${notes}。保留左右非对称细节，不要把两边做成镜像对称；肩颈产品大小必须贴近真实佩戴比例，不能缩成耳机，也不能放大成护颈枕。`
    : "未上传产品图时使用默认 SKG 产品图；生成前建议先建立同一产品素材池，锁定左右差异、厚度和佩戴比例。"
  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,
    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}\n主体动作/画面要素：${row.keyElements}\n主体真源：从已生成的相似主体白底视图中按本镜头需求选择 ${subjectRefs.length} 张；${subjectNotes}。关键帧只用于前置主体提取，不作为后续视频首尾帧参考。`
      : "本条不需要人物主体或相似主体参考；如画面里出现人物，只作为背景或局部，不作为主角。",
    scene: `镜头类型：${VISUAL_MODE_OPTIONS.find((item) => item.value === row.visualMode)?.label ?? row.visualMode}\n${row.visualPlan}\n首帧规划：${row.firstFramePlan}\n尾帧规划：${row.lastFramePlan}\n原音频依据：${row.source}`,
    product: `产品需求：${row.needsProduct ? "需要产品参考" : "本条不需要产品"}\n产品出现方式：${row.productPlacement}\n${row.needsProduct ? row.productIntegration : "本条以情绪、人物状态、空间或节奏过渡为主，不露出产品。"}\n${productGuidance}`,
    action: `${row.skgCopy}\n连续动作：从首帧规划自然过渡到尾帧规划，镜头类型和产品/人物需求不能中途改变。`,
    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 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 = job?.status === "transcribing" || job?.audio_script?.status === "rewriting"
  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(() => {
    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 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 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="relative z-20 h-screen w-screen overflow-hidden bg-black text-white">
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_9%_0%,rgba(225,29,72,0.18),transparent_28%),radial-gradient(circle_at_60%_0%,rgba(14,165,233,0.08),transparent_26%)]" />
      <div className="relative flex h-full flex-col px-4 py-4">
        <header className="mb-3 flex items-center justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3">
          <div className="min-w-0">
            <div className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/40">feed ad recreation worksheet</div>
            <h1 className="mt-1 text-[22px] font-semibold leading-tight text-white">信息流广告复刻工作表</h1>
          </div>
          <div className="grid min-w-[520px] grid-cols-5 gap-2 text-[11px] text-white/48">
            <Metric label="素材" value={`${jobs.length}`} />
            <Metric label="当前" value={shortId(activeJobId)} />
            <Metric label="视频" value={job?.video_url ? "ready" : "-"} />
            <Metric label="文案段" value={`${transcriptCount}`} />
            <Metric label="背景音" value={backgroundReady ? "ready" : "-"} />
          </div>
        </header>

        <WorkflowOrderBar steps={workflowSteps} />

        <div className="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-3">
          <MaterialColumn
            data={data}
            step={workflow.input}
            jobs={jobs}
            job={job}
            activeJobId={activeJobId}
            url={url}
            setUrl={setUrl}
            fileRef={fileRef}
            onSubmitUrl={submitUrl}
            onStartProduction={startProduction}
          />

          <section className="flex min-h-0 flex-col rounded-lg 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-7 w-7 items-center justify-center rounded-md bg-rose-500/12 text-rose-100"><Mic className="h-3.5 w-3.5" /></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>

              <div className="mt-2 grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_360px]">
                <div className="flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-white/46">
                  <Requirement label="素材" ready={!!job} detail={job ? shortId(job.id) : "待输入"} />
                  <Requirement label="视频" ready={!!job?.video_url} detail={job?.status === "downloading" ? "下载中" : job?.video_url ? "已就绪" : "待下载"} />
                  <Requirement label="音频" ready={!!job?.source_audio_url} detail={audioRunning ? "解析中" : job?.source_audio_url ? "已提取" : "待提取"} />
                  <Requirement label="文案" ready={audioReady} detail={audioReady ? `${transcriptCount} 段` : "待解析"} />
                  <Requirement label="参考帧" ready={visualReady} detail={visualRunning ? "抽帧中" : visualReady ? `${job?.frames.length ?? 0} 张` : "待抽帧"} />
                </div>

                <details className="group rounded-md border border-white/10 bg-black/28 p-2">
                  <summary className="flex cursor-pointer list-none items-center justify-between gap-2">
                    <SectionTitle icon={<FileText className="h-4 w-4" />} title="文案依据" />
                    <div className="flex items-center gap-2">
                      <span className="font-mono text-[10.5px] text-white/38">{transcriptCount ? `${transcriptCount} 段` : "待解析"}</span>
                      <ChevronDown className="h-4 w-4 text-white/38 transition group-open:rotate-180" />
                    </div>
                  </summary>
                  <div className="mt-2 max-h-20 overflow-y-auto rounded border border-white/10 bg-black/35 p-2 text-[11px] leading-relaxed text-white/58">
                    {audioPreview(job)}
                  </div>
                </details>
              </div>
              <div className="mt-2 grid grid-cols-1 gap-1.5 xl:grid-cols-4">
                <PipelineLane step={workflow.audio} />
                <PipelineLane step={workflow.visual} />
                <PipelineLane step={workflow.subject} />
                <PipelineLane step={workflow.product} />
              </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>
    </section>
  )
}

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
}) {
  return (
    <section className="flex min-h-0 flex-col gap-3 rounded-lg 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-md bg-rose-500/12 text-rose-100"><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/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/60"
        />
        <button
          type="button"
          onClick={onStartProduction}
          disabled={data.submitting || (!url.trim() && !job)}
          className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
        >
          开始分析
        </button>
        <button
          type="button"
          onClick={() => fileRef.current?.click()}
          className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-white/10 bg-white/[0.06] text-white/75 transition hover:border-white/25 hover:bg-white/[0.1]"
          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<void> | 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 [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
  const videoRef = useRef<HTMLVideoElement | null>(null)
  const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
  const syncFrameRef = useRef<number | null>(null)
  const script = job?.audio_script
  const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : ""
  const profiles = [
    { label: "讲话人", value: script?.speaker_profile },
    { label: "节奏", value: script?.rhythm_profile },
    { label: "背景音", value: script?.background_audio_profile },
  ]
  const processing = !!job && (job.status === "transcribing" || script?.status === "rewriting")
  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 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(() => {
    if (activeSegment) rowRefs.current[activeSegment.index]?.scrollIntoView({ block: "nearest" })
  }, [activeSegment?.index])

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

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

  const videoSrcUrl = apiAssetUrl(job.video_url) || videoUrl(job.id)

  return (
    <section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
      <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>

      <details className="group mb-2 rounded-md border border-white/10 bg-black/24 p-2">
        <summary className="flex cursor-pointer list-none items-center justify-between gap-3">
          <SectionTitle icon={<Mic className="h-4 w-4" />} title="音频解析结果" />
          <div className="flex min-w-0 items-center gap-2">
            <span className="hidden min-w-0 truncate text-[11px] text-white/40 md:inline">
              讲话人 / 节奏 / 背景音，展开查看完整模型判断
            </span>
            <ChevronDown className="h-4 w-4 shrink-0 text-white/38 transition group-open:rotate-180" />
          </div>
        </summary>
        <div className="mt-2 grid grid-cols-1 gap-2 md:grid-cols-3">
          {profiles.map((item) => (
            <ProfileTile key={item.label} label={item.label} value={item.value} running={processing} />
          ))}
        </div>
      </details>

      <div className="grid gap-2 border-t border-white/8 pt-2">
        <div className="grid gap-2">
          <div className="grid gap-3 xl:grid-cols-[300px_minmax(0,1fr)] 2xl:grid-cols-[330px_minmax(0,1fr)]">
            <div className="min-w-0">
              <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] h-[400px] overflow-hidden rounded-md border border-white/10 bg-black 2xl:h-[460px]">
                {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-emerald-200/30 bg-black/78 px-2 text-[10.5px] font-semibold text-emerald-100 shadow-lg backdrop-blur transition hover:border-emerald-100/65 hover:bg-emerald-300/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>
            </div>

            <div className="min-w-0 space-y-2">
              <div className="rounded-md border border-white/10 bg-black/32 p-2">
                <div className="mb-1 flex items-center justify-between gap-3 text-[10px] text-white/40">
                  <span>音频波形 / 切点参考</span>
                  <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}
                />
              </div>

              <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-[76px_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
                      <div>时间</div>
                      <div>原文 / 中文</div>
                    </div>
                    <div className="max-h-[288px] overflow-y-auto 2xl:max-h-[346px]">
                      {job.transcript.map((segment) => {
                        const active = activeSegment?.index === segment.index
                        return (
                          <div
                            key={segment.index}
                            ref={(node) => { rowRefs.current[segment.index] = node }}
                            onClick={() => seekTo(segment.start)}
                            className={`grid cursor-pointer grid-cols-[76px_minmax(0,1fr)] gap-2 border-b px-3 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={`font-mono text-[10.5px] ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
                            <div className="min-w-0">
                              <div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
                              <div className={`mt-0.5 truncate 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>
            </div>
          </div>

          <SourceReferenceBuildPanel
            job={job}
            selectedFrames={selectedFrames}
            onToggleFrame={onToggleFrame}
            onJobUpdate={onJobUpdate}
            onDeleteFrame={onDeleteFrame}
            runtimeModels={runtimeModels}
          />
        </div>
      </div>
    </section>
  )
}

function SourceReferenceBuildPanel({
  job,
  selectedFrames,
  onToggleFrame,
  onJobUpdate,
  onDeleteFrame,
  runtimeModels,
}: {
  job: Job
  selectedFrames: Set<number>
  onToggleFrame: (idx: number) => void
  onJobUpdate: (job: Job) => void
  onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
  runtimeModels?: RuntimeModels
}) {
  const [extracting, setExtracting] = useState(false)
  const [subjectBusy, setSubjectBusy] = useState(false)
  const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
  const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
  const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
  const [subjectDirection, setSubjectDirection] = useState("")
  const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
  const [selectedCharacterId, setSelectedCharacterId] = useState("")
  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 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} 张`
      : "待抽帧"

  useEffect(() => {
    let cancelled = false
    listCharacterLibrary()
      .then((items) => {
        if (!cancelled) setCharacterLibrary(items)
      })
      .catch((e) => {
        if (!cancelled) toast.error("内置形象读取失败：" + (e instanceof Error ? e.message : String(e)))
      })
    return () => { cancelled = true }
  }, [])

  const extractKeyframes = async () => {
    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 generateSimilarActor = async () => {
    if (!frames.length) {
      toast.warning("请先自动抽帧 12 张，或在原版视频上手动补帧。")
      return
    }
    const baseFrame = subjectReferenceFrames[0]
    if (!baseFrame) return
    setSubjectBusy(true)
    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(job.id, baseFrame.index, {
          name_zh: selectedCharacter
            ? `相似透明骨架主体 · ${selectedCharacter.name}`
            : subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
          name_en: selectedCharacter
            ? `similar innovative transparent skeleton humanoid subject based on ${selectedCharacter.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(job.id, 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: SUBJECT_ASSET_VIEWS.map((view) => view.value),
        character_id: selectedCharacterId,
        prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter),
        replace_views: true,
      })
      onJobUpdate(updated)
      toast.success("相似主体 10 张高清白底图已生成")
    } catch (e) {
      toast.error("相似主体重构失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubjectBusy(false)
    }
  }

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

  const regenerateSubjectAsset = async (asset: SubjectAsset) => {
    if (!actorSource) return
    setSubjectAssetBusy(`regen:${asset.id}`)
    try {
      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: selectedCharacterId,
        prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter),
        replace_views: true,
      })
      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)
    }
  }

  return (
    <div className="min-w-0">
      <div className="mb-2 flex items-center justify-between gap-3">
        <SectionTitle icon={<ImageIcon 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>
          <button
            type="button"
            onClick={() => void extractKeyframes()}
            disabled={!job.video_url || extracting || job.status === "splitting"}
            title="自动按动作峰值抽 12 张参考帧，更偏向手势、表情变化、节奏点和镜头变化"
            className="inline-flex h-8 items-center justify-center gap-1 rounded-md bg-white px-3 text-[11px] font-semibold text-black transition hover:bg-white/90 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="h-[250px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[290px]">
        <div className="flex items-center justify-between gap-2">
          <span className="text-[10.5px] text-white/34">缩略图完整显示，悬停看大图。</span>
          <span className="text-[10.5px] text-white/30">不勾选则默认用全部帧</span>
        </div>

        <div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1">
          {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={`关键帧 ${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 ? () => void deleteReferenceFrame(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 text-[11px] text-white/34">
              点击“自动抽帧 12 张”，或在原版视频播放器上用“当前点抽帧”补充人物参考。
            </div>
          )}
        </div>

        <div className="mt-2 border-t border-white/8 pt-2">
          <div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
            <div className="flex items-center gap-2">
              <span>相似主体白底视图</span>
              <ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
            </div>
            <span className="text-[10px] text-white/32">内置形象只做创意参考，不照抄</span>
          </div>

          <div className="mb-2">
            <div className="mb-1 flex items-center justify-between gap-2">
              <span className="text-[10px] text-white/38">内置形象选择</span>
              <span className="text-[9px] text-white/28">{selectedCharacter ? `${selectedCharacter.name} · ${selectedCharacter.images.length} 张参考` : "源视频主角相似创新"}</span>
            </div>
            <div className="grid grid-cols-[repeat(auto-fill,minmax(86px,1fr))] gap-1.5">
              <button
                type="button"
                onClick={() => setSelectedCharacterId("")}
                className={`min-h-[58px] rounded-md border px-2 py-1.5 text-left transition ${
                  !selectedCharacterId ? "border-cyan-200/55 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/25 text-white/45 hover:border-white/22 hover:text-white/70"
                }`}
              >
                <span className="block text-[10.5px] font-semibold">源视频相似</span>
                <span className="mt-1 block text-[9px] leading-tight opacity-70">根据关键帧创新</span>
              </button>
              {characterLibrary.map((character) => {
                const preview = characterPreviewImage(character)
                const active = selectedCharacterId === character.id
                return (
                  <button
                    key={character.id}
                    type="button"
                    onClick={() => {
                      setSelectedCharacterId(character.id)
                      setSubjectStyle("transparent_human")
                    }}
                    className={`group flex min-h-[58px] items-center gap-1.5 rounded-md border px-1.5 py-1 text-left transition ${
                      active ? "border-emerald-200/65 bg-emerald-300/12 text-emerald-50" : "border-white/10 bg-black/25 text-white/50 hover:border-emerald-200/35 hover:text-white/80"
                    }`}
                  >
                    <span className="h-12 w-9 shrink-0 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="min-w-0">
                      <span className="block truncate text-[10px] font-semibold">{character.name}</span>
                      <span className="mt-0.5 block text-[8.5px] opacity-58">7 图参考</span>
                    </span>
                  </button>
                )
              })}
            </div>
            {selectedCharacter?.images?.length ? (
              <div className="mt-1.5 flex gap-1 overflow-x-auto pb-0.5">
                {selectedCharacter.images.slice(0, 7).map((image) => (
                  <div key={image.id} className="h-12 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-white" title={image.label}>
                    <img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" />
                  </div>
                ))}
              </div>
            ) : null}
          </div>

          <div className="mb-1.5 flex flex-wrap items-center justify-end gap-2 text-[10px] text-white/36">
            <div className="flex min-w-0 flex-wrap items-center justify-end 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-6 rounded px-2 text-[10px] font-semibold transition ${
                      subjectStyle === 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-7 w-[240px] min-w-[180px] 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"
              />
              <span>{visibleActorAssets.length}/{SUBJECT_ASSET_VIEWS.length}</span>
              <button
                type="button"
                onClick={() => void generateSimilarActor()}
                disabled={!frames.length || subjectBusy}
                className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 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" />}
                生成 10 张高清图
              </button>
            </div>
          </div>
          {visibleActorAssets.length ? (
            <div className="flex flex-wrap gap-1.5">
              {visibleActorAssets.map((asset) => {
                const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
                return (
                  <MediaAssetTile
                    key={asset.id}
                    src={subjectAssetUrl(job, asset)}
                    href={subjectAssetUrl(job, asset)}
                    alt={asset.label || asset.view}
                    label={asset.label || asset.view || "主体视图预览"}
                    meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
                    className="aspect-[9/16] w-12 bg-white 2xl:w-14"
                    objectFit="contain"
                    title={asset.label || asset.view}
                    actions={[{
                      key: "regen",
                      label: "重新生成这一张",
                      icon: <RefreshCw className="h-3 w-3" />,
                      tone: "cyan",
                      busy: busyMode === "regen",
                      disabled: !!subjectAssetBusy,
                      onClick: () => void regenerateSubjectAsset(asset),
                    }]}
                    onDelete={() => void deleteActorAsset(asset)}
                    deleting={busyMode === "delete"}
                    deleteDisabled={!!subjectAssetBusy}
                    deleteLabel="删除这一张"
                  />
                )
              })}
            </div>
          ) : (
            <div className="rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
              可直接用全部关键帧生成；勾选关键帧后会只用已选帧。选择内置形象后，会围绕同一个统一主体生成全身多视角和肩颈/背部特写。
            </div>
          )}
        </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 [planOverrides, setPlanOverrides] = useState<Record<number, RowPlanPatch>>({})
  const [authorIntent, setAuthorIntent] = useState("")
  const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
  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])

  useEffect(() => {
    setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
    setCopyOverrides({})
    setPlanOverrides({})
    setAuthorIntent("")
    setScriptRewriteBusy(null)
  }, [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 patchRowCopy = (rowIndex: number, value: string) => {
    setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
  }

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

  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) : "",
      productPlacement: defaults.productPlacement,
    })
  }

  const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) =>
    applyPlanPatch(applyPlanPatch(row, savedScenePatch(frame?.storyboard)), 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 referenceFrameForRow = (row: AudioStoryboardRow) =>
    closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))

  const videosForFrame = (frame: KeyFrame | null) => {
    if (!frame) return []
    return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index)
  }

  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 }>) => {
    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
    })
  }

  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) }
    const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs) : []
    const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, {
      firstImage: endpointAssetRef(frame, "first_frame"),
      lastImage: endpointAssetRef(frame, "last_frame"),
    })
    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) : []
    if (plannedRow.needsSubject && !selectedSubjectRefs.length) {
      toast.warning("先在上方生成相似主体白底视图，再生成首尾帧")
      return
    }
    if (plannedRow.needsProduct && !productItems.length) {
      toast.warning("本条需要产品，请先上传并识别产品素材池")
      return
    }
    const selectedProductItems = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems) : []
    const busyKey = `${row.index}:${role}`
    setEndpointFrameBusy(busyKey)
    try {
      await saveRowStoryboardDraft(plannedRow, frame)
      const updated = await generateSceneAsset(job.id, frame.index, {
        size: SUBJECT_ASSET_SIZE,
        scene_mode: "similar",
        scene_style: "premium_product",
        asset_role: role,
        prompt: buildEndpointFramePrompt(plannedRow, role, selectedProductItems, selectedSubjectRefs),
        subject_images: selectedSubjectRefs,
        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 () => {
    if (!job || !rows.length) return
    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) {
      toast.warning("先完成前置抽帧，让每条分镜有可保存的承载位置")
      return
    }
    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 (failed) toast.warning(`已保存 ${ok} 条规划，${failed} 条失败`)
      else toast.success(`已保存全部 ${ok} 条分镜规划；视频生成入口已暂停`)
    } finally {
      setStoryboardSaveBusyRow(null)
      setBatchStoryboardSaveBusy(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>
            <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>
          </div>
          <div className="flex shrink-0 items-center gap-2">
            <button
              type="button"
              onClick={() => void reanalyzeProductViews()}
              disabled={!productItems.length || productAnalyzing || !!productAngleBusy}
              className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] 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="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] 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>

        <div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1 md:grid-cols-2 2xl:grid-cols-3">
          {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>
      </div>

      {rows.length ? (
        <>
        <div className="mb-2 grid gap-2 rounded-md border border-white/10 bg-black/24 p-2 xl:grid-cols-[minmax(0,1fr)_auto]">
          <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 items-center justify-end gap-2">
            <ModelTrace trace={scriptRewriteModelTrace(runtimeModels)} compact />
            <button
              type="button"
              onClick={() => void rewriteAllRows()}
              disabled={scriptRewriteBusy !== null || !rows.length}
              className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-white px-2.5 text-[11px] font-semibold text-black transition hover:bg-white/90 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>
            <button
              type="button"
              onClick={() => setCopyOverrides({})}
              disabled={scriptRewriteBusy !== null || !Object.keys(copyOverrides).length}
              className="inline-flex h-9 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] px-2.5 text-[11px] font-semibold text-white/60 transition hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
            >
              还原初稿
            </button>
            <button
              type="button"
              onClick={() => void saveAllStoryboardDrafts()}
              disabled={batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
              className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-white px-2.5 text-[11px] font-semibold text-black transition hover:bg-white/90 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>
        <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 = videosForFrame(referenceFrame)
            const savingStoryboard = storyboardSaveBusyRow === row.index
            const copyText = copyForRow(row)
            const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems).length : 0
            const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs) : []
            return (
              <article
                key={row.index}
                className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[54px_120px_minmax(170px,0.48fr)_minmax(420px,1.2fr)_360px] 2xl: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">
                    {row.role}
                  </div>
                </StoryboardPlanCell>

                <StoryboardPlanCell label="原内容">
                  <p className="line-clamp-2 text-[10.5px] leading-snug" title={row.source}>{row.source}</p>
                </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"
                  />
                  <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>
                </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"
                    />
                    {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-violet-300/12 bg-violet-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-violet-50/78 outline-none placeholder:text-white/25 focus:border-violet-300/50"
                      />
                    )}
                    <div className="grid gap-1 md:grid-cols-2">
                      <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>
                    <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"
                    />
                    <div className="grid gap-1.5 md:grid-cols-[minmax(0,1fr)_88px_88px]">
                      <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"
                        busy={endpointFrameBusy === `${row.index}:first_frame`}
                        deleting={endpointFrameBusy === `${row.index}:clear_first_frame`}
                        disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (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"
                        busy={endpointFrameBusy === `${row.index}:last_frame`}
                        deleting={endpointFrameBusy === `${row.index}:clear_last_frame`}
                        disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (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={selectedSubjectRefs.map((ref) => ref.label || subjectViewLabel(ref.view)).join(" / ")}>
                        {plannedRow.needsSubject ? `主体参考 ${selectedSubjectRefs.length}/${subjectRefs.length} 张` : "本条不传主体"} · {plannedRow.needsProduct ? `产品参考 ${selectedProductCount || 0} 张` : "本条不传产品图"}
                      </span>
                      <button
                        type="button"
                        onClick={() => patchRowPlan(row.index, {
                          ...visualModeDefaults(plannedRow.visualMode),
                          subjectDescription: buildSubjectDescription(plannedRow.role, plannedRow.visualMode),
                        })}
                        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="xl:border-r-0">
                  <StoryboardVideoSlots
                    job={job}
                    videos={rowVideos}
                    enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")}
                    onDeleteVideo={onDeleteVideo}
                  />
                  <div className="mt-1 truncate text-[10px] text-white/34" title="视频生成已暂停，首尾帧确认后再开放单条提交">
                    {endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame")
                      ? "首尾帧已就绪 · 待开放单条视频提交"
                      : "先生成并确认首帧 / 尾帧"}
                  </div>
                  <div className="mt-1 flex items-center justify-between gap-2">
                    <span className="text-[10px] text-white/34">视频生成</span>
                    <span className="rounded border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70">已暂停</span>
                  </div>
                  <div className="mt-1 rounded border border-amber-300/12 bg-amber-300/[0.045] px-2 py-1 text-[10px] leading-snug text-amber-100/62">
                    先保存画面规划；等 SKG 首帧/尾帧资产确认后再开放单条视频提交。
                  </div>
                  <button
                    type="button"
                    onClick={() => void saveSingleRowStoryboardDraft(plannedRow, referenceFrame)}
                    disabled={!referenceFrame || savingStoryboard}
                    className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 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>
              </article>
            )
          })}
        </div>
        </>
      ) : (
        <EmptyState text="音频解析完成后，这里会按逐句时间轴生成信息流复刻分镜工作台。先抽帧并生成相似主体，再逐条规划首尾帧。" />
      )}
    </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 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>
    </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-b border-white/8 p-2 xl:border-b-0 xl:border-r ${className}`}>
      <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/32">{label}</div>
      {children}
    </div>
  )
}

function StoryboardVideoSlots({
  job,
  videos,
  enabled,
  onDeleteVideo,
}: {
  job: Job
  videos: GeneratedVideo[]
  enabled: boolean
  onDeleteVideo?: (videoId: string) => void
}) {
  const visible = videos.slice(0, 6)
  const emptyCount = Math.max(0, 6 - visible.length)
  return (
    <div>
      <div className="grid grid-cols-6 gap-1.5">
        {visible.map((video) => (
          <StoryboardVideoPreview
            key={video.id}
            job={job}
            video={video}
            className="aspect-[9/16] min-h-[86px] w-full"
            onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
          />
        ))}
        {Array.from({ length: emptyCount }).map((_, index) => (
          <div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[86px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
            {enabled ? `候选 ${visible.length + index + 1}` : "待首尾帧"}
          </div>
        ))}
      </div>
      {videos.length > 6 && (
        <div className="mt-1 text-[10px] text-white/34">另有 {videos.length - 6} 条候选在任务记录中</div>
      )}
    </div>
  )
}

function EndpointFrameSlot({
  job,
  frame,
  role,
  busy,
  deleting,
  disabled,
  onGenerate,
  onDelete,
}: {
  job: Job
  frame: KeyFrame | null
  role: "first_frame" | "last_frame"
  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" ? "首帧" : "尾帧"
  return (
    <div className="overflow-hidden rounded border border-white/10 bg-black/32">
      <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",
  onDelete,
}: {
  job: Job
  video: GeneratedVideo
  className?: string
  onDelete?: () => void
}) {
  const src = videoSrc(video)
  const poster = videoPoster(job, video)
  const running = video.status === "queued" || video.status === "in_progress"
  return (
    <MediaAssetTile
      kind="video"
      src={src && video.status === "completed" ? src : undefined}
      poster={poster}
      href={src || undefined}
      alt={`片段 ${shortId(video.id)}`}
      label={`${shortId(video.id)} · ${video.model}`}
      meta={video.status}
      className={`shrink-0 bg-black/45 ${className}`}
      objectFit="cover"
      title={`${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}
      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="relative h-24 cursor-pointer overflow-hidden rounded-md border border-white/10 bg-black/35 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="absolute inset-x-0 top-1/2 h-px bg-white/14" />
        {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="rgba(209,213,219,0.74)"
            />
            <polyline
              points={topPoints}
              fill="none"
              stroke="rgba(229,231,235,0.7)"
              strokeWidth="0.55"
              vectorEffect="non-scaling-stroke"
            />
            <polyline
              points={bottomPoints}
              fill="none"
              stroke="rgba(229,231,235,0.52)"
              strokeWidth="0.55"
              vectorEffect="non-scaling-stroke"
            />
          </svg>
        )}
      </div>
      {segments.map((segment, index) => (
        <div
          key={`${segment.start}-${index}`}
          className="absolute inset-y-0 w-px bg-white/12"
          style={{ left: `${clampNumber((segment.start / Math.max(duration, 1)) * 100, 0, 100)}%` }}
        />
      ))}
      {hoverPct !== null && (
        <div
          className="pointer-events-none absolute inset-y-0 w-px bg-cyan-100/70"
          style={{ left: `${hoverPct}%` }}
        />
      )}
      <div
        className="pointer-events-none absolute inset-y-0 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.85)] will-change-[left]"
        style={{ left: `${pointerPct}%` }}
      />
    </div>
  )
}

function ProfileTile({ label, value, running }: { label: string; value?: string; running?: boolean }) {
  return (
    <div className="min-h-[58px] rounded-md border border-white/10 bg-black/35 p-2">
      <div className="mb-1 flex items-center justify-between gap-2">
        <span className="text-[11px] font-semibold text-white/48">{label}</span>
        {running ? <Loader2 className="h-3.5 w-3.5 animate-spin text-cyan-200" /> : value ? <Check className="h-3.5 w-3.5 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 text-white/32" />}
      </div>
      <p className="line-clamp-2 text-[11px] leading-snug text-white/58" title={value}>
        {value || (running ? "模型分析中..." : "等待音频分析结果。")}
      </p>
    </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 gap-2 lg:grid-cols-3">
            <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 gap-3 lg:grid-cols-[170px_minmax(0,1fr)_auto]">
            <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 gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
            <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 gap-2 sm:grid-cols-2 xl:grid-cols-3">
              {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 gap-2 lg:grid-cols-3">
            <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 gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
            <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 gap-2 lg:grid-cols-[220px_220px]">
            <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={selectedVideoCount > 0} detail={`${selectedVideoCount}`} />
        <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)
  return (
    <button
      type="button"
      onClick={onClick}
      className={`group w-full rounded-lg border p-3 text-left transition ${active ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/28 hover:border-white/24 hover:bg-white/[0.045]"}`}
    >
      <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>
      {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-rose-300/40 hover:text-rose-200 group-hover:flex"
        >
          <Trash2 className="h-3.5 w-3.5" />
          删除素材
        </span>
      )}
    </button>
  )
}

function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) {
  return (
    <div className={`rounded-md border border-white/10 bg-black/35 ${compact ? "px-2 py-1" : "px-2 py-1.5"}`}>
      <div>{label}</div>
      <div className="mt-0.5 truncate font-mono text-[12px] text-white/78">{value}</div>
    </div>
  )
}

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

function workflowStatusClass(status: WorkflowStepStatus) {
  if (status === "ready") return "border-emerald-300/25 bg-emerald-400/10 text-emerald-100"
  if (status === "running") return "border-cyan-300/25 bg-cyan-400/10 text-cyan-100"
  if (status === "paused") return "border-amber-300/22 bg-amber-300/[0.08] text-amber-100/76"
  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="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-rose-200" />
          流程顺序
        </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-rose-200">{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 rounded-md px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "bg-white text-black hover:bg-white/90" : "border border-white/10 bg-white/[0.04] text-white/72 hover:border-white/25 hover:text-white"}`}
    >
      {children}
    </button>
  )
}

function EmptyState({ text }: { text: string }) {
  return (
    <div className="rounded-lg border border-dashed border-white/12 bg-black/25 px-3 py-8 text-center text-[12px] text-white/38">
      {text}
    </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"
  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">
        <button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
          {src && video.status === "completed" ? (
            <video src={src} 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>
        </button>
        <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>}
          {src && video.status === "completed" && (
            <a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
              <Play className="h-3 w-3" />
              预览片段
            </a>
          )}
        </div>
      </div>
    </div>
  )
}
