"use client"
import {
  useEffect, useRef, useState,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
  type RefObject,
} from "react"
import { createPortal } from "react-dom"
import { type NodeProps, useReactFlow } from "@xyflow/react"
import {
  Link2, Upload, Download, Scissors, Image as ImageIcon,
  Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Maximize2,
  Copy, Trash2, Move, PanelLeft, PanelRight, PanelBottom, ChevronLeft, ChevronRight, SlidersHorizontal,
  CheckCircle2, AlertTriangle, Sparkles, Package, PlayCircle, RotateCcw,
} from "lucide-react"
import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { HoverPreview } from "./hover-preview"
import {
  type Job, type ImageRef, type ProductFusionShot, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
  apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"

export type CanvasPanelDock = "canvas" | "left" | "right" | "bottom"

export interface NodeData {
  job: Job | null            // 当前 active job
  jobs: Job[]                // 所有 job 列表
  activeJobId: string | null
  submitting: boolean
  analyzing: boolean
  frameTargets: Record<string, FrameExtractTarget>
  frameCounts: Record<string, number>
  frameQualities: Record<string, FrameExtractQuality>
  selectedFrames: Set<number>
  expandedFrame: number | null
  framePanelScale?: number
  framePanelPinned?: boolean
  framePanelDock?: CanvasPanelDock
  videoPanelJobId?: string | null
  videoPanelScale?: number
  videoPanelDock?: CanvasPanelDock
  onSubmitUrl: (url: string) => Promise<Job | void> | Job | void
  onStartProduction?: (url?: string) => Promise<void> | void
  onUploadFile: (file: File) => void
  onAnalyze: (options?: { mode?: FrameExtractMode }) => void
  onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
  onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void
  onFrameCountChange: (jobId: string, count: number) => void
  onFrameQualityChange: (jobId: string, quality: FrameExtractQuality) => void
  onToggleFrame: (idx: number) => void
  onExpandFrame: (idx: number) => void
  onOpenFramePanel?: (idx: number) => void  // 打开/找回画布内关键帧详情面板
  onFramePanelScaleChange?: (scale: number) => void
  onFramePanelPinnedChange?: (pinned: boolean) => void
  onFramePanelDockChange?: (dock: CanvasPanelDock) => void
  onCloseExpandedFrame: () => void
  onAddManualFrame: (t: number) => void
  onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
  onOpenVideoPanel?: (jobId: string) => void
  onCloseVideoPanel?: () => void
  onVideoPanelScaleChange?: (scale: number) => void
  onVideoPanelDockChange?: (dock: CanvasPanelDock) => void
  onSwitchJob: (id: string) => void
  onJobUpdate: (j: Job) => void
  onDeleteJob?: (id: string) => void
  onOpenPanel?: (key: string) => void  // 控制 sidebar 哪个 drawer 展开
  onDeleteFrame?: (idx: number) => void  // 删整张关键帧
  onDeleteFrameForJob?: (jobId: string, idx: number) => Promise<void> | void
  onDeleteGenerated?: (frameIdx: number, genId: string) => void  // 删单张生成图
  onDeleteVideo?: (videoId: string) => void  // 删 Video Gen 任务
  onDeleteCutout?: (frameIdx: number, elementId: string, cutoutId: string) => void  // 删元素提取图
  onOpenStoryboard?: (frameIdx: number) => void  // 打开分镜头编排专属面板
  onOpenWorkbench?: (frameIdx?: number) => void  // 展开顶部分镜编排内嵌面板
  clipboard?: ImageRef | null
  onCopyImage?: (ref: ImageRef) => void  // 复制图片到全局剪贴板（粘贴到分镜头编排插槽）
  onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise<void> | void
  onTranscribeAudio?: (jobId?: string) => Promise<void> | void
  onOpenAudioStrip?: (jobId?: string) => void
  pinnedNodes?: Set<string>              // 已钉住的节点 id 集合 — 钉住后位置 + 尺寸锁定
  onToggleNodePin?: (id: string) => void
}

/* ---- 状态映射工具 ---- */
function inputStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  return "done"
}
function downloadStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  if (job.status === "failed" && job.progress < 30) return "failed"
  if (job.status === "downloading") return "running"
  if (job.video_url) return "done"
  return "pending"
}
function splitStatus(job: Job | null): NodeStatus {
  if (!job || !job.video_url) return "pending"
  if (job.status === "failed" && job.progress >= 20 && job.progress < 50) return "failed"
  if (job.status === "splitting") return "running"
  if (["frames_extracted", "transcribing", "transcribed"].includes(job.status)) return "done"
  return "pending"
}
function keyframeStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  if (job.status === "failed" && job.progress >= 50 && job.progress < 70) return "failed"
  if (job.frames.length === 0 && job.status === "splitting") return "running"
  if (job.frames.length > 0) return "done"
  return "pending"
}
function asrStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  if (job.status === "transcribing") return "running"
  if (job.transcript.length > 0) return "done"
  if (job.status === "failed" && job.progress >= 70) return "failed"
  return "pending"
}

type PreviewAnchor<T extends string | number> = { id: T; x: number; y: number }

type ScrollRailState = {
  visible: boolean
  leftPct: number
  widthPct: number
  now: number
  max: number
}

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

const THUMBNAIL_HEIGHT = 192
const FLOATING_PANEL_EDGE_INSET = 8
const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [
  { value: "transparent_human", label: "透明骨架人", hint: "本地算力筛清晰主体，不逐帧调用 Vision" },
  { value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" },
  { value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" },
  { value: "transition", label: "转场变化", hint: "切镜和画面变化优先" },
  { value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" },
  { value: "motion", label: "动作峰值", hint: "动作变化更明显" },
]
const FRAME_COUNT_OPTIONS = [12, 8, 5, 3]
const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [
  { value: "auto", label: "自动", hint: "展示友好：按电脑性能选择，最高只到精细" },
  { value: "fast", label: "快速", hint: "2fps / 360px，长视频省电" },
  { value: "accurate", label: "精细", hint: "8fps / 720px，M2 Max 轻松可用" },
  { value: "ultra", label: "极准", hint: "12fps / 960px，本机约 3 秒扫描 1 分钟视频" },
]

function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) {
  if (!root) return { x: 160, y: 0 }
  const rootRect = root.getBoundingClientRect()
  const targetRect = target.getBoundingClientRect()
  if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 }
  const xRatio = (targetRect.left + targetRect.width / 2 - rootRect.left) / rootRect.width
  const yRatio = (targetRect.top - rootRect.top) / rootRect.height
  return {
    x: xRatio * root.clientWidth,
    y: yRatio * root.clientHeight,
  }
}

function ThumbnailScrollRail({
  scrollRef,
  label = "缩略图横向滑动条",
}: {
  scrollRef: RefObject<HTMLDivElement | null>
  label?: string
}) {
  const railRef = useRef<HTMLDivElement>(null)
  const dragRef = useRef<{
    pointerId: number
    startX: number
    startScrollLeft: number
    maxScroll: number
    trackRange: number
  } | null>(null)
  const [dragging, setDragging] = useState(false)
  const [rail, setRail] = useState<ScrollRailState>({
    visible: false,
    leftPct: 0,
    widthPct: 100,
    now: 0,
    max: 0,
  })

  useEffect(() => {
    const el = scrollRef.current
    if (!el) return

    const update = () => {
      const max = Math.max(0, el.scrollWidth - el.clientWidth)
      const visible = max > 2
      const widthPct = visible
        ? Math.max(26, Math.min(92, (el.clientWidth / Math.max(el.scrollWidth, 1)) * 100))
        : 100
      const leftPct = visible ? (el.scrollLeft / max) * (100 - widthPct) : 0
      const next = {
        visible,
        leftPct: Number(leftPct.toFixed(2)),
        widthPct: Number(widthPct.toFixed(2)),
        now: Math.round(el.scrollLeft),
        max: Math.round(max),
      }
      setRail((prev) => (
        prev.visible === next.visible &&
        Math.abs(prev.leftPct - next.leftPct) < 0.1 &&
        Math.abs(prev.widthPct - next.widthPct) < 0.1 &&
        prev.now === next.now &&
        prev.max === next.max
      ) ? prev : next)
    }

    const raf = window.requestAnimationFrame(update)
    el.addEventListener("scroll", update, { passive: true })
    const resizeObserver = new ResizeObserver(update)
    resizeObserver.observe(el)
    const mutationObserver = new MutationObserver(update)
    mutationObserver.observe(el, { childList: true, subtree: true, attributes: true })

    return () => {
      window.cancelAnimationFrame(raf)
      el.removeEventListener("scroll", update)
      resizeObserver.disconnect()
      mutationObserver.disconnect()
    }
  }, [scrollRef])

  if (!rail.visible) return null

  const syncScrollFromPointer = (e: ReactPointerEvent<HTMLDivElement>) => {
    const drag = dragRef.current
    const el = scrollRef.current
    if (!drag || !el) return
    const delta = e.clientX - drag.startX
    el.scrollLeft = clamp(
      drag.startScrollLeft + (delta / drag.trackRange) * drag.maxScroll,
      0,
      drag.maxScroll,
    )
  }

  return (
    <div
      ref={railRef}
      role="scrollbar"
      aria-label={label}
      aria-orientation="horizontal"
      aria-valuemin={0}
      aria-valuemax={rail.max}
      aria-valuenow={rail.now}
      tabIndex={0}
      className={`thumbnail-scroll-rail nodrag nopan relative mt-2.5 h-9 rounded-full border shadow-[0_14px_28px_rgba(0,0,0,0.34),0_0_0_1px_rgba(255,255,255,0.08)] outline-none transition ${
        dragging
          ? "cursor-grabbing border-violet-100/95 bg-violet-500/55 ring-2 ring-violet-100/80"
          : "cursor-grab border-violet-200/70 bg-violet-500/32 hover:border-violet-100/90 hover:bg-violet-400/45 focus-visible:border-violet-100 focus-visible:ring-2 focus-visible:ring-violet-100/80"
      }`}
      onPointerDown={(e) => {
        const el = scrollRef.current
        const track = railRef.current
        if (!el || !track) return
        const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth)
        if (maxScroll <= 0) return

        e.preventDefault()
        e.stopPropagation()

        const rect = track.getBoundingClientRect()
        const thumbWidth = rect.width * (rail.widthPct / 100)
        const trackRange = Math.max(1, rect.width - thumbWidth)
        const pointerX = e.clientX - rect.left
        const thumbLeft = (el.scrollLeft / maxScroll) * trackRange
        let startScrollLeft = el.scrollLeft

        if (pointerX < thumbLeft || pointerX > thumbLeft + thumbWidth) {
          startScrollLeft = clamp(((pointerX - thumbWidth / 2) / trackRange) * maxScroll, 0, maxScroll)
          el.scrollLeft = startScrollLeft
        }

        dragRef.current = {
          pointerId: e.pointerId,
          startX: e.clientX,
          startScrollLeft,
          maxScroll,
          trackRange,
        }
        setDragging(true)
        e.currentTarget.setPointerCapture(e.pointerId)
      }}
      onPointerMove={(e) => {
        if (dragRef.current?.pointerId !== e.pointerId) return
        e.preventDefault()
        e.stopPropagation()
        syncScrollFromPointer(e)
      }}
      onPointerUp={(e) => {
        if (dragRef.current?.pointerId !== e.pointerId) return
        e.preventDefault()
        e.stopPropagation()
        dragRef.current = null
        setDragging(false)
        e.currentTarget.releasePointerCapture(e.pointerId)
      }}
      onPointerCancel={(e) => {
        if (dragRef.current?.pointerId !== e.pointerId) return
        dragRef.current = null
        setDragging(false)
      }}
      onKeyDown={(e) => {
        const el = scrollRef.current
        if (!el) return
        const page = Math.max(80, el.clientWidth * 0.65)
        const small = Math.max(32, el.clientWidth * 0.18)
        let next: number | null = null
        if (e.key === "ArrowLeft") next = el.scrollLeft - small
        if (e.key === "ArrowRight") next = el.scrollLeft + small
        if (e.key === "PageUp") next = el.scrollLeft - page
        if (e.key === "PageDown") next = el.scrollLeft + page
        if (e.key === "Home") next = 0
        if (e.key === "End") next = rail.max
        if (next === null) return
        e.preventDefault()
        e.stopPropagation()
        el.scrollTo({ left: clamp(next, 0, rail.max), behavior: "smooth" })
      }}
    >
      <div
        className="absolute bottom-[6px] top-[6px] rounded-full bg-white shadow-[0_0_0_1px_rgba(255,255,255,0.78),0_0_24px_rgba(216,180,254,0.86)] transition-colors"
        style={{ left: `${rail.leftPct}%`, width: `${rail.widthPct}%` }}
      />
    </div>
  )
}

