import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import { Play } from "lucide-react";
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { MediaLightbox } from "../components/overlays";
import { IconKitSvg } from "../components/IconKitSvg";

// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// 基础资产组 kind → 中文区块名(对齐 design.md 商品/人物/场景三类)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "人物", scene: "场景" };
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
  if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
  if (["failed", "error"].includes(status)) return "失败";
  if (["running", "queued", "polling"].includes(status)) return "生成中";
  if (status === "needs_review") return "待确认";
  return "待生成";
}
// 毫秒 → mm:ss(时间轴 / 字幕展示)
function fmtMs(ms: number): string {
  const total = Math.max(0, Math.round(ms / 1000));
  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}

// 时间轴 clip 真实定位:start_ms / duration_ms 相对轨道总长(timeline.duration_seconds)算百分比
function clipLayout(startMs: number, durationMs: number, totalMs: number): { leftPct: number; widthPct: number } {
  const total = totalMs > 0 ? totalMs : 1;
  return { leftPct: Math.max(0, (startMs / total) * 100), widthPct: Math.max(0, Math.min(100, (durationMs / total) * 100)) };
}
// 标尺刻度:按轨道总秒数生成每秒一刻,偶数秒为主刻度带秒标
function buildRuler(totalSec: number): Array<{ leftPct: number; major: boolean; t?: string }> {
  const secs = Math.max(1, Math.round(totalSec));
  const ticks: Array<{ leftPct: number; major: boolean; t?: string }> = [];
  for (let s = 0; s <= secs; s += 1) {
    const major = s % 2 === 0;
    ticks.push({ leftPct: (s / secs) * 100, major, t: major ? `${s}s` : undefined });
  }
  return ticks;
}

// 装饰用伪波形(BGM 轨铺底,纯视觉,无真实波形数据时使用)
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
const STAGE_STEPS: Array<{ n: number; label: string }> = [
  { n: 1, label: "脚本" },
  { n: 2, label: "基础资产" },
  { n: 3, label: "故事板" },
  { n: 4, label: "视频" },
  { n: 5, label: "拼接导出" }
];

