// 把预生成的解说语音(e2e/voice/clips.json)按字幕出现的真实时间点(narration.json)合成到视频。 // 因为 run.mjs 已让每步停留足够长,时间点本身不重叠,故直接按时间点放置即可保持声画同步。 import { execSync } from "child_process"; import fs from "fs"; import path from "path"; import { clean } from "./narration.mjs"; const DIR = path.resolve("e2e/videos"); const VIDEO_IN = path.join(DIR, "wenwumap-e2e.mp4"); const VIDEO_OUT = path.join(DIR, "wenwumap-e2e-voiced.mp4"); const sh = (cmd) => execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString(); const durationOf = (f) => parseFloat(sh(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${f}"`).trim()); if (!fs.existsSync(VIDEO_IN)) { console.error("缺少视频:先 node e2e/run.mjs 并转码生成 wenwumap-e2e.mp4"); process.exit(1); } const { narration } = JSON.parse(fs.readFileSync(path.join(DIR, "narration.json"), "utf-8")); const clipsMap = JSON.parse(fs.readFileSync(path.resolve("e2e/voice/clips.json"), "utf-8")); // 按字幕时间点匹配预生成语音 const clips = []; for (const n of narration) { const key = clean(n.text); const c = clipsMap[key]; if (!c) { console.warn(`× 缺少语音:${key.slice(0, 24)}…`); continue; } clips.push({ file: path.resolve(c.file), start: n.t, dur: c.dur }); console.log(`✓ [${(n.t / 1000).toFixed(1)}s ${c.dur.toFixed(1)}s] ${key.slice(0, 26)}…`); } if (clips.length === 0) { console.error("无可用解说,请先运行 node e2e/gen-voice.mjs"); process.exit(1); } // 视频不够长则冻结末帧补足 const videoDur = durationOf(VIDEO_IN); const audioEnd = Math.max(...clips.map((c) => c.start / 1000 + c.dur)) + 0.6; const pad = audioEnd - videoDur; const inputs = clips.map((c) => `-i "${c.file}"`).join(" "); const audioFilters = clips .map((c, i) => `[${i + 1}:a]adelay=${Math.round(c.start)}|${Math.round(c.start)}[a${i}]`) .join(";"); const mixIn = clips.map((_, i) => `[a${i}]`).join(""); const amix = `${mixIn}amix=inputs=${clips.length}:normalize=0[a]`; let videoMap = "0:v"; let vcodec = "-c:v copy"; let vfilter = ""; if (pad > 0.3) { vfilter = `[0:v]tpad=stop_mode=clone:stop_duration=${pad.toFixed(2)}[v];`; videoMap = '"[v]"'; vcodec = "-c:v libx264 -pix_fmt yuv420p"; } const cmd = `ffmpeg -y -i "${VIDEO_IN}" ${inputs} ` + `-filter_complex "${vfilter}${audioFilters};${amix}" ` + `-map ${videoMap} -map "[a]" ${vcodec} -c:a aac -b:a 160k "${VIDEO_OUT}"`; console.log(`视频 ${videoDur.toFixed(1)}s / 解说至 ${audioEnd.toFixed(1)}s${pad > 0.3 ? `(末帧补 ${pad.toFixed(1)}s)` : ""}`); execSync(cmd, { stdio: "inherit" }); console.log(`完成:${VIDEO_OUT}`);