function FloatingThumbnailStrip({
  children,
  label,
  toolbar,
}: {
  children: ReactNode
  label?: string
  toolbar?: ReactNode
}) {
  const scrollRef = useRef<HTMLDivElement>(null)

  return (
    <div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
      {toolbar && <div className="mb-2">{toolbar}</div>}
      <div ref={scrollRef} className="thumbnail-strip flex items-end gap-1.5 overflow-x-auto">
        {children}
      </div>
      <ThumbnailScrollRail scrollRef={scrollRef} label={label} />
    </div>
  )
}

function DeleteConfirmDialog({
  title,
  description,
  confirmLabel = "删除",
  onCancel,
  onConfirm,
}: {
  title: string
  description: string
  confirmLabel?: string
  onCancel: () => void
  onConfirm: () => void
}) {
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") onCancel()
    }
    document.addEventListener("keydown", onKeyDown)
    return () => document.removeEventListener("keydown", onKeyDown)
  }, [onCancel])

  if (typeof document === "undefined") return null

  return createPortal(
    <div
      className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm"
      onMouseDown={(e) => {
        e.stopPropagation()
        if (e.target === e.currentTarget) onCancel()
      }}
    >
      <div
        role="dialog"
        aria-modal="true"
        aria-label={title}
        className="w-full max-w-sm rounded-xl border border-white/14 bg-zinc-950/95 p-4 text-white shadow-2xl"
        onMouseDown={(e) => e.stopPropagation()}
      >
        <div className="mb-2 flex items-center gap-2">
          <div className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/16 text-rose-200">
            <Trash2 className="h-4 w-4" />
          </div>
          <div className="text-sm font-semibold">{title}</div>
        </div>
        <p className="text-xs leading-5 text-white/68">{description}</p>
        <div className="mt-4 flex justify-end gap-2">
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); onCancel() }}
            className="rounded-md border border-white/12 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-white/78 transition hover:bg-white/[0.08] hover:text-white"
          >
            取消
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); onConfirm() }}
            className="rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg shadow-rose-950/35 transition hover:bg-rose-400"
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>,
    document.body,
  )
}

function FrameExtractQuickBar({
  target,
  count,
  quality,
  disabled,
  running,
  hasFrames,
  onTargetChange,
  onCountChange,
  onQualityChange,
  onAnalyze,
}: {
  target: FrameExtractTarget
  count: number
  quality: FrameExtractQuality
  disabled: boolean
  running: boolean
  hasFrames: boolean
  onTargetChange: (target: FrameExtractTarget) => void
  onCountChange: (count: number) => void
  onQualityChange: (quality: FrameExtractQuality) => void
  onAnalyze: () => void
}) {
  const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0]
  const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[0]
  const [settingsOpen, setSettingsOpen] = useState(false)

  return (
    <div
      className="nodrag nopan w-full rounded-lg border border-white/16 bg-zinc-950/90 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
      onClick={(e) => e.stopPropagation()}
      onMouseDown={(e) => e.stopPropagation()}
    >
      <div className="flex gap-1">
        <select
          value={target}
          disabled={disabled}
          onChange={(e) => onTargetChange(e.target.value as FrameExtractTarget)}
          className="h-8 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-2 text-[10.5px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
          aria-label="选择自动抽帧目标"
          title={option.hint}
        >
          {FRAME_TARGET_OPTIONS.map((item) => (
            <option key={item.value} value={item.value}>{item.label}</option>
          ))}
        </select>
        <button
          type="button"
          disabled={disabled}
          onClick={onAnalyze}
          title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
          className="inline-flex h-8 shrink-0 items-center justify-center gap-1 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-2.5 text-[10.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
        >
          {running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Scissors className="h-3 w-3" />}
          {running ? "抽取" : hasFrames ? "追加" : "抽帧"}
        </button>
        <button
          type="button"
          disabled={disabled}
          onClick={() => setSettingsOpen((open) => !open)}
          title={`设置 · ${count} 张 · ${qualityOption.label}`}
          className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-white/12 transition disabled:cursor-not-allowed disabled:opacity-45 ${
            settingsOpen ? "bg-white text-violet-700" : "bg-white/[0.06] text-white/75 hover:bg-white/[0.12] hover:text-white"
          }`}
        >
          <SlidersHorizontal className="h-3.5 w-3.5" />
        </button>
      </div>
      {settingsOpen && (
        <div className="mt-1.5 flex gap-1">
          <select
            value={count}
            disabled={disabled}
            onChange={(e) => onCountChange(Number(e.target.value))}
            className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
            aria-label="选择抽帧张数"
          >
            {FRAME_COUNT_OPTIONS.map((item) => (
              <option key={item} value={item}>{item} 张</option>
            ))}
          </select>
          <select
            value={quality}
            disabled={disabled}
            onChange={(e) => onQualityChange(e.target.value as FrameExtractQuality)}
            title={qualityOption.hint}
            className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
            aria-label="选择抽帧精度"
          >
            {FRAME_QUALITY_OPTIONS.map((item) => (
              <option key={item.value} value={item.value}>{item.label}</option>
            ))}
          </select>
        </div>
      )}
    </div>
  )
}

/* ============================================================
   1. InputNode — TK 链接 / 上传
   ============================================================ */
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
  const d: NodeData = data
  const [url, setUrl] = useState("")
  const [hoverPreviewJob, setHoverPreviewJob] = useState<PreviewAnchor<string> | null>(null)
  const [pinnedPreviewJob, setPinnedPreviewJob] = useState<PreviewAnchor<string> | null>(null)
  const [deleteJobTarget, setDeleteJobTarget] = useState<Job | null>(null)
  const rootRef = useRef<HTMLDivElement>(null)
  const fileRef = useRef<HTMLInputElement>(null)
  const job = d.job

  // 点击 input 节点外的任何位置 → 取消 pin（capture 阶段，避免 ReactFlow pane 拦截）
  useEffect(() => {
    if (pinnedPreviewJob === null) return
    const handler = (e: MouseEvent) => {
      const t = e.target as HTMLElement
      if (t.closest('.react-flow__node[data-id="input"]')) return
      setPinnedPreviewJob(null)
    }
    document.addEventListener("mousedown", handler, true)
    return () => document.removeEventListener("mousedown", handler, true)
  }, [pinnedPreviewJob])

  // 是否已下载 → 显示视频 + 解析按钮
  const hasVideo = !!job?.video_url
  const isDownloading = job?.status === "downloading" || job?.status === "created"
  const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status)
  const hasFrames = (job?.frames.length ?? 0) > 0
  const inputLocked = isDownloading || d.submitting

  return (
    <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
      {/* 多视频缩略图浮条 — 「+」在最左，job 按时间倒序（最新靠左高亮），每个视频上方绑定独立抽帧快捷条。
          浮条宽度 = 节点宽度（节点拖宽后浮条同步变宽，可见更多缩略图，少滚动）。 */}
      {d.jobs.length > 0 && (
        <FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
          {/* + 再上传一个（放在最前面） */}
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
            title="再上传一个视频"
            className="shrink-0 rounded-md border border-dashed border-white/30 hover:border-white/50 bg-white/[0.04] hover:bg-white/[0.08] inline-flex items-center justify-center text-white/60 hover:text-white transition"
            style={{ width: 96, height: THUMBNAIL_HEIGHT }}
          >
            <Plus className="h-4 w-4" />
          </button>
          {[...d.jobs].reverse().map((j) => {
            const isActive = j.id === d.activeJobId
            const ready = !!j.video_url
            const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
            const thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96
            const toolWidth = Math.max(148, thumbNaturalWidth)
            const target = d.frameTargets[j.id] ?? "transparent_human"
            const count = d.frameCounts[j.id] ?? 12
            const quality = d.frameQualities[j.id] ?? "auto"
            const jHasFrames = j.frames.length > 0
            const jRunning = ["splitting", "transcribing"].includes(j.status)
            return (
              <div
                key={j.id}
                className="group relative flex shrink-0 flex-col gap-1.5"
                style={{ width: toolWidth }}
              >
                {ready ? (
                  <FrameExtractQuickBar
                    target={target}
                    count={count}
                    quality={quality}
                    disabled={jRunning}
                    running={jRunning}
                    hasFrames={jHasFrames}
                    onTargetChange={(next) => d.onFrameTargetChange(j.id, next)}
                    onCountChange={(next) => d.onFrameCountChange(j.id, next)}
                    onQualityChange={(next) => d.onFrameQualityChange(j.id, next)}
                    onAnalyze={() => d.onAnalyzeJob(j.id, { mode: jHasFrames ? "append" : "replace" })}
                  />
                ) : (
                  <div className="h-[44px] rounded-lg border border-white/10 bg-white/[0.03]" />
                )}
                <div
                  className={`relative self-center rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
                    isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
                  }`}
                  style={{ width: thumbNaturalWidth, height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
                  onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
                  onMouseLeave={() => setHoverPreviewJob(null)}
                >
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation()
                      if (!ready) return
                      setPinnedPreviewJob(null)
                      if (!isActive) d.onSwitchJob(j.id)
                      d.onOpenVideoPanel?.(j.id)
                    }}
                    onDoubleClick={(e) => {
                      e.stopPropagation()
                      if (ready) {
                        setPinnedPreviewJob(null)
                        if (!isActive) d.onSwitchJob(j.id)
                        d.onOpenVideoPanel?.(j.id)
                      }
                    }}
                    title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).toFixed(1)}s · 单击打开抽帧面板` : "下载中…"}
                    className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
                  >
                    {ready ? (
                      <video
                        src={videoUrl(j.id)}
                        muted
                        loop
                        playsInline
                        preload="metadata"
                        poster=""
                        className="block w-full h-full object-cover bg-black"
                      />
                    ) : (
                      <div className="w-full h-full bg-black/60 flex items-center justify-center">
                        <Loader2 className="h-4 w-4 animate-spin text-white/60" />
                      </div>
                    )}
                    <div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
                      {ready ? `${(j.duration ?? 0).toFixed(1)}s` : "…"}
                    </div>
                  </button>
                  {d.onDeleteJob && (
                    <button
                      type="button"
                      onClick={(e) => {
                        e.stopPropagation()
                        setDeleteJobTarget(j)
                      }}
                      title="删除这个输入视频"
                      className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
                    >
                      <Trash2 className="h-3.5 w-3.5" />
                    </button>
                  )}
                </div>
              </div>
            )
          })}
        </FloatingThumbnailStrip>
      )}

      {deleteJobTarget && (
        <DeleteConfirmDialog
          title="删除这个输入视频？"
          description={`会同时删除 ${deleteJobTarget.id.slice(0, 8)} 的源视频、关键帧、元素提取图、生成视频和本地任务目录。`}
          confirmLabel="删除视频"
          onCancel={() => setDeleteJobTarget(null)}
          onConfirm={() => {
            const id = deleteJobTarget.id
            setDeleteJobTarget(null)
            d.onDeleteJob?.(id)
          }}
        />
      )}

      {(() => {
        const anchor = pinnedPreviewJob ?? hoverPreviewJob
        if (!anchor) return null
        const previewJob = d.jobs.find((j) => j.id === anchor.id)
        if (!previewJob?.video_url) return null
        const aspectStr = previewJob.height ? `${previewJob.width}/${previewJob.height}` : "9/16"
        return (
          <HoverPreview
            videoSrc={videoUrl(previewJob.id)}
            aspect={aspectStr}
            label={previewJob.width && previewJob.height ? `${previewJob.width}×${previewJob.height}` : "原视频"}
            caption={previewJob.duration ? `${previewJob.duration.toFixed(1)}s` : undefined}
            borderClass="border-violet-300/60"
            visible={!!hoverPreviewJob && !pinnedPreviewJob}
            anchorX={anchor.x}
            anchorY={anchor.y}
            pinned={!!pinnedPreviewJob}
            onClose={() => setPinnedPreviewJob(null)}
          />
        )
      })()}

    <NodeShell
      type="input" status={inputStatus(job)}
      icon={<Link2 className="h-4 w-4" />}
      title="输入 · Input"
      subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
      selected={selected}
      hasTarget={false}
      pinned={d.pinnedNodes?.has("input")}
      onTogglePin={() => d.onToggleNodePin?.("input")}
    >
      {/* URL + 上传入口 — 一直显示（即使已有视频，也可以继续加新的） */}
      <>
          <input
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            placeholder={hasVideo ? "再加一个 TK 链接" : "粘贴 TikTok 链接"}
            disabled={inputLocked}
            className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/60 dark:bg-black/40 border border-black/10 dark:border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
          />
          <div className="mt-2 flex gap-1.5">
            <button
              type="button"
              disabled={inputLocked || !url.trim()}
              onClick={() => { d.onSubmitUrl(url.trim()); setUrl("") }}
              className="flex-1 text-[11.5px] py-1.5 rounded-md bg-black text-white dark:bg-white dark:text-black hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center gap-1"
            >
              {(d.submitting || isDownloading) ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
              {isDownloading ? "下载中…" : hasVideo ? "+ 加链接" : "提交链接"}
            </button>
            <button
              type="button"
              disabled={inputLocked}
              onClick={() => fileRef.current?.click()}
              className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/60 dark:bg-white/[0.06] border border-black/10 dark:border-white/15 hover:bg-white/80 dark:hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
            >
              <Upload className="h-3 w-3" /> {hasVideo ? "再传一个" : "上传"}
            </button>
            <input
              ref={fileRef}
              type="file"
              accept="video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0]
                if (f) d.onUploadFile(f)
                e.target.value = ""
              }}
            />
          </div>
        </>

      {/* 已下载：仅元数据（视频缩略图浮在节点上方，点击进 lightbox） */}
      {hasVideo && job && (
        <>
          <div className="rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2 flex items-center justify-between text-[10.5px] font-mono">
            <span className="text-[var(--text-strong)]">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
            <span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
          </div>

          {hasFrames && (
            <div className="mt-2 rounded-md border border-violet-400/20 bg-violet-500/10 px-3 py-2 text-[11px] font-medium text-violet-100">
              已抽 {job.frames.length} 张 · 上方可继续追加
            </div>
          )}
        </>
      )}
    </NodeShell>
    </div>
  )
}

