// 3 · 复刻爆款
// T2V (RemixVideo)：真接入 /api/jobs/* 与 /api/jobs/{id}/replicate
// T2I (RemixImage)：// TODO M5 待 T2I 接入；保留原渐进式 mock，组件不动
//
// 当前架构：
// - GET /api/jobs 拉所有 parent，每个 parent 带 replicate_runs[] 聚合（child 不出现在主列表）
// - 复刻发起：POST /api/jobs/{parent_id}/replicate 创建 child job → subscribeJobProgress(child_id)
// - 4 个状态分类（未复刻 / 复刻中 / 已复刻 / 失败）100% 前端从 replicate_runs 聚合派生
//
// "选模型" 即 "选凭据"：每条凭据 extra.model_name 绑一个具体模型；同一 provider 多模型 = 多条凭据。

// ─────────── Stepper（顶部进度条 · 可点击已完成步骤回溯） ───────────
const Stepper = ({ steps, stage, onJump, color = "purple" }) => (
  <div className="row gap-0" style={{ padding:"10px 14px", borderBottom:"1px solid var(--line-1)", background:"var(--bg-1)", gap:6, alignItems:"center" }}>
    {steps.map((s, i) => {
      const state = i < stage ? "done" : i === stage ? "active" : "locked";
      const clickable = state !== "locked";
      return (
        <React.Fragment key={i}>
          <div
            onClick={() => clickable && onJump(i)}
            className="row gap-6"
            style={{
              padding:"6px 10px", borderRadius:5,
              cursor: clickable ? "pointer" : "not-allowed",
              background: state === "active" ? `color-mix(in oklch, var(--${color}) 16%, var(--bg-2))` : "transparent",
              border: state === "active" ? `1px solid color-mix(in oklch, var(--${color}) 40%, var(--line-2))` : "1px solid transparent",
              opacity: state === "locked" ? 0.4 : 1,
              transition:"all .15s",
            }}>
            <div style={{
              width:18, height:18, borderRadius:"50%",
              background: state === "done" ? "var(--green)" : state === "active" ? `var(--${color})` : "var(--bg-3)",
              color: state === "locked" ? "var(--fg-3)" : "var(--bg-0)",
              display:"flex", alignItems:"center", justifyContent:"center",
              fontSize:10, fontWeight:700, fontFamily:"var(--font-mono)",
            }}>
              {state === "done" ? <Icon name="check" size={10}/> : i+1}
            </div>
            <span style={{
              fontSize:11.5, fontWeight: state === "active" ? 600 : 500,
              color: state === "active" ? "var(--fg-0)" : state === "done" ? "var(--fg-1)" : "var(--fg-3)"
            }}>{s}</span>
          </div>
          {i < steps.length-1 && <div style={{ flex:"0 0 20px", height:1, background: i < stage ? "var(--green)" : "var(--line-2)" }}/>}
        </React.Fragment>
      );
    })}
  </div>
);

// 锁定态遮罩（显示上一步 hint）
const Locked = ({ hint }) => (
  <div className="col" style={{ alignItems:"center", justifyContent:"center", padding:"40px 20px", gap:10, color:"var(--fg-3)" }}>
    <div style={{ width:36, height:36, borderRadius:"50%", background:"var(--bg-2)", display:"flex", alignItems:"center", justifyContent:"center", border:"1px dashed var(--line-2)" }}>
      <Icon name="folder" size={16}/>
    </div>
    <span style={{ fontSize:11.5 }}>{hint}</span>
  </div>
);

// ─────────── 公共：五维度拆解卡片（带渐进揭示） ───────────
const FiveDimsProgressive = ({ dims, revealed, onRewrite, onNext, color="purple" }) => (
  <div className="card">
    <div className="card-hd">
      <span className="card-title">五维度拆解 · 粘贴改写</span>
      <span className="card-sub">{revealed}/{dims.length} 已改写</span>
      <div className="grow"/>
      <div className="bar" style={{ width:80, height:3 }}>
        <span style={{ width:`${(revealed/dims.length)*100}%`, background:`var(--${color})`, transition:"width .3s" }}/>
      </div>
    </div>
    <div className="col" style={{ padding:12, gap:10 }}>
      {dims.map((d, i) => {
        const done = i < revealed;
        const current = i === revealed;
        const locked = i > revealed;
        return (
          <div key={i} className="col gap-6" style={{
            padding:10, background:"var(--bg-0)",
            border:`1px solid ${current ? `color-mix(in oklch, var(--${color}) 40%, var(--line-1))` : "var(--line-1)"}`,
            borderRadius:6,
            opacity: locked ? 0.35 : 1,
            transition:"all .25s",
          }}>
            <div className="row between">
              <div className="row gap-6">
                <span className={`tag ${d.col}`}>{d.k}</span>
                {done && <span style={{ fontSize:10, color:"var(--green)" }}>✓ 已改写</span>}
                {current && <span style={{ fontSize:10, color:`var(--${color})` }}>● 当前</span>}
                {locked && <span style={{ fontSize:10, color:"var(--fg-3)" }}>待处理</span>}
              </div>
              <span className="mono" style={{ fontSize:9.5, color:"var(--fg-3)" }}>保留: {d.keep}</span>
            </div>
            <div className="row gap-6" style={{ alignItems:"stretch" }}>
              <div className="col grow gap-2" style={{ padding:"6px 8px", background:"var(--bg-2)", borderRadius:4 }}>
                <span className="kpi-l">原</span>
                <span style={{ fontSize:10.5, color:"var(--fg-3)", lineHeight:1.4 }}>{d.src}</span>
              </div>
              <div style={{ alignSelf:"center", color: done ? "var(--green)" : current ? `var(--${color})` : "var(--fg-3)" }}>
                <Icon name="right" size={12}/>
              </div>
              <div className="col grow gap-2" style={{
                padding:"6px 8px",
                background: done ? "color-mix(in oklch, var(--cyan) 10%, var(--bg-2))" : "var(--bg-2)",
                border: done ? "1px solid color-mix(in oklch, var(--cyan) 30%, var(--line-1))" : "1px dashed var(--line-2)",
                borderRadius:4,
              }}>
                <span className="kpi-l" style={{ color: done ? "var(--cyan)" : "var(--fg-3)" }}>二创</span>
                <span style={{ fontSize:10.5, color: done ? "var(--fg-0)" : "var(--fg-3)", lineHeight:1.4, fontWeight: done?500:400, fontStyle: done?"normal":"italic" }}>
                  {done ? d.tgt : current ? "点击「改写本维度」开始生成…" : "锁定"}
                </span>
              </div>
            </div>
            {current && (
              <button className="btn sm primary" onClick={onRewrite} style={{ alignSelf:"flex-start" }}>
                <Icon name="magic" size={10}/>改写本维度
              </button>
            )}
          </div>
        );
      })}
      {revealed === dims.length && (
        <button className="btn primary" onClick={onNext} style={{ justifyContent:"center", marginTop:4, background: `var(--${color})`, color:"var(--bg-0)" }}>
          <Icon name="right" size={12}/>下一步：按二创文案生成分镜
        </button>
      )}
    </div>
  </div>
);

const OriginalCopy = ({ id, text }) => (
  <div className="card">
    <div className="card-hd"><span className="card-title">原文案 · 参考爆款</span><span className="card-sub">{id}</span></div>
    <div style={{ padding:"10px 12px", fontSize:11.5, color:"var(--fg-2)", lineHeight:1.5, background:"var(--bg-0)", borderRadius:"0 0 8px 8px" }}>
      「{text}」
    </div>
  </div>
);

const TrackRow = ({ label, ic, segs }) => (
  <div className="row gap-8">
    <div className="row gap-4" style={{ width:60, flexShrink:0, color:"var(--fg-3)" }}>
      <Icon name={ic} size={11}/>
      <span style={{ fontSize:10.5 }}>{label}</span>
    </div>
    <div className="row gap-2 grow" style={{ height:24 }}>
      {segs.map((s,i)=>(
        <div key={i} style={{
          flex: s.w,
          background: s.faded ? `color-mix(in oklch, ${s.col} 25%, var(--bg-2))` : s.col,
          color: s.faded ? "var(--fg-1)" : "var(--bg-0)",
          borderRadius:3, padding:"0 6px", display:"flex", alignItems:"center",
          fontSize:9.5, fontWeight:600, fontFamily:"var(--font-mono)", overflow:"hidden",
          whiteSpace:"nowrap", textOverflow:"ellipsis",
          borderLeft: s.faded ? `2px solid ${s.col}` : "none",
        }}>{s.t}</div>
      ))}
    </div>
  </div>
);

// ─────────── 3A · 短视频爆款复刻（真接入） ───────────

const VIDEO_PROVIDERS = ["runway", "seedance"];
const T2I_PROVIDERS = ["siliconflow", "openai_multimodal", "gemini", "seedance"];
const PROVIDER_LABEL = {
  runway: "Runway",
  seedance: "Seedance（豆包）",
  siliconflow: "硅基流动 FLUX",
  openai_multimodal: "OpenAI gpt-image",
  gemini: "Gemini Nano Banana",
};

// 把一个 parent job 按 replicate_runs 聚合到 4 状态分类
function classifyParent(parent) {
  const runs = parent.replicate_runs || [];
  if (!runs.length) return "todo";
  if (runs.some(r => r.status === "running" || r.status === "pending")) return "running";
  if (runs.some(r => r.status === "completed" || r.status === "partial")) return "done";
  return "failed";
}

