// 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
BGM 文件
BGM 音量 ({gainToDb(params.bgm_gain ?? 0.794)} dB) setP("bgm_gain", parseFloat(e.target.value || "0.794"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
音量
口播音量 ({gainToDb(params.voice_gain ?? 1)} dB) setP("voice_gain", parseFloat(e.target.value || "1"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
视频自带音频 (UI 占位 · pipeline 默认静音) setP("src_audio_gain", parseFloat(e.target.value || "0"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
edge-tts 语速 (默认 +10%) setP("voice_rate", e.target.value)} style={{ fontSize: 11, padding: "4px 6px" }}/>
字幕
位置
颜色
字号 (默认 13) setP("sub_font_size", parseInt(e.target.value || "13"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
距底 px (默认 640 ≈ 画面 2/3 处) setP("sub_margin_bottom", parseInt(e.target.value || "640"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
转场 / 时长
转场重叠 (默认 1.0s) setP("xfade_dur", parseFloat(e.target.value || "1"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
最短 scene (默认 2.0s) setP("min_scene_dur", parseFloat(e.target.value || "2"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
字幕单行字数 (默认 16) setP("max_chars", parseInt(e.target.value || "16"))} style={{ fontSize: 11, padding: "4px 6px" }}/>
产物 → 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 复选行(按列) */}
QC
{rows.map(r => (
))}
{/* ① 文案 */}
① 文案
{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 ? ( <>
); })}
{/* ④ 口播 */}
④ 口播
{rows.map(r => (
{r.rewrite_text ? (
))}
{/* ⑤ BGM 全宽 */}
⑤ BGM
{bgmFilename && (
); }; // ─── 文案二创待合成清单(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) => ( ))} {rows.map(r => { const m = r.visual_remix && r.visual_remix.match; return ( ); })}
{h}
#{r.index + 1}
{r.chunk_id}
{r.rewrite_text || } {m ? (
) : ( ⚠ 未匹配 · 去复刻爆款选片 )}
{r.tts_duration_sec.toFixed(1)}s
); }; Object.assign(window, { QCPage, QcTrackPanel, QcComposePanel, QcChunkChecks, TextRewriteComposeList });