/* ============================================================
   1b. VideoFramePanelNode — 画布内视频抽帧工作面板
   ============================================================ */
export function VideoFramePanelNode({ data }: any) {
  const d: NodeData = data
  const { getZoom } = useReactFlow()
  const panelJob = d.videoPanelJobId
    ? d.jobs.find((j) => j.id === d.videoPanelJobId) ?? null
    : null
  const videoRef = useRef<HTMLVideoElement>(null)
  const scale = d.videoPanelScale ?? 1
  const dock = d.videoPanelDock ?? "canvas"
  const docked = dock !== "canvas"
  const [currentT, setCurrentT] = useState(0)
  const [adding, setAdding] = useState(false)
  const [deletingFrame, setDeletingFrame] = useState<number | null>(null)

  useEffect(() => {
    setCurrentT(0)
    setAdding(false)
    setDeletingFrame(null)
  }, [panelJob?.id])

  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") d.onCloseVideoPanel?.()
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [d])

  if (!panelJob?.video_url) return null

  const panelWidth = Math.round(760 * scale)
  const panelHeight = Math.round(620 * scale)
  const bodyHeight = Math.max(500, panelHeight - 28)
  const duration = panelJob.duration ?? 0
  const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp)
  const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16"
  const panelTarget = d.frameTargets[panelJob.id] ?? "transparent_human"
  const panelCount = d.frameCounts[panelJob.id] ?? 12
  const panelQuality = d.frameQualities[panelJob.id] ?? "auto"
  const panelRunning = ["splitting", "transcribing"].includes(panelJob.status)
  const dockText: Record<CanvasPanelDock, string> = {
    canvas: "画布模式",
    left: "吸附左侧",
    right: "吸附右侧",
    bottom: "吸附底部",
  }

  const setScale = (next: number) => {
    const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
    d.onVideoPanelScaleChange?.(clamped)
  }

  const startResize = (e: ReactPointerEvent) => {
    e.preventDefault()
    e.stopPropagation()
    const startX = e.clientX
    const startY = e.clientY
    const startScale = scale
    const zoom = docked ? 1 : getZoom()
    const onMove = (ev: PointerEvent) => {
      const dx = (ev.clientX - startX) / zoom
      const dy = (ev.clientY - startY) / zoom
      const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 620
      setScale(startScale + delta)
    }
    const onUp = () => {
      window.removeEventListener("pointermove", onMove)
      window.removeEventListener("pointerup", onUp)
    }
    window.addEventListener("pointermove", onMove)
    window.addEventListener("pointerup", onUp)
  }

  const seekTo = (next: number) => {
    const t = clamp(next, 0, Math.max(duration, 0))
    setCurrentT(t)
    if (videoRef.current) videoRef.current.currentTime = t
  }

  const addCurrentFrame = async () => {
    const t = videoRef.current?.currentTime ?? currentT
    setAdding(true)
    try {
      if (d.onAddManualFrameForJob) await d.onAddManualFrameForJob(panelJob.id, t)
      else await d.onAddManualFrame(t)
    } finally {
      setAdding(false)
    }
  }

  const deleteFrameFromPanel = async (idx: number) => {
    setDeletingFrame(idx)
    try {
      if (d.onDeleteFrameForJob) await d.onDeleteFrameForJob(panelJob.id, idx)
      else await d.onDeleteFrame?.(idx)
    } finally {
      setDeletingFrame(null)
    }
  }

  const dockButtonClass = (value: CanvasPanelDock) =>
    `nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
      dock === value
        ? "bg-white text-violet-700 shadow"
        : "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
    }`

  const panel = (
    <div
      className="relative overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
      style={{
        width: panelWidth,
        height: panelHeight,
        maxWidth: "calc(100vw - 32px)",
        maxHeight: `calc(100vh - ${FLOATING_PANEL_EDGE_INSET * 2}px)`,
        boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)",
      }}
    >
      <div
        className={`video-frame-panel-drag flex h-7 items-center justify-between px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}
        style={{ background: "var(--grad-input)" }}
      >
        <div className="flex min-w-0 items-center gap-2">
          <FileVideo className="h-3.5 w-3.5 shrink-0" />
          <span className="truncate text-[12px] font-semibold">视频抽帧 · Input</span>
          <span className="shrink-0 text-[10px] font-mono text-white/65">
            {panelJob.width}×{panelJob.height} · {duration.toFixed(1)}s
          </span>
        </div>
        <div className="flex items-center gap-1.5">
          <span className="mr-1 text-[10px] text-white/60">
            {dockText[dock]}
          </span>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
            <Move className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
            <PanelLeft className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
            <PanelRight className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
            <PanelBottom className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="缩小面板">
            -
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); setScale(1) }} className="nodrag h-6 min-w-10 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center" title="重置为 100%">
            {Math.round(scale * 100)}%
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="放大面板">
            +
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onCloseVideoPanel?.() }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center" title="关闭">
            <X className="h-3.5 w-3.5" />
          </button>
        </div>
      </div>
      <div className="nodrag nowheel grid gap-3 p-3 lg:grid-cols-[minmax(260px,0.8fr)_minmax(280px,1fr)]" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
        <div className="flex min-h-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black">
          <video
            ref={videoRef}
            src={videoUrl(panelJob.id)}
            controls
            autoPlay
            muted
            playsInline
            preload="auto"
            onTimeUpdate={(e) => setCurrentT((e.target as HTMLVideoElement).currentTime)}
            className="max-h-full max-w-full bg-black"
            style={{ aspectRatio: aspect }}
          />
        </div>
        <div className="flex min-h-0 flex-col gap-3 overflow-hidden">
          <FrameExtractQuickBar
            target={panelTarget}
            count={panelCount}
            quality={panelQuality}
            disabled={panelRunning}
            running={panelRunning}
            hasFrames={frames.length > 0}
            onTargetChange={(next) => d.onFrameTargetChange(panelJob.id, next)}
            onCountChange={(next) => d.onFrameCountChange(panelJob.id, next)}
            onQualityChange={(next) => d.onFrameQualityChange(panelJob.id, next)}
            onAnalyze={() => d.onAnalyzeJob(panelJob.id, { mode: frames.length > 0 ? "append" : "replace" })}
          />
          <div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
            <div className="mb-2 flex items-center justify-between gap-3">
              <div className="font-mono text-[12px] text-white/72">
                当前 <span className="text-white">{currentT.toFixed(2)}s</span>
              </div>
              <div className="font-mono text-[11px] text-white/45">
                {frames.length} 张关键帧
              </div>
            </div>
            <input
              type="range"
              min={0}
              max={Math.max(duration, 0.1)}
              step={0.01}
              value={clamp(currentT, 0, Math.max(duration, 0.1))}
              onChange={(e) => seekTo(Number(e.target.value))}
              className="w-full accent-violet-400"
              aria-label="视频时间轴"
            />
            <button
              type="button"
              disabled={adding}
              onClick={(e) => { e.stopPropagation(); void addCurrentFrame() }}
              className="mt-3 w-full rounded-lg bg-emerald-500 px-4 py-2.5 text-[13px] font-semibold text-white shadow-lg shadow-emerald-950/35 transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-55 inline-flex items-center justify-center gap-2"
            >
              {adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
              {adding ? "抽帧中…" : `把 ${currentT.toFixed(1)}s 加为关键帧`}
            </button>
          </div>

          <div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-white/10 bg-white/[0.035] p-3">
            <div className="mb-2 flex items-center justify-between">
              <div className="text-[12px] font-semibold text-white/85">已抽关键帧</div>
              <div className="text-[10px] font-mono text-white/40">点击缩略图定位时间</div>
            </div>
            {frames.length > 0 ? (
              <div className="grid max-h-full grid-cols-3 gap-2 overflow-y-auto pr-1">
                {frames.map((f) => (
                  <div
                    key={f.index}
                    className="group relative overflow-hidden rounded-md border border-white/10 bg-black text-left transition hover:border-violet-300/70"
                  >
                    <button
                      type="button"
                      onClick={(e) => { e.stopPropagation(); seekTo(f.timestamp) }}
                      className="block w-full text-left"
                      title={`跳到 ${f.timestamp.toFixed(2)}s`}
                    >
                      <img src={effectiveFrameUrl(panelJob.id, f)} alt="" className="aspect-[9/16] w-full object-cover opacity-90 transition group-hover:opacity-100" />
                      <div className="px-1.5 py-1 font-mono text-[9.5px] text-white/65">
                        {f.timestamp.toFixed(1)}s
                      </div>
                    </button>
                    {(d.onDeleteFrameForJob || d.onDeleteFrame) && (
                      <button
                        type="button"
                        disabled={deletingFrame === f.index}
                        onClick={(e) => {
                          e.stopPropagation()
                          void deleteFrameFromPanel(f.index)
                        }}
                        className="absolute right-1 top-1 z-10 inline-flex h-6 w-6 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400 disabled:cursor-wait disabled:opacity-70"
                        title={`删除 ${f.timestamp.toFixed(1)}s 关键帧`}
                        aria-label={`删除 ${f.timestamp.toFixed(1)}s 关键帧`}
                      >
                        {deletingFrame === f.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
                      </button>
                    )}
                  </div>
                ))}
              </div>
            ) : (
              <div className="flex h-full min-h-32 items-center justify-center rounded-lg border border-dashed border-white/10 text-[12px] text-white/35">
                还没有关键帧，拖动时间轴后点击抽帧。
              </div>
            )}
          </div>
        </div>
      </div>
      <button
        type="button"
        onPointerDown={startResize}
        className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-violet-400/35 hover:text-white inline-flex items-center justify-center"
        title="拖动右下角缩放面板"
      >
        <Maximize2 className="h-3.5 w-3.5" />
      </button>
    </div>
  )

  if (docked && typeof document !== "undefined") {
    const fixedStyle =
      dock === "left"
        ? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
        : dock === "right"
          ? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
          : { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" }
    return createPortal(
      <div className="fixed z-[240]" style={fixedStyle}>
        {panel}
      </div>,
      document.body,
    )
  }

  return panel
}

/* ============================================================
   2. DownloadNode
   ============================================================ */
export function DownloadNode({ data, selected }: any) {
  const d: NodeData = data
  const st = downloadStatus(d.job)
  return (
    <NodeShell
      type="process" status={st}
      icon={<Download className="h-4 w-4" />}
      title="下载 · Download"
      subtitle="STEP 2 · yt-dlp"
      selected={selected}
    >
      <div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
        {d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
      </div>
      {d.job && st === "done" && (
        <div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] font-mono text-[var(--text-faint)]">
          <div>分辨率<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.width}×{d.job.height}</span></div>
          <div>时长<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.duration.toFixed(1)}s</span></div>
        </div>
      )}
    </NodeShell>
  )
}

/* ============================================================
   3. SplitNode
   ============================================================ */
export function SplitNode({ data, selected }: any) {
  const d: NodeData = data
  return (
    <NodeShell
      type="process" status={splitStatus(d.job)}
      icon={<Scissors className="h-4 w-4" />}
      title="拆分 · Split"
      subtitle="STEP 3 · ffmpeg"
      selected={selected}
    >
      <div className="grid grid-cols-2 gap-2 text-[11px]">
        <div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
          <div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">视频流</div>
          <div className="text-[var(--text-strong)] mt-0.5">→ 关键帧</div>
        </div>
        <div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
          <div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">音频流</div>
          <div className="text-[var(--text-strong)] mt-0.5">→ ASR</div>
        </div>
      </div>
    </NodeShell>
  )
}

/* ============================================================
   4. KeyframeNode — 缩略图横排浮在节点上方，点击展开 lightbox
   ============================================================ */
const KEYFRAME_WIDTH = 360
const THUMB_W = 64
const THUMB_GAP = 6

type ElementPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
type SceneAssetPreview = { frameIdx: number; assetId: string; label: string; src: string; width: number; height: number; risk?: string }
type SubjectAssetPreview = { frameIdx: number; elementId: string; assetId: string; label: string; src: string; width: number; height: number; view: string }

function collectElementCrops(job: Job | null): ElementPreview[] {
  return job
    ? job.frames.flatMap((f) =>
        (f.elements ?? [])
          .filter((e) => hasCutout(e))
          .map((e) => {
            const src = representativeCutoutUrl(job.id, f.index, e) || ""
            const cid = (e.cutouts && e.cutouts.length > 0)
              ? e.cutouts[e.cutouts.length - 1]
              : (e.cutout_id ?? "")
            return {
              frameIdx: f.index,
              elementId: e.id,
              name: e.name_zh,
              src,
              cid,
              timestamp: f.timestamp,
            }
          })
          .filter((p) => p.src),
      )
    : []
}

function collectSceneAssets(job: Job | null): SceneAssetPreview[] {
  return job
    ? job.frames.flatMap((f) =>
        (f.scene_assets ?? []).map((asset) => ({
          frameIdx: f.index,
          assetId: asset.id,
          label: asset.label || `分镜 ${f.index + 1} 场景图`,
          src: apiAssetUrl(asset.url),
          width: asset.width,
          height: asset.height,
          risk: asset.quality_report?.risk,
        })).filter((p) => p.src),
      )
    : []
}

function collectSubjectAssets(job: Job | null): SubjectAssetPreview[] {
  return job
    ? job.frames.flatMap((f) =>
        (f.elements ?? []).flatMap((element) =>
          (element.subject_assets ?? []).map((asset) => ({
            frameIdx: f.index,
            elementId: element.id,
            assetId: asset.id,
            label: asset.label || `${element.name_zh} · ${asset.view}`,
            src: apiAssetUrl(asset.url),
            width: asset.width,
            height: asset.height,
            view: asset.view,
          })),
        ).filter((p) => p.src),
      )
    : []
}

function videoModelLabel(model: string) {
  const m = model.toLowerCase()
  if (m.includes("kling")) return "Kling"
  if (m.includes("veo")) return "Veo 3"
  if (m.includes("seedance")) return "Seedance"
  return model || "Video"
}

/* ============================================================
   4. VisualLabNode — 合并镜头拆解 / 元素改造 / 生视频展示入口
   ============================================================ */
export function VisualLabNode({ data, selected }: any) {
  const d: NodeData = data
  const job = d.job
  const frames = job?.frames ?? []
  const videos = job?.generated_videos ?? []
  const jobId = job?.id
  const aspect = job && (job.width ?? 0) > 0 && (job.height ?? 0) > 0
    ? `${job.width}/${job.height}`
    : "9/16"
  const sceneAssets = collectSceneAssets(job)
  const subjectAssets = collectSubjectAssets(job)
  const cleanedCount = frames.filter((x) => x.cleaned_url).length
  const sceneAssetCount = sceneAssets.length
  const subjectAssetCount = subjectAssets.length
  const selectedFrameCount = frames.filter((f) => d.selectedFrames.has(f.index)).length
  const targetFrameCount = selectedFrameCount || frames.length
  const qualityRiskCount = frames.filter((f) => f.quality_report?.risk && f.quality_report.risk !== "ok").length
  const preparedUnits = Math.min(targetFrameCount, sceneAssetCount) + (subjectAssetCount > 0 ? 1 : 0)
  const totalUnits = Math.max(1, targetFrameCount + 1)
  const prepPct = Math.min(100, Math.round((preparedUnits / totalUnits) * 100))
  const runningVideo = videos.some((v) => v.status === "queued" || v.status === "in_progress")
  const completedVideos = videos.filter((v) => v.status === "completed" && v.url)
  const failedVideo = videos.some((v) => v.status === "failed")
  const status: NodeStatus = runningVideo
    ? "running"
    : failedVideo
    ? "failed"
    : frames.length > 0 || subjectAssets.length > 0 || completedVideos.length > 0
    ? "done"
    : keyframeStatus(job)

  type VisualPreview =
    | { id: string; kind: "frame"; group: string; frameIdx: number; src: string; label: string; caption: string; borderClass: string; aspect: string }
    | { id: string; kind: "scene"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
    | { id: string; kind: "subject"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
    | { id: string; kind: "video"; group: string; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string; aspect: string }

  const [hoverPreview, setHoverPreview] = useState<PreviewAnchor<string> | null>(null)
  const [pinnedPreview, setPinnedPreview] = useState<PreviewAnchor<string> | null>(null)
  const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null)
  const rootRef = useRef<HTMLDivElement>(null)

  const previews: VisualPreview[] = [
    ...(job && jobId ? frames.map((f) => ({
      id: `frame:${f.index}`,
      kind: "frame" as const,
      group: "关键帧",
      frameIdx: f.index,
      src: effectiveFrameUrl(jobId, f),
      label: `分镜 ${f.index + 1}`,
      caption: `${f.timestamp.toFixed(2)}s${f.quality_report?.risk && f.quality_report.risk !== "ok" ? " · 风险" : ""}`,
      borderClass: f.quality_report?.risk === "bad" ? "border-rose-300/70" : f.quality_report?.risk === "warn" ? "border-amber-300/70" : "border-orange-300/50",
      aspect,
    })) : []),
    ...subjectAssets.map((p) => ({
      id: `subject:${p.frameIdx}:${p.assetId}`,
      kind: "subject" as const,
      group: "主体包",
      frameIdx: p.frameIdx,
      assetId: p.assetId,
      src: p.src,
      label: p.label,
      caption: `${p.width}×${p.height}`,
      borderClass: "border-violet-300/65",
      aspect: p.width && p.height ? `${p.width}/${p.height}` : "1/1",
    })),
    ...sceneAssets.map((p) => ({
      id: `scene:${p.frameIdx}:${p.assetId}`,
      kind: "scene" as const,
      group: "场景图",
      frameIdx: p.frameIdx,
      assetId: p.assetId,
      src: p.src,
      label: p.label,
      caption: `${p.width}×${p.height}`,
      borderClass: p.risk === "bad" ? "border-rose-300/70" : p.risk === "warn" ? "border-amber-300/70" : "border-emerald-300/60",
      aspect: p.width && p.height ? `${p.width}/${p.height}` : aspect,
    })),
    ...videos.map((v, i) => {
      const videoSrc = apiAssetUrl(v.url)
      const posterSrc = apiAssetUrl(v.poster_url)
      return {
        id: `video:${v.id}`,
        kind: "video" as const,
        group: "视频任务",
        videoId: v.id,
        videoSrc: v.status === "completed" && videoSrc ? videoSrc : undefined,
        posterSrc: posterSrc || undefined,
        label: `视频 ${i + 1}`,
        caption: `${videoModelLabel(v.model)} · ${v.status}`,
        borderClass: v.status === "completed" ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55",
        aspect,
      }
    }),
  ]

  useEffect(() => {
    if (pinnedPreview === null) return
    const handler = (e: MouseEvent) => {
      const t = e.target as HTMLElement
      if (t.closest('.react-flow__node[data-id="visual"]')) return
      setPinnedPreview(null)
    }
    document.addEventListener("mousedown", handler, true)
    return () => document.removeEventListener("mousedown", handler, true)
  }, [pinnedPreview])

  const openFirstFrame = () => {
    const idx = frames[0]?.index
    if (typeof idx === "number") (d.onOpenFramePanel ?? d.onExpandFrame)(idx)
  }

  return (
    <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
      {previews.length > 0 && (
        <FloatingThumbnailStrip label="画面工作台缩略图横向滑动条">
          {previews.map((p) => {
            const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx)
            return (
              <div
                key={p.id}
                className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
                  p.kind === "frame"
                    ? isSelected ? "border-emerald-400 ring-2 ring-emerald-400/60" : "border-white/30 dark:border-white/20"
                    : p.borderClass
                } ${p.kind === "subject" ? "bg-white" : "bg-black"}`}
                style={{ height: THUMBNAIL_HEIGHT, aspectRatio: p.aspect }}
                onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
                onMouseLeave={() => setHoverPreview(null)}
              >
                <div className="absolute -top-6 left-0 z-[68] rounded bg-black/75 px-1.5 py-0.5 text-[9px] font-medium text-white/80 backdrop-blur">
                  {p.group}
                </div>
                <button
                  type="button"
                  onClick={(e) => {
                    e.stopPropagation()
                    const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
                    setPinnedPreview((prev) => (prev?.id === p.id ? null : { id: p.id, ...anchor }))
                    if (p.kind === "frame") {
                      ;(d.onOpenFramePanel ?? d.onExpandFrame)(p.frameIdx)
                    } else if (p.kind === "scene" || p.kind === "subject") {
                      d.onCopyImage?.({
                        kind: "asset",
                        frame_idx: p.frameIdx,
                        element_id: p.assetId,
                        cutout_id: p.assetId,
                        label: p.label,
                      })
                      if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
                      d.onOpenWorkbench?.(p.frameIdx)
                    } else {
                      const video = videos.find((v) => v.id === p.videoId)
                      if (video) {
                        void navigator.clipboard?.writeText(video.prompt).catch(() => {})
                        toast.success("已复制视频 prompt")
                      }
                    }
                  }}
                  title={`${p.label} · 单击钉住预览${p.kind === "frame" ? " / 打开素材审核" : p.kind === "video" ? " / 复制 prompt" : " / 复制到分镜编排"}`}
                  className="absolute inset-0 h-full w-full overflow-hidden rounded-md"
                >
                  {p.kind === "video" ? (
                    p.videoSrc ? (
                      <video src={p.videoSrc} poster={p.posterSrc} muted loop playsInline preload="metadata" className="absolute inset-0 h-full w-full object-cover" />
                    ) : p.posterSrc ? (
                      <img src={p.posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
                    ) : (
                      <div className="absolute inset-0 bg-violet-950/50" />
                    )
                  ) : (
                    <img src={p.src} alt={p.label} className={`absolute inset-0 h-full w-full ${p.kind === "frame" ? "object-cover" : "object-contain"}`} />
                  )}
                  {p.kind === "frame" && isSelected && (
                    <div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
                  )}
                  <div className="absolute bottom-0 right-0 bg-black/70 px-1 py-0.5 text-[8.5px] font-mono leading-none text-white rounded-bl rounded-br-md">
                    {p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "scene" ? "场景" : p.kind === "subject" ? "主体" : "视频"}
                  </div>
                </button>

                {p.kind === "frame" && d.onCopyImage && (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation()
                      d.onCopyImage?.({ kind: "keyframe", frame_idx: p.frameIdx, label: `${p.label} 关键帧` })
                    }}
                    title="复制此图（到分镜头编排工作台插槽粘贴）"
                    className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
                  >
                    <Copy className="h-3.5 w-3.5" />
                  </button>
                )}

                {(p.kind === "scene" || p.kind === "subject") && d.onCopyImage && (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation()
                      d.onCopyImage?.({
                        kind: "asset",
                        frame_idx: p.frameIdx,
                        element_id: p.assetId,
                        cutout_id: p.assetId,
                        label: p.label,
                      })
                    }}
                    title="复制此素材（到分镜头编排工作台插槽粘贴）"
                    className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
                  >
                    <Copy className="h-3.5 w-3.5" />
                  </button>
                )}

                {p.kind === "video" && (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation()
                      const video = videos.find((v) => v.id === p.videoId)
                      if (!video) return
                      void navigator.clipboard?.writeText(video.prompt).catch(() => {})
                      toast.success("已复制视频 prompt")
                    }}
                    className="absolute left-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
                    title="复制视频 prompt"
                  >
                    <Copy className="h-3.5 w-3.5" />
                  </button>
                )}

                {p.kind === "frame" && d.onDeleteFrame && (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation()
                      d.onDeleteFrame?.(p.frameIdx)
                    }}
                    title="删除该关键帧"
                    className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
                  >
                    <Trash2 className="h-3.5 w-3.5" />
                  </button>
                )}

                {p.kind === "video" && d.onDeleteVideo && (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation()
                      setDeleteVideoTarget({ id: p.videoId, label: p.label, caption: p.caption })
                    }}
                    className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
                    title="删除这个视频任务"
                  >
                    <Trash2 className="h-3.5 w-3.5" />
                  </button>
                )}
              </div>
            )
          })}
        </FloatingThumbnailStrip>
      )}

      {deleteVideoTarget && (
        <DeleteConfirmDialog
          title="删除这个视频任务？"
          description={`会删除${deleteVideoTarget.label}的生成任务和本地视频文件。${deleteVideoTarget.caption}`}
          confirmLabel="删除视频任务"
          onCancel={() => setDeleteVideoTarget(null)}
          onConfirm={() => {
            const id = deleteVideoTarget.id
            setDeleteVideoTarget(null)
            d.onDeleteVideo?.(id)
          }}
        />
      )}

      {(() => {
        const anchor = pinnedPreview ?? hoverPreview
        if (!anchor) return null
        const p = previews.find((x) => x.id === anchor.id)
        if (!p) return null
        return (
          <HoverPreview
            imgSrc={p.kind === "video" ? p.posterSrc : p.src}
            videoSrc={p.kind === "video" ? p.videoSrc : undefined}
            poster={p.kind === "video" ? p.posterSrc : undefined}
            aspect={p.aspect}
            label={p.label}
            caption={p.caption}
            borderClass={p.borderClass}
            visible={!!hoverPreview && !pinnedPreview}
            anchorX={anchor.x}
            anchorY={anchor.y}
            pinned={!!pinnedPreview}
            onClose={() => setPinnedPreview(null)}
          />
        )
      })()}

      <NodeShell
        type="ai"
        status={status}
        icon={<LayoutGrid className="h-4 w-4" />}
        title="画面工作台 · Visual Lab"
        subtitle={`STEP 2-7 · ${frames.length ? `${frames.length} 帧` : "等待解析"}${videos.length ? ` · ${videos.length} 视频` : ""}`}
        selected={selected}
        pinned={d.pinnedNodes?.has("visual")}
        onTogglePin={() => d.onToggleNodePin?.("visual")}
      >
        <div className="rounded-md border border-white/10 bg-black/20 p-2">
          <div className="mb-1.5 flex items-center justify-between gap-2">
            <div className="text-[11px] font-semibold text-[var(--text-strong)]">素材准备进度</div>
            <div className="font-mono text-[10px] text-[var(--text-faint)]">{prepPct}%</div>
          </div>
          <div className="h-2 overflow-hidden rounded-full bg-white/10">
            <div
              className="h-full rounded-full bg-emerald-400 transition-all"
              style={{ width: `${prepPct}%` }}
            />
          </div>
          <div className="mt-1.5 flex items-center justify-between gap-2 text-[9.5px] text-[var(--text-faint)]">
            <span>{targetFrameCount} 张素材帧</span>
            {qualityRiskCount > 0 ? (
              <span className="inline-flex items-center gap-1 text-amber-300/85">
                <AlertTriangle className="h-2.5 w-2.5" />
                {qualityRiskCount} 个质量风险
              </span>
            ) : (
              <span className="inline-flex items-center gap-1 text-emerald-300/80">
                <CheckCircle2 className="h-2.5 w-2.5" />
                无质量风险
              </span>
            )}
          </div>
        </div>

        <div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] text-[var(--text-soft)]">
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
            disabled={frames.length === 0}
            className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-orange-300/50 hover:bg-orange-400/10 disabled:opacity-35"
            title="打开素材准备 / 审核面板"
          >
            <div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
              <ImageIcon className="h-3 w-3 text-orange-300" />
              {frames.length}
            </div>
            <div>关键帧素材</div>
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
            disabled={!job || frames.length === 0}
            className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-violet-300/50 hover:bg-violet-400/10 disabled:opacity-35"
            title="用多张关键帧参考重绘统一主体的六张标准站立图"
          >
            <div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
              <Package className="h-3 w-3 text-violet-300" />
              {subjectAssetCount}
            </div>
            <div>统一主体</div>
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
            disabled={!job || frames.length === 0}
            className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-emerald-300/50 hover:bg-emerald-400/10 disabled:opacity-35"
            title="基于主体资产生成去主体 / 相似 / 换风格场景图"
          >
            <div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
              <Sparkles className="h-3 w-3 text-emerald-300" />
              {sceneAssetCount}/{targetFrameCount}
            </div>
            <div>场景图</div>
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.(frames.find((f) => d.selectedFrames.has(f.index))?.index ?? frames[0]?.index) }}
            disabled={!job || frames.length === 0}
            className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-pink-300/50 hover:bg-pink-400/10 disabled:opacity-35"
            title="进入分镜编排和视频生成"
          >
            <div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
              <Film className="h-3 w-3 text-pink-300" />
              {videos.length}
            </div>
            <div>分镜 / 视频</div>
          </button>
        </div>
        <div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
          {frames.length > 0 ? (
            <>
              {cleanedCount} 已清洗 · 统一主体 {subjectAssetCount} 张 · {sceneAssetCount} 场景图 · {targetFrameCount} 素材帧 · {completedVideos.length} 已完成
            </>
          ) : (
            "解析后这里变成素材准备看板：先审关键帧，再生成主体资产包和去主体场景图。"
          )}
        </div>
      </NodeShell>
    </div>
  )
}

