2d847e154f
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
69 lines
2.7 KiB
JavaScript
69 lines
2.7 KiB
JavaScript
// 把预生成的解说语音(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}`);
|