export function PipelinePage(props: {
  project: Project;
  loading: boolean;
  navigate: (page: Page, options?: { projectId?: string; productId?: string }) => void;
  user: User;
  team: Team;
  products: Product[];
  projects: Project[];
  assets: Asset[];
  billing: BillingSummary | null;
  notice: Notice | null;
  unreadCount: number;
  avatarChar: string;
  logout: () => void;
  onRefresh: () => void;
  onGenerateScript: (prompt: string) => void;
  onAdoptScript: (scriptId: string) => void | Promise<unknown>;
  onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
  onGenerateStoryboard: (prompt: string) => void;
  onSkipStoryboard: () => void;
  onSubmitVideo: (segmentId: string, prompt: string) => void;
  onPollVideo: (segmentId: string) => void;
  onSubmitAllVideos: (prompt: string) => void;
  onPollVideosQuiet: () => void | Promise<void>;
  onPollAllVideos: () => void;
  exportResult: ExportPoll | null;
  onRefreshExport: () => void;
  onUploadVideoSegment: (segmentId: string, file: File) => void;
  onUploadBgm: (file: File, volume: number) => void;
  onSaveTimeline: (payload: TimelineSavePayload) => void;
  onSubmitExport: (payload?: TimelineSavePayload) => void;
}) {
  const {
    project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
    onGenerateScript, onAdoptScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
    onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport,
    onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
  } = props;

  // ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
  const byId = new Map(assets.map((a) => [a.id, a] as const));
  const assetUrl = (id: string | null | undefined): string => {
    if (!id) return "";
    const a = byId.get(id);
    return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || "";
  };
  const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : "");
  // 缩略图解析:优先用后端内嵌的 preview_url(不受团队 assets 分页 20 条影响),回退到 assets 列表解析
  type GroupLike = { adopted_asset: string | null; adopted_asset_url?: string; candidate_assets?: string[]; candidate_asset_urls?: Record<string, string> };
  const groupMainUrl = (g?: GroupLike | null): string =>
    g?.adopted_asset_url || assetUrl(g?.adopted_asset) || (g?.candidate_assets?.[0] ? (g?.candidate_asset_urls?.[g.candidate_assets[0]] || assetUrl(g.candidate_assets[0])) : "");
  const candUrl = (g: GroupLike | null | undefined, id: string): string => g?.candidate_asset_urls?.[id] || assetUrl(id);
  const frameUrl = (f?: { asset: string; asset_url?: string } | null): string => f?.asset_url || assetUrl(f?.asset);
  const segUrl = (s?: { adopted_asset?: string | null; adopted_asset_url?: string } | null): string => s?.adopted_asset_url || assetUrl(s?.adopted_asset);

  // ── Stage 1:脚本(采用版优先,否则最近一版)+ 镜头列表 ──
  const scripts = [...(project.script_versions ?? [])];
  const currentScript =
    scripts.find((s) => s.is_adopted) ||
    [...scripts].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0] ||
    null;
  const scriptAdopted = Boolean(currentScript?.is_adopted);
  const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);

  // ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
  const groups = project.base_asset_groups ?? [];
  const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
  const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
  const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
  function jumpAssetSection(kind: "product" | "person" | "scene") {
    setAssetTab(kind);
    if (typeof document !== "undefined") {
      document.getElementById(`asset-sec-${kind}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
    }
  }

  // ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ──
  const storyboards = project.storyboard_versions ?? [];
  const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
  const sbFrames = [...(adoptedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
  const [sbSelected, setSbSelected] = useState(0);
  const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null;

  // ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
  const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
  const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
  const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);

  // ── Stage 5:时间轴 / 字幕 / BGM(真实定位,轨道总长用 timeline.duration_seconds)──
  const timeline = project.timeline;
  const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
  const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
  const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000;
  const ruler = buildRuler(tlRulerMs / 1000);
  const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
  const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms);
  const bgmTracks = timeline?.bgm_tracks ?? [];

  // 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
  // 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
  const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
  const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
  const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
  const [navigated, setNavigated] = useState(Boolean(initHash));
  const activeDot = navigated ? viewStage : projectStage;
  const completed = Math.max(projectStage - 1, activeDot - 1);
  const [chatText, setChatText] = useState("");
  // 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
  const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
  const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
  const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
  const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
  const chatFileRef = useRef<HTMLInputElement | null>(null);
  function clearChat() {
    setChatText("");
    setChatAttachments([]);
    setChatMode("ai");
  }
  function pickScriptMode() {
    setChatMode("manual");
    chatFileRef.current?.click();
  }
  function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      const text = String(reader.result || "").trim();
      setChatText((prev) => (prev ? `${prev}\n${text}` : text));
      setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
      setChatMode("manual");
      chatTextareaRef.current?.focus();
    };
    reader.readAsText(file);
  }
  function focusThemeMode() {
    setChatMode("theme");
    chatTextareaRef.current?.focus();
  }
  const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
  const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
  const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));

  // ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
  const isVideoAsset = (id: string | null | undefined): boolean => {
    const a = id ? byId.get(id) : null;
    if (!a) return false;
    if (a.asset_type === "video") return true;
    const f = a.files?.find((x) => x.is_primary) || a.files?.[0];
    return !!f && /video\//.test(f.content_type || "");
  };
  // ── Stage 5 · 编辑器状态(可改片段/字幕文本/转场/BGM音量,本地编辑,保存草稿/导出时落盘)──
  type EdClipState = { key: string; asset: string; url: string; isVideo: boolean; durMs: number; trimStartMs: number; trimEndMs: number | null; subtitle: string };
  type EditorState = { clips: EdClipState[]; subtitleEnabled: boolean; subtitleStyle: string; transition: string; bgmVolume: number };
  const buildInitialEditor = useCallback((): EditorState => {
    const tl = project.timeline;
    const sub = (tl?.subtitle_tracks ?? [])[0];
    const savedTexts: string[] = (sub?.content ?? []).map((c) => String(c?.text || ""));
    const scriptScript = (project.script_versions ?? []).find((s) => s.is_adopted) || (project.script_versions ?? [])[0];
    const scriptTexts = [...(scriptScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).map((s) => (s.narration || "").trim());
    const subFor = (i: number) => savedTexts[i] || scriptTexts[i] || "";
    const baseClips: EdClipState[] = tl?.clips?.length
      ? [...tl.clips].sort((a, b) => a.sort_order - b.sort_order).map((c, i) => ({
          key: `c${i}-${c.id}`, asset: c.asset, url: c.asset_url || assetUrl(c.asset),
          isVideo: c.asset_is_video ?? isVideoAsset(c.asset), durMs: c.duration_ms || 0,
          trimStartMs: c.trim_start_ms || 0, trimEndMs: c.trim_end_ms ?? null, subtitle: subFor(i)
        }))
      : segments.filter((s) => s.adopted_asset).map((s, i) => ({
          key: `s${i}-${s.id}`, asset: s.adopted_asset as string, url: segUrl(s), isVideo: true,
          durMs: (s.target_duration_seconds || 0) * 1000, trimStartMs: 0, trimEndMs: null, subtitle: subFor(i)
        }));
    const bgm = (tl?.bgm_tracks ?? [])[0];
    return {
      clips: baseClips,
      subtitleEnabled: sub ? sub.enabled : true,
      subtitleStyle: (sub?.style?.key as string) || "plain",
      transition: tl?.metadata?.transition?.type || "none",
      bgmVolume: bgm?.volume ?? 60
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [project.timeline, project.script_versions, segments]);
  const [edState, setEdState] = useState<EditorState>(buildInitialEditor);
  const [edHistory, setEdHistory] = useState<EditorState[]>([]);
  const [edFuture, setEdFuture] = useState<EditorState[]>([]);
  const [selectedClip, setSelectedClip] = useState(0);
  const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
  const edHydratedRef = useRef(false);
  const bgmFileRef = useRef<HTMLInputElement | null>(null);
  const videoUploadRef = useRef<HTMLInputElement | null>(null);
  const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
  // 提交一个新编辑态(进 undo 栈)
  const commitEdit = useCallback((next: EditorState) => {
    setEdHistory((h) => [...h.slice(-49), edState]);
    setEdFuture([]);
    setEdState(next);
  }, [edState]);

  const edClips = edState.clips.map((c) => ({ id: c.key, assetId: c.asset, url: c.url, isVideo: c.isVideo, durMs: c.durMs }));
  const edTotalMs = edClips.reduce((sum, c) => sum + c.durMs, 0) || tlRulerMs;
  const edOffsetMs = (idx: number) => edClips.slice(0, idx).reduce((sum, c) => sum + c.durMs, 0);

  const videoRef = useRef<HTMLVideoElement | null>(null);
  const [edIdx, setEdIdx] = useState(0);
  const [edPlaying, setEdPlaying] = useState(false);
  const [edClipMs, setEdClipMs] = useState(0);
  const edCur = edClips[Math.min(edIdx, Math.max(0, edClips.length - 1))] || null;
  const edGlobalMs = edOffsetMs(edIdx) + edClipMs;

  const gotoClip = useCallback((idx: number, atEnd = false) => {
    if (idx < 0 || idx >= edClips.length) return;
    setEdIdx(idx);
    setEdClipMs(atEnd ? Math.max(0, (edClips[idx]?.durMs || 0) - 200) : 0);
  }, [edClips]);

  // 在时间轴上跳转到全局毫秒位置(点击标尺 seek)
  const seekToMs = useCallback((globalMs: number) => {
    let acc = 0;
    for (let i = 0; i < edClips.length; i += 1) {
      const dur = edClips[i].durMs || 0;
      if (globalMs <= acc + dur || i === edClips.length - 1) {
        const within = Math.max(0, Math.min(dur, globalMs - acc));
        setEdIdx(i);
        setEdClipMs(within);
        if (edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = within / 1000;
        return;
      }
      acc += dur;
    }
  }, [edClips]);

  const togglePlay = useCallback(() => {
    setEdPlaying((p) => {
      const next = !p;
      const v = videoRef.current;
      if (edCur?.isVideo && v) {
        if (next) v.play().catch(() => undefined);
        else v.pause();
      }
      return next;
    });
  }, [edCur]);

  // 上一帧 / 下一帧:视频按 ±1/25s 逐帧 seek,越界切相邻段;静态图切相邻片段
  const stepFrame = useCallback((dir: 1 | -1) => {
    const v = videoRef.current;
    if (edCur?.isVideo && v && v.duration) {
      const t = v.currentTime + dir * (1 / 25);
      if (t < 0) { gotoClip(edIdx - 1, true); return; }
      if (t > v.duration) { gotoClip(edIdx + 1, false); return; }
      v.currentTime = t;
      setEdClipMs(t * 1000);
    } else {
      gotoClip(edIdx + dir, false);
    }
  }, [edCur, edIdx, gotoClip]);

  // 静态图(无视频文件)播放:定时推进虚拟播放头,到段尾自动进下一段
  useEffect(() => {
    if (!edPlaying || edCur?.isVideo || edClips.length === 0) return;
    const tick = window.setInterval(() => {
      setEdClipMs((ms) => {
        const dur = edCur?.durMs || 2000;
        if (ms + 120 >= dur) {
          if (edIdx + 1 < edClips.length) { setEdIdx(edIdx + 1); return 0; }
          setEdPlaying(false);
          return dur;
        }
        return ms + 120;
      });
    }, 120);
    return () => window.clearInterval(tick);
  }, [edPlaying, edCur, edIdx, edClips.length]);

  // 切换片段时同步 video 进度,正在播放则续播
  useEffect(() => {
    const v = videoRef.current;
    if (!v || !edCur?.isVideo) return;
    v.currentTime = edClipMs / 1000;
    if (edPlaying) v.play().catch(() => undefined);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [edIdx]);

  // 键盘:仅 stage5 生效(空格播放/暂停,←/→ 逐帧)
  useEffect(() => {
    if (viewStage !== 5) return;
    function onKey(e: KeyboardEvent) {
      const tag = (e.target as HTMLElement)?.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA") return;
      if (e.code === "Space") { e.preventDefault(); togglePlay(); }
      else if (e.code === "ArrowLeft") { e.preventDefault(); stepFrame(-1); }
      else if (e.code === "ArrowRight") { e.preventDefault(); stepFrame(1); }
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [viewStage, togglePlay, stepFrame]);

  // Stage 4:有运行中的视频段时静默轮询推进(本机无 Celery worker,前端驱动 poll-video-segment),进度条/缩略图实时刷新
  const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length;
  useEffect(() => {
    if (viewStage !== 4 || activeVideoCount === 0) return;
    const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000);
    return () => window.clearInterval(timer);
  }, [viewStage, activeVideoCount, onPollVideosQuiet]);

  // Stage 5:进入拼接页时回填已有导出成片(若此前导过)
  useEffect(() => {
    if (viewStage !== 5) return;
    onRefreshExport();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [viewStage]);

  // 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
  async function confirmScript() {
    if (currentScript && !scriptAdopted) {
      await onAdoptScript(currentScript.id);
    }
    goStage(2);
  }

  // ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
  const [edZoom, setEdZoom] = useState(100);
  useEffect(() => {
    if (viewStage !== 5) { edHydratedRef.current = false; return; }
    if (edHydratedRef.current) return;
    const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset);
    if (!hasData) return;
    edHydratedRef.current = true;
    setEdState(buildInitialEditor());
    setEdHistory([]); setEdFuture([]); setSelectedClip(0);
  }, [viewStage, project, segments, buildInitialEditor]);

  const edUndo = useCallback(() => {
    setEdHistory((h) => {
      if (!h.length) return h;
      setEdFuture((f) => [edState, ...f].slice(0, 50));
      setEdState(h[h.length - 1]);
      return h.slice(0, -1);
    });
  }, [edState]);
  const edRedo = useCallback(() => {
    setEdFuture((f) => {
      if (!f.length) return f;
      setEdHistory((h) => [...h, edState].slice(-50));
      setEdState(f[0]);
      return f.slice(1);
    });
  }, [edState]);

  function edDeleteClip(idx: number) {
    if (idx < 0 || idx >= edState.clips.length || edState.clips.length <= 1) return;
    commitEdit({ ...edState, clips: edState.clips.filter((_, i) => i !== idx) });
    setSelectedClip((s) => Math.max(0, Math.min(s, edState.clips.length - 2)));
  }
  function edCopyClip(idx: number) {
    const c = edState.clips[idx];
    if (!c) return;
    const dup = { ...c, key: `${c.key}-copy-${Date.now()}` };
    commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx + 1), dup, ...edState.clips.slice(idx + 1)] });
  }
  function edSplitAtPlayhead() {
    const idx = edIdx;
    const c = edState.clips[idx];
    if (!c || c.durMs <= 400) return;
    const offset = Math.max(200, Math.min(c.durMs - 200, edClipMs));
    const a: EdClipState = { ...c, key: `${c.key}-a-${Date.now()}`, durMs: offset, trimEndMs: c.trimStartMs + offset };
    const b: EdClipState = { ...c, key: `${c.key}-b-${Date.now()}`, durMs: c.durMs - offset, trimStartMs: c.trimStartMs + offset, trimEndMs: c.trimEndMs };
    commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx), a, b, ...edState.clips.slice(idx + 1)] });
  }

  const buildSavePayload = useCallback((): TimelineSavePayload => {
    let acc = 0;
    const content = edState.clips.map((c) => { const start = acc; acc += c.durMs; return { start_ms: Math.round(start), text: c.subtitle || "" }; });
    return {
      clips: edState.clips.map((c) => ({ asset: c.asset, duration_ms: Math.round(c.durMs), trim_start_ms: Math.round(c.trimStartMs), trim_end_ms: c.trimEndMs == null ? null : Math.round(c.trimEndMs) })),
      subtitle: { enabled: edState.subtitleEnabled, style_key: edState.subtitleStyle, content },
      bgm: { volume: edState.bgmVolume },
      transition: { type: edState.transition }
    };
  }, [edState]);

  // 字幕文本编辑(打字不进 undo 栈,避免逐字刷历史)
  function setClipSubtitle(idx: number, text: string) {
    setEdState((s) => ({ ...s, clips: s.clips.map((c, i) => (i === idx ? { ...c, subtitle: text } : c)) }));
  }
  // 片段拖拽重排
  const [dragIdx, setDragIdx] = useState<number | null>(null);
  function reorderClip(from: number, to: number) {
    if (from === to || from < 0 || to < 0 || from >= edState.clips.length || to >= edState.clips.length) return;
    const clips = [...edState.clips];
    const [moved] = clips.splice(from, 1);
    clips.splice(to, 0, moved);
    commitEdit({ ...edState, clips });
    setSelectedClip(to);
  }
  // 转场实时预览:切片段时若有转场,画面做一次淡场(导出才是真 xfade,这里给即时视觉提示)
  const [fadeKey, setFadeKey] = useState(0);
  const prevEdIdxRef = useRef(edIdx);
  useEffect(() => {
    if (prevEdIdxRef.current !== edIdx) {
      prevEdIdxRef.current = edIdx;
      if (edState.transition !== "none") setFadeKey((k) => k + 1);
    }
  }, [edIdx, edState.transition]);

  // Stage 4 / 5 文件上传
  function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file);
    setUploadTargetSeg(null);
  }
  function triggerVideoUpload(segmentId: string) {
    setUploadTargetSeg(segmentId);
    videoUploadRef.current?.click();
  }
  function onPickBgmFile(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (file) onUploadBgm(file, edState.bgmVolume);
  }

  // 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
  const productRecord = products.find((item) => item.id === project.product);
  const productName = productRecord?.title || "透真补水面膜";
  const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;

  function goStage(n: number) {
    setViewStage(n);
    setNavigated(true);
    if (typeof location !== "undefined") location.hash = `stage-${n}`;
  }
  function dotCls(n: number) {
    if (n === activeDot) return "sp-dot active";
    if (n <= completed) return "sp-dot done";
    return "sp-dot";
  }

  return (
    <div className="app pipeline-page">
      <Sidebar page="pipeline" navigate={navigate} user={user} team={team} products={products} projects={projects} />
      <main>
        <Decorations />
        <header className="topbar">
          <div className="pipeline-topbar-left">
            <a className="btn btn-ghost pipeline-back" href="/projects" onClick={(event) => { event.preventDefault(); navigate("projects"); }} aria-label="返回视频项目">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>
              返回视频项目
            </a>
            <div className="pipeline-topbar-title" title={project.name}>
              {project.name}<span className="mono">// PIPELINE</span>
            </div>
          </div>
          <div className="right">
            <span className="balance-chip" onClick={() => navigate("account")}>
              <IconKitSvg name="creditCard" />
              余额 <strong>{money(billing?.account.balance)}</strong>
            </span>
            <button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
              <IconKitSvg name="bell" />
              {unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
            </button>
            <div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
              <span>{avatarChar}</span>
            </div>
          </div>
          <div className="stage-pill" id="stage-pill">
            {STAGE_STEPS.map((step, index) => (
              <Fragment key={step.n}>
                <a className={dotCls(step.n)} data-stage={step.n} href={`#stage-${step.n}`} onClick={(event) => { event.preventDefault(); goStage(step.n); }}>
                  <span className="d"></span><span className="l">{step.label}</span>
                </a>
                {index < STAGE_STEPS.length - 1 && <span className={`sp-line${step.n <= completed ? " done" : ""}`}></span>}
              </Fragment>
            ))}
          </div>
        </header>

        <div className="content content--fh content--fh-flat" id="page-content">
          <CornerMarks />
          {notice && <ToastLike notice={notice} />}

          {/* ============= STAGE 1 · 脚本 ============= */}
          <section className={`stage${viewStage === 1 ? " active" : ""}`} data-stage-pane="1">
            <div className="stage-script">
              <div className="pane shot-list">
                <div className="pane-h">
                  <div className="shot-headline">
                    <strong>镜头脚本</strong>
                    <span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>
                      {shots.length ? `· ${shots.length} 镜 · ${scriptAdopted ? "已采用" : "待采用"}` : "· 空 · 待生成"}
                    </span>
                  </div>
                  <div className="script-brief-summary" aria-label="当前创作方向">
                    <span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">未选择</span></span>
                    <span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">待确认</span></span>
                    <span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">待确认</span></span>
                  </div>
                  <div className="script-tags" id="script-tags">
                    <div className="tag-group" data-kind="char">
                      <span className="tg-lbl">// 人物</span>
                      <button className="tag-add" type="button" aria-label="添加人物" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个人物角色:"); }}>+</button>
                    </div>
                    <div className="tag-group" data-kind="scene">
                      <span className="tg-lbl">// 场景</span>
                      <button className="tag-add" type="button" aria-label="添加场景" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个场景:"); }}>+</button>
                    </div>
                  </div>
                  <span className="spacer"></span>
                  <button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放")}>↻ 整体重写</button>
                </div>
                <div className="shots-body" id="shots-body">
                  {shots.length ? shots.map((shot) => (
                    <div className="shot-card" key={shot.id}>
                      <div className="shot-n">{shot.sort_order + 1}</div>
                      <div className="shot-main">
                        <div className="shot-meta">// 场 {shot.sort_order + 1} · {shot.duration_seconds || 15}s</div>
                        <div className="shot-narration">{shot.narration?.trim() || "（本镜暂无文案,可在右侧重写脚本）"}</div>
                      </div>
                    </div>
                  )) : (
                    <div className="shots-empty">
                      <div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
                      <div className="empty-title">还没有镜头脚本</div>
                      <div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
                    </div>
                  )}
                </div>
              </div>

              <div className="stage-script-gutter" id="stage-script-gutter" role="separator" aria-orientation="vertical" aria-label="拖动调整脚本助手宽度"></div>

              <div className="pane chat-pane">
                <div className="pane-h">
                  <div className="ai-avatar">AI</div>
                  <strong>脚本助手</strong>
                  <span className="muted-2 mono" style={{ fontSize: "11px" }}>· GPT-4o</span>
                  <span className="spacer"></span>
                  <button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0} onClick={clearChat}>清空对话</button>
                </div>
                <div className="chat-body" id="chat-body">
                  <div className="chat-empty">
                    <div className="ce-title">选择一种生成方式开始</div>
                    <div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
                    <div className="chat-modes">
                      <button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => { setChatMode("ai"); onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放"); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
                      <button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={focusThemeMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
                      <button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={pickScriptMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
                    </div>
                  </div>
                </div>
                <div className="chat-input">
                  <div className="chat-input-card">
                    <div className="chat-attach-row" id="chat-attach-row" hidden={chatAttachments.length === 0}>
                      {chatAttachments.map((att, index) => (
                        <span className="chip" key={`${att.name}-${index}`} style={{ marginRight: 6 }}>
                          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
                          {att.name} · {att.chars} 字
                          <button type="button" aria-label="移除附件" style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => setChatAttachments((list) => list.filter((_, i) => i !== index))}>×</button>
                        </span>
                      ))}
                    </div>
                    <textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
                    <input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
                    <div className="chat-input-foot">
                      <button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
                      </button>
                      <span className="spacer"></span>
                      <button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); clearChat(); }}>
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            </div>

            <div className="stage-foot">
              <div className="info"><span className="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
              <div className="hstack">
                <button className="btn" type="button" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
                <button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
              </div>
            </div>
          </section>

          {/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
          {viewStage === 2 && (() => {
            const productGroup = groupsByKind("product")[0] || null;
            const productAssetUrl = groupMainUrl(productGroup) || assetUrl(productCover);
            const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
            return (
            <section className="stage active" data-stage-pane="2">
              <div className="stage-assets">
                <div className="asset-side">
                  {KIND_ORDER.map((kind) => {
                    const list = groupsByKind(kind);
                    const adopted = list.filter((g) => g.adopted_asset).length;
                    return (
                      <div className={`ttab${kind === assetTab ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => jumpAssetSection(kind)}>
                        <span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
                      </div>
                    );
                  })}
                  <div className="info">
                    基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
                    <br /><br />
                    <strong className="mono">// 人物 +¥0.20/张</strong>
                    <strong className="mono">// 场景 +¥0.15/张</strong>
                    <span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
                  </div>
                </div>

                <div className="asset-main">
                  <section className="asset-sec" id="asset-sec-product">
                    <div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
                    <div className="prod-row">
                      <div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={productGroup?.adopted_asset || "prod-main"} id="asset-prod-card">
                        <div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
                          {!productGroup?.adopted_asset && (
                            <span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
                              <span className="ico" aria-hidden="true"></span>
                              <span className="lbl-mono">缺三视图</span>
                              <span className="tri-missing-pop" role="tooltip">
                                <span className="pop-h">
                                  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
                                  MISSING TRI-VIEW
                                </span>
                                <span className="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
                                <span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
                              </span>
                            </span>
                          )}
                          <span className="ph-frame" id="asset-prod-thumb-label">{productName} · 主图</span>
                        </div>
                        <div className="prod-body">
                          <div className="prod-name" id="asset-prod-card-name">{productName}</div>
                          <div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
                          <div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
                        </div>
                        <div className="prod-action" id="asset-prod-action">
                          <button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
                            <svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
                            AI 生成三视图
                          </button>
                        </div>
                      </div>
                      <div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
                        <div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
                        <div className={`placeholder prod-preview-img${candUrl(productGroup, productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={candUrl(productGroup, productCandidates[0]) ? mediaStyle(candUrl(productGroup, productCandidates[0])) : undefined}><span className="ph-frame">候选 #1</span></div>
                        <div className="prod-preview-foot" id="prod-preview-foot">
                          {productCandidates.slice(0, 4).map((id) => (
                            <div className={`placeholder${candUrl(productGroup, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(productGroup, id) ? mediaStyle(candUrl(productGroup, id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
                          ))}
                        </div>
                      </div>
                    </div>
                  </section>

                  {(["person", "scene"] as const).map((kind) => {
                    const list = groupsByKind(kind);
                    const genPrompt = kind === "person"
                      ? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏`
                      : `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`;
                    return (
                      <section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
                        <div className="sec-h">
                          <h3>{KIND_LABEL[kind]} · {list.length} 个</h3>
                          <span className="spacer"></span>
                          <button className="btn-aigen" type="button" data-stop disabled={loading} onClick={() => onGenerateBaseAsset(kind, genPrompt)}>
                            <svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
                            AI 生成{KIND_LABEL[kind]}
                          </button>
                        </div>
                        {list.length ? (
                          <div className="asset-grid-2">
                            {list.map((group, gi) => {
                              const mainUrl = groupMainUrl(group);
                              const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
                              return (
                                <div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
                                  <div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
                                    <span className="ph-frame">{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</span>
                                  </div>
                                  <div className="body-2">
                                    <div className="hstack">
                                      <strong style={{ fontSize: "13.5px" }}>{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</strong>
                                      <span className="spacer"></span>
                                      {group.adopted_asset
                                        ? <span className="pill ok"><span className="dot"></span>已采用</span>
                                        : <span className="pill neutral"><span className="dot"></span>待采用</span>}
                                    </div>
                                    <div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>{group.prompt || "（暂无提示词）"}</div>
                                    {cands.length > 0 && (
                                      <div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
                                        {cands.map((id) => (
                                          <div className={`placeholder${candUrl(group, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(group, id) ? mediaStyle(candUrl(group, id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
                                        ))}
                                      </div>
                                    )}
                                  </div>
                                </div>
                              );
                            })}
                          </div>
                        ) : (
                          <div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
                            <span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「AI 生成{KIND_LABEL[kind]}」生成</span>
                          </div>
                        )}
                      </section>
                    );
                  })}
                </div>
              </div>

              <div className="stage-foot">
                <div className="info"><span className="mono">[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]</span></div>
                <div className="hstack">
                  <button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回脚本</button>
                  <button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
                </div>
              </div>
            </section>
            );
          })()}
          {/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
          {viewStage === 3 && (
            <section className="stage active" data-stage-pane="3">
              <div className="stage-storyboard">
                <div className="sb-canvas">
                  <div className="sb-scenes-col" id="sb-scenes-row">
                    {sbFrames.length ? sbFrames.map((frame, idx) => {
                      const url = frameUrl(frame);
                      return (
                        <div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
                          <div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame">场 {idx + 1}</span></div>
                          <div className="nm">场 {idx + 1}</div>
                          <div className="sub">#{frame.sort_order + 1}</div>
                        </div>
                      );
                    }) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
                  </div>
                  {(() => {
                    const url = frameUrl(sbActiveFrame);
                    return (
                      <div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}>
                        <span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
                      </div>
                    );
                  })()}
                </div>

                <div className="sb-side">
                  <div className="pane" style={{ padding: "18px" }}>
                    <div className="hstack" style={{ marginBottom: "10px" }}>
                      <strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">{sbActiveFrame ? `场 ${sbSelected + 1}` : "—"}</span></strong>
                      <span className="spacer"></span>
                      {adoptedStoryboard
                        ? <span className="pill ok"><span className="dot"></span>已生成</span>
                        : <span className="pill neutral"><span className="dot"></span>未生成</span>}
                    </div>
                    <div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。</div>
                    <div className="sb-rerun-note">
                      <span className="warn-ic" aria-hidden="true">
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
                      </span>
                      <div className="note-copy"><strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。</div>
                    </div>
                    <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 本场提示词</div>
                    <div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{sbActiveFrame?.prompt || adoptedStoryboard?.prompt || "（暂无提示词）"}</div>
                    <div className="sb-stage-actions">
                      <button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
                        {adoptedStoryboard ? "整张重跑" : "生成故事板"}
                      </button>
                      <span className="spacer"></span>
                      <span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/场</span>
                    </div>
                    <div className="sb-history">
                      <div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
                      <div className="sb-history-row" id="sb-history-row">
                        {storyboards.length ? storyboards.map((ver) => {
                          const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]);
                          return (
                            <div className={`sb-history-thumb${ver.is_adopted ? " current" : ""}`} key={ver.id} data-vi={ver.id}>
                              <div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
                              <div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div>
                            </div>
                          );
                        }) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无历史</span>}
                      </div>
                    </div>
                    <div className="divider" style={{ marginTop: "16px" }}></div>
                    <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
                    <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
                      {groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => (
                        <span className="asset-tag" key={g.id}><span className="dotc"></span>{assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind})</span>
                      )) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无绑定资产</span>}
                    </div>
                  </div>
                </div>
              </div>

              <div className="stage-foot">
                <div className="info"><span className="mono">[ image-2 整张输出 · {sbFrames.length} 场 · 整张重跑,失败不扣 ]</span></div>
                <div className="hstack">
                  <button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
                  <button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>确认故事板,开始生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
                </div>
              </div>
            </section>
          )}
          {/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
          {viewStage === 4 && (() => {
            const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
            const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
            const statusText = !segments.length
              ? "暂无片段"
              : segDone === segments.length
                ? "已完成所有场次"
                : activeVideoCount > 0
                  ? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
                  : "待生成";
            return (
            <section className="stage active" data-stage-pane="4">
              <div className="queue-bar">
                <div>
                  <div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
                  <div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {statusText}</div>
                </div>
                <div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
                <span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
                <button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => onSubmitAllVideos(videoPrompt)}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
                <button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
                  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
                  上传视频
                </button>
              </div>
              <input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />

              {segments.length ? (
                <div className="video-grid" id="video-grid">
                  {segments.map((seg) => {
                    const url = segUrl(seg);
                    const tone = statusPill(seg.status);
                    const busy = ["running", "queued"].includes(seg.status);
                    return (
                      <div className="video-card" key={seg.id} data-video-id={seg.id}>
                        <div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击播放" : undefined} onClick={url ? () => setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }); } } : undefined}>
                          {url
                            ? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
                            : <span className="ph-frame">场 {seg.sort_order + 1}</span>}
                          {url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
                        </div>
                        <div className="body">
                          <div className="video-card-head">
                            <strong className="video-card-title">场 {seg.sort_order + 1}</strong>
                            <span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
                          </div>
                          <div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
                          <div className="video-actions">
                            <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => onSubmitVideo(seg.id, `${videoPrompt} 第 ${seg.sort_order + 1} 段，时长 ${seg.target_duration_seconds} 秒`)}>{busy ? "生成中…" : "重跑"}</button>
                            <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => triggerVideoUpload(seg.id)}>上传</button>
                            <span className="spacer"></span>
                            {url
                              ? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
                              : <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled>下载</button>}
                          </div>
                        </div>
                      </div>
                    );
                  })}
                </div>
              ) : (
                <div className="placeholder" style={{ minHeight: "200px", margin: "18px 28px" }}><span className="ph-frame">// 暂无视频片段 · 先在故事板确认后生成</span></div>
              )}

              <div className="stage-foot">
                <div className="info"><span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span></div>
                <div className="hstack">
                  <button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
                  <button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
                </div>
              </div>
            </section>
            );
          })()}
          {/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
          {viewStage === 5 && (() => {
            const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.adopted_asset));
            const aspect = timeline?.aspect_ratio || "9:16";
            const resolution = timeline?.resolution || "1080×1920";
            const bgm = bgmTracks[0] || null;
            const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : "");
            const showVideo = !!(edCur?.isVideo && edCur.url);
            // 拼接成片:导出成功后用整片预览/下载;导出中显示进度
            const finalUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
            const exporting = exportResult?.status === "queued" || exportResult?.status === "running";
            const exportFailed = exportResult?.status === "failed";
            // ── 编辑器派生(随本地编辑实时变化的时间轴)──
            const STYLE_SWATCHES = [
              { key: "plain", demo: "", nm: "朴素白底" }, { key: "cinema", demo: "b", nm: "影视黑底" },
              { key: "handwrite", demo: "c", nm: "手写描边" }, { key: "variety", demo: "d", nm: "综艺暖黄" }
            ];
            const TRANSITIONS = [
              { key: "none", nm: "无转场" }, { key: "fade", nm: "淡入淡出" }, { key: "dissolve", nm: "溶解" },
              { key: "slideleft", nm: "左滑" }, { key: "wiperight", nm: "擦除" }
            ];
            const edRulerMs = edTotalMs;
            const edRuler = buildRuler(edRulerMs / 1000);
            const edOffsets = edClips.map((_, i) => edClips.slice(0, i).reduce((sum, c) => sum + c.durMs, 0));
            const serverBgm = (project.timeline?.bgm_tracks ?? [])[0] || null;
            const serverBgmUrl = serverBgm?.asset_url || "";
            const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : "");
            const subVisible = edState.subtitleEnabled;
            return (
            <section className="stage active" data-stage-pane="5">
              <div className="editor">
                <div className="editor-preview">
                  <div className={`canvas${!showVideo && !finalUrl && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !finalUrl && previewUrl ? mediaStyle(previewUrl) : undefined}>
                    {finalUrl ? (
                      <>
                        <video
                          src={finalUrl}
                          controls
                          playsInline
                          style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", background: "#000", borderRadius: "inherit" }}
                        />
                        <span className="pill ok" style={{ position: "absolute", top: 10, left: 10, zIndex: 4 }}><span className="dot"></span>成片</span>
                      </>
                    ) : showVideo ? (
                      <video
                        ref={videoRef}
                        src={edCur!.url}
                        playsInline
                        style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }}
                        onTimeUpdate={(e) => setEdClipMs(e.currentTarget.currentTime * 1000)}
                        onEnded={() => { if (edIdx + 1 < edClips.length) gotoClip(edIdx + 1, false); else setEdPlaying(false); }}
                        onPlay={() => setEdPlaying(true)}
                        onPause={() => setEdPlaying(false)}
                      />
                    ) : (
                      <span id="ed-canvas-label">{exporting ? `拼接中… ${exportResult?.progress ?? 0}%` : `${aspect} 预览 · ${resolution}${edClips.length ? ` · 片段 ${Math.min(edIdx + 1, edClips.length)}/${edClips.length}` : ""}`}</span>
                    )}
                    {showVideo && !finalUrl && edState.transition !== "none" && <div key={fadeKey} className="ed-xfade-flash" aria-hidden="true" />}
                  </div>
                  <div className="controls">
                    <button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)" onClick={() => stepFrame(-1)} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
                    <button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)" onClick={togglePlay} disabled={edClips.length === 0}>
                      {edPlaying
                        ? <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor" /></svg>
                        : <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg>}
                    </button>
                    <button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)" onClick={() => stepFrame(1)} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
                    <span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">{fmtMs(edGlobalMs)}</span> / <span id="ed-total-time">{fmtMs(edTotalMs)}</span></span>
                  </div>
                </div>

                <div className="editor-props">
                  <div className="props-tabs">
                    <div className={propsTab === "subtitle" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("subtitle")}>字幕</div>
                    <div className={propsTab === "transition" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("transition")}>转场</div>
                    <div className={propsTab === "bgm" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("bgm")}>BGM</div>
                  </div>

                  {propsTab === "subtitle" && (
                    <>
                      <div className="props-row" style={{ marginBottom: 8 }}>
                        <span className="k">烧入字幕</span>
                        <button className={`btn btn-sm ${edState.subtitleEnabled ? "btn-primary" : "btn-ghost"}`} type="button" onClick={() => commitEdit({ ...edState, subtitleEnabled: !edState.subtitleEnabled })}>{edState.subtitleEnabled ? "已开启" : "已关闭"}</button>
                      </div>
                      <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式(导出烧入)</div>
                      <div className="style-swatch">
                        {STYLE_SWATCHES.map((sw) => (
                          <div className={`swatch-card${edState.subtitleStyle === sw.key ? " selected" : ""}`} key={sw.key} role="button" tabIndex={0} style={{ cursor: "pointer", opacity: edState.subtitleEnabled ? 1 : 0.5 }} onClick={() => commitEdit({ ...edState, subtitleStyle: sw.key, subtitleEnabled: true })}>
                            <div className={`demo${sw.demo ? ` ${sw.demo}` : ""}`}>真实分享</div><div className="nm">{sw.nm}</div>
                          </div>
                        ))}
                      </div>
                      <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, margin: "12px 0 6px", letterSpacing: ".04em" }}>// 字幕文本(默认取脚本旁白,可逐段改)</div>
                      <div style={{ display: "flex", flexDirection: "column", gap: "6px", maxHeight: "186px", overflowY: "auto" }}>
                        {edState.clips.map((c, idx) => (
                          <div key={c.key} style={{ display: "flex", gap: "6px", alignItems: "flex-start" }}>
                            <span className="mono" style={{ fontSize: "10px", color: "var(--black-alpha-48)", marginTop: "7px", flex: "0 0 auto" }}>{idx + 1}</span>
                            <textarea value={c.subtitle} onChange={(e) => setClipSubtitle(idx, e.target.value)} rows={1} disabled={!edState.subtitleEnabled} placeholder={`第 ${idx + 1} 段字幕`} style={{ flex: 1, minWidth: 0, resize: "vertical", fontSize: "12px", lineHeight: 1.4, padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)", fontFamily: "inherit" }} />
                          </div>
                        ))}
                      </div>
                    </>
                  )}

                  {propsTab === "transition" && (
                    <>
                      <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 片段间转场(导出 xfade 烧入)</div>
                      <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
                        {TRANSITIONS.map((tr) => (
                          <button className={`btn btn-sm ${edState.transition === tr.key ? "btn-primary" : "btn-ghost"}`} key={tr.key} type="button" style={{ justifyContent: "flex-start" }} onClick={() => commitEdit({ ...edState, transition: tr.key })}>{tr.nm}</button>
                        ))}
                      </div>
                    </>
                  )}

                  {propsTab === "bgm" && (
                    <>
                      <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 背景音乐(导出混音)</div>
                      <div className="props-row"><span style={{ fontSize: "12px", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{serverBgm ? serverBgmName : "未设置 BGM"}</span></div>
                      {serverBgmUrl && <audio src={serverBgmUrl} controls style={{ width: "100%", height: 30, marginBottom: 8 }} />}
                      <div className="props-row"><span className="k">音量 {edState.bgmVolume}</span>
                        <input type="range" min={0} max={100} value={edState.bgmVolume} onChange={(e) => setEdState((s) => ({ ...s, bgmVolume: Number(e.target.value) }))} style={{ flex: 1 }} />
                      </div>
                      <button className="btn btn-sm" type="button" disabled={loading} onClick={() => bgmFileRef.current?.click()} style={{ marginTop: 6 }}>{serverBgm ? "替换 BGM" : "上传 BGM"}</button>
                      <input ref={bgmFileRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={onPickBgmFile} />
                    </>
                  )}

                  <div className="divider"></div>
                  <div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
                  <div className="props-row"><span className="k">总时长</span><input className="input-mini" value={fmtMs(edRulerMs)} readOnly /></div>
                  <div className="props-row"><span className="k">片段</span><input className="input-mini" value={`${edClips.length} 段`} readOnly /></div>
                  <div className="props-row"><span className="k">字幕</span><input className="input-mini" value={subVisible ? `${edClips.length} 条` : "关"} readOnly /></div>
                  <div className="props-row"><span className="k">转场</span><input className="input-mini" value={(TRANSITIONS.find((t) => t.key === edState.transition) || TRANSITIONS[0]).nm} readOnly /></div>
                  <div className="props-row"><span className="k">分辨率</span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
                </div>

                <div className="timeline" id="ed-timeline" style={{ overflowX: edZoom > 100 ? "auto" : "hidden" }}>
                  <div className="tl-toolbar">
                    <button className="tl-action" type="button" title="撤销" disabled={!edHistory.length} onClick={edUndo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
                    <button className="tl-action" type="button" title="重做" disabled={!edFuture.length} onClick={edRedo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
                    <span className="tl-sep"></span>
                    <button className="tl-action" type="button" title="在播放头处分割所在片段" disabled={!edClips.length} onClick={edSplitAtPlayhead}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg>分割</button>
                    <button className="tl-action" type="button" title="复制选中片段" disabled={!edClips.length} onClick={() => edCopyClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>复制</button>
                    <button className="tl-action danger" type="button" title="删除选中片段" disabled={edClips.length <= 1} onClick={() => edDeleteClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg>删除</button>
                    <span className="spacer"></span>
                    <div className="tl-zoom"><span className="lbl">// zoom {edZoom}%</span><input type="range" min={100} max={300} value={edZoom} onChange={(e) => setEdZoom(Number(e.target.value))} /></div>
                  </div>

                  <div style={{ width: `${edZoom}%`, minWidth: "100%" }}>
                  <div className="tl-ruler">
                    <div className="l">// time</div>
                    <div
                      className="rule-track"
                      id="ed-ruler"
                      style={{ cursor: edClips.length ? "pointer" : "default" }}
                      onClick={(event) => {
                        if (!edClips.length) return;
                        const rect = event.currentTarget.getBoundingClientRect();
                        const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
                        seekToMs(frac * edRulerMs);
                      }}
                    >
                      {edRuler.map((tick, i) => (
                        <span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
                      ))}
                      {edClips.length > 0 && (
                        <span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 5, pointerEvents: "none" }} />
                      )}
                    </div>
                  </div>

                  <div className="tl-track video-track">
                    <div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span>视频</div>
                    <div className="lane" id="ed-lane-video" data-track="video">
                      {edClips.length > 0 && (
                        <span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 6, pointerEvents: "none" }} />
                      )}
                      {edClips.length ? edClips.map((c, idx) => {
                        const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
                        const lbl = assetName(c.assetId) || `片段 ${idx + 1}`;
                        const frameCount = Math.max(1, Math.round(c.durMs / 1000));
                        return (
                          <div
                            className="clip video"
                            key={c.id}
                            data-track="video"
                            data-label={lbl}
                            draggable
                            onDragStart={() => setDragIdx(idx)}
                            onDragOver={(e) => e.preventDefault()}
                            onDrop={(e) => { e.preventDefault(); if (dragIdx != null) reorderClip(dragIdx, idx); setDragIdx(null); }}
                            onDragEnd={() => setDragIdx(null)}
                            title="拖拽可重排片段"
                            style={{ left: `${leftPct}%`, width: `${widthPct}%`, cursor: "grab", opacity: dragIdx === idx ? 0.4 : 1, outline: idx === selectedClip ? "2px solid var(--heat)" : undefined, outlineOffset: "-2px" }}
                            onClick={() => { setSelectedClip(idx); gotoClip(idx, false); }}
                          >
                            <span className="frames">{Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
                            <span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
                          </div>
                        );
                      }) : <span className="muted-2 mono" style={{ position: "absolute", left: "8px", top: "50%", transform: "translateY(-50%)", fontSize: "11px" }}>// 暂无片段</span>}
                    </div>
                  </div>

                  <div className="tl-track subtitle-track">
                    <div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span>字幕</div>
                    <div className="lane" id="ed-lane-subtitle" data-track="subtitle">
                      {subVisible && edState.clips.map((c, idx) => {
                        const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
                        const text = c.subtitle || `字幕 ${idx + 1}`;
                        return (
                          <div className="clip subtitle" key={c.key} data-track="subtitle" data-label={text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{text}</span></div>
                        );
                      })}
                      <div className="playhead" id="ed-playhead" style={{ left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%` }}><span className="ph-grab"></span></div>
                    </div>
                  </div>

                  {serverBgm && (
                    <div className="tl-track bgm-track">
                      <div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
                      <div className="lane">
                        <div className="clip bgm" data-track="bgm" data-label={serverBgmName} style={{ left: "0%", width: "100%" }}>
                          <span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
                          <span className="lbl">{serverBgmName} · 音量 {edState.bgmVolume}</span>
                        </div>
                      </div>
                    </div>
                  )}
                  </div>
                </div>
              </div>

              <div className="stage-foot">
                <div className="info">
                  <span className="mono">[ 时间轴 {fmtMs(edRulerMs)} · {edClips.length} 段 · 拼接 / 导出全程 0 token ]</span>
                  {exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 拼接中 {exportResult?.progress ?? 0}%</span>}
                  {finalUrl && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 成片已就绪</span>}
                  {exportFailed && <span className="mono" style={{ marginLeft: 10, color: "var(--err, #d33)" }}>// 导出失败:{exportResult?.error_message || "请重试"}</span>}
                  {!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>// 待全部视频片段生成完成后可导出</span>}
                </div>
                <div className="hstack">
                  <button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
                  <button className="btn" type="button" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>保存草稿</button>
                  {finalUrl && (
                    <a className="btn" href={finalUrl} target="_blank" rel="noreferrer" download>
                      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg> 下载成片
                    </a>
                  )}
                  <button className="btn btn-primary btn-lg" type="button" disabled={!canExport || loading || exporting} onClick={() => onSubmitExport(buildSavePayload())}>
                    {exporting ? "拼接中…" : finalUrl ? "重新导出" : "导出 MP4"} · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect}
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
                  </button>
                </div>
              </div>
            </section>
            );
          })()}
        </div>
      </main>
      <MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
    </div>
  );
}