export function KeyframeNode({ data, selected }: any) {
  const d: NodeData = data
  const st = keyframeStatus(d.job)
  const frames = d.job?.frames ?? []
  const jobId = d.job?.id
  const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16"
  const [hoverPreviewFrame, setHoverPreviewFrame] = useState<PreviewAnchor<number> | null>(null)
  const [pinnedPreviewFrame, setPinnedPreviewFrame] = useState<PreviewAnchor<number> | null>(null)
  const rootRef = useRef<HTMLDivElement>(null)

  // 点击 keyframe 节点外的任何位置 → 取消 pin（capture 阶段，避免 ReactFlow pane 拦截）
  useEffect(() => {
    if (pinnedPreviewFrame === null) return
    const handler = (e: MouseEvent) => {
      const t = e.target as HTMLElement
      if (t.closest('.react-flow__node[data-id="keyframe"]')) return
      setPinnedPreviewFrame(null)
    }
    document.addEventListener("mousedown", handler, true)
    return () => document.removeEventListener("mousedown", handler, true)
  }, [pinnedPreviewFrame])

  return (
    <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
      {/* 缩略图浮条 — 单行横滚 + 固定高度，跟节点宽度对齐；超出横滚（视觉类节点统一规则） */}
      {frames.length > 0 && jobId && (
        <FloatingThumbnailStrip label="关键帧缩略图横向滑动条">
          {frames.map((f) => {
            const isSel = d.selectedFrames.has(f.index)
            return (
              <div
                key={f.index}
                className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
                  isSel
                    ? "border-emerald-400 ring-2 ring-emerald-400/60"
                    : "border-white/30 dark:border-white/20"
                }`}
                style={{
                  height: THUMBNAIL_HEIGHT,
                  aspectRatio: d.job && d.job.height > 0
                    ? `${d.job.width}/${d.job.height}`
                    : "16/9",
                }}
                onMouseEnter={(e) => setHoverPreviewFrame({ id: f.index, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
                onMouseLeave={() => setHoverPreviewFrame(null)}
              >
              <button
                onClick={(e) => {
                  e.stopPropagation()
                  const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
                  setPinnedPreviewFrame((prev) => (prev?.id === f.index ? null : { id: f.index, ...anchor }))
                  ;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
                }}
                title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 单击钉住大预览 / 打开详情面板`}
                className="absolute inset-0 w-full h-full"
              >
                <img
                  src={effectiveFrameUrl(jobId, f)}
                  alt={`frame ${f.index}`}
                  className="absolute inset-0 w-full h-full object-cover rounded-md"
                />
                {isSel && (
                  <div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
                )}
                {(f.cleaned_url || (f.elements?.some((e) => hasCutout(e)))) && (
                  <div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
                    {f.cleaned_url && (
                      <span title="已清洗" className="bg-cyan-500/85 text-white text-[8px] font-bold px-1 py-0.5 rounded-sm">✨</span>
                    )}
                    {(() => {
                      const cutN = f.elements?.filter((e) => hasCutout(e)).length ?? 0
                      return cutN > 0 ? (
                        <span title={`${cutN} 个元素已抠图`} className="bg-violet-500/85 text-white text-[8px] font-mono font-bold px-1 py-0.5 rounded-sm">
                          {cutN}
                        </span>
                      ) : null
                    })()}
                  </div>
                )}
                <div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
                  {f.timestamp.toFixed(1)}s
                </div>
              </button>
              {/* 复制按钮：常驻可见 — 复制该关键帧到剪贴板 */}
              {d.onCopyImage && (
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    d.onCopyImage?.({
                      kind: "keyframe",
                      frame_idx: f.index,
                      label: `分镜 ${f.index + 1} 关键帧`,
                    })
                  }}
                  title="复制此图（到分镜头编排工作台插槽粘贴）"
                  className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
                >
                  <Copy className="h-3.5 w-3.5" />
                </button>
              )}
              {/* 删除按钮：常驻可见 */}
              {d.onDeleteFrame && (
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    d.onDeleteFrame?.(f.index)
                  }}
                  title="删除该关键帧"
                  className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
                >
                  <Trash2 className="h-3.5 w-3.5" />
                </button>
              )}
              </div>
            )
          })}
        </FloatingThumbnailStrip>
      )}

      {(() => {
        const anchor = pinnedPreviewFrame ?? hoverPreviewFrame
        if (!anchor || !jobId) return null
        const frame = frames.find((f) => f.index === anchor.id)
        if (!frame) return null
        return (
          <HoverPreview
            imgSrc={effectiveFrameUrl(jobId, frame)}
            aspect={aspectStr}
            label={`分镜 ${frame.index + 1}`}
            caption={`${frame.timestamp.toFixed(2)}s`}
            borderClass="border-orange-300/50"
            visible={!!hoverPreviewFrame && !pinnedPreviewFrame}
            anchorX={anchor.x}
            anchorY={anchor.y}
            pinned={!!pinnedPreviewFrame}
            onClose={() => setPinnedPreviewFrame(null)}
          />
        )
      })()}

      <NodeShell
        type="process" status={st}
      icon={<ImageIcon className="h-4 w-4" />}
        title="镜头拆解 · 素材准备"
        subtitle={`STEP 2 · ${frames.length ? `${frames.length} 素材帧` : "等待抽取"}`}
        selected={selected}
        pinned={d.pinnedNodes?.has("keyframe")}
        onTogglePin={() => d.onToggleNodePin?.("keyframe")}
      >
        {frames.length > 0 ? (() => {
          const cleanedCount = frames.filter((x) => x.cleaned_url).length
          const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0)
          return (
            <div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
              自动 <span className="text-[var(--text-strong)] font-medium">{frames.length}</span> 张
              {" · "}
              <span className={cleanedCount > 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗</span>
              {" · "}
              <span className={elementsCount > 0 ? "text-violet-300/90 font-medium" : ""}>{elementsCount} 主体候选</span>
              <br />
              <span className="text-[10.5px] text-[var(--text-faint)]">
                点缩略图 → 清洗水印 / 准备主体资产和去主体场景图 → 改造成 SKG 画面素材
              </span>
            </div>
          )
        })() : (
          <div className="text-[11.5px] text-[var(--text-faint)] py-1">
            等待解析（默认 5 张）
          </div>
        )}
      </NodeShell>

    </div>
  )
}

