// 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" }) => (
{steps.map((s, i) => { const state = i < stage ? "done" : i === stage ? "active" : "locked"; const clickable = state !== "locked"; return (
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", }}>
{state === "done" ? : i+1}
{s}
{i < steps.length-1 &&
} ); })}
); // 锁定态遮罩(显示上一步 hint) const Locked = ({ hint }) => (
{hint}
); // ─────────── 公共:五维度拆解卡片(带渐进揭示) ─────────── const FiveDimsProgressive = ({ dims, revealed, onRewrite, onNext, color="purple" }) => (
五维度拆解 · 粘贴改写 {revealed}/{dims.length} 已改写
{dims.map((d, i) => { const done = i < revealed; const current = i === revealed; const locked = i > revealed; return (
{d.k} {done && ✓ 已改写} {current && ● 当前} {locked && 待处理}
保留: {d.keep}
{d.src}
二创 {done ? d.tgt : current ? "点击「改写本维度」开始生成…" : "锁定"}
{current && ( )}
); })} {revealed === dims.length && ( )}
); const OriginalCopy = ({ id, text }) => (
原文案 · 参考爆款{id}
「{text}」
); const TrackRow = ({ label, ic, segs }) => (
{label}
{segs.map((s,i)=>(
{s.t}
))}
); // ─────────── 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 (
正在加载文案二创任务 {jobId.slice(0, 8)}…
); } 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 ( <> {/* 状态卡 */}
文案二创独立链路 · {jobId.slice(0, 8)} {job.title || "(无标题)"} · {job.industry || "通用"}
{isDone ? "✓ 已完成" : isFail ? "✗ 失败" : `${step} ${pct}%`}
{/* 7 stage 进度 */}
{_STAGES.map((s, i) => { const done = isDone || (stageDoneIdx >= 0 && i < stageDoneIdx); const cur = !isDone && stageDoneIdx === i; return ( {i + 1}.{s.l} {i < _STAGES.length - 1 && } ); })}
{isRunning && (
{job.progress?.message || "—"}
)} {isFail && (
{job.error || "失败"}
)}
{/* 模板选择 / 当前模板 */} {(isDone || stageDoneIdx >= 5) && (
复刻模板 {(matchData?.applied_templates || []).length ? `当前:${matchData.applied_templates.join(" / ")}` : "当前:默认(未设置)— 不干预即用默认"}
去模板库
{tplPick && (
复刻类模板 ({templates.length}) — 选一条即触发 rewrite stage 重跑(前面 stage 已落盘 skip) {templates.length === 0 && (
尚无 replicate 类模板。去模板库新建。
)} {templates.map(t => (
{t.name} {t.description || "—"}
))}
)}
)} {/* 原文总文 */} {matchData && (
原文总文(从链接提炼) {(matchData.raw_script || "").length} 字 {matchData.strategy_label && ( 策略:{matchData.strategy_label} )}
{matchData.raw_script || "—"}
{matchData.rewritten_script && (
二创总文(点开看完整)
{matchData.rewritten_script}
)}
)} {/* 3 列对照表:① 原文 ② 二创文案 ③ 分镜素材(合并画面提示词 + 命中素材)*/} {matchData && matchData.rows && matchData.rows.length > 0 && (
分镜对照表(3 列) {matchData.rows.length} 段 · 原文 → 二创 → 分镜素材 · 1:1 绑定
{matchData.gc?.deleted_count > 0 && ( 刚 GC {matchData.gc.deleted_count} 条 )}
{[ "① 原文", "② 二创文案", "③ 分镜素材(意境匹配)", ].map((h, i) => ( ))} {matchData.rows.map(r => { const m = r.visual_remix && r.visual_remix.match; const isGenBusy = !!genBusy[r.chunk_id]; return ( {/* 第①列:原文 + 段元信息 */} {/* 第②列:二创文案 */} {/* 第③列:分镜素材 = 画面提示词 + 命中素材卡 */} ); })}
{h}
#{r.index + 1} · {r.chunk_id} {r.duration_sec.toFixed(1)}s tts {r.tts_duration_sec.toFixed(1)}s
{r.segment_text || }
{r.rewrite_text || "—"}
{/* 画面意图(mood + anchors,简化展示) */} {(r.mood || (r.visual_anchors || []).length > 0) && (
画面意图 {r.mood ? "· " + r.mood.slice(0, 30) : ""} {(r.visual_anchors || []).length > 0 ? ` · ${r.visual_anchors.length} 个锚点` : ""}
{r.mood &&
{r.mood}
} {(r.visual_anchors || []).length > 0 && (
{r.visual_anchors.map((a, i) => ( {a} ))}
)}
)} {m ? ( <>
) : (
种子库未命中 → 走生成路线(点击编辑两段提示词后提交)
将带的载荷:
user_prompt: {(r.aigc_prompt || "—").slice(0, 50)}…
tts_duration_sec: {r.tts_duration_sec}
)}
)} {/* 走生成弹窗:复用拆解侧 GenerateShotDialog,但 onSubmit 路由到 text_rewrite API */} {genDialogRow && ( 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 (
e.stopPropagation()} style={{ width: "min(1200px, 96vw)", maxHeight: "92vh", display: "flex", flexDirection: "column", gap: 12 }}> {/* 顶部 bar */}
{preview.title || "图片预览"} 第 {page + 1}/{totalPages} 页 · {images.length} 张总数
{/* 主体:3 张并排 */}
{visible.map((img, i) => (
{img.url ? {img.label :
(占位)
{img.caption || ""}
}
{img.label || `#${start + i + 1}`}
{img.caption && (
{img.caption}
)}
))}
); }; // 二创标题 / 文案行内编辑器:展示时是只读 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 (
{label} {!editing && ( )}
{editing ? ( <> {multiline ?