// ─────────── 文案二创独立链路（仅在 URL 带 ?text_rewrite_job 时启用） ───────────
const TextRewriteTrack = ({ jobId }) => {
  const [job, setJob] = React.useState(null);
  const [matchData, setMatchData] = React.useState(null);
  const [templates, setTemplates] = React.useState([]);
  const [tplPick, setTplPick] = React.useState(null);
  const [rerunBusy, setRerunBusy] = React.useState(false);
  const [rejectBusy, setRejectBusy] = React.useState({});
  const [genDialogRow, setGenDialogRow] = React.useState(null);
  const pollerRef = React.useRef(null);

  const loadAll = React.useCallback(async () => {
    try {
      const j = await API.textRewrite.get(jobId);
      setJob(j);
      if (j && j.status === "completed") {
        try {
          const t = await API.textRewrite.matchTable(jobId);
          setMatchData(t);
        } catch (_) {}
      }
    } catch (_) {}
  }, [jobId]);

  React.useEffect(() => {
    loadAll();
    pollerRef.current = setInterval(loadAll, 2000);
    return () => { if (pollerRef.current) clearInterval(pollerRef.current); };
  }, [loadAll]);

  // running/pending 时不停轮询；completed/failed 时降到 8s
  React.useEffect(() => {
    if (!job) return;
    if (pollerRef.current) clearInterval(pollerRef.current);
    const interval = (job.status === "running" || job.status === "pending") ? 2000 : 8000;
    pollerRef.current = setInterval(loadAll, interval);
    return () => { if (pollerRef.current) clearInterval(pollerRef.current); };
  }, [job?.status, loadAll]);

  // 拉复刻类模板（提供"换模板重做"）
  React.useEffect(() => {
    API.templates.list({ category: "replicate" })
      .then(d => setTemplates(d.items || []))
      .catch(() => {});
  }, []);

  const rerunWithTemplate = async (templateId) => {
    setRerunBusy(true);
    try {
      await API.textRewrite.rerunWithTemplate(jobId, templateId);
      setTplPick(null);
      await loadAll();
    } catch (e) {
      alert("换模板重跑失败：" + (e.message || e));
    } finally { setRerunBusy(false); }
  };

  const reject = async (cid) => {
    setRejectBusy(b => ({ ...b, [cid]: true }));
    try {
      await API.textRewrite.rejectMatch(jobId, cid);
      const t = await API.textRewrite.matchTable(jobId);
      setMatchData(t);
    } catch (e) { alert(e.message || e); }
    finally { setRejectBusy(b => ({ ...b, [cid]: false })); }
  };

  // 文案二创侧的提交回调（GenerateShotDialog onSubmit 注入）
  const submitTextGen = async (payload) => {
    if (!genDialogRow) return;
    return API.textRewrite.generateShot(jobId, genDialogRow.chunk_id, payload);
  };

  if (!job) {
    return (
      <div className="card col center" style={{ padding: 40, gap: 8 }}>
        <Icon name="edit" size={20} style={{ color: "var(--fg-3)" }}/>
        <span style={{ fontSize: 11.5, color: "var(--fg-3)" }}>正在加载文案二创任务 {jobId.slice(0, 8)}…</span>
      </div>
    );
  }

  const pct = job.progress?.pct || 0;
  const step = job.progress?.step || "queued";
  const isRunning = job.status === "running" || job.status === "pending";
  const isDone = job.status === "completed";
  const isFail = job.status === "failed";

  const _STAGES = [
    { id: "downloader",         l: "下载" },
    { id: "audio_extract",      l: "抽音" },
    { id: "asr",                l: "ASR" },
    { id: "transcript_correct", l: "修字" },
    { id: "chunk_init",         l: "句切" },
    { id: "chunk_segment",      l: "段切" },
    { id: "rewrite",            l: "段级二创" },
  ];
  const stageDoneIdx = _STAGES.findIndex(s => s.id === step);

  return (
    <>
      {/* 状态卡 */}
      <div className="card">
        <div className="card-hd">
          <span className="card-title">文案二创独立链路 · {jobId.slice(0, 8)}</span>
          <span className="card-sub">{job.title || "（无标题）"} · {job.industry || "通用"}</span>
          <div className="grow"/>
          <span className={`tag ${isDone ? "green" : isFail ? "pink" : "cyan"}`}
                style={{ fontSize: 10 }}>
            {isDone ? "✓ 已完成" : isFail ? "✗ 失败" : `${step} ${pct}%`}
          </span>
        </div>
        {/* 7 stage 进度 */}
        <div className="row" style={{ padding: "10px 14px", gap: 4, alignItems: "center" }}>
          {_STAGES.map((s, i) => {
            const done = isDone || (stageDoneIdx >= 0 && i < stageDoneIdx);
            const cur = !isDone && stageDoneIdx === i;
            return (
              <React.Fragment key={s.id}>
                <span style={{
                  fontSize: 10,
                  padding: "2px 7px", borderRadius: 3,
                  background: done ? "color-mix(in oklch, var(--green) 14%, var(--bg-1))"
                            : cur  ? "color-mix(in oklch, var(--cyan)  14%, var(--bg-1))"
                                   : "var(--bg-1)",
                  color: done ? "var(--green)" : cur ? "var(--cyan)" : "var(--fg-3)",
                  border: `1px solid ${done ? "var(--green)" : cur ? "var(--cyan)" : "var(--line-1)"}`,
                }}>{i + 1}.{s.l}</span>
                {i < _STAGES.length - 1 && <span style={{ color: "var(--fg-3)" }}>→</span>}
              </React.Fragment>
            );
          })}
        </div>
        {isRunning && (
          <div style={{ padding: "0 14px 10px" }}>
            <div className="bar"><span style={{ width: `${pct}%`, background: "var(--cyan)", transition: "width .3s" }}/></div>
            <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginTop: 4 }}>
              {job.progress?.message || "—"}
            </div>
          </div>
        )}
        {isFail && (
          <div style={{ padding: "8px 14px", color: "var(--pink)", fontSize: 11 }}>
            {job.error || "失败"}
          </div>
        )}
      </div>

      {/* 模板选择 / 当前模板 */}
      {(isDone || stageDoneIdx >= 5) && (
        <div className="card">
          <div className="card-hd">
            <span className="card-title">复刻模板</span>
            <span className="card-sub">
              {(matchData?.applied_templates || []).length
                ? `当前：${matchData.applied_templates.join(" / ")}`
                : "当前：默认（未设置）— 不干预即用默认"}
            </span>
            <div className="grow"/>
            <a className="btn sm ghost" href="/templates" target="_blank" rel="noreferrer">
              <Icon name="ext" size={10}/>去模板库
            </a>
            <button className="btn sm" onClick={() => setTplPick("opening")}
                    disabled={rerunBusy}
                    style={{ background: "var(--purple)", color: "var(--bg-0)" }}>
              <Icon name="folder" size={10}/>{rerunBusy ? "重做中…" : "选择模板 / 换模板重做"}
            </button>
          </div>
          {tplPick && (
            <div className="col" style={{ padding: 10, gap: 6, borderTop: "1px solid var(--line-1)" }}>
              <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
                复刻类模板 ({templates.length}) — 选一条即触发 rewrite stage 重跑（前面 stage 已落盘 skip）
              </span>
              {templates.length === 0 && (
                <div style={{ fontSize: 11, color: "var(--fg-3)" }}>
                  尚无 replicate 类模板。<a href="/templates" target="_blank" rel="noreferrer" style={{ color: "var(--cyan)" }}>去模板库</a>新建。
                </div>
              )}
              {templates.map(t => (
                <div key={t.id} className="row between" style={{
                  padding: 6, border: "1px solid var(--line-1)", borderRadius: 4, background: "var(--bg-1)",
                  alignItems: "center",
                }}>
                  <div className="col gap-2">
                    <span style={{ fontSize: 11.5, color: "var(--fg-0)", fontWeight: 600 }}>{t.name}</span>
                    <span style={{ fontSize: 10, color: "var(--fg-3)" }}>{t.description || "—"}</span>
                  </div>
                  <button className="btn sm primary" onClick={() => rerunWithTemplate(t.id)} disabled={rerunBusy}
                          style={{ background: "var(--purple)", color: "var(--bg-0)" }}>
                    用这条 → 重做
                  </button>
                </div>
              ))}
              <div className="row gap-6" style={{ marginTop: 4 }}>
                <button className="btn sm" onClick={() => rerunWithTemplate(null)} disabled={rerunBusy}>
                  恢复默认 → 重做
                </button>
                <button className="btn sm" onClick={() => setTplPick(null)}>取消</button>
              </div>
            </div>
          )}
        </div>
      )}

      {/* 原文总文 */}
      {matchData && (
        <div className="card">
          <div className="card-hd">
            <span className="card-title">原文总文（从链接提炼）</span>
            <span className="card-sub">{(matchData.raw_script || "").length} 字</span>
            {matchData.strategy_label && (
              <span className="tag" style={{ fontSize: 9, marginLeft: 8,
                background: "color-mix(in oklch, var(--purple) 14%, var(--bg-1))",
                color: "var(--purple)" }}>
                策略：{matchData.strategy_label}
              </span>
            )}
          </div>
          <div className="col" style={{ padding: 12, gap: 8 }}>
            <div style={{ fontSize: 11.5, color: "var(--fg-1)", lineHeight: 1.7,
                          background: "var(--bg-1)", padding: 10, borderRadius: 4 }}>
              {matchData.raw_script || "—"}
            </div>
            {matchData.rewritten_script && (
              <details>
                <summary style={{ fontSize: 10.5, color: "var(--fg-3)", cursor: "pointer" }}>
                  二创总文（点开看完整）
                </summary>
                <div style={{ fontSize: 11.5, color: "var(--fg-0)", lineHeight: 1.7,
                              background: "color-mix(in oklch, var(--purple) 6%, var(--bg-0))",
                              padding: 10, borderRadius: 4, marginTop: 4 }}>
                  {matchData.rewritten_script}
                </div>
              </details>
            )}
          </div>
        </div>
      )}

      {/* 3 列对照表：① 原文 ② 二创文案 ③ 分镜素材（合并画面提示词 + 命中素材）*/}
      {matchData && matchData.rows && matchData.rows.length > 0 && (
        <div className="card">
          <div className="card-hd">
            <span className="card-title">分镜对照表（3 列）</span>
            <span className="card-sub">
              {matchData.rows.length} 段 · 原文 → 二创 → 分镜素材 · 1:1 绑定
            </span>
            <div className="grow"/>
            {matchData.gc?.deleted_count > 0 && (
              <span className="tag" style={{ fontSize: 9, padding: "0 5px", color: "var(--pink)" }}>
                刚 GC {matchData.gc.deleted_count} 条
              </span>
            )}
          </div>
          <div style={{ overflow: "auto", maxHeight: 720 }}>
            <table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed" }}>
              <colgroup>
                <col style={{ width: "26%" }}/>
                <col style={{ width: "30%" }}/>
                <col style={{ width: "44%" }}/>
              </colgroup>
              <thead>
                <tr>
                  {[
                    "① 原文",
                    "② 二创文案",
                    "③ 分镜素材（意境匹配）",
                  ].map((h, i) => (
                    <th key={i} style={{
                      padding: "8px 10px", textAlign: "left",
                      fontSize: 10.5, fontWeight: 600, color: "var(--fg-2)",
                      background: "var(--bg-1)", borderBottom: "1px solid var(--line-1)",
                      position: "sticky", top: 0, zIndex: 1,
                    }}>{h}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {matchData.rows.map(r => {
                  const m = r.visual_remix && r.visual_remix.match;
                  const isGenBusy = !!genBusy[r.chunk_id];
                  return (
                    <tr key={r.chunk_id}>
                      {/* 第①列：原文 + 段元信息 */}
                      <td style={{ padding: 10, fontSize: 11.5, lineHeight: 1.6,
                                   color: "var(--fg-0)", verticalAlign: "top",
                                   borderBottom: "1px solid var(--line-soft)" }}>
                        <div className="row gap-6" style={{ alignItems: "center", marginBottom: 6 }}>
                          <span className="mono" style={{ fontSize: 9, color: "var(--fg-3)" }}>
                            #{r.index + 1} · {r.chunk_id}
                          </span>
                          <span className="mono" style={{ fontSize: 9, color: "var(--fg-3)" }}>
                            {r.duration_sec.toFixed(1)}s
                          </span>
                          <span className="mono" style={{ fontSize: 9, color: "var(--purple)" }}>
                            tts {r.tts_duration_sec.toFixed(1)}s
                          </span>
                        </div>
                        {r.segment_text || <span style={{ color: "var(--fg-3)" }}>—</span>}
                      </td>
                      {/* 第②列：二创文案 */}
                      <td style={{ padding: 10, fontSize: 11.5, lineHeight: 1.6,
                                   color: "var(--fg-0)", verticalAlign: "top",
                                   borderBottom: "1px solid var(--line-soft)" }}>
                        <div style={{ fontWeight: 500 }}>{r.rewrite_text || "—"}</div>
                      </td>
                      {/* 第③列：分镜素材 = 画面提示词 + 命中素材卡 */}
                      <td style={{ padding: 10, verticalAlign: "top",
                                   borderBottom: "1px solid var(--line-soft)" }}>
                        {/* 画面意图（mood + anchors，简化展示） */}
                        {(r.mood || (r.visual_anchors || []).length > 0) && (
                          <details style={{ marginBottom: 8 }}>
                            <summary style={{ fontSize: 10, color: "var(--fg-3)", cursor: "pointer" }}>
                              画面意图 {r.mood ? "· " + r.mood.slice(0, 30) : ""}
                              {(r.visual_anchors || []).length > 0
                                ? ` · ${r.visual_anchors.length} 个锚点` : ""}
                            </summary>
                            <div style={{ marginTop: 4, fontSize: 10.5, lineHeight: 1.55,
                                          color: "var(--fg-2)" }}>
                              {r.mood && <div style={{ color: "var(--fg-1)" }}>{r.mood}</div>}
                              {(r.visual_anchors || []).length > 0 && (
                                <div className="row gap-3" style={{ marginTop: 4, flexWrap: "wrap" }}>
                                  {r.visual_anchors.map((a, i) => (
                                    <span key={i} className="tag" style={{ fontSize: 9.5, padding: "1px 5px",
                                      background: "color-mix(in oklch, var(--cyan) 12%, var(--bg-1))",
                                      color: "var(--cyan)" }}>{a}</span>
                                  ))}
                                </div>
                              )}
                            </div>
                          </details>
                        )}
                        {m ? (
                          <>
                            <SeedMatchCard match={m} assignedNow={r.visual_remix.assigned}/>
                            <div className="row gap-6" style={{ marginTop: 6, flexWrap: "wrap" }}>
                              <button className="btn sm" disabled={rejectBusy[r.chunk_id]}
                                      onClick={() => reject(r.chunk_id)}
                                      style={{ fontSize: 10 }}>
                                <Icon name="close" size={9}/>
                                {rejectBusy[r.chunk_id] ? "重选中…" : "✗ 不合适"}
                              </button>
                              <button className="btn sm"
                                      onClick={() => {
                                        const ret = encodeURIComponent(`/remix?text_rewrite_job=${jobId}`);
                                        const bind = encodeURIComponent(`tr-${jobId}:${r.chunk_id}`);
                                        const url = `/shots-library?bind_to=${bind}&current=${m.clip_id}&return=${ret}`;
                                        window.history.pushState({}, "", url);
                                        window.dispatchEvent(new PopStateEvent("popstate"));
                                      }}
                                      style={{ fontSize: 10, color: "var(--cyan)" }}>
                                <Icon name="folder" size={9}/>手动选片 →
                              </button>
                            </div>
                          </>
                        ) : (
                          <div className="col gap-6" style={{
                            padding: 8, border: "1px dashed var(--purple)", borderRadius: 4,
                            background: "color-mix(in oklch, var(--purple) 6%, var(--bg-0))",
                          }}>
                            <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
                              种子库未命中 → 走生成路线（点击编辑两段提示词后提交）
                            </span>
                            <div style={{ fontSize: 10, color: "var(--fg-2)", lineHeight: 1.5 }}>
                              将带的载荷：
                              <div className="mono" style={{ background: "var(--bg-1)", padding: 6, borderRadius: 3, marginTop: 3 }}>
                                user_prompt: {(r.aigc_prompt || "—").slice(0, 50)}…<br/>
                                tts_duration_sec: {r.tts_duration_sec}
                              </div>
                            </div>
                            <button className="btn sm"
                                    onClick={() => setGenDialogRow(r)}
                                    style={{ background: "var(--purple)", color: "var(--bg-0)" }}>
                              <Icon name="magic" size={10}/>
                              → 编辑提示词 + 提交单镜生成
                            </button>
                          </div>
                        )}
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        </div>
      )}

      {/* 走生成弹窗：复用拆解侧 GenerateShotDialog，但 onSubmit 路由到 text_rewrite API */}
      {genDialogRow && (
        <GenerateShotDialog
          jobId={jobId}
          chunkId={genDialogRow.chunk_id}
          originalVideoUrl={null}
          initialUserPrompt={genDialogRow.aigc_prompt || ""}
          extraInfo={{
            tts_duration_sec: genDialogRow.tts_duration_sec,
            rewrite_text: genDialogRow.rewrite_text,
          }}
          onSubmit={submitTextGen}
          onClose={() => setGenDialogRow(null)}
          onSubmitted={() => { setTimeout(() => setGenDialogRow(null), 1500); }}
        />
      )}
    </>
  );
};


// 图片预览灯箱：一次显示 3 张，左右翻页；ESC / backdrop 关闭
// preview = { images: [{url, label?, caption?}], startIndex }
const ImagePreviewModal = ({ preview, onClose }) => {
  const [page, setPage] = React.useState(0);
  const PAGE_SIZE = 3;
  React.useEffect(() => {
    if (preview) setPage(Math.floor((preview.startIndex || 0) / PAGE_SIZE));
  }, [preview]);
  React.useEffect(() => {
    if (!preview) return;
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
      if (e.key === "ArrowRight") setPage(p => Math.min(p + 1, Math.ceil((preview.images.length || 1) / PAGE_SIZE) - 1));
      if (e.key === "ArrowLeft")  setPage(p => Math.max(p - 1, 0));
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [preview, onClose]);
  if (!preview) return null;
  const images = preview.images || [];
  const totalPages = Math.max(1, Math.ceil(images.length / PAGE_SIZE));
  const start = page * PAGE_SIZE;
  const visible = images.slice(start, start + PAGE_SIZE);
  return (
    <div onClick={onClose}
         style={{
           position: "fixed", inset: 0, zIndex: 9999,
           background: "rgba(0,0,0,0.78)",
           display: "flex", alignItems: "center", justifyContent: "center",
           padding: 24,
         }}>
      <div onClick={e => e.stopPropagation()}
           style={{ width: "min(1200px, 96vw)", maxHeight: "92vh",
                    display: "flex", flexDirection: "column", gap: 12 }}>
        {/* 顶部 bar */}
        <div className="row" style={{ alignItems: "center", color: "white", gap: 10 }}>
          <span style={{ fontSize: 12, fontWeight: 600 }}>{preview.title || "图片预览"}</span>
          <span style={{ fontSize: 11, opacity: 0.7 }}>
            第 {page + 1}/{totalPages} 页 · {images.length} 张总数
          </span>
          <div className="grow"/>
          <button className="btn sm ghost" onClick={() => setPage(p => Math.max(0, p - 1))}
                  disabled={page === 0}
                  style={{ color: "white", borderColor: "rgba(255,255,255,0.3)" }}>
            ← 上一页
          </button>
          <button className="btn sm ghost" onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
                  disabled={page >= totalPages - 1}
                  style={{ color: "white", borderColor: "rgba(255,255,255,0.3)" }}>
            下一页 →
          </button>
          <button className="btn sm ghost" onClick={onClose}
                  style={{ color: "white", borderColor: "rgba(255,255,255,0.3)" }}>
            <Icon name="close" size={11}/> 关闭
          </button>
        </div>
        {/* 主体：3 张并排 */}
        <div style={{ display: "grid",
                      gridTemplateColumns: `repeat(${Math.min(PAGE_SIZE, visible.length || 1)}, 1fr)`,
                      gap: 12, flex: 1, minHeight: 0 }}>
          {visible.map((img, i) => (
            <div key={start + i} className="col" style={{ gap: 6, minHeight: 0 }}>
              <div style={{ flex: 1, minHeight: 0, background: "#111",
                            borderRadius: 6, overflow: "hidden",
                            display: "flex", alignItems: "center", justifyContent: "center" }}>
                {img.url
                  ? <img src={img.url} alt={img.label || ""} style={{
                      width: "100%", height: "100%", objectFit: "contain",
                    }}/>
                  : <div style={{ color: "rgba(255,255,255,0.4)", fontSize: 12, padding: 16,
                                  textAlign: "center" }}>
                      （占位）<br/>{img.caption || ""}
                    </div>}
              </div>
              <div style={{ fontSize: 11, color: "rgba(255,255,255,0.85)" }}>
                {img.label || `#${start + i + 1}`}
              </div>
              {img.caption && (
                <div style={{ fontSize: 10.5, color: "rgba(255,255,255,0.6)",
                              lineHeight: 1.45,
                              maxHeight: 84, overflowY: "auto" }}>
                  {img.caption}
                </div>
              )}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

// 二创标题 / 文案行内编辑器：展示时是只读 div；点 ✎ 切到 input/textarea；保存通过 onSave 落库
const EditableCopyField = ({ label, value, placeholder, multiline, onSave }) => {
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft]     = React.useState(value || "");
  const [busy, setBusy]       = React.useState(false);
  const [err, setErr]         = React.useState("");
  React.useEffect(() => { setDraft(value || ""); }, [value, editing]);

  const cancel = () => { setDraft(value || ""); setErr(""); setEditing(false); };
  const save   = async () => {
    setBusy(true); setErr("");
    try { await onSave(draft); setEditing(false); }
    catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  return (
    <div>
      <div className="row" style={{ display: "flex", alignItems: "center", marginBottom: 2 }}>
        <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>{label}</span>
        <span style={{ flex: 1 }}/>
        {!editing && (
          <button className="btn sm ghost" onClick={() => setEditing(true)}
                  style={{ fontSize: 10, padding: "1px 6px" }}
                  title="人手编辑这段文案（覆盖 LLM 草稿，落 _replicate.json）">
            ✎ 编辑
          </button>
        )}
      </div>
      {editing ? (
        <>
          {multiline
            ? <textarea className="ppinput" rows={6} value={draft}
                        onChange={e => setDraft(e.target.value)}
                        style={{ fontSize: 11.5, lineHeight: 1.55, fontFamily: "inherit" }}/>
            : <input className="ppinput" value={draft}
                     onChange={e => setDraft(e.target.value)}
                     style={{ fontSize: 12.5, fontWeight: 600 }}/>}
          {err && <div style={{ fontSize: 10.5, color: "var(--pink)", marginTop: 4 }}>⚠ {err}</div>}
          <div className="row" style={{ display: "flex", gap: 6, marginTop: 6 }}>
            <button className="btn primary sm" disabled={busy} onClick={save}>
              {busy ? "保存中…" : "✓ 保存"}
            </button>
            <button className="btn sm ghost" disabled={busy} onClick={cancel}>取消</button>
          </div>
        </>
      ) : (
        <div style={{
          fontSize: multiline ? 11.5 : 12.5,
          fontWeight: multiline ? 400 : 600,
          color: multiline ? "var(--fg-1)" : "var(--fg-0)",
          lineHeight: multiline ? 1.55 : 1.4,
          whiteSpace: "pre-wrap", wordBreak: "break-word",
          maxHeight: multiline ? 240 : undefined,
          overflowY: multiline ? "auto" : undefined,
          minHeight: 18,
        }}>
          {value || <span style={{ color: "var(--fg-3)", fontWeight: 400 }}>{placeholder}</span>}
        </div>
      )}
    </div>
  );
};

const RemixVideo = () => {
  const [tab, setTab] = React.useState("todo");
  // /remix?text_rewrite_job=<id> 时切到独立链路
  const [textRewriteJobId, setTextRewriteJobId] = React.useState(() => {
    const u = new URL(window.location.href);
    return u.searchParams.get("text_rewrite_job") || null;
  });
  React.useEffect(() => {
    const onPop = () => {
      const u = new URL(window.location.href);
      setTextRewriteJobId(u.searchParams.get("text_rewrite_job") || null);
    };
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);
  const [parents, setParents] = React.useState([]);
  const [credentials, setCredentials] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [activeParentId, setActiveParentId] = React.useState(null);
  const [activeStoryboard, setActiveStoryboard] = React.useState(null);
  const [activeImagePost, setActiveImagePost] = React.useState(null);
  const [activeImageChildId, setActiveImageChildId] = React.useState(null);
  const [activeImagePlan, setActiveImagePlan] = React.useState(null);
  const [activeImageGen, setActiveImageGen] = React.useState(null);
  const [imageReplicating, setImageReplicating] = React.useState(false);
  const [imageGenerating, setImageGenerating] = React.useState(false);
  const [imageReplicateErr, setImageReplicateErr] = React.useState("");
  const [t2iCreds, setT2ICreds] = React.useState([]);
  const [selT2ICredId, setSelT2ICredId] = React.useState(null);
  // 规则模板：图文复刻 LLM 在该规则约束下产出；空 = 无规则限制；localStorage 记忆
  const [rulePacks, setRulePacks] = React.useState([]);
  const [selRulePackId, setSelRulePackId] = React.useState(() => {
    try { return localStorage.getItem("remix.imageReplicate.ruleId") || ""; } catch { return ""; }
  });
  // 1-based 索引集合；选哪几张原图作为复刻参考
  const [selectedOrigIdx, setSelectedOrigIdx] = React.useState(() => new Set());
  // { images: [{url, label?, prompt?}], startIndex: number } | null
  const [preview, setPreview] = React.useState(null);
  const [showDialog, setShowDialog] = React.useState(false);

  // 弹窗 state
  const [selCredId, setSelCredId] = React.useState(null);
  const [targetSec, setTargetSec] = React.useState(15);
  const [aspectRatio, setAspectRatio] = React.useState("9:16");
  const [submitting, setSubmitting] = React.useState(false);
  const [submitErr, setSubmitErr] = React.useState("");

  // 实时进度（订阅 child job）
  const [activeChildId, setActiveChildId] = React.useState(null);
  const [childProgress, setChildProgress] = React.useState(null);
  const disposeRef = React.useRef(null);

  const loadAll = async () => {
    setLoading(true);
    try {
      const [j, c, rp] = await Promise.all([
        API.jobs.list().catch(() => ({ items: [] })),
        API.credentials.list().catch(() => ({ items: [] })),
        API.rulePacks.list().catch(() => ({ items: [] })),
      ]);
      setParents((j.items || []).filter(p => p.has_result && p.status === "completed"));
      const allCreds = c.items || [];
      setCredentials(allCreds.filter(
        cc => VIDEO_PROVIDERS.includes(cc.provider) && (cc.extra?.model_name)
      ));
      setT2ICreds(allCreds.filter(
        cc => T2I_PROVIDERS.includes(cc.provider) && (cc.extra?.model_name)
      ));
      setRulePacks((rp.items || []).filter(r => r.enabled !== false));
    } finally { setLoading(false); }
  };
  React.useEffect(() => {
    loadAll();
    const t = setInterval(loadAll, 5000);
    return () => clearInterval(t);
  }, []);

  // 拉一个 image-replicate child 的 plan/gen 状态
  const loadImageReplicateState = React.useCallback(async (childId) => {
    if (!childId) {
      setActiveImagePlan(null);
      setActiveImageGen(null);
      return;
    }
    try {
      const r = await API.replicate.getResult(childId);
      const plan = r?.image_replication?.plan || null;
      const gen  = r?.image_replication?.image_generation || null;
      setActiveImagePlan(plan);
      setActiveImageGen(gen);
    } catch {
      setActiveImagePlan(null);
      setActiveImageGen(null);
    }
  }, []);

  React.useEffect(() => {
    if (!activeParentId) {
      setActiveStoryboard(null);
      setActiveImagePost(null);
      setActiveImageChildId(null);
      setActiveImagePlan(null);
      setActiveImageGen(null);
      setSelectedOrigIdx(new Set());
      setImageReplicateErr("");
      return;
    }
    setSelectedOrigIdx(new Set());
    let cancelled = false;
    setImageReplicateErr("");
    API.jobs.result(activeParentId).then(d => {
      if (cancelled) return;
      if (d?.meta?.is_image_post) {
        setActiveStoryboard(null);
        setActiveImagePost(d.image_post || { image_count: 0, image_urls: [] });
        // 找最近一次 image-replicate child（按 created_at 倒序）
        const parent = (parents || []).find(p => p.job_id === activeParentId);
        const runs = (parent?.replicate_runs || [])
          .filter(r => r.kind === "image_replicate")
          .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
        const latest = runs[0];
        const childId = latest?.child_job_id || null;
        setActiveImageChildId(childId);
        loadImageReplicateState(childId);
        return;
      }
      setActiveImagePost(null);
      setActiveImageChildId(null);
      setActiveImagePlan(null);
      setActiveImageGen(null);
      const sb = d?.rewrite?.storyboard || null;
      setActiveStoryboard(sb);
      if (sb) {
        setTargetSec(Math.max(1, Math.round(sb.total_duration_sec) || 15));
        setAspectRatio(sb.aspect_ratio || "9:16");
      }
    }).catch(() => {
      if (!cancelled) {
        setActiveStoryboard(null);
        setActiveImagePost(null);
        setActiveImageChildId(null);
        setActiveImagePlan(null);
        setActiveImageGen(null);
      }
    });
    return () => { cancelled = true; };
  }, [activeParentId, parents, loadImageReplicateState]);

  const onStartImageReplicate = async () => {
    if (!activeParentId || imageReplicating) return;
    setImageReplicating(true);
    setImageReplicateErr("");
    try {
      const sel = Array.from(selectedOrigIdx).sort((a, b) => a - b);
      const r = await API.replicate.start(activeParentId, {
        image_count: sel.length || activeImagePost?.image_count || undefined,
        selected_image_indices: sel.length ? sel : undefined,
        rule_pack_id: selRulePackId || undefined,
      });
      if (r?.child_job_id) {
        setActiveImageChildId(r.child_job_id);
        subscribeChild(r.child_job_id);
        loadAll();
      } else {
        setImageReplicateErr("提交成功但未返回 child_job_id");
      }
    } catch (e) {
      setImageReplicateErr(`提交失败：${e.message || e}`);
    } finally {
      setImageReplicating(false);
    }
  };

  const onGenerateImages = async () => {
    if (!activeImageChildId || imageGenerating) return;
    if (!selT2ICredId) {
      setImageReplicateErr("请先选择出图模型（T2I 凭据）");
      return;
    }
    setImageGenerating(true);
    setImageReplicateErr("");
    try {
      await API.replicate.generateImages(activeImageChildId, selT2ICredId);
      await loadImageReplicateState(activeImageChildId);
      loadAll();
    } catch (e) {
      setImageReplicateErr(`出图失败：${e.message || e}`);
    } finally {
      setImageGenerating(false);
    }
  };

  const subscribeChild = (childId) => {
    if (disposeRef.current) disposeRef.current();
    setActiveChildId(childId);
    setChildProgress({ step: "queued", pct: 0, message: "已排队" });
    disposeRef.current = subscribeJobProgress(childId, (kind, payload) => {
      if (kind === "progress") setChildProgress(payload);
      if (kind === "done") {
        setChildProgress(prev => ({ ...prev, pct: 100, message: payload.error || "已完成" }));
        loadAll();
        if (disposeRef.current) { disposeRef.current(); disposeRef.current = null; }
      }
    });
  };
  React.useEffect(() => () => { if (disposeRef.current) disposeRef.current(); }, []);

  const counts = React.useMemo(() => {
    const out = { todo: 0, running: 0, done: 0, failed: 0 };
    for (const p of parents) out[classifyParent(p)]++;
    return out;
  }, [parents]);
  const visible = parents.filter(p => classifyParent(p) === tab);

  const onSubmit = async () => {
    setSubmitErr("");
    if (!activeParentId) return;
    if (!selCredId) { setSubmitErr("请选择视频生成模型（凭据）"); return; }
    const total = activeStoryboard?.total_duration_sec || 0;
    if (total > 0) {
      const lo = total * 0.5, hi = total * 1.5;
      if (targetSec < lo || targetSec > hi) {
        setSubmitErr(`时长应在 ${lo.toFixed(1)}–${hi.toFixed(1)}s 之间（原 storyboard ${total}s 的 ±50%）`);
        return;
      }
    }
    setSubmitting(true);
    try {
      const r = await API.replicate.start(activeParentId, {
        credential_id: selCredId,
        target_duration_sec: targetSec,
        aspect_ratio: aspectRatio,
      });
      setShowDialog(false);
      setTab("running");
      subscribeChild(r.child_job_id);
      loadAll();
    } catch (e) {
      setSubmitErr(String(e.message || e));
    } finally { setSubmitting(false); }
  };

  // ─── 文案二创独立链路：覆盖 RemixVideo 渲染 ───
  if (textRewriteJobId) {
    return (
      <>
        <Topbar
          crumbs={["3 · 复刻爆款", "文案二创独立链路", textRewriteJobId.slice(0, 8)]}
          right={<>
            <span className="pill" style={{
              background: "color-mix(in oklch, var(--purple) 14%, var(--bg-1))",
              color: "var(--purple)",
            }}>
              <Icon name="edit" size={10}/>纯文案 · 7 stage
            </span>
            <button className="btn ghost" onClick={() => {
              window.history.pushState({}, "", "/remix");
              window.dispatchEvent(new PopStateEvent("popstate"));
            }}>
              <Icon name="close" size={10}/>退出独立链路
            </button>
          </>}
        />
        <div className="col grow scroll" style={{ padding: "12px 14px 24px", gap: 12, background: "var(--bg-0)" }}>
          {/* 共享进度组件（与拆解 / 质检页 UI 一致；含 7 stage 步骤条 + 跳转按钮 + 段级二创编辑）*/}
          <TextRewriteProgress jobId={textRewriteJobId} currentRoute="remix"/>
          {/* remix 特有：4 列对照表 + 素材匹配 + 走生成路线 */}
          <TextRewriteTrack jobId={textRewriteJobId}/>
        </div>
      </>
    );
  }

  return (
    <>
      <Topbar
        crumbs={["3 · 复刻爆款", activeParentId ? (parents.find(p=>p.job_id===activeParentId)?.title || "选中") : "选择视频"]}
        right={<>
          <span className="pill" style={{ background:"color-mix(in oklch, var(--purple) 14%, var(--bg-1))", color:"var(--purple)" }}>
            <Icon name="play" size={10}/>T2V · 文生视频
          </span>
          <button className="btn ghost" onClick={loadAll}><Icon name="refresh" size={11}/>刷新</button>
        </>}
      />

      {/* 4 tab */}
      <div className="row gap-6" style={{ padding:"10px 14px", borderBottom:"1px solid var(--line-1)", background:"var(--bg-1)" }}>
        {[
          { k:"todo", label:`未复刻 ${counts.todo}` },
          { k:"running", label:`复刻中 ${counts.running}` },
          { k:"done", label:`已复刻 ${counts.done}` },
          { k:"failed", label:`失败 ${counts.failed}` },
        ].map(t => (
          <button key={t.k} className="btn sm" onClick={() => setTab(t.k)} style={{
            background: tab===t.k ? "color-mix(in oklch, var(--purple) 16%, var(--bg-1))" : "transparent",
            color: tab===t.k ? "var(--purple)" : "var(--fg-1)",
            border: tab===t.k ? "1px solid var(--purple)" : "1px solid var(--line-1)",
          }}>{t.label}</button>
        ))}
      </div>

      <div className="main-body">
        {/* 左：parent 列表 */}
        <div className="col scroll" style={{ width:380, flexShrink:0, padding:"12px 12px", gap:8, borderRight:"1px solid var(--line-soft)", background:"var(--bg-0)" }}>
          {loading && !visible.length && <div style={{ fontSize:11, color:"var(--fg-3)", padding:20, textAlign:"center" }}>加载中…</div>}
          {!loading && !visible.length && (
            <div style={{ fontSize:11, color:"var(--fg-3)", padding:20, textAlign:"center", lineHeight:1.7 }}>
              {tab==="todo" && <>暂无未复刻视频<br/>到 <a href="/workspace" style={{color:"var(--cyan)"}}>2·拆解爆款</a> 完成拆解后回来</>}
              {tab==="running" && "暂无复刻中任务"}
              {tab==="done" && "暂无已复刻视频"}
              {tab==="failed" && "暂无失败任务"}
            </div>
          )}
          {visible.map(p => {
            const runs = p.replicate_runs || [];
            const last = runs[runs.length - 1];
            return (
              <div key={p.job_id} onClick={() => setActiveParentId(p.job_id)} className="col gap-4" style={{
                padding:10, borderRadius:6, cursor:"pointer",
                background: p.job_id===activeParentId ? "color-mix(in oklch, var(--purple) 8%, var(--bg-1))" : "var(--bg-1)",
                border:`1px solid ${p.job_id===activeParentId ? "var(--purple)" : "var(--line-1)"}`,
              }}>
                <span style={{ fontSize:12, fontWeight:600, color:"var(--fg-0)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                  {p.title || p.job_id.slice(0,8)}
                </span>
                <div className="row gap-4" style={{ alignItems:"center", flexWrap:"wrap" }}>
                  <span className="mono" style={{ fontSize:9.5, color:"var(--fg-3)" }}>
                    {p.industry || "general"} · {(p.updated_at||"").slice(0,16).replace("T"," ")}
                  </span>
                  {p.replicable
                    ? (p.kind === "image_post"
                        ? <span className="tag green" style={{ fontSize:9 }}>✓ {p.image_count || 0} 图可复刻</span>
                        : <span className="tag green" style={{ fontSize:9 }}>✓ {p.shots_count || 0} 镜可复刻</span>)
                    : <span className="tag pink" style={{ fontSize:9 }} title={p.replicable_reason||"无 storyboard"}>⚠ 不可复刻</span>}
                </div>
                {runs.length > 0 && last && (
                  <div className="row gap-4" style={{ fontSize:10, color:"var(--fg-2)" }}>
                    <span>{runs.length} 次复刻 · 最近 {PROVIDER_LABEL[last.platform]||last.platform} · {last.status}</span>
                  </div>
                )}
              </div>
            );
          })}
        </div>

        {/* 右：详情 */}
        <div className="col grow scroll" style={{ padding:"14px 14px 20px", gap:12, background:"var(--bg-0)" }}>
          {!activeParentId && (
            <div className="card col center" style={{ padding:"60px 20px", gap:10, textAlign:"center" }}>
              <Icon name="magic" size={24} style={{ color:"var(--fg-3)" }} />
              <div style={{ fontSize:13, color:"var(--fg-1)" }}>请在左侧选择一个拆解视频</div>
              <div style={{ fontSize:11, color:"var(--fg-3)" }}>右侧将显示 storyboard 预览与复刻按钮</div>
            </div>
          )}
          {activeParentId && activeImagePost && (() => {
            const origImgs   = activeImagePost.image_urls || [];
            const origTitle  = activeImagePost.original_title || "";
            const origCap    = activeImagePost.original_caption || "";
            const plan       = activeImagePlan || null;
            const newTitle   = plan?.title_replicate?.draft || plan?.title_draft || "";
            const newCap     = plan?.caption_replicate?.draft || plan?.caption_draft || "";
            const newShots   = plan?.new_images || [];
            const genItems   = activeImageGen?.items || [];
            const childInProgress = activeImageChildId && !plan;
            const hasPlan    = !!plan;
            const hasImages  = genItems.some(it => it.ok);

            // 二创图片：若已生成，按 index 拿；否则留空位
            const newImgUrlByIdx = {};
            for (const it of genItems) {
              if (it.ok && it.image_url) newImgUrlByIdx[it.index] = it.image_url;
            }
            const slotCount = newShots.length || origImgs.length || 0;

            return (
            <div className="card" style={{ padding: 0 }}>
              {/* ── 顶部 action bar ── */}
              <div className="card-hd row" style={{ gap: 8, alignItems: "center" }}>
                <span className="card-title">图文复刻预览</span>
                <span className="card-sub">
                  {hasImages ? "✓ 已生成新图" :
                   hasPlan   ? "二创文字就绪 · 待出图" :
                   childInProgress ? "二创拆解中…" :
                                     `未开始复刻 · 已选 ${selectedOrigIdx.size}/${origImgs.length} 张`}
                </span>
                <div className="grow"/>
                {!hasPlan && (
                  <>
                    <select
                      className="ppinput"
                      style={{ width: 200, fontSize: 11 }}
                      value={selRulePackId}
                      disabled={imageReplicating || childInProgress}
                      onChange={e => {
                        setSelRulePackId(e.target.value);
                        try { localStorage.setItem("remix.imageReplicate.ruleId", e.target.value); } catch {}
                      }}
                      title="选规则模板：复刻 LLM 会在该规则约束下产出。不选 = 无规则限制">
                      <option value="">无规则限制</option>
                      {rulePacks.map(r => (
                        <option key={r.pack_id} value={r.pack_id}>{r.display_name}</option>
                      ))}
                    </select>
                    <button
                      className="btn sm primary"
                      onClick={onStartImageReplicate}
                      disabled={imageReplicating || childInProgress}
                      title={selectedOrigIdx.size === 0
                        ? "未勾选则用全部原图作参考；勾选后只用所选图"
                        : `用所选 ${selectedOrigIdx.size} 张作参考复刻`}
                      style={{
                        background: (imageReplicating||childInProgress) ? "var(--bg-2)" : "var(--purple)",
                        color: (imageReplicating||childInProgress) ? "var(--fg-3)" : "var(--bg-0)",
                        cursor: (imageReplicating||childInProgress) ? "not-allowed" : "pointer",
                      }}>
                      <Icon name="magic" size={11}/>
                      {imageReplicating ? "提交中…" :
                       childInProgress  ? "二创拆解中…" :
                       selectedOrigIdx.size > 0
                                        ? `用 ${selectedOrigIdx.size} 张图开始复刻`
                                        : "开始复刻（用全部原图）"}
                    </button>
                  </>
                )}
                {hasPlan && (
                  <>
                    <select
                      className="ppinput"
                      style={{ width: 200, fontSize: 11 }}
                      value={selT2ICredId || ""}
                      onChange={e => setSelT2ICredId(parseInt(e.target.value, 10) || null)}>
                      <option value="">— 选 T2I 出图模型 —</option>
                      {t2iCreds.map(c => (
                        <option key={c.id} value={c.id}>
                          {PROVIDER_LABEL[c.provider] || c.provider} · {c.extra?.model_name || c.name}
                        </option>
                      ))}
                    </select>
                    <button
                      className="btn sm primary"
                      onClick={onGenerateImages}
                      disabled={imageGenerating || !selT2ICredId}
                      style={{
                        background: (imageGenerating||!selT2ICredId) ? "var(--bg-2)" : "var(--purple)",
                        color: (imageGenerating||!selT2ICredId) ? "var(--fg-3)" : "var(--bg-0)",
                        cursor: (imageGenerating||!selT2ICredId) ? "not-allowed" : "pointer",
                      }}>
                      <Icon name="play" size={11}/>
                      {imageGenerating ? "出图中…" : hasImages ? "重新生成二创图片" : "执行生成二创图片"}
                    </button>
                  </>
                )}
              </div>
              {imageReplicateErr && (
                <div style={{ padding:"8px 12px", borderTop:"1px solid var(--line-1)",
                              fontSize:11, color:"var(--pink)" }}>
                  ⚠ {imageReplicateErr}
                </div>
              )}

              {/* ── 双栏：原帖 vs 二创 ── */}
              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr",
                            gap: 12, padding: 12, borderTop: "1px solid var(--line-1)" }}>
                {/* 左：原帖 */}
                <div className="col" style={{ gap: 8, padding: 10, background: "var(--bg-1)",
                                              border: "1px solid var(--line-1)", borderRadius: 6 }}>
                  <div className="row" style={{ alignItems: "center", gap: 6, flexWrap: "wrap" }}>
                    <span className="tag" style={{ fontSize: 9.5 }}>原帖</span>
                    <span style={{ fontSize: 11, color: "var(--fg-3)" }}>{origImgs.length} 张</span>
                    {!hasPlan && origImgs.length > 0 && (
                      <>
                        <span style={{ fontSize: 11, color: "var(--purple)", marginLeft: 4 }}>
                          已选 {selectedOrigIdx.size}
                        </span>
                        <button className="btn sm ghost"
                                onClick={() => setSelectedOrigIdx(new Set(
                                  origImgs.map((_, i) => i + 1)
                                ))}
                                style={{ fontSize: 10, padding: "2px 8px" }}>全选</button>
                        <button className="btn sm ghost"
                                onClick={() => setSelectedOrigIdx(new Set())}
                                style={{ fontSize: 10, padding: "2px 8px" }}>清空</button>
                      </>
                    )}
                    <div className="grow"/>
                    <button className="btn sm ghost"
                            onClick={() => setPreview({
                              title: "原帖图片预览",
                              images: origImgs.map((u, i) => ({
                                url: u, label: `原图 #${i+1}`,
                              })),
                              startIndex: 0,
                            })}
                            disabled={origImgs.length === 0}
                            style={{ fontSize: 10, padding: "2px 8px" }}>
                      <Icon name="eye" size={10}/> 预览
                    </button>
                  </div>
                  <div>
                    <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginBottom: 2 }}>标题</div>
                    <div style={{ fontSize: 12.5, fontWeight: 600, color: "var(--fg-0)",
                                  whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
                      {origTitle || <span style={{ color: "var(--fg-3)", fontWeight: 400 }}>（原帖无标题）</span>}
                    </div>
                  </div>
                  <div>
                    <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginBottom: 2 }}>文案</div>
                    <div style={{ fontSize: 11.5, color: "var(--fg-1)", lineHeight: 1.55,
                                  whiteSpace: "pre-wrap", wordBreak: "break-word",
                                  maxHeight: 240, overflowY: "auto" }}>
                      {origCap || <span style={{ color: "var(--fg-3)" }}>（原帖无文案）</span>}
                    </div>
                  </div>
                  <div>
                    <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginBottom: 4 }}>图片</div>
                    <div style={{ display: "grid",
                                  gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))",
                                  gap: 6 }}>
                      {origImgs.map((u, i) => {
                        const idx = i + 1;
                        const checked = selectedOrigIdx.has(idx);
                        const selectable = !hasPlan;
                        return (
                          <div key={i}
                               onClick={() => {
                                 if (!selectable) return;
                                 setSelectedOrigIdx(prev => {
                                   const next = new Set(prev);
                                   if (next.has(idx)) next.delete(idx); else next.add(idx);
                                   return next;
                                 });
                               }}
                               title={selectable
                                 ? `${checked ? "取消" : "选中"}原图 #${idx}（点击切换）`
                                 : `原图 #${idx}`}
                               style={{
                                 aspectRatio: "3/4", borderRadius: 4,
                                 overflow: "hidden", background: "var(--bg-2)",
                                 border: checked
                                   ? "2px solid var(--purple)"
                                   : "1px solid var(--line-1)",
                                 cursor: selectable ? "pointer" : "default",
                                 position: "relative",
                                 opacity: !selectable || checked || selectedOrigIdx.size === 0 ? 1 : 0.55,
                                 transition: "opacity .15s, border-color .15s",
                               }}>
                            <img src={u} alt={`orig_${idx}`} loading="lazy"
                                 style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
                            {selectable && (
                              <div style={{
                                position: "absolute", top: 3, right: 3,
                                width: 16, height: 16, borderRadius: 8,
                                background: checked ? "var(--purple)" : "rgba(255,255,255,0.85)",
                                color: checked ? "white" : "var(--fg-3)",
                                fontSize: 10, fontWeight: 700,
                                display: "flex", alignItems: "center", justifyContent: "center",
                                boxShadow: "0 1px 3px rgba(0,0,0,0.25)",
                              }}>
                                {checked ? "✓" : idx}
                              </div>
                            )}
                            <button
                              onClick={(e) => {
                                e.stopPropagation();
                                setPreview({
                                  title: "原帖图片预览",
                                  images: origImgs.map((uu, ii) => ({
                                    url: uu, label: `原图 #${ii+1}`,
                                  })),
                                  startIndex: i,
                                });
                              }}
                              title="放大预览"
                              style={{
                                position: "absolute", bottom: 2, right: 2,
                                width: 18, height: 18, borderRadius: 4,
                                border: "none", padding: 0,
                                background: "rgba(0,0,0,0.55)",
                                color: "white", cursor: "pointer",
                                display: "flex", alignItems: "center", justifyContent: "center",
                              }}>
                              <Icon name="eye" size={9}/>
                            </button>
                          </div>
                        );
                      })}
                    </div>
                  </div>
                </div>

                {/* 右：二创 */}
                <div className="col" style={{ gap: 8, padding: 10,
                                              background: "color-mix(in oklch, var(--purple) 6%, var(--bg-1))",
                                              border: "1px solid color-mix(in oklch, var(--purple) 30%, var(--line-1))",
                                              borderRadius: 6 }}>
                  <div className="row" style={{ alignItems: "center", gap: 6 }}>
                    <span className="tag purple" style={{ fontSize: 9.5 }}>二创</span>
                    <span style={{ fontSize: 11, color: "var(--fg-3)" }}>
                      {hasPlan
                        ? `${slotCount} 张 · ${hasImages ? `${genItems.filter(i=>i.ok).length}/${slotCount} 已出图` : "图片占位"}`
                        : "待开始"}
                    </span>
                    <div className="grow"/>
                    {hasPlan && (
                      <button className="btn sm ghost"
                              onClick={() => setPreview({
                                title: hasImages
                                  ? "二创图片预览（含已生成图）"
                                  : "二创图片预览（占位 · 含画面提示词）",
                                images: newShots.map((shot, i) => {
                                  const idx = shot.index || (i+1);
                                  return {
                                    url: newImgUrlByIdx[idx] || null,
                                    label: `#${idx} · ${shot.role || "—"}`,
                                    caption: shot.prompt_zh || shot.purpose || "",
                                  };
                                }),
                                startIndex: 0,
                              })}
                              style={{ fontSize: 10, padding: "2px 8px" }}>
                        <Icon name="eye" size={10}/> 预览
                      </button>
                    )}
                  </div>
                  <EditableCopyField
                    label="标题（草稿）"
                    value={newTitle}
                    placeholder={childInProgress ? "二创拆解中…" : "—"}
                    multiline={false}
                    onSave={async (v) => {
                      await API.replicate.editImageCopy(activeImageChildId, { title: v });
                      await loadImageReplicateState(activeImageChildId);
                    }}/>
                  <EditableCopyField
                    label="文案（草稿）"
                    value={newCap}
                    placeholder={childInProgress ? "二创拆解中…" : "—"}
                    multiline={true}
                    onSave={async (v) => {
                      await API.replicate.editImageCopy(activeImageChildId, { caption: v });
                      await loadImageReplicateState(activeImageChildId);
                    }}/>
                  <div>
                    <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginBottom: 4 }}>
                      图片（{hasImages ? "已生成" : "占位 · 待生成"}）
                    </div>
                    <div style={{ display: "grid",
                                  gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))",
                                  gap: 6 }}>
                      {hasPlan
                        ? newShots.map((shot, i) => {
                            const idx = shot.index || (i+1);
                            const url = newImgUrlByIdx[idx];
                            const openHere = () => setPreview({
                              title: hasImages
                                ? "二创图片预览（含已生成图）"
                                : "二创图片预览（占位 · 含画面提示词）",
                              images: newShots.map((s, j) => {
                                const ix = s.index || (j+1);
                                return {
                                  url: newImgUrlByIdx[ix] || null,
                                  label: `#${ix} · ${s.role || "—"}`,
                                  caption: s.prompt_zh || s.purpose || "",
                                };
                              }),
                              startIndex: i,
                            });
                            return (
                              <div key={i}
                                   onClick={openHere}
                                   title={shot.prompt_zh || shot.purpose || ""}
                                   style={{
                                     aspectRatio: "3/4", borderRadius: 4, overflow: "hidden",
                                     background: url ? "var(--bg-2)" : "var(--bg-0)",
                                     border: url
                                       ? "1px solid var(--line-1)"
                                       : "1px dashed color-mix(in oklch, var(--purple) 40%, var(--line-1))",
                                     position: "relative",
                                     cursor: "pointer",
                                     display: url ? "block" : "flex",
                                     alignItems: "center", justifyContent: "center",
                                   }}>
                                {url
                                  ? <img src={url} alt={`new_${idx}`} loading="lazy"
                                         style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
                                  : <span style={{ fontSize: 10, color: "var(--purple)",
                                                   textAlign: "center", padding: 4 }}>
                                      #{idx}<br/>{shot.role || "—"}
                                    </span>}
                              </div>
                            );
                          })
                        : Array.from({ length: origImgs.length || 6 }).map((_, i) => (
                            <div key={i} style={{
                              aspectRatio: "3/4", borderRadius: 4,
                              background: "var(--bg-0)",
                              border: "1px dashed var(--line-1)",
                              display: "flex", alignItems: "center", justifyContent: "center",
                              color: "var(--fg-3)", fontSize: 10,
                            }}>占位 #{i+1}</div>
                          ))}
                    </div>
                  </div>
                  {hasPlan && newShots.length > 0 && (
                    <details style={{ marginTop: 4 }}>
                      <summary style={{ fontSize: 10.5, color: "var(--fg-3)", cursor: "pointer" }}>
                        逐张画面提示词（{newShots.length}）
                      </summary>
                      <div className="col" style={{ gap: 4, marginTop: 6 }}>
                        {newShots.map((s, i) => (
                          <div key={i} style={{ fontSize: 10.5, color: "var(--fg-2)",
                                                lineHeight: 1.5, padding: "4px 6px",
                                                background: "var(--bg-0)", borderRadius: 3 }}>
                            <span className="mono" style={{ color: "var(--purple)" }}>
                              #{s.index || (i+1)} · {s.role || "—"}
                            </span>
                            {" "}{s.prompt_zh || s.purpose || ""}
                          </div>
                        ))}
                      </div>
                    </details>
                  )}
                </div>
              </div>
            </div>
            );
          })()}
          {activeParentId && activeStoryboard && (() => {
            const canReplicate = (activeStoryboard.shots?.length || 0) > 0;
            const activeParent = parents.find(p => p.job_id === activeParentId);
            const replicableReason = activeParent?.replicable_reason || "拆解未产出分镜";
            return (
            <div className="card">
              <div className="card-hd">
                <span className="card-title">Storyboard 预览</span>
                <span className="card-sub">{activeStoryboard.shots?.length || 0} 镜 · {(activeStoryboard.total_duration_sec||0).toFixed(1)}s · {activeStoryboard.aspect_ratio}</span>
                <div className="grow"/>
                <button
                  className="btn sm primary"
                  onClick={() => canReplicate && setShowDialog(true)}
                  disabled={!canReplicate}
                  title={canReplicate ? "" : replicableReason}
                  style={{
                    background: canReplicate ? "var(--purple)" : "var(--bg-2)",
                    color: canReplicate ? "var(--bg-0)" : "var(--fg-3)",
                    cursor: canReplicate ? "pointer" : "not-allowed",
                  }}>
                  <Icon name="magic" size={11}/>{canReplicate ? "复刻这条" : "无 storyboard"}
                </button>
              </div>
              {!canReplicate && (
                <div style={{ padding:"8px 12px", borderTop:"1px solid var(--line-1)", fontSize:11, color:"var(--fg-3)", lineHeight:1.5 }}>
                  ⚠ {replicableReason}。建议重新触发拆解，新版 R2 强制要求 ≥3 个分镜，重跑后即可复刻。
                </div>
              )}
              <div className="col" style={{ padding:12, gap:8 }}>
                {(activeStoryboard.shots || []).map((s, i) => (
                  <div key={i} style={{ padding:10, background:"var(--bg-1)", border:"1px solid var(--line-1)", borderRadius:5 }}>
                    <div className="row between" style={{ marginBottom:4 }}>
                      <span className="mono" style={{ fontSize:10, color:"var(--purple)" }}>SHOT {(s.index ?? i)+1}</span>
                      <span className="mono" style={{ fontSize:10, color:"var(--fg-3)" }}>{(s.duration_sec||0).toFixed(1)}s</span>
                    </div>
                    <div style={{ fontSize:11.5, color:"var(--fg-0)", lineHeight:1.5 }}>{s.narration || "（无文案）"}</div>
                    <div style={{ fontSize:10.5, color:"var(--fg-3)", lineHeight:1.5, marginTop:4 }}>视觉：{s.visual_prompt}</div>
                  </div>
                ))}
              </div>
            </div>
            );
          })()}
          {activeParentId && (
            <ReplicateTableWithCompose jobId={activeParentId}/>
          )}
          {activeParentId && (
            <ReplicateRunsPanel
              parent={parents.find(p => p.job_id === activeParentId)}
              activeChildId={activeChildId}
              childProgress={childProgress}
              onSubscribe={subscribeChild}
            />
          )}
        </div>
      </div>

      {showDialog && (
        <ReplicateDialog
          credentials={credentials}
          selCredId={selCredId} setSelCredId={setSelCredId}
          targetSec={targetSec} setTargetSec={setTargetSec}
          aspectRatio={aspectRatio} setAspectRatio={setAspectRatio}
          submitting={submitting} submitErr={submitErr}
          onClose={() => setShowDialog(false)}
          onSubmit={onSubmit}
          storyboard={activeStoryboard}
        />
      )}
      <ImagePreviewModal preview={preview} onClose={() => setPreview(null)}/>
    </>
  );
};

// ─────────── 复刻发起弹窗 ───────────
const ReplicateDialog = ({
  credentials, selCredId, setSelCredId,
  targetSec, setTargetSec, aspectRatio, setAspectRatio,
  submitting, submitErr, onClose, onSubmit, storyboard,
}) => {
  const noCreds = !credentials.length;
  return (
    <div style={{ position:"fixed", inset:0, background:"rgba(0,0,0,.4)", zIndex:100, display:"flex", alignItems:"center", justifyContent:"center" }} onClick={onClose}>
      <div className="card" style={{ width:480, padding:0 }} onClick={e => e.stopPropagation()}>
        <div className="card-hd">
          <span className="card-title">发起复刻</span>
          <span className="card-sub">用 storyboard 提示词调视频生成 API</span>
          <div className="grow"/>
          <button className="btn sm" onClick={onClose}><Icon name="close" size={10}/></button>
        </div>
        <div className="col" style={{ padding:14, gap:14 }}>
          {noCreds && (
            <div style={{ padding:"10px 12px", borderRadius:5, background:"color-mix(in oklch, var(--orange) 14%, var(--bg-1))", color:"var(--orange)", fontSize:11, lineHeight:1.5 }}>
              尚未配置任何视频生成凭据。请到 <a href="/models" style={{ color:"var(--cyan)", textDecoration:"underline" }}>/models</a> 添加 Runway / Seedance（含模型名称）后回来。
            </div>
          )}
          <div className="col gap-4">
            <span className="kpi-l">视频模型 <span style={{ color:"var(--fg-3)", fontWeight:400 }}>· 选凭据</span></span>
            <div className="col gap-4">
              {credentials.map(c => (
                <button key={c.id} type="button" onClick={() => setSelCredId(c.id)} style={{
                  display:"flex", alignItems:"center", justifyContent:"space-between",
                  padding:"8px 10px", border:`1px solid ${selCredId===c.id ? "var(--purple)" : "var(--line-1)"}`,
                  background: selCredId===c.id ? "color-mix(in oklch, var(--purple) 10%, var(--bg-1))" : "var(--bg-1)",
                  borderRadius:5, cursor:"pointer", fontFamily:"inherit",
                }}>
                  <span className="row gap-6" style={{ overflow:"hidden" }}>
                    <span className="tag purple" style={{ fontSize:9 }}>{PROVIDER_LABEL[c.provider] || c.provider}</span>
                    <span style={{ fontSize:11.5, color:"var(--fg-0)" }}>{c.name}</span>
                    <span className="mono" style={{ fontSize:10, color:"var(--fg-3)" }}>· {c.extra?.model_name}</span>
                  </span>
                  {c.is_active && <span className="tag green" style={{ fontSize:9 }}>已激活</span>}
                </button>
              ))}
            </div>
          </div>
          <div className="row gap-12">
            <div className="col gap-4 grow">
              <span className="kpi-l">目标时长（秒）</span>
              <input type="number" min={1} max={600} value={targetSec}
                onChange={e => setTargetSec(parseFloat(e.target.value || "0"))}
                className="input" style={{ fontSize:12.5, padding:"6px 8px", border:"1px solid var(--line-1)", borderRadius:4, background:"var(--bg-0)" }}/>
              {storyboard && <span style={{ fontSize:10, color:"var(--fg-3)" }}>原 storyboard {(storyboard.total_duration_sec||0).toFixed(1)}s（约束 ±50%）</span>}
            </div>
            <div className="col gap-4" style={{ width:120 }}>
              <span className="kpi-l">画幅比例</span>
              <select value={aspectRatio} onChange={e => setAspectRatio(e.target.value)}
                className="input" style={{ fontSize:12.5, padding:"6px 8px", border:"1px solid var(--line-1)", borderRadius:4, background:"var(--bg-0)" }}>
                <option value="9:16">9:16 竖屏</option>
                <option value="16:9">16:9 横屏</option>
                <option value="1:1">1:1 方形</option>
              </select>
            </div>
          </div>
          {submitErr && <div style={{ padding:"6px 10px", borderRadius:4, background:"color-mix(in oklch, var(--pink) 14%, var(--bg-1))", color:"var(--pink)", fontSize:11 }}>{submitErr}</div>}
          <div className="row gap-8">
            <button className="btn" onClick={onClose} disabled={submitting}>取消</button>
            <div className="grow"/>
            <button className="btn primary" onClick={onSubmit} disabled={submitting || noCreds || !selCredId} style={{ background:"var(--purple)", color:"var(--bg-0)" }}>
              <Icon name="magic" size={11}/>{submitting ? "提交中…" : "发起复刻"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

// ─────────── 抽出的核心信息元 + 匹配路径徽章 ───────────
const MatchTraceBadges = ({ trace }) => {
  if (!trace) return null;
  const ext = trace.extracted || {};
  const primary = ext.primary || [];
  const via = trace.matched_via;
  const viaColor = via === "core" ? "var(--cyan)"
                 : via === "existing_binding" ? "var(--fg-3)"
                 : via === "full" ? "var(--orange)"
                 : "var(--pink)";
  const viaLabel = via === "core" ? "✓ 核心命中"
                 : via === "existing_binding" ? "已绑定"
                 : via === "full" ? "⚠ 全文兜底"
                 : "✗ 未命中";
  return (
    <div className="col gap-4" style={{ marginTop: 6, padding: 6,
        background: "var(--bg-1)", borderRadius: 4, border: "1px solid var(--line-1)" }}>
      <div className="row between" style={{ alignItems: "center" }}>
        <span className="kpi-l" style={{ fontSize: 9.5 }}>核心信息元</span>
        <span style={{ fontSize: 9.5, color: viaColor, fontWeight: 600 }}>{viaLabel}</span>
      </div>
      <div className="row gap-4" style={{ flexWrap: "wrap" }}>
        {primary.length > 0 ? primary.map((k, i) => (
          <span key={i} className="tag" style={{
            fontSize: 9.5, padding: "1px 6px",
            background: "color-mix(in oklch, var(--purple) 12%, var(--bg-1))",
            color: "var(--purple)", borderRadius: 3,
          }}>{k}</span>
        )) : (
          <span style={{ fontSize: 9.5, color: "var(--fg-3)" }}>抽不到视觉关键词，走全文兜底</span>
        )}
      </div>
      {(trace.stages || []).length > 0 && (
        <details style={{ marginTop: 2 }}>
          <summary style={{ fontSize: 9.5, color: "var(--fg-3)", cursor: "pointer" }}>
            搜索路径（{trace.stages.length} 阶段）
          </summary>
          <div className="col gap-2" style={{ marginTop: 4, fontSize: 9.5, color: "var(--fg-2)" }}>
            {trace.stages.map((s, i) => (
              <div key={i} className="row between">
                <span className="mono">{s.name}: <span style={{ color: "var(--fg-3)" }}>"{s.query?.slice(0, 30)}"</span></span>
                <span className="mono" style={{ color: s.hits.length ? "var(--cyan)" : "var(--fg-3)" }}>
                  {s.hits.length ? `${s.hits.length} hit · top=${s.hits[0][1]}` : "0 hit"}
                </span>
              </div>
            ))}
          </div>
        </details>
      )}
    </div>
  );
};


// ─────────── 可编辑的 aigc_prompt（点开 textarea，保存后调 PATCH） ───────────
const EditablePrompt = ({ jobId, chunkId, initialPrompt, onSaved }) => {
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState(initialPrompt || "");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");

  React.useEffect(() => { setDraft(initialPrompt || ""); }, [initialPrompt]);

  const save = async () => {
    if (!draft.trim()) { setErr("提示词不能为空"); return; }
    setBusy(true); setErr("");
    try {
      await API.shotQc.patchPrompt(jobId, chunkId, draft.trim());
      setEditing(false);
      onSaved && onSaved(draft.trim());
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  if (!editing) {
    return (
      <details style={{ marginTop: 6 }} open>
        <summary style={{ fontSize: 10, color: "var(--fg-3)", cursor: "pointer" }}>
          aigc_prompt
          <button className="btn sm ghost" onClick={(e) => { e.preventDefault(); setEditing(true); }}
                  style={{ marginLeft: 6, fontSize: 9, padding: "0 4px", height: 16 }}>
            <Icon name="edit" size={9}/>编辑
          </button>
        </summary>
        <div style={{ fontSize: 10.5, color: "var(--fg-2)", marginTop: 4, lineHeight: 1.5 }}>
          {initialPrompt || "—"}
        </div>
      </details>
    );
  }
  return (
    <div className="col gap-4" style={{ marginTop: 6 }}>
      <textarea className="textarea" value={draft} onChange={e => setDraft(e.target.value)}
                style={{ fontSize: 10.5, lineHeight: 1.5, minHeight: 80, fontFamily: "var(--font-sans)" }}
                disabled={busy}/>
      {err && <span style={{ fontSize: 10, color: "var(--pink)" }}>{err}</span>}
      <div className="row gap-6">
        <button className="btn sm primary" onClick={save} disabled={busy}>
          <Icon name="check" size={9}/>{busy ? "保存…" : "保存提示词"}
        </button>
        <button className="btn sm" onClick={() => { setEditing(false); setDraft(initialPrompt || ""); setErr(""); }}>
          取消
        </button>
      </div>
    </div>
  );
};


// ─────────── 「手动选片」对话框（候选 top-K + 视频预览 + 一键 bind） ───────────
const ManualClipPickerDialog = ({ jobId, chunkId, currentClipId, onClose, onPicked }) => {
  const [items, setItems] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [err, setErr] = React.useState("");
  const [bindBusy, setBindBusy] = React.useState(null);

  React.useEffect(() => {
    let cancel = false;
    setLoading(true); setErr("");
    API.shotQc.matchCandidates(jobId, chunkId, 10).then(d => {
      if (cancel) return;
      setItems(d.items || []);
    }).catch(e => { if (!cancel) setErr(String(e.message || e)); })
      .finally(() => { if (!cancel) setLoading(false); });
    return () => { cancel = true; };
  }, [jobId, chunkId]);

  const bind = async (clipId) => {
    setBindBusy(clipId);
    try {
      await API.shotQc.manualBind(jobId, chunkId, clipId);
      onPicked && onPicked(clipId);
      onClose();
    } catch (e) {
      setErr(String(e.message || e));
    } finally { setBindBusy(null); }
  };

  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.45)", zIndex: 200,
                  display: "flex", alignItems: "center", justifyContent: "center" }}
         onClick={onClose}>
      <div className="card" style={{ width: 720, maxHeight: "85vh", padding: 0 }}
           onClick={e => e.stopPropagation()}>
        <div className="card-hd">
          <span className="card-title">手动选片 · {chunkId}</span>
          <span className="card-sub">从种子库 top-10 候选挑一条</span>
          <div className="grow"/>
          <button className="btn sm" onClick={onClose}><Icon name="close" size={10}/></button>
        </div>
        <div className="col scroll" style={{ padding: 12, gap: 8, maxHeight: "70vh" }}>
          {loading && <div style={{ fontSize: 11, color: "var(--fg-3)" }}>加载候选…</div>}
          {err && <div style={{ fontSize: 11, color: "var(--pink)" }}>{err}</div>}
          {!loading && items.length === 0 && (
            <div style={{ fontSize: 11, color: "var(--fg-3)", padding: 20, textAlign: "center" }}>
              候选为空（库里全部冷却 / 耗尽 / 已用 / 已拒）
            </div>
          )}
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
            {items.map(c => (
              <div key={c.clip_id} style={{
                border: `2px solid ${c.clip_id === currentClipId ? "var(--cyan)" : "var(--line-1)"}`,
                borderRadius: 5, padding: 6, background: "var(--bg-1)",
              }}>
                <video src={window.withApiToken(c.preview_url)} controls preload="metadata"
                       style={{ width: "100%", maxHeight: 200, background: "#000", borderRadius: 3 }}/>
                <div className="row between" style={{ marginTop: 4, alignItems: "center" }}>
                  <span className="mono" style={{ fontSize: 9.5, color: "var(--purple)" }}>
                    BM25 {c.score} · {c.usage.total_uses}/{c.usage.max_uses}
                  </span>
                  <button className="btn sm primary"
                          disabled={bindBusy === c.clip_id || c.clip_id === currentClipId}
                          onClick={() => bind(c.clip_id)}
                          style={{ fontSize: 10, height: 22,
                            background: c.clip_id === currentClipId ? "var(--cyan)" : "var(--purple)",
                            color: "var(--bg-0)" }}>
                    {c.clip_id === currentClipId ? "当前" : (bindBusy === c.clip_id ? "绑定中…" : "选这条")}
                  </button>
                </div>
                <div style={{ fontSize: 9.5, color: "var(--fg-3)", marginTop: 3,
                              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
                     title={c.matched_caption}>{c.matched_caption}</div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};


// ─────────── 「走生成路线」弹窗 ───────────
const _MANDATORY_GEN_PREFIX = (
  "【系统强制约束 · 高权重】\n" +
  "1. 必须去除参考分镜视频中的所有字幕、文字水印、台标、Logo 与任何叠加文字元素。\n" +
  "2. 严格基于参考视频画面与下方提示词生成新视频，保留画面构图与节奏的可参考性。\n" +
  "3. 输出画面中不得出现任何字幕、文字、水印或可识别 Logo。"
);

const GenerateShotDialog = ({
  jobId, chunkId, originalVideoUrl,
  initialUserPrompt = "",       // /remix 拆解侧默认空；文案二创侧带 row.aigc_prompt
  initialMandatoryPrefix = _MANDATORY_GEN_PREFIX,
  extraInfo = null,             // { tts_duration_sec, rewrite_text } 等可选附加
  onSubmit,                     // 自定义提交回调；不传则走拆解侧默认
  onClose, onSubmitted,
}) => {
  const [model, setModel] = React.useState("seedance");
  const [mandatoryPrefix, setMandatoryPrefix] = React.useState(initialMandatoryPrefix);
  const [userPrompt, setUserPrompt] = React.useState(initialUserPrompt);
  const [busy, setBusy] = React.useState(false);
  const [msg, setMsg] = React.useState("");

  // 实时计算 final_prompt（与后端 compose_with_prefix 等价）
  const finalPrompt = React.useMemo(() => {
    const pfx = (mandatoryPrefix || "").trimEnd();
    const usr = (userPrompt || "").trim();
    if (!pfx && !usr) return "";
    if (!pfx) return usr;
    if (!usr) return pfx;
    return `${pfx}\n\n【用户/LLM 提示词】\n${usr}`;
  }, [mandatoryPrefix, userPrompt]);

  const submit = async () => {
    setBusy(true); setMsg("");
    try {
      const payload = {
        model, use_original: true,
        aigc_prompt: userPrompt,
        mandatory_prefix: mandatoryPrefix,
        ...(extraInfo || {}),
      };
      let r;
      if (onSubmit) {
        r = await onSubmit(payload);
      } else {
        r = await API.shotQc.generateShot(jobId, chunkId, payload);
      }
      setMsg(r?.message || r?.status || "已排队");
      onSubmitted && onSubmitted();
    } catch (e) {
      setMsg(String(e.message || e));
    } finally { setBusy(false); }
  };
  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.45)",
                  zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
         onClick={onClose}>
      <div className="card" style={{ width: 460, padding: 0 }} onClick={e => e.stopPropagation()}>
        <div className="card-hd">
          <span className="card-title">走生成路线 · {chunkId}</span>
          <span className="card-sub">把原视频片段作为参考，调第三方模型生成新分镜</span>
          <div className="grow"/>
          <button className="btn sm" onClick={onClose}><Icon name="close" size={10}/></button>
        </div>
        <div className="col" style={{ padding: 14, gap: 12 }}>
          <div className="col gap-4">
            <span className="kpi-l">参考视频（原 chunk 切片）</span>
            {originalVideoUrl ? (
              <video src={window.withApiToken(originalVideoUrl)} controls
                     style={{ width: "100%", maxHeight: 200, background: "#000", borderRadius: 4 }}/>
            ) : <span style={{ fontSize: 11, color: "var(--fg-3)" }}>该 chunk 无原切片</span>}
          </div>
          {/* 强制约束前缀 — 可编辑，默认值是系统常量，提交时如果改了就覆盖 */}
          <div className="col gap-4">
            <div className="row between" style={{ alignItems: "center" }}>
              <span className="kpi-l">① 系统强制约束前缀（可编辑 · 高权重）</span>
              <div className="row gap-6">
                <span className="tag" style={{ fontSize: 9, padding: "0 5px",
                  background: "color-mix(in oklch, var(--orange) 14%, var(--bg-1))",
                  color: "var(--orange)" }}>
                  {mandatoryPrefix === _MANDATORY_GEN_PREFIX ? "默认" : "已修改"}
                </span>
                <button className="btn sm ghost" type="button"
                        onClick={() => setMandatoryPrefix(_MANDATORY_GEN_PREFIX)}
                        disabled={busy || mandatoryPrefix === _MANDATORY_GEN_PREFIX}
                        style={{ fontSize: 10, padding: "0 6px", height: 18 }}>
                  <Icon name="refresh" size={9}/>恢复默认
                </button>
              </div>
            </div>
            <textarea className="textarea"
                      value={mandatoryPrefix}
                      onChange={e => setMandatoryPrefix(e.target.value)}
                      disabled={busy}
                      style={{
                        fontSize: 10.5, lineHeight: 1.5, minHeight: 110,
                        fontFamily: "var(--font-mono)",
                        background: "color-mix(in oklch, var(--orange) 6%, var(--bg-0))",
                        border: "1px solid var(--orange)",
                      }}/>
          </div>

          {/* 用户/LLM 提示词 — 可编辑 */}
          <div className="col gap-4">
            <span className="kpi-l">② 用户/LLM 提示词（可编辑）</span>
            <textarea className="textarea"
                      value={userPrompt}
                      onChange={e => setUserPrompt(e.target.value)}
                      disabled={busy}
                      placeholder="描述要生成的画面（场景 / 人物 / 动作 / 氛围…）"
                      style={{
                        fontSize: 11, lineHeight: 1.5, minHeight: 90,
                        fontFamily: "var(--font-sans)",
                      }}/>
            {extraInfo && extraInfo.tts_duration_sec && (
              <span style={{ fontSize: 10, color: "var(--fg-3)" }}>
                tts 时长（自动估算）：<span className="mono">{extraInfo.tts_duration_sec}s</span>
              </span>
            )}
          </div>

          {/* 最终发给模型的 prompt 预览 */}
          <details>
            <summary className="kpi-l" style={{ cursor: "pointer", padding: "4px 0" }}>
              ③ final_prompt 预览（① + ② 拼接结果）
            </summary>
            <pre style={{
              fontSize: 10, lineHeight: 1.5, color: "var(--fg-1)", margin: "4px 0 0",
              background: "var(--bg-1)", padding: 8, borderRadius: 4,
              whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto",
            }}>{finalPrompt || "（空）"}</pre>
          </details>

          <div className="col gap-4">
            <span className="kpi-l">模型</span>
            <select className="input" value={model} onChange={e => setModel(e.target.value)}
                    style={{ fontSize: 12 }}>
              <option value="seedance">Seedance（豆包）</option>
              <option value="runway">Runway</option>
            </select>
          </div>
          {msg && (
            <div style={{ padding: "8px 10px", borderRadius: 4, fontSize: 10.5,
                          background: "color-mix(in oklch, var(--orange) 12%, var(--bg-1))",
                          color: "var(--orange)", lineHeight: 1.5 }}>{msg}</div>
          )}
          <div className="row gap-8">
            <div className="grow"/>
            <button className="btn" onClick={onClose} disabled={busy}>取消</button>
            <button className="btn primary" onClick={submit} disabled={busy}
                    style={{ background: "var(--purple)", color: "var(--bg-0)" }}>
              <Icon name="magic" size={11}/>{busy ? "提交中…" : "提交生成请求"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};


// ─────────── 单条种子库命中卡（含寿命进度条 + 冷却信息） ───────────
const SeedMatchCard = ({ match, assignedNow }) => {
  const u = match.usage || {};
  const total = u.total_uses || 0;
  const max = u.max_uses || 7;
  const remaining = u.life_remaining ?? Math.max(0, max - total);
  const pct = Math.min(100, Math.round((u.life_pct || 0) * 100));
  // 寿命颜色随消耗递进：cyan → orange → pink
  const lifeColor = pct >= 86 ? "var(--pink)" : pct >= 57 ? "var(--orange)" : "var(--cyan)";
  return (
    <div style={{ border: "1px solid var(--line-1)", borderRadius: 4, padding: 6, background: "var(--bg-1)" }}>
      <div className="row between" style={{ marginBottom: 4, alignItems: "center" }}>
        <span className="row gap-4" style={{ alignItems: "center" }}>
          {match.score != null && (
            <span className="mono" style={{ fontSize: 9.5, color: "var(--purple)" }}>
              BM25 {match.score}
            </span>
          )}
          <span className="tag" style={{ fontSize: 9, padding: "0 5px",
            color: match.has_kh ? "var(--cyan)" : "var(--fg-3)" }}>
            {match.has_kh ? "kh ✓" : "filename"}
          </span>
          {assignedNow && (
            <span className="tag green" style={{ fontSize: 9 }}>新分配</span>
          )}
          {!assignedNow && (
            <span className="tag" style={{ fontSize: 9, color: "var(--fg-3)" }}>已绑定</span>
          )}
        </span>
        <span className="mono" style={{ fontSize: 9.5, color: lifeColor, fontWeight: 600 }}>
          {total}/{max}
        </span>
      </div>
      <video src={window.withApiToken(match.preview_url)} controls preload="metadata"
             style={{ width: "100%", maxHeight: 180, background: "#000", borderRadius: 3 }}/>
      <div style={{ fontSize: 10, color: "var(--fg-3)", marginTop: 4,
                    overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
           title={match.matched_caption}>
        {match.matched_caption}
      </div>

      {/* 寿命进度条 */}
      <div className="col gap-2" style={{ marginTop: 6 }}>
        <div className="row between" style={{ fontSize: 9.5 }}>
          <span style={{ color: "var(--fg-3)" }}>寿命</span>
          <span style={{ color: lifeColor }}>剩 {remaining} 次</span>
        </div>
        <div className="bar" style={{ height: 4 }}>
          <span style={{ width: `${pct}%`, background: lifeColor, transition: "width .3s" }}/>
        </div>
        <div className="row between" style={{ fontSize: 9.5, color: "var(--fg-3)" }}>
          {u.in_cooldown ? (
            <span style={{ color: "var(--orange)" }}>
              冷却中 · {u.cooldown_until ? u.cooldown_until.slice(5, 16).replace("T", " ") : "—"} 后可再用
            </span>
          ) : u.exhausted ? (
            <span style={{ color: "var(--pink)" }}>已耗尽 · 下次刷新自动删除</span>
          ) : (
            <span>{u.last_used_at ? `上次 ${u.last_used_at.slice(5,16).replace("T"," ")}` : "首次使用"}</span>
          )}
        </div>
      </div>
    </div>
  );
};


// ─────────── 质检合成进度面板（含参数表单） ───────────
const ComposePanel = ({ parentJobId, allMatched, rowsCount }) => {
  const [job, setJob] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [history, setHistory] = React.useState([]);
  const [defaults, setDefaults] = React.useState(null);
  const [bgmList, setBgmList] = React.useState([]);
  const [params, setParams] = React.useState({});
  const [showParams, setShowParams] = React.useState(false);
  const pollerRef = React.useRef(null);

  // 拉默认值 + BGM 列表
  React.useEffect(() => {
    API.compose.defaults().then(d => {
      setDefaults(d.defaults);
      setParams(d.defaults);  // 初始化表单
    }).catch(() => {});
    API.compose.bgmList().then(d => setBgmList(d.items || [])).catch(() => {});
  }, []);

  const loadHistory = React.useCallback(async () => {
    try {
      const r = await API.compose.listForParent(parentJobId);
      setHistory(r.items || []);
      const top = (r.items || [])[0];
      if (top && (top.status === "running" || top.status === "pending")) {
        setJob(top);
        if (!pollerRef.current) startPolling(top.compose_id);
      }
    } catch (_) {}
  }, [parentJobId]);

  React.useEffect(() => { loadHistory(); }, [loadHistory]);
  React.useEffect(() => () => { if (pollerRef.current) clearInterval(pollerRef.current); }, []);

  const startPolling = (composeId) => {
    if (pollerRef.current) clearInterval(pollerRef.current);
    pollerRef.current = setInterval(async () => {
      try {
        const next = await API.compose.get(composeId);
        setJob(next);
        if (next.status === "completed" || next.status === "failed") {
          clearInterval(pollerRef.current); pollerRef.current = null;
          setBusy(false);
          loadHistory();
        }
      } catch (_) {}
    }, 1500);
  };

  const submit = async () => {
    setBusy(true); setErr("");
    try {
      const j = await API.compose.start(parentJobId, params);
      setJob(j);
      startPolling(j.compose_id);
    } catch (e) {
      setErr(String(e.message || e));
      setBusy(false);
    }
  };

  const setP = (k, v) => setParams(p => ({ ...p, [k]: v }));
  const resetP = () => defaults && setParams(defaults);

  // 把 0..2.0 gain 与 dB 互转，展示更友好
  const gainToDb = (g) => g > 0 ? (20 * Math.log10(g)).toFixed(1) : "-∞";

  const isDone = job?.status === "completed";
  const isFail = job?.status === "failed";
  const pct = job?.progress || 0;

  return (
    <div className="card">
      <div className="card-hd">
        <span className="card-title">质检合成（AIGC混剪 → BGM 混音）</span>
        <span className="card-sub">
          scene 时长 = TTS 时长 · 转场 {params.xfade_dur ?? 1}s 重叠 · 黄底黑字 ·
          BGM {gainToDb(params.bgm_gain ?? 0.794)}dB
        </span>
        <div className="grow"/>
        <button className="btn sm ghost" onClick={() => setShowParams(s => !s)}>
          <Icon name="settings" size={10}/>{showParams ? "收起参数" : "参数"}
        </button>
        <button
          className={`btn sm ${allMatched ? "primary" : ""}`}
          onClick={submit}
          disabled={!allMatched || busy}
          title={allMatched ? "" : "还有 chunk 未匹配到素材"}
          style={{
            background: allMatched ? "var(--purple)" : "var(--bg-2)",
            color: allMatched ? "var(--bg-0)" : "var(--fg-3)",
            cursor: allMatched ? "pointer" : "not-allowed",
          }}>
          <Icon name="play" size={11}/>
          {busy ? "合成中…" : allMatched ? `送 ${rowsCount} 镜质检合成` : "等待全部匹配"}
        </button>
      </div>

      {showParams && defaults && (
        <div className="col" style={{ padding: 12, gap: 10, borderTop: "1px solid var(--line-1)",
                                       background: "var(--bg-1)" }}>
          <div className="row between" style={{ alignItems: "center" }}>
            <span className="kpi-l">合成参数（不调即按文档默认）</span>
            <button className="btn sm ghost" onClick={resetP}>
              <Icon name="refresh" size={9}/>恢复默认
            </button>
          </div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
            {/* BGM 选择 */}
            <div className="col gap-3">
              <span className="kpi-l">BGM</span>
              <select className="input" value={params.bgm_filename || ""}
                      onChange={e => setP("bgm_filename", e.target.value || null)}
                      style={{ fontSize: 11, padding: "4px 6px" }}>
                <option value="">随机</option>
                {bgmList.map(b => (
                  <option key={b.filename} value={b.filename}>{b.filename.slice(0, 36)}</option>
                ))}
              </select>
            </div>
            {/* 字幕字号 */}
            <div className="col gap-3">
              <span className="kpi-l">字幕字号 (默认 9)</span>
              <input type="number" className="input" value={params.sub_font_size ?? 9}
                     min={4} max={32}
                     onChange={e => setP("sub_font_size", parseInt(e.target.value || "9"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* 字幕距底 px */}
            <div className="col gap-3">
              <span className="kpi-l">字幕距底 px (默认 60)</span>
              <input type="number" className="input" value={params.sub_margin_bottom ?? 60}
                     min={10} max={400}
                     onChange={e => setP("sub_margin_bottom", parseInt(e.target.value || "60"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* 口播音量 */}
            <div className="col gap-3">
              <span className="kpi-l">口播音量 ({gainToDb(params.voice_gain ?? 1)} dB)</span>
              <input type="number" className="input" step="0.05" min="0" max="2.5"
                     value={params.voice_gain ?? 1}
                     onChange={e => setP("voice_gain", parseFloat(e.target.value || "1"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* BGM 音量 */}
            <div className="col gap-3">
              <span className="kpi-l">BGM 音量 ({gainToDb(params.bgm_gain ?? 0.794)} dB)</span>
              <input type="number" className="input" step="0.05" min="0" max="2"
                     value={params.bgm_gain ?? 0.794}
                     onChange={e => setP("bgm_gain", parseFloat(e.target.value || "0.794"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* 转场 */}
            <div className="col gap-3">
              <span className="kpi-l">转场重叠 (默认 1.0s)</span>
              <input type="number" className="input" step="0.1" min="0" max="3"
                     value={params.xfade_dur ?? 1}
                     onChange={e => setP("xfade_dur", parseFloat(e.target.value || "1"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* min scene */}
            <div className="col gap-3">
              <span className="kpi-l">最短 scene (默认 2.0s)</span>
              <input type="number" className="input" step="0.5" min="0" max="15"
                     value={params.min_scene_dur ?? 2}
                     onChange={e => setP("min_scene_dur", parseFloat(e.target.value || "2"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* 字幕单行字数 */}
            <div className="col gap-3">
              <span className="kpi-l">字幕单行字数 (默认 16)</span>
              <input type="number" className="input" value={params.max_chars ?? 16}
                     min={4} max={40}
                     onChange={e => setP("max_chars", parseInt(e.target.value || "16"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            {/* 语速 */}
            <div className="col gap-3">
              <span className="kpi-l">edge-tts 语速 (默认 +10%)</span>
              <input type="text" className="input" value={params.voice_rate ?? "+10%"}
                     onChange={e => setP("voice_rate", e.target.value)}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
          </div>
          <span style={{ fontSize: 10, color: "var(--fg-3)" }}>
            产物落到 <span className="mono">AIGC混剪/output/final/月日（N）.mp4</span>，N 同日序号自增
          </span>
        </div>
      )}

      {err && (
        <div style={{ padding: "8px 12px", borderTop: "1px solid var(--line-1)",
                      fontSize: 11, color: "var(--pink)" }}>{err}</div>
      )}

      {job && (
        <div className="col" style={{ padding: 12, gap: 8, borderTop: "1px solid var(--line-1)" }}>
          <div className="row between" style={{ alignItems: "center" }}>
            <span className="mono" style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
              {job.compose_id} · {job.status} · scene {job.scenes_used || "?"} / chunk {job.chunks_count}
            </span>
            <span className="mono" style={{ fontSize: 11, color: isFail ? "var(--pink)" : "var(--purple)" }}>
              {pct}%
            </span>
          </div>
          <div className="bar"><span style={{
            width: `${pct}%`,
            background: isFail ? "var(--pink)" : "var(--purple)",
            transition: "width .3s",
          }}/></div>
          <div style={{ fontSize: 11, color: "var(--fg-1)" }}>
            {job.message || (isFail ? (job.error || "失败") : "运行中…")}
          </div>
          {(job.log_tail || []).length > 0 && (
            <pre style={{
              fontSize: 10.5, lineHeight: 1.5, color: "var(--fg-3)",
              background: "var(--bg-1)", padding: 8, borderRadius: 4,
              maxHeight: 100, overflow: "auto", margin: 0, whiteSpace: "pre-wrap",
            }}>{(job.log_tail || []).slice(-6).join("\n")}</pre>
          )}
          {isDone && (
            <div className="col gap-6">
              <video src={API.compose.videoUrl(job.compose_id)} controls
                     style={{ width: "100%", maxHeight: 480, background: "#000", borderRadius: 4 }}/>
              <div className="row gap-8">
                <a className="btn sm" href={API.compose.videoUrl(job.compose_id)} download>
                  <Icon name="ext" size={10}/>下载 mp4
                </a>
                <a className="btn sm ghost" href={API.compose.manifestUrl(job.compose_id)} target="_blank" rel="noreferrer">
                  <Icon name="ext" size={10}/>manifest
                </a>
                <span style={{ fontSize: 10, color: "var(--fg-3)" }}>BGM: {job.bgm_picked}</span>
              </div>
            </div>
          )}
        </div>
      )}

      {history.length > 1 && (
        <div className="col" style={{ padding: "8px 12px", gap: 4, borderTop: "1px solid var(--line-1)" }}>
          <span className="kpi-l">历史合成（{history.length} 次）</span>
          {history.slice(0, 5).map(h => (
            <div key={h.compose_id} className="row between" style={{ fontSize: 10.5 }}>
              <span className="mono" style={{ color: "var(--fg-3)" }}>{h.compose_id}</span>
              <span className={`tag ${h.status==="completed"?"green":h.status==="failed"?"pink":"cyan"}`}
                    style={{ fontSize: 9 }}>{h.status}</span>
              {h.output_url && (
                <a href={h.output_url} target="_blank" rel="noreferrer"
                   style={{ fontSize: 10, color: "var(--cyan)" }}>预览</a>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
};


// ─────────── 4 列复刻对照表（原文 / 原视频 / 二创文案 / 画面二创） ───────────
const ReplicateTable = ({ jobId }) => {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [genBusy, setGenBusy] = React.useState({});   // chunk_id -> bool
  const [genResult, setGenResult] = React.useState({}); // chunk_id -> message
  const [rejectBusy, setRejectBusy] = React.useState({});
  const [genDialogChunk, setGenDialogChunk] = React.useState(null);  // {chunk_id, originalVideoUrl}
  const [manualPickChunk, setManualPickChunk] = React.useState(null);  // {chunk_id, current_clip_id}

  const load = async () => {
    if (!jobId) return;
    setLoading(true); setErr("");
    try {
      const d = await API.replicateTable.get(jobId);
      setData(d);
    } catch (e) {
      setErr(String(e.message || e));
    } finally { setLoading(false); }
  };
  React.useEffect(() => { load(); }, [jobId]);

  const refreshSeedLib = async () => {
    try {
      await API.seedLibrary.refresh();
      await load();
    } catch (e) { setErr(String(e.message || e)); }
  };

  const triggerGenerate = async (row) => {
    const cid = row.chunk_id;
    setGenBusy(b => ({ ...b, [cid]: true }));
    setGenResult(g => ({ ...g, [cid]: "" }));
    try {
      const r = await API.seedLibrary.generate({
        aigc_prompt: row.aigc_prompt || row.rewrite_text,
        chunk_id: cid,
        job_id: jobId,
      });
      setGenResult(g => ({ ...g, [cid]: r.message || r.status || "未实现" }));
    } catch (e) {
      setGenResult(g => ({ ...g, [cid]: String(e.message || e) }));
    } finally {
      setGenBusy(b => ({ ...b, [cid]: false }));
    }
  };

  // 人工拒绝当前匹配 → 自动重选
  const rejectMatch = async (row) => {
    const cid = row.chunk_id;
    setRejectBusy(b => ({ ...b, [cid]: true }));
    try {
      await API.shotQc.rejectMatch(jobId, cid);
      await load();   // 刷新对照表会触发后端重新搜索（排除被拒的 clip）
    } catch (e) {
      setErr(String(e.message || e));
    } finally {
      setRejectBusy(b => ({ ...b, [cid]: false }));
    }
  };

  if (!data) {
    return (
      <div className="card">
        <div className="card-hd">
          <span className="card-title">分镜对照表</span>
          <span className="card-sub">原文 · 原视频 · 二创文案 · 画面二创</span>
        </div>
        <div style={{ padding: 18, fontSize: 11.5, color: "var(--fg-3)", textAlign: "center" }}>
          {loading ? "加载中…" : (err || "暂无数据")}
        </div>
      </div>
    );
  }

  const { rows = [], seed_library: lib = {} } = data;
  const TH_STYLE = {
    padding: "8px 10px", textAlign: "left",
    fontSize: 10.5, fontWeight: 600, color: "var(--fg-2)",
    background: "var(--bg-1)", borderBottom: "1px solid var(--line-1)",
    position: "sticky", top: 0, zIndex: 1,
  };
  const TD_STYLE = {
    padding: "10px 10px", verticalAlign: "top",
    fontSize: 11.5, lineHeight: 1.6, color: "var(--fg-0)",
    borderBottom: "1px solid var(--line-soft)",
  };

  return (
    <div className="card">
      <div className="card-hd">
        <span className="card-title">分镜对照表</span>
        <span className="card-sub">{rows.length} 镜 · 1:1 绑定 · 3 天冷却 · 7 次寿命</span>
        <div className="grow"/>
        <span className="tag" style={{
          fontSize: 9, padding: "0 5px",
          color: lib.with_kh > 0 ? "var(--cyan)" : "var(--fg-3)",
        }}>
          种子库 {lib.count || 0} 条 · 已 kh 索引 {lib.with_kh || 0}
        </span>
        {data.gc && data.gc.deleted_count > 0 && (
          <span className="tag" style={{ fontSize: 9, padding: "0 5px", color: "var(--pink)" }}>
            刚删了 {data.gc.deleted_count} 条耗尽 clip
          </span>
        )}
        <button className="btn sm ghost" onClick={refreshSeedLib} title="重新扫描 /Users/xm/Desktop/分镜素材">
          <Icon name="refresh" size={10}/>重扫
        </button>
        <button className="btn sm ghost" onClick={load}>
          <Icon name="refresh" size={10}/>刷新
        </button>
      </div>
      <div style={{ overflow: "auto", maxHeight: 720 }}>
        <table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed" }}>
          <colgroup>
            <col style={{ width: 60 }}/>
            <col style={{ width: "20%" }}/>
            <col style={{ width: "20%" }}/>
            <col style={{ width: "20%" }}/>
            <col style={{ width: "40%" }}/>
          </colgroup>
          <thead>
            <tr>
              <th style={TH_STYLE}>#</th>
              <th style={TH_STYLE}>① 原文（语义切句）</th>
              <th style={TH_STYLE}>② 原视频片段</th>
              <th style={TH_STYLE}>③ 文案二创</th>
              <th style={TH_STYLE}>④ 画面二创（1:1 绑定 · 3 天冷却 · 7 次寿命）</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r, i) => {
              const vr = r.visual_remix || {};
              const matches = vr.matches || [];
              const cid = r.chunk_id;
              const isGenBusy = !!genBusy[cid];
              const genMsg = genResult[cid] || "";
              const match = vr.match || null;
              return (
                <tr key={cid}>
                  <td style={{ ...TD_STYLE, color: "var(--fg-3)", fontFamily: "var(--font-mono)", fontSize: 10.5 }}>
                    <div>{r.index + 1}</div>
                    <div style={{ marginTop: 4, color: "var(--fg-3)" }}>{cid}</div>
                    <div style={{ marginTop: 4, color: "var(--fg-3)" }}>{r.duration_sec.toFixed(1)}s</div>
                  </td>
                  <td style={TD_STYLE}>{r.original_text || <span style={{ color: "var(--fg-3)" }}>—</span>}</td>
                  <td style={TD_STYLE}>
                    {r.original_video_url ? (
                      <video src={window.withApiToken(r.original_video_url)} controls preload="metadata"
                             style={{ width: "100%", maxHeight: 220, background: "#000", borderRadius: 4 }}/>
                    ) : <span style={{ color: "var(--fg-3)", fontSize: 10.5 }}>切片未生成</span>}
                  </td>
                  <td style={TD_STYLE}>
                    <div>{r.rewrite_text || <span style={{ color: "var(--fg-3)" }}>—</span>}</div>
                    <EditablePrompt jobId={jobId} chunkId={cid}
                                    initialPrompt={r.aigc_prompt}
                                    onSaved={() => load()}/>
                  </td>
                  <td style={TD_STYLE}>
                    {/* 复刻流水线 kh material_match 命中（优先于 seed_library）*/}
                    {r.material_remix && r.material_remix.segment_url && (
                      <div className="card" style={{ padding: 8, marginBottom: 8,
                        background: "color-mix(in oklch, var(--cyan) 8%, var(--bg-0))",
                        border: "1px solid var(--cyan)" }}>
                        <div className="row between" style={{ marginBottom: 4 }}>
                          <span className="tag" style={{ fontSize: 9, color: "var(--cyan)" }}>
                            ✓ 复刻流水线命中
                          </span>
                          <span className="mono" style={{ fontSize: 9, color: "var(--fg-3)" }}>
                            score {((r.material_remix.score || 0)).toFixed(2)}
                          </span>
                        </div>
                        <video src={window.withApiToken(`${r.material_remix.segment_url}#t=${(r.material_remix.time_start || 0).toFixed(2)},${(r.material_remix.time_end || 0).toFixed(2)}`)}
                          controls preload="metadata" muted
                          style={{ width: "100%", maxHeight: 160, borderRadius: 3,
                                   background: "#000" }}/>
                        <div className="mono" style={{ fontSize: 9, color: "var(--fg-3)", marginTop: 4 }}>
                          kh chunk: {r.material_remix.kh_chunk_id || "—"} · {(r.material_remix.time_start || 0).toFixed(1)}–{(r.material_remix.time_end || 0).toFixed(1)}s
                        </div>
                      </div>
                    )}
                    {match ? (
                      <>
                        <SeedMatchCard match={match} assignedNow={vr.assigned}/>
                        <MatchTraceBadges trace={vr.match_trace}/>
                        <div className="row gap-6" style={{ marginTop: 6, flexWrap: "wrap" }}>
                          <button className="btn sm" disabled={rejectBusy[cid]}
                                  onClick={() => rejectMatch(r)}
                                  title="判断不合适 → 加入拒绝列表 + 自动重新匹配"
                                  style={{ fontSize: 10 }}>
                            <Icon name="close" size={9}/>
                            {rejectBusy[cid] ? "重选中…" : "✗ 不合适"}
                          </button>
                          <button className="btn sm"
                                  onClick={() => {
                                    const ret = encodeURIComponent(`/remix?job=${jobId}`);
                                    const bind = encodeURIComponent(`${jobId}:${cid}`);
                                    const url = `/shots-library?bind_to=${bind}&current=${match.clip_id}&return=${ret}`;
                                    window.history.pushState({}, "", url);
                                    window.dispatchEvent(new PopStateEvent("popstate"));
                                  }}
                                  title="跳到分镜素材库，从全库选一条"
                                  style={{ fontSize: 10, color: "var(--cyan)" }}>
                            <Icon name="folder" size={9}/>手动选片 →
                          </button>
                          <button className="btn sm"
                                  onClick={() => setGenDialogChunk({
                                    chunk_id: cid, original_url: r.original_video_url,
                                    aigc_prompt: r.aigc_prompt,
                                  })}
                                  title="走第三方生成路线，把原 chunk 作为参考"
                                  style={{ fontSize: 10, color: "var(--purple)" }}>
                            <Icon name="magic" size={9}/>→ 走生成
                          </button>
                        </div>
                      </>
                    ) : vr.generated_clip_url ? (
                      <div className="col gap-4">
                        <span className="tag green" style={{ fontSize: 9 }}>第三方生成 · 已落库</span>
                        <video src={window.withApiToken(vr.generated_clip_url)} controls preload="metadata"
                               style={{ width: "100%", maxHeight: 200, background: "#000", borderRadius: 4 }}/>
                      </div>
                    ) : (
                      <div className="col gap-6" style={{
                        padding: 8, border: "1px dashed var(--line-1)", borderRadius: 4,
                        background: "var(--bg-1)",
                      }}>
                        <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
                          种子库未命中（或全部冷却 / 已耗尽）
                        </span>
                        <MatchTraceBadges trace={vr.match_trace}/>
                        <button className="btn sm" disabled={isGenBusy}
                                onClick={() => triggerGenerate(r)}
                                style={{ background: "var(--purple)", color: "var(--bg-0)" }}>
                          <Icon name="magic" size={10}/>
                          {isGenBusy ? "生成中…" : "第三方模型生成 + kh 落库"}
                        </button>
                        {genMsg && (
                          <div style={{ fontSize: 10, color: "var(--orange)", lineHeight: 1.5 }}>
                            {genMsg}
                          </div>
                        )}
                      </div>
                    )}
                  </td>
                </tr>
              );
            })}
            {rows.length === 0 && (
              <tr><td colSpan={5} style={{ ...TD_STYLE, textAlign: "center", color: "var(--fg-3)" }}>
                暂无 chunks，先到 2·拆解爆款 完成 R2 二创
              </td></tr>
            )}
          </tbody>
        </table>
      </div>
      {genDialogChunk && (
        <GenerateShotDialog
          jobId={jobId}
          chunkId={genDialogChunk.chunk_id}
          originalVideoUrl={genDialogChunk.original_url}
          initialUserPrompt={genDialogChunk.aigc_prompt || ""}
          onClose={() => setGenDialogChunk(null)}
          onSubmitted={() => { setTimeout(() => setGenDialogChunk(null), 1500); }}
        />
      )}
      {manualPickChunk && (
        <ManualClipPickerDialog
          jobId={jobId}
          chunkId={manualPickChunk.chunk_id}
          currentClipId={manualPickChunk.current_clip_id}
          onClose={() => setManualPickChunk(null)}
          onPicked={() => load()}
        />
      )}
    </div>
  );
};


// ─────────── 横向 Track（替代 4 列对照表）───────────
//
// 每个 chunk 一个纵列；列内五层（自上而下）：
//   ① 二创文案 rewrite_text
//   ② 画面提示词 aigc_prompt
//   ③ 分镜素材（命中 → <video>，未命中 → 留白点击进生成）
//   ④ 口播 TTS（按需调 /api/compose/tts-preview 生成 mp3 → <audio>）
// 列宽与 chunk 时长成比例（min 200px / max 420px）。
// 列底固定一个全宽 BGM <audio>，可切歌、可播放预览。
//
const HorizontalTrackPanel = ({ jobId, allBgm, defaultBgmFilename, voiceForTts = "zh-CN-XiaoxiaoNeural", rateForTts = "+10%" }) => {
  const [data, setData] = React.useState(null);
  const [err, setErr] = React.useState("");
  const [rejectBusy, setRejectBusy] = React.useState({});
  const [genDialogChunk, setGenDialogChunk] = React.useState(null);
  const [bgmFilename, setBgmFilename] = React.useState(defaultBgmFilename || null);

  React.useEffect(() => {
    if (!bgmFilename && (allBgm || []).length) {
      setBgmFilename(allBgm[0].filename);
    }
  }, [allBgm, bgmFilename]);

  const load = async () => {
    if (!jobId) return;
    setErr("");
    try {
      const d = await API.replicateTable.get(jobId);
      setData(d);
    } catch (e) { setErr(String(e.message || e)); }
  };
  React.useEffect(() => { load(); }, [jobId]);

  const rejectMatch = async (cid) => {
    setRejectBusy(b => ({ ...b, [cid]: true }));
    try {
      await API.shotQc.rejectMatch(jobId, cid);
      await load();
    } catch (e) { setErr(String(e.message || e)); }
    finally { setRejectBusy(b => ({ ...b, [cid]: false })); }
  };

  if (!data) {
    return (
      <div className="card">
        <div className="card-hd">
          <span className="card-title">质检合成横向轨道</span>
          <span className="card-sub">{err || "加载中…"}</span>
        </div>
      </div>
    );
  }

  const { rows = [], rules = {} } = data;
  // 列宽：duration_sec * 60 px，clamp [200, 420]
  const colWidth = (sec) => Math.round(Math.max(200, Math.min(420, (sec || 3) * 60)));
  // 横向轨道左侧 sticky labels 列宽
  const LABEL_W = 96;
  const ROW_H_TEXT  = 70;   // 文案 / 提示词 行高
  const ROW_H_PROMPT = 80;
  const ROW_H_CLIP  = 180;  // 分镜素材
  const ROW_H_TTS   = 56;   // 口播 audio
  const ROW_H_BGM   = 64;   // BGM audio (全宽)

  const cellBase = {
    borderRight: "1px solid var(--line-soft)",
    borderBottom: "1px solid var(--line-soft)",
    padding: 6, boxSizing: "border-box", overflow: "hidden",
    background: "var(--bg-0)",
  };
  const labelBase = {
    ...cellBase,
    background: "var(--bg-1)", color: "var(--fg-2)",
    fontSize: 10.5, fontWeight: 600,
    position: "sticky", left: 0, zIndex: 2,
    width: LABEL_W, minWidth: LABEL_W, maxWidth: LABEL_W,
    display: "flex", alignItems: "center", justifyContent: "center",
  };

  return (
    <div className="card" style={{ overflow: "hidden" }}>
      <div className="card-hd">
        <span className="card-title">质检合成横向轨道</span>
        <span className="card-sub">
          {rows.length} chunk · 列宽 ∝ TTS 时长 · 留白处可点 → 走生成
        </span>
        <div className="grow"/>
        <button className="btn sm ghost" onClick={load}>
          <Icon name="refresh" size={10}/>刷新
        </button>
      </div>

      <div style={{ overflowX: "auto", maxHeight: 720 }}>
        {/* ① 文案二创 */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: ROW_H_TEXT }}>① 文案二创</div>
          {rows.map(r => (
            <div key={`tx-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: ROW_H_TEXT,
              fontSize: 11.5, lineHeight: 1.5, color: "var(--fg-0)",
            }}>
              <div className="mono" style={{ fontSize: 9, color: "var(--fg-3)", marginBottom: 2 }}>
                {r.chunk_id} · {r.duration_sec.toFixed(1)}s
              </div>
              {r.rewrite_text || <span style={{ color: "var(--fg-3)" }}>—</span>}
            </div>
          ))}
        </div>

        {/* ② 画面提示词 */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: ROW_H_PROMPT }}>② 画面提示词</div>
          {rows.map(r => (
            <div key={`pr-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: ROW_H_PROMPT,
              fontSize: 10, lineHeight: 1.5, color: "var(--fg-2)",
            }}
                 title={r.aigc_prompt}>
              {r.aigc_prompt || <span style={{ color: "var(--fg-3)" }}>—</span>}
            </div>
          ))}
        </div>

        {/* ③ 分镜素材 */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: ROW_H_CLIP }}>③ 分镜素材</div>
          {rows.map(r => {
            const m = r.visual_remix && r.visual_remix.match;
            const w = colWidth(r.duration_sec);
            const tr = r.visual_remix && r.visual_remix.match_trace;
            const via = tr && tr.matched_via;
            const primary = (tr && tr.extracted && tr.extracted.primary) || [];
            return (
              <div key={`cl-${r.chunk_id}`} style={{
                ...cellBase, width: w, height: ROW_H_CLIP, padding: 4,
                position: "relative",
              }}>
                {m ? (
                  <div className="col gap-2" style={{ height: "100%" }}>
                    <video src={window.withApiToken(m.preview_url)} muted preload="metadata"
                           onClick={(e) => { const v = e.currentTarget; v.paused ? v.play() : v.pause(); }}
                           style={{ width: "100%", height: ROW_H_CLIP - 50, background: "#000",
                                    borderRadius: 3, objectFit: "cover", cursor: "pointer" }}/>
                    <div className="row gap-3" style={{ alignItems: "center", flexWrap: "wrap" }}>
                      <span className="tag" style={{ fontSize: 8.5, padding: "0 4px",
                        color: via === "core" ? "var(--cyan)" : via === "full" ? "var(--orange)" : "var(--fg-3)",
                      }}>
                        {via === "core" ? "✓核心" : via === "full" ? "⚠兜底" : "已绑"}
                      </span>
                      {primary.slice(0, 3).map((k, i) => (
                        <span key={i} className="tag" style={{ fontSize: 8.5, padding: "0 4px",
                          background: "color-mix(in oklch, var(--purple) 12%, var(--bg-1))",
                          color: "var(--purple)" }}>{k}</span>
                      ))}
                      {m.usage && (
                        <span className="mono" style={{ fontSize: 8.5,
                          color: m.usage.life_pct >= 0.86 ? "var(--pink)"
                                : m.usage.life_pct >= 0.57 ? "var(--orange)" : "var(--cyan)" }}>
                          {m.usage.total_uses}/{m.usage.max_uses}
                        </span>
                      )}
                      <div className="grow"/>
                      <button className="btn sm" onClick={() => rejectMatch(r.chunk_id)}
                              disabled={rejectBusy[r.chunk_id]}
                              style={{ fontSize: 9, padding: "1px 4px", height: 18 }}
                              title="重选">
                        ✗
                      </button>
                    </div>
                  </div>
                ) : (
                  // 留白：点击进生成弹窗
                  <button onClick={() => setGenDialogChunk({
                            chunk_id: r.chunk_id, original_url: r.original_video_url,
                            aigc_prompt: r.aigc_prompt,
                          })}
                          style={{
                            width: "100%", height: "100%",
                            border: "2px dashed var(--purple)", borderRadius: 4,
                            background: "color-mix(in oklch, var(--purple) 6%, var(--bg-0))",
                            color: "var(--purple)", cursor: "pointer",
                            display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
                            gap: 6, fontSize: 11, lineHeight: 1.5, padding: 8,
                            textAlign: "center", fontFamily: "inherit",
                          }}
                          title={r.aigc_prompt}>
                    <Icon name="magic" size={18}/>
                    <span style={{ fontWeight: 600 }}>留白 · 点击生成</span>
                    <span style={{ fontSize: 9.5, color: "var(--fg-3)", lineHeight: 1.4,
                                   maxHeight: 32, overflow: "hidden" }}>
                      {(r.aigc_prompt || "").slice(0, 40)}…
                    </span>
                  </button>
                )}
              </div>
            );
          })}
        </div>

        {/* ④ 口播 TTS */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: ROW_H_TTS }}>④ 口播 TTS</div>
          {rows.map(r => (
            <div key={`tts-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: ROW_H_TTS,
              padding: 6, display: "flex", alignItems: "center",
            }}>
              {r.rewrite_text ? (
                <audio src={API.compose.ttsPreviewUrl(r.rewrite_text, voiceForTts, rateForTts)}
                       controls preload="none" style={{ width: "100%", height: 28 }}/>
              ) : <span style={{ fontSize: 9.5, color: "var(--fg-3)" }}>无文案</span>}
            </div>
          ))}
        </div>

        {/* ⑤ BGM（全宽 + 切歌） */}
        <div style={{
          display: "flex", minWidth: "fit-content", height: ROW_H_BGM,
          alignItems: "center", background: "var(--bg-1)",
          borderTop: "1px solid var(--line-1)",
        }}>
          <div style={{ ...labelBase, height: ROW_H_BGM, background: "var(--bg-1)" }}>
            ⑤ BGM
          </div>
          <div className="row gap-8" style={{ padding: "0 10px", flex: 1, alignItems: "center" }}>
            <select className="input" value={bgmFilename || ""}
                    onChange={e => setBgmFilename(e.target.value || null)}
                    style={{ fontSize: 10.5, padding: "3px 6px", maxWidth: 260 }}>
              {(allBgm || []).length === 0 && <option value="">（无 BGM）</option>}
              {(allBgm || []).map(b => (
                <option key={b.filename} value={b.filename}>{b.filename.slice(0, 36)}</option>
              ))}
            </select>
            {bgmFilename ? (
              <audio src={API.compose.bgmStreamUrl(bgmFilename)}
                     controls preload="none"
                     key={bgmFilename}
                     style={{ flex: 1, height: 30, minWidth: 240 }}/>
            ) : (
              <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>未选 BGM（合成时随机挑）</span>
            )}
            <span style={{ fontSize: 9.5, color: "var(--fg-3)" }}>
              合成时此选择会传给后端
            </span>
          </div>
        </div>
      </div>

      {genDialogChunk && (
        <GenerateShotDialog
          jobId={jobId}
          chunkId={genDialogChunk.chunk_id}
          originalVideoUrl={genDialogChunk.original_url}
          onClose={() => setGenDialogChunk(null)}
          onSubmitted={() => { setTimeout(() => setGenDialogChunk(null), 1500); load(); }}
        />
      )}
    </div>
  );
};


// /remix 用：4 列纵表（带编辑提示词 + 手动选片）+ 「送质检合成」按钮跳到 /qc
const ReplicateTableWithCompose = ({ jobId }) => {
  const [tableData, setTableData] = React.useState(null);

  React.useEffect(() => {
    let cancel = false;
    const reload = () => {
      API.replicateTable.get(jobId).then(d => { if (!cancel) setTableData(d); }).catch(() => {});
    };
    reload();
    const t = setInterval(reload, 8000);
    return () => { cancel = true; clearInterval(t); };
  }, [jobId]);

  const rows = tableData?.rows || [];
  const allMatched = rows.length > 0 && rows.every(r => r.visual_remix && r.visual_remix.match);

  const sendToQc = () => {
    window.history.pushState({}, "", `/qc?job=${encodeURIComponent(jobId)}`);
    window.dispatchEvent(new PopStateEvent("popstate"));
  };

  return (
    <>
      <ReplicateTable jobId={jobId}/>
      {tableData && (
        <div className="card" style={{
          padding: "10px 14px",
          background: allMatched ? "color-mix(in oklch, var(--purple) 8%, var(--bg-1))" : "var(--bg-1)",
        }}>
          <div className="row between" style={{ alignItems: "center" }}>
            <div className="col gap-3">
              <span style={{ fontSize: 12, fontWeight: 600, color: "var(--fg-0)" }}>
                {allMatched ? "✓ 全部分镜已匹配" : "⚠ 还有分镜未匹配"} · 共 {rows.length} 镜
              </span>
              <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
                确认完文案与分镜后点 OK 把数据移交给「4 · 质检合成」做轨道质检 + ffmpeg 出片
              </span>
            </div>
            <button className="btn primary" onClick={sendToQc}
                    disabled={!allMatched}
                    style={{
                      background: allMatched ? "var(--purple)" : "var(--bg-2)",
                      color: allMatched ? "var(--bg-0)" : "var(--fg-3)",
                      cursor: allMatched ? "pointer" : "not-allowed",
                    }}>
              <Icon name="check" size={11}/>OK · 送质检合成 →
            </button>
          </div>
        </div>
      )}
    </>
  );
};


// ─────────── 复刻历史 + 实时进度 ───────────
const ReplicateRunsPanel = ({ parent, activeChildId, childProgress, onSubscribe }) => {
  if (!parent) return null;
  const runs = (parent.replicate_runs || []).slice().reverse();
  if (!runs.length && !activeChildId) return null;

  return (
    <div className="card">
      <div className="card-hd">
        <span className="card-title">复刻历史</span>
        <span className="card-sub">{runs.length} 次</span>
      </div>
      <div className="col" style={{ padding:12, gap:10 }}>
        {runs.map(r => {
          const showProgress = activeChildId === r.child_job_id && childProgress;
          return (
            <div key={r.child_job_id} style={{ padding:10, background:"var(--bg-1)", border:"1px solid var(--line-1)", borderRadius:5 }}>
              <div className="row between" style={{ alignItems:"center" }}>
                <span className="row gap-6">
                  <span className="tag purple" style={{ fontSize:9 }}>{PROVIDER_LABEL[r.platform]||r.platform||"—"}</span>
                  {r.model_name && <span style={{ fontSize:11, color:"var(--fg-1)" }}>{r.model_name}</span>}
                  <span className={`tag ${r.status==="completed"?"green":r.status==="failed"?"pink":r.status==="partial"?"orange":"cyan"}`} style={{ fontSize:9 }}>
                    {r.status==="completed"?"完成":r.status==="failed"?"失败":r.status==="partial"?"部分完成":r.status==="running"?"进行中":"排队"}
                  </span>
                </span>
                <span className="mono" style={{ fontSize:9.5, color:"var(--fg-3)" }}>{(r.created_at||"").slice(5,16).replace("T"," ")}</span>
              </div>
              {showProgress && (
                <div className="col gap-4" style={{ marginTop:6 }}>
                  <div className="bar" style={{ height:3 }}>
                    <span style={{ width:`${childProgress.pct||0}%`, background:"var(--purple)", transition:"width .3s" }}/>
                  </div>
                  <span style={{ fontSize:10.5, color:"var(--fg-3)" }}>{childProgress.message || "—"}</span>
                </div>
              )}
              {!showProgress && (r.status === "running" || r.status === "pending") && (
                <button className="btn sm" onClick={() => onSubscribe(r.child_job_id)} style={{ marginTop:6 }}>
                  <Icon name="play" size={10}/>订阅进度
                </button>
              )}
              {r.error && <div style={{ marginTop:6, fontSize:10.5, color:"var(--pink)", lineHeight:1.4 }}>{r.error.slice(0,200)}</div>}
              {r.final_video_path && (r.status === "completed" || r.status === "partial") && (
                <video src={window.withApiToken(`/api/jobs/${encodeURIComponent(r.child_job_id)}/replicate/video`)}
                  controls style={{ width:"100%", marginTop:8, borderRadius:5, background:"#000" }}/>
              )}
              {r.shots?.length > 0 && (
                <details style={{ marginTop:6 }}>
                  <summary style={{ fontSize:10.5, color:"var(--fg-3)", cursor:"pointer" }}>{r.shots.length} 镜明细</summary>
                  <div className="col gap-2" style={{ marginTop:4, fontSize:10.5 }}>
                    {r.shots.map((s, i) => (
                      <div key={i} className="row between">
                        <span className="mono" style={{ color:"var(--fg-3)" }}>shot_{s.shot_index}</span>
                        <span className={`tag ${s.status==="completed"?"green":s.status==="failed"?"pink":"cyan"}`} style={{ fontSize:8.5 }}>
                          {s.status}
                        </span>
                      </div>
                    ))}
                  </div>
                </details>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
};

// ===== 旧 mock 数据保留给老组件用，但 RemixVideo 不再消费（M5 删除） =====
const _LegacyVideoMock = () => {
  const dims = [
    { k:"钩子", col:"orange", src:"别再交智商税了—价格差十倍", tgt:"你家 3 居室总价差 100 万，差在哪里？", keep:"否定祈使+数字冲突" },
    { k:"文案结构", col:"cyan", src:"起·承·转·合 · 24s", tgt:"保留结构 · 替换主体为房产", keep:"Hook→Build→Core→CTA" },
    { k:"视觉节奏", col:"purple", src:"13 分镜 / 1.85s 平均切", tgt:"同节奏 · 替换素材库", keep:"节拍点对齐 BGM" },
    { k:"情绪曲线", col:"pink", src:"好奇→愤怒→顿悟→行动", tgt:"迁移 · 愤怒点换作「开发商套路」", keep:"同路径 8 情绪点" },
    { k:"评论区钩子", col:"green", src:"评论扣「拆」→ 第二期", tgt:"评论扣「房」→ 户型对比表", keep:"留钩方式不变" },
  ];
  const shots = [
    { t:"00:00", tag:"hook",  col:"orange", txt:"你家 3 居室总价差 100 万，差在哪里？今天我用 8 组户型拆给你看。", model:"Sora 2",    desc:"俯视房屋模型阵列，快切推近特写",     cost:"¥14.40", eta:"42s" },
    { t:"00:06", tag:"build", col:"cyan",   txt:"第一组：朝向相同、面积相同，但一个朝南一个朝北，价格差 18 万。", model:"可灵 2.0", desc:"户型图对比·左右分屏·推镜揭示",       cost:"¥8.20",  eta:"22s" },
    { t:"00:12", tag:"core",  col:"purple", txt:"真正决定价格的，是这个藏在户型里的「动静分区」设计。",          model:"Sora 2",   desc:"局部高亮动线+文字标注，推镜揭示",     cost:"¥16.80", eta:"28s" },
    { t:"00:18", tag:"build", col:"cyan",   txt:"再看这组——同楼层、同户型，唯一区别是楼间距从 28 米变成 42 米。",model:"即梦 Pro", desc:"两楼正午光线对比·时间推移",           cost:"¥7.60",  eta:"20s" },
    { t:"00:22", tag:"cta",   col:"green",  txt:"想要完整户型对比表？评论区扣「房」，下期给你整理 20 个户型避坑清单。", model:"可灵 2.0", desc:"博主手持清单·CTA 大字·镜头拉近", cost:"¥5.60",  eta:"14s" },
  ];

  const [stage, setStage] = React.useState(0);         // 0 读取原文 · 1 五维二创 · 2 生成分镜 · 3 合成
  const [dimsRevealed, setDimsRevealed] = React.useState(0);
  const [shotProgress, setShotProgress] = React.useState([0,0,0,0,0]); // 0 待生成 · 1..100 生成中 · 100 完成

  // 模拟分镜生成
  React.useEffect(() => {
    if (stage !== 2) return;
    const id = setInterval(() => {
      setShotProgress(p => {
        const next = [...p];
        let advanced = false;
        for (let i=0; i<next.length; i++) {
          if (next[i] < 100) { next[i] = Math.min(100, next[i] + (i===0?22:i===1?18:i===2?14:i===3?16:20)); advanced=true; break; }
        }
        if (!advanced) clearInterval(id);
        return next;
      });
    }, 300);
    return () => clearInterval(id);
  }, [stage]);

  const allShotsReady = shotProgress.every(p => p >= 100);
  const cost = (stage >= 2 ? 52.60 : 0).toFixed(2);

  return (
    <>
      <Topbar
        crumbs={["3A · 短视频爆款复刻", "基于 #A8F3K2 二创", `Stage ${stage+1}/4`]}
        right={<>
          <span className="pill" style={{ background:"color-mix(in oklch, var(--purple) 14%, var(--bg-1))", color:"var(--purple)" }}>
            <Icon name="play" size={10}/>文生视频 · T2V
          </span>
          <button className="btn ghost" onClick={()=>{ setStage(0); setDimsRevealed(0); setShotProgress([0,0,0,0,0]); }}>
            <Icon name="refresh" size={12}/>重置
          </button>
        </>}
      />
      <Stepper
        steps={["1. 读取原文", "2. 五维度二创", "3. 分镜 · 文生视频", "4. 合成发布"]}
        stage={stage}
          onJump={setStage}
          color="purple"
        />
        <div className="main-body">

          {/* 左栏：原文案 + 五维度（阶段 0/1） */}
          <div className="col scroll" style={{ width:380, flexShrink:0, padding:"14px 14px 20px", gap:12, borderRight:"1px solid var(--line-soft)" }}>
            <OriginalCopy id="#A8F3K2 · 24s 竖屏" text="别再交智商税了——一模一样的东西，价格差了整整十倍。为了搞清楚区别，我拆了 8 台不同价位的设备，结果发现真正决定体验的，是藏在里面的一个模组……"/>
            {stage === 0 && (
              <div className="card" style={{ padding:14 }}>
                <span className="kpi-l">Step 1 · 读取原文</span>
                <div style={{ fontSize:12, color:"var(--fg-2)", lineHeight:1.5, marginTop:8 }}>
                  已从存储库加载爆款 #A8F3K2 原文案，准备进入五维度二创。
                </div>
                <button className="btn primary" style={{ marginTop:12, justifyContent:"center" }} onClick={()=>setStage(1)}>
                  <Icon name="right" size={12}/>开始五维度二创
                </button>
              </div>
            )}
            {stage >= 1 && (
              <FiveDimsProgressive
                dims={dims}
                revealed={dimsRevealed}
                onRewrite={() => setDimsRevealed(v => Math.min(dims.length, v+1))}
                onNext={() => setStage(2)}
                color="purple"
              />
            )}
          </div>

          {/* 中栏：分镜生成（阶段 2/3） */}
          <div className="col grow scroll" style={{ padding:"14px 14px 20px", gap:12, background:"var(--bg-0)" }}>
            {stage < 2 ? (
              <div className="card" style={{ padding:28, flex:1 }}>
                <Locked hint={stage === 0 ? "先读取原文，再进入五维度二创" : `完成五维度二创后解锁 · ${dimsRevealed}/${dims.length}`}/>
              </div>
            ) : (
              <>
                <div className="card">
                  <div className="card-hd">
                    <span className="card-title">二创文案 · 分镜 → 文生视频</span>
                    <span className="card-sub">{shotProgress.filter(p=>p>=100).length}/5 已生成</span>
                    <div className="grow"/>
                    <div className="bar" style={{ width:80, height:3 }}>
                      <span style={{ width:`${(shotProgress.reduce((a,b)=>a+b,0)/500)*100}%`, background:"var(--purple)", transition:"width .2s" }}/>
                    </div>
                  </div>
                  <div className="col" style={{ padding:12, gap:10 }}>
                    {shots.map((l,i) => {
                      const p = shotProgress[i];
                      const done = p >= 100;
                      const generating = p > 0 && p < 100;
                      return (
                        <div key={i} style={{
                          padding:12, background:"var(--bg-1)",
                          border:`1px solid ${generating?"color-mix(in oklch, var(--purple) 40%, var(--line-1))":"var(--line-1)"}`,
                          borderRadius:6, opacity: p === 0 ? 0.55 : 1, transition:"all .2s",
                        }}>
                          <div className="row" style={{ gap:12, alignItems:"flex-start" }}>
                            <div className="mono" style={{ fontSize:10, color:"var(--fg-3)", minWidth:38, paddingTop:2 }}>{l.t}</div>
                            <span className={`tag ${l.col}`} style={{ minWidth:48, justifyContent:"center" }}>{l.tag.toUpperCase()}</span>
                            <div className="col grow gap-6">
                              <div style={{ fontSize:12.5, color:"var(--fg-0)", lineHeight:1.55 }}>{l.txt}</div>
                              <div className="row gap-8" style={{ padding:"8px 10px", background:"var(--bg-0)", border:"1px dashed var(--line-2)", borderRadius:5 }}>
                                <div style={{ width:72, height:44, background:"linear-gradient(135deg,#1a1f2e,#0e1118)", borderRadius:4, display:"flex", alignItems:"center", justifyContent:"center", color: done?"var(--green)":"var(--purple)", border:"1px solid var(--line-1)", position:"relative" }}>
                                  <Icon name={done?"check":"play"} size={16}/>
                                  {generating && (
                                    <div style={{ position:"absolute", bottom:0, left:0, height:2, width:`${p}%`, background:"var(--purple)" }}/>
                                  )}
                                </div>
                                <div className="col grow gap-2">
                                  <div className="row gap-6">
                                    <span className="tag purple">文生视频</span>
                                    <span className="mono" style={{ fontSize:10, color:"var(--fg-2)" }}>{l.model}</span>
                                    <span className="mono" style={{ fontSize:10, color:"var(--fg-3)" }}>· 9:16 · 1080p</span>
                                    {done && <span style={{ fontSize:9.5, color:"var(--green)" }}>✓</span>}
                                    {generating && <span className="mono" style={{ fontSize:9.5, color:"var(--purple)" }}>{p}%</span>}
                                  </div>
                                  <span style={{ fontSize:10.5, color:"var(--fg-3)", lineHeight:1.4 }}>Prompt：{l.desc}</span>
                                </div>
                                <div className="col gap-2" style={{ alignItems:"flex-end" }}>
                                  <span className="mono" style={{ fontSize:11, color:"var(--orange)", fontWeight:600 }}>{l.cost}</span>
                                  <span className="mono" style={{ fontSize:9.5, color:"var(--fg-3)" }}>~{l.eta}</span>
                                </div>
                              </div>
                            </div>
                          </div>
                        </div>
                      );
                    })}
                    {allShotsReady && stage === 2 && (
                      <button className="btn primary" onClick={()=>setStage(3)} style={{ justifyContent:"center", marginTop:4, background:"var(--purple)", color:"var(--bg-0)" }}>
                        <Icon name="right" size={12}/>下一步：合成时间轴
                      </button>
                    )}
                  </div>
                </div>

                {stage >= 3 && (
                  <div className="card">
                    <div className="card-hd">
                      <span className="card-title">合成时间轴</span>
                      <span className="card-sub">3 轨 · 24.0s</span>
                      <div className="grow"/>
                      <span className="mono" style={{ fontSize:10.5, color:"var(--green)" }}>✓ 已就绪</span>
                    </div>
                    <div className="col" style={{ padding:12, gap:8 }}>
                      <TrackRow label="画面" ic="layers" segs={[
                        { w:18, col:"var(--purple)", t:"Sora2 · Hook" },
                        { w:22, col:"var(--purple)", t:"可灵 · Build" },
                        { w:24, col:"var(--purple)", t:"Sora2 · Core" },
                        { w:19, col:"var(--purple)", t:"即梦 · Build" },
                        { w:17, col:"var(--purple)", t:"可灵 · CTA" },
                      ]}/>
                      <TrackRow label="字幕" ic="sparkle" segs={[
                        { w:25, col:"var(--fg-3)", t:"钩子", faded:true },
                        { w:46, col:"var(--fg-3)", t:"铺垫+核心", faded:true },
                        { w:29, col:"var(--fg-3)", t:"CTA", faded:true },
                      ]}/>
                      <TrackRow label="音乐" ic="music" segs={[
                        { w:75, col:"var(--cyan)", t:"Tension Rise · 128 BPM", faded:true },
                        { w:25, col:"var(--green)", t:"Outro", faded:true },
                      ]}/>
                      <button className="btn primary" style={{ marginTop:6, justifyContent:"center", background:"var(--green)", color:"var(--bg-0)" }}>
                        <Icon name="send" size={12}/>推送至 5·分发模块
                      </button>
                    </div>
                  </div>
                )}
              </>
            )}
          </div>

          {/* 右栏 */}
          <div className="vr"/>
          <div className="col scroll" style={{ width:280, flexShrink:0, padding:"14px 14px 20px", gap:12 }}>
            <div className="card" style={{ padding:10 }}>
              <div className="card-sub" style={{ marginBottom:8 }}>PREVIEW · 9:16 · T2V</div>
              <div style={{ position:"relative", aspectRatio:"9/16", background:"#000", borderRadius:8, overflow:"hidden", border:"1px solid var(--line-1)" }}>
                <div className="thumb" style={{ position:"absolute", inset:0, borderRadius:0, border:"none", opacity: stage>=2 ? 1 : 0.3 }}>
                  <div className="thumb-pf"><PF type="douyin"/></div>
                </div>
                {stage >= 1 && (
                  <div style={{ position:"absolute", bottom:16, left:12, right:12, zIndex:3 }}>
                    <div style={{ fontSize:11, color:"white", fontWeight:600, textShadow:"0 1px 2px rgba(0,0,0,.6)", lineHeight:1.3 }}>你家 3 居室总价差 100 万，差在哪里？</div>
                  </div>
                )}
                {stage >= 3 && (
                  <div style={{ position:"absolute", top:"50%", left:"50%", transform:"translate(-50%,-50%)", width:44, height:44, borderRadius:"50%", background:"rgba(0,0,0,.5)", display:"flex", alignItems:"center", justifyContent:"center", color:"white" }}>
                    <Icon name="play" size={18}/>
                  </div>
                )}
                <div style={{ position:"absolute", top:10, left:10, fontSize:9, color:"white", padding:"2px 6px", background:"rgba(124,92,255,.8)", borderRadius:3, fontWeight:600 }}>
                  {stage===0?"等待":stage===1?"文案中":stage===2?"生成中":"就绪"}
                </div>
              </div>
            </div>

            <div className="card" style={{ padding:10 }}>
              <div className="card-sub" style={{ marginBottom:6 }}>成本累计</div>
              <div className="row between" style={{ alignItems:"baseline" }}>
                <span className="kpi-v" style={{ fontSize:20 }}>¥{cost}</span>
                {stage >= 2 && <span className="delta down">-38%</span>}
              </div>
              <div className="col gap-4" style={{ marginTop:10, fontSize:10.5, color:"var(--fg-3)" }}>
                <div className="row between">
                  <span>文案二创 (Haiku)</span>
                  <span className="mono" style={{ color: dimsRevealed>0 ? "var(--fg-1)" : "var(--fg-3)" }}>
                    {dimsRevealed>0 ? `¥${(dimsRevealed*0.02).toFixed(2)}` : "—"}
                  </span>
                </div>
                <div className="row between">
                  <span>文生视频 × 5</span>
                  <span className="mono" style={{ color: stage>=2 ? "var(--fg-1)" : "var(--fg-3)" }}>
                    {stage>=2 ? "¥52.60" : "—"}
                  </span>
                </div>
              </div>
            </div>
          </div>
        </div>
      </>
  );
};

// ─────────── 3B · 图文爆款复刻（文生图） ───────────
// TODO M5: T2I 接入待 M5 阶段；当前为前端 mock，所有数据写死，不调真实后端
const RemixImage = () => {
  const dims = [
    { k:"标题钩子",   col:"orange", src:"姐妹们冲！这个 99 块的平替，效果真的炸裂",        tgt:"租房党注意！200 块改造出租屋，房东都震惊",       keep:"感叹号+身份+数字" },
    { k:"正文结构",   col:"cyan",   src:"痛点 → 对比 → 种草 → 避坑 → 求赞",                tgt:"保留 5 段结构 · 替换主体",                         keep:"5 段 emoji 分段" },
    { k:"封面构图",   col:"purple", src:"大字报 · 左字右图 · 对比色块",                     tgt:"同构图 · 换品类图片 + 红底白字",                   keep:"黄金比例 + 字号层级" },
    { k:"情绪关键词", col:"pink",   src:"绝绝子 / 救命 / yyds / 真的爱了",                  tgt:"迁移风格 · 换作装修关键词",                         keep:"形容词密度 ≥ 4/段" },
    { k:"话题标签",   col:"green",  src:"#平价好物 #学生党 #性价比",                        tgt:"#租房改造 #出租屋变身 #小户型",                     keep:"3 大词 + 2 长尾" },
  ];
  const slides = [
    { idx:"01", role:"封面", txt:"租房党注意！200 块改造出租屋\n房东都震惊",           model:"Seedream 3.0", style:"大字报 · 红黄撞色 · 对角线构图", cost:"¥0.12", eta:"5s" },
    { idx:"02", role:"痛点", txt:"墙皮发黄 · 家具老气 · 灯光昏暗\n每天回家像进古董店",  model:"Nano Banana",  style:"前后对比图 · 暗黄滤镜",          cost:"¥0.08", eta:"3s" },
    { idx:"03", role:"对比", txt:"改造前 VS 改造后\n同一个角度 · 光线 · 视角",            model:"Seedream 3.0", style:"左右分屏 · 日系清新",             cost:"¥0.12", eta:"5s" },
    { idx:"04", role:"种草", txt:"3 样核心单品 · 单价都不超过 80 元\n便利贴式标注价格",  model:"Nano Banana",  style:"白底产品陈列 · 手写箭头",         cost:"¥0.08", eta:"3s" },
    { idx:"05", role:"避坑", txt:"两个看起来便宜但千万别买的\n踩坑血泪劝退",             model:"Seedream 3.0", style:"红叉警告图 · 黑红警示色",         cost:"¥0.12", eta:"5s" },
    { idx:"06", role:"CTA",  txt:"完整清单放评论区置顶\n扣「改」送户型改造方案",        model:"Nano Banana",  style:"手持清单图 · 暖色调",             cost:"¥0.08", eta:"3s" },
  ];

  const [stage, setStage] = React.useState(0);
  const [dimsRevealed, setDimsRevealed] = React.useState(0);
  const [imgProgress, setImgProgress] = React.useState([0,0,0,0,0,0]);

  React.useEffect(() => {
    if (stage !== 2) return;
    const id = setInterval(() => {
      setImgProgress(p => {
        const next = [...p];
        let advanced = false;
        for (let i=0; i<next.length; i++) {
          if (next[i] < 100) { next[i] = Math.min(100, next[i] + 34); advanced=true; break; }
        }
        if (!advanced) clearInterval(id);
        return next;
      });
    }, 220);
    return () => clearInterval(id);
  }, [stage]);

  const allReady = imgProgress.every(p => p >= 100);
  const cost = (stage >= 2 ? 0.60 : 0).toFixed(2);

  return (
    <>
      <Topbar
        crumbs={["3B · 图文爆款复刻", "基于 #R7X9M4 二创", `Stage ${stage+1}/4`]}
        right={<>
          <span className="pill" style={{ background:"color-mix(in oklch, var(--cyan) 14%, var(--bg-1))", color:"var(--cyan)" }}>
            <Icon name="layers" size={10}/>文生图 · T2I
          </span>
          <button className="btn ghost" onClick={()=>{ setStage(0); setDimsRevealed(0); setImgProgress([0,0,0,0,0,0]); }}>
            <Icon name="refresh" size={12}/>重置
          </button>
        </>}
      />
      <Stepper
        steps={["1. 读取原文", "2. 五维度二创", "3. 图集 · 文生图", "4. 排版发布"]}
        stage={stage}
          onJump={setStage}
          color="cyan"
        />
        <div className="main-body">
          <div className="col scroll" style={{ width:380, flexShrink:0, padding:"14px 14px 20px", gap:12, borderRight:"1px solid var(--line-soft)" }}>
            <OriginalCopy id="#R7X9M4 · 小红书 6 图" text="姐妹们冲！这个 99 块的平替，效果真的炸裂……上次去朋友家看到她在用，回来立马下单，用了两周来汇报，真的救我大命！"/>
            {stage === 0 && (
              <div className="card" style={{ padding:14 }}>
                <span className="kpi-l">Step 1 · 读取原文</span>
                <div style={{ fontSize:12, color:"var(--fg-2)", lineHeight:1.5, marginTop:8 }}>
                  已从存储库加载 #R7X9M4 小红书 6 图爆款原文案，准备五维度二创。
                </div>
                <button className="btn primary" style={{ marginTop:12, justifyContent:"center" }} onClick={()=>setStage(1)}>
                  <Icon name="right" size={12}/>开始五维度二创
                </button>
              </div>
            )}
            {stage >= 1 && (
              <FiveDimsProgressive
                dims={dims}
                revealed={dimsRevealed}
                onRewrite={() => setDimsRevealed(v => Math.min(dims.length, v+1))}
                onNext={() => setStage(2)}
                color="cyan"
              />
            )}
          </div>

          <div className="col grow scroll" style={{ padding:"14px 14px 20px", gap:12, background:"var(--bg-0)" }}>
            {stage < 2 ? (
              <div className="card" style={{ padding:28, flex:1 }}>
                <Locked hint={stage === 0 ? "先读取原文，再进入五维度二创" : `完成五维度二创后解锁 · ${dimsRevealed}/${dims.length}`}/>
              </div>
            ) : (
              <>
                <div className="card">
                  <div className="card-hd">
                    <span className="card-title">二创文案 · 按图序 → 文生图</span>
                    <span className="card-sub">{imgProgress.filter(p=>p>=100).length}/6 已生成</span>
                    <div className="grow"/>
                    <div className="bar" style={{ width:80, height:3 }}>
                      <span style={{ width:`${(imgProgress.reduce((a,b)=>a+b,0)/600)*100}%`, background:"var(--cyan)", transition:"width .2s" }}/>
                    </div>
                  </div>
                  <div className="col" style={{ padding:12, gap:10 }}>
                    {slides.map((l,i) => {
                      const p = imgProgress[i];
                      const done = p >= 100;
                      const generating = p > 0 && p < 100;
                      return (
                        <div key={i} style={{
                          padding:12, background:"var(--bg-1)",
                          border:`1px solid ${generating?"color-mix(in oklch, var(--cyan) 40%, var(--line-1))":"var(--line-1)"}`,
                          borderRadius:6, opacity: p === 0 ? 0.55 : 1, transition:"all .2s",
                        }}>
                          <div className="row" style={{ gap:12, alignItems:"flex-start" }}>
                            <div className="col gap-2" style={{ minWidth:38, alignItems:"center", paddingTop:2 }}>
                              <div className="mono" style={{ fontSize:13, color:i===0?"var(--orange)":"var(--fg-1)", fontWeight:700 }}>{l.idx}</div>
                              <span className={`tag ${i===0?"orange":"cyan"}`} style={{ fontSize:8.5, padding:"1px 5px" }}>{l.role}</span>
                            </div>
                            <div className="col grow gap-6">
                              <div style={{ fontSize:12.5, color:"var(--fg-0)", lineHeight:1.55, whiteSpace:"pre-line" }}>{l.txt}</div>
                              <div className="row gap-8" style={{ padding:"8px 10px", background:"var(--bg-0)", border:"1px dashed var(--line-2)", borderRadius:5 }}>
                                <div style={{ width:54, height:72, background:"linear-gradient(135deg,#0e1f22,#0a1518)", borderRadius:4, display:"flex", alignItems:"center", justifyContent:"center", color: done?"var(--green)":"var(--cyan)", border:"1px solid var(--line-1)", position:"relative" }}>
                                  <Icon name={done?"check":"layers"} size={18}/>
                                  {generating && <div style={{ position:"absolute", bottom:0, left:0, height:2, width:`${p}%`, background:"var(--cyan)" }}/>}
                                </div>
                                <div className="col grow gap-2">
                                  <div className="row gap-6">
                                    <span className="tag cyan">文生图</span>
                                    <span className="mono" style={{ fontSize:10, color:"var(--fg-2)" }}>{l.model}</span>
                                    <span className="mono" style={{ fontSize:10, color:"var(--fg-3)" }}>· 3:4 · 2K</span>
                                    {done && <span style={{ fontSize:9.5, color:"var(--green)" }}>✓</span>}
                                    {generating && <span className="mono" style={{ fontSize:9.5, color:"var(--cyan)" }}>{p}%</span>}
                                  </div>
                                  <span style={{ fontSize:10.5, color:"var(--fg-3)", lineHeight:1.4 }}>风格：{l.style}</span>
                                </div>
                                <div className="col gap-2" style={{ alignItems:"flex-end" }}>
                                  <span className="mono" style={{ fontSize:11, color:"var(--orange)", fontWeight:600 }}>{l.cost}</span>
                                  <span className="mono" style={{ fontSize:9.5, color:"var(--fg-3)" }}>~{l.eta}</span>
                                </div>
                              </div>
                            </div>
                          </div>
                        </div>
                      );
                    })}
                    {allReady && stage === 2 && (
                      <button className="btn primary" onClick={()=>setStage(3)} style={{ justifyContent:"center", marginTop:4, background:"var(--cyan)", color:"var(--bg-0)" }}>
                        <Icon name="right" size={12}/>下一步：排版预览
                      </button>
                    )}
                  </div>
                </div>

                {stage >= 3 && (
                  <div className="card">
                    <div className="card-hd">
                      <span className="card-title">图集排版预览</span>
                      <span className="card-sub">6 图 · 3:4 · 小红书</span>
                      <div className="grow"/>
                      <span className="mono" style={{ fontSize:10.5, color:"var(--green)" }}>✓ 已就绪</span>
                    </div>
                    <div className="row" style={{ padding:12, gap:8, overflowX:"auto" }}>
                      {slides.map((l,i)=>(
                        <div key={i} className="col gap-4" style={{ flexShrink:0, width:96 }}>
                          <div style={{ width:96, height:128, borderRadius:5, background:i===0?"linear-gradient(135deg,#dc2626,#f59e0b)":"linear-gradient(135deg,#1a1f2e,#0e1118)", border:"1px solid var(--line-1)", position:"relative", padding:6, display:"flex", flexDirection:"column", justifyContent:"space-between" }}>
                            <div style={{ fontSize:7, color:"white", fontWeight:600, textShadow:"0 1px 1px rgba(0,0,0,.4)", lineHeight:1.2 }}>{l.txt.split("\n")[0]}</div>
                            <div style={{ fontSize:7, color:"rgba(255,255,255,.7)", fontFamily:"var(--font-mono)" }}>{l.idx} · {l.role}</div>
                            {i===0 && <div style={{ position:"absolute", top:4, right:4, fontSize:7, padding:"1px 4px", background:"rgba(255,255,255,.2)", color:"white", borderRadius:2, fontWeight:600 }}>封面</div>}
                          </div>
                          <span className="mono" style={{ fontSize:9, color:"var(--fg-3)", textAlign:"center" }}>{l.idx}</span>
                        </div>
                      ))}
                    </div>
                    <div style={{ padding:"0 12px 12px" }}>
                      <button className="btn primary" style={{ width:"100%", justifyContent:"center", background:"var(--green)", color:"var(--bg-0)" }}>
                        <Icon name="send" size={12}/>推送至 5·分发模块
                      </button>
                    </div>
                  </div>
                )}
              </>
            )}
          </div>

          <div className="vr"/>
          <div className="col scroll" style={{ width:280, flexShrink:0, padding:"14px 14px 20px", gap:12 }}>
            <div className="card" style={{ padding:10 }}>
              <div className="card-sub" style={{ marginBottom:8 }}>PREVIEW · 小红书封面</div>
              <div style={{ position:"relative", aspectRatio:"3/4", background: stage>=1?"linear-gradient(135deg,#dc2626,#f59e0b)":"var(--bg-2)", borderRadius:8, overflow:"hidden", border:"1px solid var(--line-1)", padding:14, display:"flex", flexDirection:"column", justifyContent:"space-between", transition:"all .3s" }}>
                {stage >= 1 ? (
                  <>
                    <div style={{ fontSize:20, color:"white", fontWeight:800, lineHeight:1.15, textShadow:"0 2px 4px rgba(0,0,0,.25)", letterSpacing:"-0.5px" }}>
                      租房党注意！<br/>200 块改造<br/>出租屋
                    </div>
                    <div className="col gap-2">
                      <div style={{ fontSize:10, color:"rgba(255,255,255,.92)", fontWeight:500 }}>房东看了都震惊 ✨</div>
                      <div className="row gap-3">
                        <span style={{ fontSize:8, padding:"2px 6px", background:"rgba(255,255,255,.22)", color:"white", borderRadius:10, fontWeight:600 }}>#租房改造</span>
                      </div>
                    </div>
                  </>
                ) : (
                  <div style={{ color:"var(--fg-3)", fontSize:11, margin:"auto" }}>等待二创…</div>
                )}
                <div style={{ position:"absolute", top:10, left:10, fontSize:9, color:"white", padding:"2px 6px", background:"rgba(56,178,255,.85)", borderRadius:3, fontWeight:600 }}>
                  {stage===0?"等待":stage===1?"文案中":stage===2?"生成中":"就绪"}
                </div>
              </div>
            </div>

            <div className="card" style={{ padding:10 }}>
              <div className="card-sub" style={{ marginBottom:6 }}>成本累计</div>
              <div className="row between" style={{ alignItems:"baseline" }}>
                <span className="kpi-v" style={{ fontSize:20 }}>¥{cost}</span>
                {stage >= 2 && <span className="delta down">-92%</span>}
              </div>
              <div className="col gap-4" style={{ marginTop:10, fontSize:10.5, color:"var(--fg-3)" }}>
                <div className="row between">
                  <span>文案二创 (Haiku)</span>
                  <span className="mono" style={{ color: dimsRevealed>0 ? "var(--fg-1)" : "var(--fg-3)" }}>
                    {dimsRevealed>0 ? `¥${(dimsRevealed*0.02).toFixed(2)}` : "—"}
                  </span>
                </div>
                <div className="row between">
                  <span>文生图 × 6</span>
                  <span className="mono" style={{ color: stage>=2 ? "var(--fg-1)" : "var(--fg-3)" }}>
                    {stage>=2 ? "¥0.60" : "—"}
                  </span>
                </div>
              </div>
            </div>
          </div>
        </div>
      </>
  );
};

// QC 模块用到的子组件 export 到 window，给 /qc 页（qc.jsx）复用
Object.assign(window, {
  RemixVideo, RemixImage,
  ReplicateTable, ReplicateTableWithCompose,
  ComposePanel, GenerateShotDialog, HorizontalTrackPanel,
  SeedMatchCard, MatchTraceBadges,
  EditablePrompt, ManualClipPickerDialog,
});
