chore: 初始化仓库

中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
This commit is contained in:
selfrelease
2026-06-13 20:55:44 +08:00
commit 2d847e154f
161 changed files with 22629 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
// 把预生成的解说语音(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}`);