/* ============================================================
   4b. KeyframePanelNode — 画布内可移动详情面板
   ============================================================ */
export function KeyframePanelNode({ data }: any) {
  const d: NodeData = data
  const { getZoom } = useReactFlow()
  const panelRef = useRef<HTMLDivElement>(null)
  const scale = d.framePanelScale ?? 1
  const dock = d.framePanelDock ?? (d.framePanelPinned ? "left" : "canvas")
  const docked = dock !== "canvas"

  if (!d.job || d.expandedFrame === null) return null
  const active = d.job.frames.find((f) => f.index === d.expandedFrame)
  const arrayPos = active ? d.job.frames.findIndex((f) => f.index === active.index) : -1
  const prevFrame = arrayPos > 0 ? d.job.frames[arrayPos - 1] : null
  const nextFrame = arrayPos >= 0 && arrayPos < d.job.frames.length - 1 ? d.job.frames[arrayPos + 1] : null
  const panelWidth = Math.round(760 * scale)
  const panelHeight = Math.round(746 * scale)
  const bodyHeight = Math.max(520, panelHeight - 27)
  const setScale = (next: number) => {
    const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
    d.onFramePanelScaleChange?.(clamped)
  }
  const dockText: Record<CanvasPanelDock, string> = {
    canvas: "画布模式",
    left: "吸附左侧",
    right: "吸附右侧",
    bottom: "吸附底部",
  }
  const dockButtonClass = (value: CanvasPanelDock) =>
    `nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
      dock === value
        ? "bg-white text-violet-700 shadow"
        : "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
    }`

  const startResize = (e: React.PointerEvent) => {
    e.preventDefault()
    e.stopPropagation()
    const startX = e.clientX
    const startY = e.clientY
    const startScale = scale
    const zoom = docked ? 1 : getZoom()
    const onMove = (ev: PointerEvent) => {
      const dx = (ev.clientX - startX) / zoom
      const dy = (ev.clientY - startY) / zoom
      const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 746
      setScale(startScale + delta)
    }
    const onUp = () => {
      window.removeEventListener("pointermove", onMove)
      window.removeEventListener("pointerup", onUp)
    }
    window.addEventListener("pointermove", onMove)
    window.addEventListener("pointerup", onUp)
  }

  const panel = (
    <div
      ref={panelRef}
      className="relative overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
      style={{
        width: panelWidth,
        height: panelHeight,
        maxWidth: "calc(100vw - 32px)",
        maxHeight: `calc(100vh - ${FLOATING_PANEL_EDGE_INSET * 2}px)`,
        boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)",
      }}
    >
      <div
        className={`keyframe-panel-drag flex h-7 items-center justify-between px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}
        style={{ background: "var(--grad-ai)" }}
      >
        <div className="flex min-w-0 items-center gap-2">
          <ImageIcon className="h-3.5 w-3.5 shrink-0" />
          <span className="truncate text-[12px] font-semibold">关键帧素材准备</span>
          <span className="shrink-0 text-[10px] font-mono text-white/65">
            {active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
          </span>
          <div className="ml-1 flex items-center gap-1">
            <button
              type="button"
              disabled={!prevFrame}
              onClick={(e) => {
                e.stopPropagation()
                if (prevFrame) d.onExpandFrame(prevFrame.index)
              }}
              className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 inline-flex items-center justify-center"
              title="上一张关键帧"
            >
              <ChevronLeft className="h-3.5 w-3.5" />
            </button>
            <button
              type="button"
              disabled={!nextFrame}
              onClick={(e) => {
                e.stopPropagation()
                if (nextFrame) d.onExpandFrame(nextFrame.index)
              }}
              className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 inline-flex items-center justify-center"
              title="下一张关键帧"
            >
              <ChevronRight className="h-3.5 w-3.5" />
            </button>
            <span className="shrink-0 text-[10px] font-mono text-white/50">
              {arrayPos >= 0 ? `${String(arrayPos + 1).padStart(2, "0")} / ${String(d.job.frames.length).padStart(2, "0")}` : ""}
            </span>
          </div>
        </div>
        <div className="flex items-center gap-1.5">
          <span className="mr-1 text-[10px] text-white/60">
            {dockText[dock]}
          </span>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
            <Move className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
            <PanelLeft className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
            <PanelRight className="h-3.5 w-3.5" />
          </button>
          <button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
            <PanelBottom className="h-3.5 w-3.5" />
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
            className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none"
            title="缩小面板"
          >
            -
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); setScale(1) }}
            className="nodrag h-6 min-w-10 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
            title="重置为 100%"
          >
            {Math.round(scale * 100)}%
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }}
            className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none"
            title="放大面板"
          >
            +
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
            className="nodrag h-6 w-6 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
            title="关闭"
          >
            <X className="h-3.5 w-3.5" />
          </button>
        </div>
      </div>
      <div className="nodrag nowheel" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
        <FrameLightbox
          embedded
          jobId={d.job.id}
          frames={d.job.frames}
          generatedVideos={d.job.generated_videos ?? []}
          activeIndex={d.expandedFrame}
          selected={d.selectedFrames}
          onClose={d.onCloseExpandedFrame}
          onChange={d.onExpandFrame}
          onToggleSelect={d.onToggleFrame}
          onJobUpdate={d.onJobUpdate}
          clipboard={d.clipboard}
          onCopyImage={d.onCopyImage}
          onGenerateProductFusionVideo={d.onGenerateProductFusionVideo}
          onDeleteVideo={d.onDeleteVideo}
        />
      </div>
      <button
        type="button"
        onPointerDown={startResize}
        className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-pink-400/35 hover:text-white inline-flex items-center justify-center"
        title="拖动右下角缩放面板"
      >
        <Maximize2 className="h-3.5 w-3.5" />
      </button>
    </div>
  )

  if (docked && typeof document !== "undefined") {
    const fixedStyle =
      dock === "left"
        ? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
        : dock === "right"
          ? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
          : { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" }
    return createPortal(
      <div
        className="fixed z-[240]"
        style={fixedStyle}
      >
        {panel}
      </div>,
      document.body,
    )
  }

  return panel
}

/* ============================================================
   5. ASRNode — 音频转写
   ============================================================ */
export function ASRNode({ data, selected }: any) {
  const d: NodeData = data
  return (
    <NodeShell
      type="ai" status={asrStatus(d.job)}
      icon={<Mic className="h-4 w-4" />}
      title="声音文案 · ASR"
      subtitle="STEP 3 · 可选文案轨"
      selected={selected}
      pinned={d.pinnedNodes?.has("asr")}
      onTogglePin={() => d.onToggleNodePin?.("asr")}
    >
      <div className="text-[11.5px] text-[var(--text-soft)]">
        OpenAI-compatible ASR · 英文带时间戳分段
      </div>
      {d.job && d.job.transcript.length > 0 && (
        <div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
          {d.job.transcript.slice(0, 3).map((s) => (
            <div key={s.index} className="leading-snug">
              <span className="text-[var(--text-faint)] font-mono text-[10px] mr-1">
                {s.start.toFixed(1)}s
              </span>
              {s.en.slice(0, 60)}
              {s.en.length > 60 && "…"}
            </div>
          ))}
          {d.job.transcript.length > 3 && (
            <div className="text-[var(--text-faint)] text-[10px]">还有 {d.job.transcript.length - 3} 段…</div>
          )}
        </div>
      )}
    </NodeShell>
  )
}

/* ============================================================
   6. TranslateNode
   ============================================================ */
export function TranslateNode({ data, selected }: any) {
  const d: NodeData = data
  const hasZh = d.job?.transcript.some((s) => s.zh) ?? false
  const st: NodeStatus = !d.job ? "pending" :
    d.job.status === "transcribing" ? "running" :
    hasZh ? "done" :
    d.job.status === "failed" ? "failed" : "pending"
  return (
    <NodeShell
      type="ai" status={st}
      icon={<Languages className="h-4 w-4" />}
      title="翻译理解 · Translate"
      subtitle="STEP 4 · EN → ZH"
      selected={selected}
      pinned={d.pinnedNodes?.has("translate")}
      onTogglePin={() => d.onToggleNodePin?.("translate")}
    >
      <div className="text-[11.5px] text-[var(--text-soft)]">
        中文翻译 · 段落级 · 实时输出
      </div>
      {hasZh && d.job && (
        <div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
          {d.job.transcript.slice(0, 3).map((s) => (
            <div key={s.index} className="leading-snug">{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}</div>
          ))}
        </div>
      )}
    </NodeShell>
  )
}

/* ============================================================
   7. RewriteNode
   ============================================================ */
export function RewriteNode({ data, selected }: any) {
  const d: NodeData = data
  const rewrittenText = d.job?.audio_script?.rewritten_text?.trim() ?? ""
  return (
    <NodeShell
      type="ai" status={rewrittenText ? "done" : d.job?.audio_script?.status === "rewriting" ? "running" : "pending"}
      icon={<FileEdit className="h-4 w-4" />}
      title="产品文案 · Rewrite"
      subtitle="STEP 5 · 接 SKG 卖点"
      selected={selected}
      pinned={d.pinnedNodes?.has("rewrite")}
      onTogglePin={() => d.onToggleNodePin?.("rewrite")}
    >
      {rewrittenText ? (
        <div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2 text-[11.5px] leading-relaxed text-[var(--text-strong)]">
          {rewrittenText}
        </div>
      ) : (
        <div className="text-[11px] text-[var(--text-soft)]">转录完成后自动接 SKG 卖点改写。</div>
      )}
      <div className="mt-1.5 text-[10px] text-[var(--text-faint)]">{d.job?.audio_script?.rewrite_model || "AUDIO_REWRITE_MODEL"}</div>
    </NodeShell>
  )
}

/* ============================================================
   5b. AudioNode — 合并 ASR + 翻译 + 改写 + Azure OpenAI 配音
   ============================================================ */
export function AudioNode({ data, selected }: any) {
  const d: NodeData = data
  const job = d.job
  const transcript = job?.transcript ?? []
  const audioScript = job?.audio_script
  const rewrittenText = audioScript?.rewritten_text?.trim() ?? ""
  const voiceUrl = apiAssetUrl(audioScript?.voice_url)
  const hasASR = transcript.length > 0
  const isRewriting = audioScript?.status === "rewriting"
  const hasVideo = !!job?.video_url
  const isAudioBusy = !!job && (job.status === "transcribing" || isRewriting)
  const audioButtonDisabled = !job || !hasVideo || isAudioBusy
  const audioButtonLabel = !hasVideo
    ? "等待视频就绪"
    : isAudioBusy
    ? "正在提取音频"
    : hasASR || rewrittenText
    ? "重新提取音频"
    : "提取音频"
  const originalPreview = transcript
    .slice(0, 2)
    .map((s) => (s.zh || s.en).trim())
    .filter(Boolean)
    .join(" ")
  const status: NodeStatus = !job
    ? "pending"
    : job.status === "transcribing" || isRewriting
    ? "running"
    : rewrittenText || hasASR
    ? "done"
    : "pending"
  return (
    <NodeShell
      type="ai" status={status}
      icon={<Mic className="h-4 w-4" />}
      title="音频处理 · Audio"
      subtitle={hasASR ? `STEP 3 · ${transcript.length} 段` : "STEP 3"}
      selected={selected}
      pinned={d.pinnedNodes?.has("audio")}
      onTogglePin={() => d.onToggleNodePin?.("audio")}
    >
      <div
        className="cursor-pointer space-y-2 text-[11px] text-[var(--text-soft)] leading-snug"
        onClick={() => {
          if (job?.video_url) d.onOpenAudioStrip?.(job.id)
        }}
      >
        <div>
          音轨 → 取时长/节奏 → SKG 英文产品口播 → Azure OpenAI 英文配音<br />
          <span className="text-[var(--text-faint)] font-mono">
            {audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "Azure OpenAI TTS"}
          </span>
        </div>
        {job && (
          <button
            type="button"
            disabled={audioButtonDisabled}
            onClick={(e) => {
              e.stopPropagation()
              d.onOpenAudioStrip?.(job.id)
              if (audioButtonDisabled) return
              void d.onTranscribeAudio?.(job.id)
            }}
            className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18 disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/[0.03] disabled:text-[var(--text-faint)]"
          >
            {isAudioBusy ? (
              <Loader2 className="h-3.5 w-3.5 animate-spin" />
            ) : hasASR || rewrittenText ? (
              <RotateCcw className="h-3.5 w-3.5" />
            ) : (
              <PlayCircle className="h-3.5 w-3.5" />
            )}
            {audioButtonLabel}
          </button>
        )}
        {(originalPreview || rewrittenText) && (
          <div className="grid gap-2">
            {originalPreview && (
              <div className="rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-2">
                <div className="mb-1 text-[9.5px] uppercase tracking-widest text-[var(--text-faint)]">改前 · 原音频</div>
                <div className="line-clamp-3 text-[11px] leading-relaxed text-[var(--text-soft)] break-words">{originalPreview}</div>
              </div>
            )}
            {rewrittenText && (
              <div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2">
                <div className="mb-1 text-[9.5px] uppercase tracking-widest text-emerald-200/80">改后 · SKG Product VO</div>
                <div className="line-clamp-4 text-[11.5px] leading-relaxed text-[var(--text-strong)] break-words">{rewrittenText}</div>
              </div>
            )}
          </div>
        )}
        {voiceUrl && <div className="text-[10.5px] text-emerald-200/85">Azure OpenAI English voice ready · 底部音频条播放</div>}
        {isRewriting && (
          <div className="text-[10.5px] text-[var(--text-faint)]">正在按原音频时长生成英文产品口播和配音…</div>
        )}
        {audioScript?.error && rewrittenText && !voiceUrl && (
          <div className="text-[10.5px] text-amber-300/85">配音待生成：{audioScript.error}</div>
        )}
      </div>
    </NodeShell>
  )
}

/* ============================================================
   6. StoryboardNode — 元素改造 + 分镜编排入口
   ============================================================ */
const IMAGEGEN_WIDTH = 360

export function StoryboardNode({ data, selected }: any) {
  const d: NodeData = data
  const job = d?.job
  const [hoverPreviewCutout, setHoverPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
  const [pinnedPreviewCutout, setPinnedPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
  const rootRef = useRef<HTMLDivElement>(null)

  // 点击 storyboard 节点外 → 取消 pin
  useEffect(() => {
    if (pinnedPreviewCutout === null) return
    const handler = (e: MouseEvent) => {
      const t = e.target as HTMLElement
      if (t.closest('.react-flow__node[data-id="storyboard"]')) return
      setPinnedPreviewCutout(null)
    }
    document.addEventListener("mousedown", handler, true)
    return () => document.removeEventListener("mousedown", handler, true)
  }, [pinnedPreviewCutout])

  // 上方浮条 = 所有 frame 的 elements 已提取图（"分镜头编排"的输入素材）
  type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
  const elementCrops: ElPreview[] = job
    ? job.frames.flatMap((f) =>
        (f.elements ?? [])
          .filter((e) => hasCutout(e))
          .map((e) => {
            const src = representativeCutoutUrl(job.id, f.index, e) || ""
            const cid = (e.cutouts && e.cutouts.length > 0)
              ? e.cutouts[e.cutouts.length - 1]
              : (e.cutout_id ?? "")
            return {
              frameIdx: f.index,
              elementId: e.id,
              name: e.name_zh,
              src,
              cid,
              timestamp: f.timestamp,
            }
          })
          .filter((p) => p.src),
      )
    : []

  const totalElements = elementCrops.length
  const storyboardCount = job?.frames.filter((f) => d.selectedFrames.has(f.index)).length ?? 0
  const status: NodeStatus = !job ? "pending" : storyboardCount > 0 || totalElements > 0 ? "done" : "pending"
  const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"

  return (
    <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
      {/* 节点上方：所有元素 crop 图（编排输入素材）— 视觉类节点统一样板：单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */}
      {elementCrops.length > 0 && job && (
        <FloatingThumbnailStrip label="元素缩略图横向滑动条">
          {elementCrops.map((p) => {
            const key = `${p.frameIdx}_${p.elementId}`
            return (
              <div
                key={key}
                className="group relative shrink-0 rounded-md border border-violet-300/50 overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-white"
                style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
                onMouseEnter={(e) => setHoverPreviewCutout({ id: key, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
                onMouseLeave={() => setHoverPreviewCutout(null)}
              >
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
                    setPinnedPreviewCutout((prev) => (prev?.id === key ? null : { id: key, ...anchor }))
                    if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
                    d.onOpenStoryboard?.(p.frameIdx)
                    d.onOpenWorkbench?.(p.frameIdx)
                  }}
                  title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 单击钉住大预览 / 进入分镜编排`}
                  className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-white"
                >
                  <img
                    src={p.src}
                    alt={p.name}
                    className="absolute inset-0 w-full h-full object-contain"
                  />
                </button>
                {/* 左上：复制 */}
                {d.onCopyImage && (
                  <button
                    onClick={(e) => {
                      e.stopPropagation()
                      d.onCopyImage?.({
                        kind: "cutout",
                        frame_idx: p.frameIdx,
                        element_id: p.elementId,
                        cutout_id: p.cid,
                        label: p.name,
                      })
                    }}
                    title="复制此图（到分镜头编排工作台插槽粘贴）"
                    className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
                  >
                    <Copy className="h-3.5 w-3.5" />
                  </button>
                )}
                {/* 右上：删除 */}
                {d.onDeleteCutout && (
                  <button
                    onClick={(e) => {
                      e.stopPropagation()
                      d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cid)
                    }}
                    title="删除该提取图"
                    className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
                  >
                    <Trash2 className="h-3.5 w-3.5" />
                  </button>
                )}
              </div>
            )
          })}
        </FloatingThumbnailStrip>
      )}

      {(() => {
        const anchor = pinnedPreviewCutout ?? hoverPreviewCutout
        if (!anchor) return null
        const crop = elementCrops.find((p) => `${p.frameIdx}_${p.elementId}` === anchor.id)
        if (!crop) return null
        return (
          <HoverPreview
            imgSrc={crop.src}
            aspect={aspect}
            label={`分镜 ${crop.frameIdx + 1} · ${crop.name}`}
            caption={`${crop.timestamp.toFixed(2)}s`}
            borderClass="border-violet-300/60"
            visible={!!hoverPreviewCutout && !pinnedPreviewCutout}
            anchorX={anchor.x}
            anchorY={anchor.y}
            pinned={!!pinnedPreviewCutout}
            onClose={() => setPinnedPreviewCutout(null)}
          />
        )
      })()}

      <NodeShell
        type="ai" status={status}
        icon={<LayoutGrid className="h-4 w-4" />}
        title="元素改造 · Storyboard"
        subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
        selected={selected}
        pinned={d.pinnedNodes?.has("storyboard")}
        onTogglePin={() => d.onToggleNodePin?.("storyboard")}
      >
        <div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
          不是复刻原视频：先把参考图里的主体 / 场景 / 动作 / 道具拆出来，再替换成 SKG 产品画面。
          <br />
          <span className="text-[10.5px] text-[var(--text-faint)]">
            已有 {totalElements} 个提取元素 · {storyboardCount} 个分镜进入编排
          </span>
        </div>
        <button
          type="button"
          onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.() }}
          disabled={!job || storyboardCount === 0}
          className="mt-2 w-full rounded-md bg-gradient-to-r from-violet-500 to-pink-500 px-3 py-2 text-[12px] font-semibold text-white shadow-lg shadow-violet-500/25 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-35"
          title={storyboardCount === 0 ? "先准备关键帧素材" : "进入 4 图槽分镜编排"}
        >
          进入分镜编排
        </button>
      </NodeShell>

    </div>
  )
}

