// 4 · 质检合成(/qc)—— 轨道形式,做 3 项 QC 复选 + 扩展参数 + 一键合成
// /remix 点 OK 后跳到 /qc?job=xxx,本页接着完成轨道质检与合成。
//
// 自动化:用户什么都不勾、什么都不改,直接点「合成」也能跑(默认全部认定 OK + 文档默认参数)。
const _SUB_POSITION_OPTIONS = [
{ value: 2, label: "底部居中(默认)" },
{ value: 5, label: "屏幕居中" },
{ value: 8, label: "顶部居中" },
{ value: 1, label: "底部左对齐" },
{ value: 3, label: "底部右对齐" },
];
const _SUB_COLOR_OPTIONS = [
{ value: "yellow_bg_black", label: "黄底黑字(默认)" },
{ value: "white_bg_black", label: "白底黑字" },
{ value: "white_no_bg", label: "白字无底" },
{ value: "black_no_bg", label: "黑字无底" },
];
// 单 chunk QC 复选(前端态,落 localStorage 不打后端)
const _qcStorageKey = (jobId) => `qc_marks_v1_${jobId}`;
const QcChunkChecks = ({ chunkId, marks, onChange }) => {
const m = marks[chunkId] || { clip: null, tts: null, bgm: null };
// null = 未审 / true = OK / false = NG
const cycle = (cur) => cur === null ? true : cur === true ? false : null;
const cls = (v) => v === true ? "var(--green)" : v === false ? "var(--pink)" : "var(--fg-3)";
const lbl = (v) => v === true ? "✓" : v === false ? "✗" : "?";
return (
{[
{ k: "clip", t: "分镜" },
{ k: "tts", t: "口播" },
{ k: "bgm", t: "BGM" },
].map(it => (
))}
);
};
const QcComposePanel = ({ parentJobId, allMatched, rowsCount, qcMarks }) => {
const [job, setJob] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState("");
const [defaults, setDefaults] = React.useState(null);
const [bgmList, setBgmList] = React.useState([]);
const [params, setParams] = React.useState({});
const [showParams, setShowParams] = React.useState(true);
const [history, setHistory] = React.useState([]);
const pollerRef = React.useRef(null);
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);
const gainToDb = (g) => g > 0 ? (20 * Math.log10(g)).toFixed(1) : "-∞";
// 自动模式:所有 chunk 都还没审 → 默认按 ok 走(不阻拦)
const totalMarks = Object.values(qcMarks || {}).reduce((s, m) => s + ["clip","tts","bgm"].filter(k => m[k] !== null).length, 0);
const ngCount = Object.values(qcMarks || {}).reduce((s, m) => s + ["clip","tts","bgm"].filter(k => m[k] === false).length, 0);
const isAuto = totalMarks === 0;
const hasNg = ngCount > 0;
const isDone = job?.status === "completed";
const isFail = job?.status === "failed";
const pct = job?.progress || 0;
return (
质检合成(轨道 → ffmpeg 出片)
scene = TTS 时长 · 转场 {params.xfade_dur ?? 1}s · 黄底黑字 · BGM {gainToDb(params.bgm_gain ?? 0.794)}dB
{isAuto ? (
⚙ 自动模式(无人工标记)
) : hasNg ? (
⚠ {ngCount} 项 NG
) : (
✓ 已质检 {totalMarks} 项
)}
{showParams && defaults && (
合成参数
BGM
音量
字幕
转场 / 时长
产物 → AIGC混剪/output/final/月日(N).mp4
)}
{err &&
{err}
}
{job && (
{job.compose_id} · {job.status} · scene {job.scenes_used || "?"} / chunk {job.chunks_count}
{pct}%
{job.message || (isFail ? (job.error || "失败") : "运行中…")}
{(job.log_tail || []).length > 0 && (
{(job.log_tail || []).slice(-6).join("\n")}
)}
{isDone && (
)}
)}
);
};
const QCPage = () => {
// 文案二创独立链路(URL 带 ?text_rewrite_job=xxx 时显示进度卡)
const trJobId = useTextRewriteJobIdFromQuery();
// URL 接参:/qc?job=
const initialJob = (() => {
const u = new URL(window.location.href);
return u.searchParams.get("job") || null;
})();
const [parents, setParents] = React.useState([]);
const [activeParentId, setActiveParentId] = React.useState(initialJob);
const [tableData, setTableData] = React.useState(null);
const [bgmList, setBgmList] = React.useState([]);
const [qcMarks, setQcMarks] = React.useState({});
const loadJobs = async () => {
try {
const j = await API.jobs.list().catch(() => ({ items: [] }));
const items = (j.items || []).filter(p => p.has_result && p.status === "completed");
setParents(items);
if (!activeParentId && items.length) setActiveParentId(items[0].job_id);
} catch (_) {}
};
const loadTable = React.useCallback(async () => {
if (!activeParentId) { setTableData(null); return; }
try {
const d = await API.replicateTable.get(activeParentId);
setTableData(d);
} catch (_) {}
}, [activeParentId]);
React.useEffect(() => { loadJobs(); }, []);
React.useEffect(() => { loadTable(); }, [loadTable]);
React.useEffect(() => {
API.compose.bgmList().then(d => setBgmList(d.items || [])).catch(() => {});
}, []);
// QC marks 持久化(按 job 分桶 localStorage)
React.useEffect(() => {
if (!activeParentId) return;
try {
const saved = JSON.parse(localStorage.getItem(_qcStorageKey(activeParentId)) || "{}");
setQcMarks(saved);
} catch (_) { setQcMarks({}); }
}, [activeParentId]);
const updateQcMark = (chunkId, key, value) => {
setQcMarks(prev => {
const next = { ...prev, [chunkId]: { ...(prev[chunkId] || {}), [key]: value } };
try { localStorage.setItem(_qcStorageKey(activeParentId), JSON.stringify(next)); } catch (_) {}
return next;
});
};
const rows = tableData?.rows || [];
// 复刻流水线命中 material_remix 或 seed_library 命中 visual_remix.match 都算"已匹配"
const allMatched = rows.length > 0 && rows.every(r =>
(r.material_remix && r.material_remix.segment_url)
|| (r.visual_remix && r.visual_remix.match)
);
return (
<>
p.job_id === activeParentId)?.title || "选中")
: "选择视频"]}
right={<>
轨道 · ffmpeg
>}
/>
Job
交互逻辑:每行 3 个按钮(分镜 / 口播 / BGM),点击循环 ?→✓→✗。
全部空 = 自动直出;任一 ✗ 阻塞合成(请回 /remix 修正)。
{/* 文案二创独立链路进度(URL 带 ?text_rewrite_job=xxx 时显示)*/}
{trJobId &&
}
{/* 文案二创"待合成清单":从 match-table 拉每段二创+素材绑定 */}
{trJobId &&
}
{!activeParentId && !trJobId && (
请上面选一个 job,或从「3·复刻爆款」点 OK 跳过来
)}
{activeParentId && tableData && (
<>
{/* 横向轨道 + QC 复选叠在 ③ 分镜行上方 */}
{/* 合成参数 + 提交 + 进度 + 预览 */}
>
)}
>
);
};
// 复用 remix.jsx 的 HorizontalTrackPanel 视觉,但在 ③ 分镜上方插一行 QC 复选
const QcTrackPanel = ({ jobId, tableData, qcMarks, onMark, allBgm }) => {
const rows = tableData?.rows || [];
const colWidth = (sec) => Math.round(Math.max(220, Math.min(420, (sec || 3) * 60)));
const LABEL_W = 96;
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",
};
const [bgmFilename, setBgmFilename] = React.useState((allBgm || [])[0]?.filename || null);
React.useEffect(() => {
if (!bgmFilename && (allBgm || []).length) setBgmFilename(allBgm[0].filename);
}, [allBgm]);
return (
质检轨道
{rows.length} chunk · 列宽 ∝ 时长 · 每列 3 个 QC 按钮(分镜/口播/BGM)
{/* QC 复选行(按列) */}
{/* ① 文案 */}
① 文案
{rows.map(r => (
{r.chunk_id} · {r.duration_sec.toFixed(1)}s
{r.rewrite_text ||
—}
))}
{/* ② 提示词 */}
② 提示词
{rows.map(r => (
{r.aigc_prompt || —}
))}
{/* ③ 分镜素材 — 优先复刻流水线 kh material_match 命中(material_remix),
fallback 到 seed_library 直接命中(visual_remix.match)*/}
③ 分镜
{rows.map(r => {
const mr = r.material_remix; // kh material_match 命中(segment_url + time_start/end)
const m = r.visual_remix && r.visual_remix.match; // seed_library 命中
// 复刻流水线命中优先:用 #t=start,end Media Fragments 让浏览器只播命中段
let videoSrc = "";
let kindBadge = "";
if (mr && mr.segment_url) {
const t0 = (mr.time_start || 0).toFixed(2);
const t1 = (mr.time_end || 0).toFixed(2);
videoSrc = `${mr.segment_url}#t=${t0},${t1}`;
kindBadge = "复刻命中";
} else if (m && m.preview_url) {
videoSrc = m.preview_url;
kindBadge = "素材库";
}
return (
{videoSrc ? (
<>
{kindBadge}
>
) : (
未匹配(请回 /remix)
)}
);
})}
{/* ④ 口播 */}
④ 口播
{rows.map(r => (
{r.rewrite_text ? (
) :
无文案}
))}
{/* ⑤ BGM 全宽 */}
⑤ BGM
{bgmFilename && (
)}
BGM 选择仅用于试听 · 合成时见参数面板
);
};
// ─── 文案二创待合成清单(QC 页接收 ?text_rewrite_job= 时显示)─────────────────
//
// 数据源:GET /api/text-rewrite/jobs/{id}/match-table
// 展示:每段「二创口播 + 命中分镜素材 + tts 时长」+ 一个"开始合成"按钮(占位)
const TextRewriteComposeList = ({ jobId }) => {
const [matchData, setMatchData] = React.useState(null);
const [err, setErr] = React.useState("");
React.useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const d = await API.textRewrite.matchTable(jobId);
if (!cancelled) setMatchData(d);
} catch (e) {
if (!cancelled) setErr(String(e.message || e));
}
};
refresh();
const t = setInterval(refresh, 4000);
return () => { cancelled = true; clearInterval(t); };
}, [jobId]);
if (err) {
return (
加载文案二创合成清单失败:{err}(任务也许还没跑完)
);
}
if (!matchData) {
return (
加载文案二创合成清单…
);
}
const rows = matchData.rows || [];
const hit = rows.filter(r => r.visual_remix && r.visual_remix.match).length;
const allReady = rows.length > 0 && hit === rows.length;
return (
📦 文案二创合成清单
{rows.length} 段 · 已匹配 {hit}/{rows.length} 段分镜素材
{["#", "二创口播", "分镜素材", "TTS 时长"].map((h, i) => (
| {h} |
))}
{rows.map(r => {
const m = r.visual_remix && r.visual_remix.match;
return (
#{r.index + 1} {r.chunk_id}
|
{r.rewrite_text || —}
|
{m ? (
{m.name}
) : (
⚠ 未匹配 · 去复刻爆款选片
)}
|
{r.tts_duration_sec.toFixed(1)}s
|
);
})}
);
};
Object.assign(window, { QCPage, QcTrackPanel, QcComposePanel, QcChunkChecks,
TextRewriteComposeList });