Files
selfrelease 2d847e154f chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
2026-06-13 20:55:44 +08:00

69 lines
2.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 把预生成的解说语音(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}`);