/* ============================================================
   9. VideoGenNode (placeholder)
   ============================================================ */
export function VideoGenNode({ data, selected }: any) {
  const d: NodeData = data
  const videos = d.job?.generated_videos ?? []
  const rootRef = useRef<HTMLDivElement>(null)
  const [hoverPreviewVideo, setHoverPreviewVideo] = useState<PreviewAnchor<string> | null>(null)
  const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null)
  const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
  const completed = videos.filter((v) => v.status === "completed" && v.url)
  const failed = videos.some((v) => v.status === "failed")
  const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
  const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
    ? `${d.job.width}/${d.job.height}`
    : "9/16"
  const modelLabel = (model: string) => {
    const m = model.toLowerCase()
    if (m.includes("kling")) return "Kling"
    if (m.includes("veo")) return "Veo 3"
    if (m.includes("seedance")) return "Seedance"
    return model || "Video"
  }
  return (
    <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
      {videos.length > 0 && (
        <FloatingThumbnailStrip label="生成视频缩略图横向滑动条">
          {videos.map((v, i) => {
            const videoSrc = apiAssetUrl(v.url)
            const posterSrc = apiAssetUrl(v.poster_url)
            const ready = v.status === "completed" && !!videoSrc
            const progress = Math.max(0, Math.min(100, v.progress || 0))
            return (
            <div
              key={v.id}
              className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-black ${
                ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
              }`}
              style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
              onMouseEnter={(e) => setHoverPreviewVideo({ id: v.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
              onMouseLeave={() => setHoverPreviewVideo(null)}
            >
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  void navigator.clipboard?.writeText(v.prompt).catch(() => {})
                  toast.success("已复制视频 prompt")
                }}
                title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
                className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
              >
                {ready ? (
                  <video
                    src={videoSrc}
                    poster={posterSrc}
                    muted
                    loop
                    playsInline
                    preload="metadata"
                    className="absolute inset-0 h-full w-full object-cover"
                  />
                ) : posterSrc ? (
                  <img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
                ) : (
                  <div className="absolute inset-0 bg-violet-950/50" />
                )}
                {!ready && (
                  <div className="absolute inset-0 flex items-center justify-center bg-black/35">
                    {v.status === "failed" ? (
                      <X className="h-4 w-4 text-rose-200" />
                    ) : (
                      <Loader2 className="h-4 w-4 animate-spin text-white/85" />
                    )}
                  </div>
                )}
                <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
                  <div className="truncate text-[9.5px] font-semibold text-white">视频 {i + 1}</div>
                  <div className="truncate text-[8.5px] font-mono text-white/60">
                    {ready ? `${v.duration.toFixed(0)}s` : v.status === "failed" ? "failed" : `${progress}%`}
                  </div>
                </div>
              </button>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  void navigator.clipboard?.writeText(v.prompt).catch(() => {})
                  toast.success("已复制视频 prompt")
                }}
                className="absolute left-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
                title="复制视频 prompt"
              >
                <Copy className="h-3.5 w-3.5" />
              </button>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  setDeleteVideoTarget({
                    id: v.id,
                    label: `视频 ${i + 1}`,
                    caption: `分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · ${v.status}`,
                  })
                }}
                className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
                title="删除这个视频任务"
              >
                <Trash2 className="h-3.5 w-3.5" />
              </button>
            </div>
          )})}
        </FloatingThumbnailStrip>
      )}
      {deleteVideoTarget && (
        <DeleteConfirmDialog
          title="删除这个视频任务？"
          description={`会删除${deleteVideoTarget.label}的生成任务和本地视频文件。${deleteVideoTarget.caption}`}
          confirmLabel="删除视频任务"
          onCancel={() => setDeleteVideoTarget(null)}
          onConfirm={() => {
            const id = deleteVideoTarget.id
            setDeleteVideoTarget(null)
            d.onDeleteVideo?.(id)
          }}
        />
      )}
      {(() => {
        if (!hoverPreviewVideo) return null
        const item = videos.find((v) => v.id === hoverPreviewVideo.id)
        if (!item) return null
        const videoSrc = apiAssetUrl(item.url)
        const posterSrc = apiAssetUrl(item.poster_url)
        const ready = item.status === "completed" && !!videoSrc
        if (!ready && !posterSrc) return null
        return (
          <HoverPreview
            videoSrc={ready ? videoSrc : undefined}
            imgSrc={!ready ? posterSrc : undefined}
            poster={posterSrc}
            aspect={aspect}
            label={`分镜 ${item.frame_idx + 1}`}
            caption={`${modelLabel(item.model)} · ${item.status}`}
            borderClass={ready ? "border-emerald-300/60" : "border-rose-300/60"}
            visible
            anchorX={hoverPreviewVideo.x}
            anchorY={hoverPreviewVideo.y}
          />
        )
      })()}
      <NodeShell
        type="ai" status={status}
        icon={<Film className="h-4 w-4" />}
        title="生成视频 · Video Gen"
        subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
        selected={selected}
        pinned={d.pinnedNodes?.has("videogen")}
        onTogglePin={() => d.onToggleNodePin?.("videogen")}
      >
        <div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
          {["Seedance", "Kling", "Veo 3"].map((m) => (
            <div key={m} className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-center text-[var(--text-faint)]">
              <span className="text-[var(--text-strong)] text-[11px]">{m}</span>
            </div>
          ))}
        </div>
        {videos.length > 0 && (
          <div className="mt-2 rounded-md border border-rose-300/25 bg-rose-500/10 px-2 py-1.5 text-[10.5px] text-[var(--text-soft)]">
            已提交 {videos.length} 个视频任务 · 完成 {completed.length} 个{running ? " · 生成中" : ""}
          </div>
        )}
      </NodeShell>
    </div>
  )
}

/* ============================================================
   10. ComposeNode (placeholder)
   ============================================================ */
export function ComposeNode({ data, selected }: any) {
  const d: NodeData = data
  return (
    <NodeShell
      type="output" status="pending"
      icon={<FileVideo className="h-4 w-4" />}
      title="合成成品 · Compose"
      subtitle="STEP 8 · ffmpeg + 字幕"
      selected={selected}
      hasSource={false}
      pinned={d.pinnedNodes?.has("compose")}
      onTogglePin={() => d.onToggleNodePin?.("compose")}
    >
      <div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
        视频片段 + 字幕 / TTS<br />→ 最终 mp4 输出
      </div>
    </NodeShell>
  )
}
