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
+6
View File
@@ -0,0 +1,6 @@
# 只发布 package.json files 字段列出的内容;忽略本地产物
demo-video-out/
*.webm
*.mp4
voice/
node_modules/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 demo-video-kit contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
# demo-video-kit
> 配置驱动的演示视频生成工具:**Playwright 自动操作录屏 + 与配音逐句同步的字幕解说 + 中文 TTS 自然配音**,一条命令产出带解说的 MP4。可独立安装到任意项目。
## 安装
```bash
# 发布到 npm 后:
npm i -D demo-video-kit
# 或从 Git / 本地路径安装:
npm i -D <git-url>
npm i -D file:../tools/demo-video-kit
```
还需要(一次性):
```bash
npx playwright install chromium # 录屏用浏览器
# 系统安装 ffmpeg / ffprobe(含 libx264),例如:brew install ffmpeg
export AI_API_KEY=sk-xxx # DashScope qwen-tts 密钥(或写入项目 .env
```
> 无密钥或调用失败时,自动回退 macOS `say` 离线语音,流程不中断。
## 快速开始
```bash
npx demo-video init # 生成 demo.config.mjs 模板
# 编辑 demo.config.mjs(填 baseUrl 与 steps
npx demo-video demo.config.mjs --out out
```
产物:
- `out/demo.mp4` — 无声(含字幕)
- `out/demo-voiced.mp4`**带配音成品**
## 配置(demo.config.mjs
```js
export default {
baseUrl: "http://localhost:3000",
brand: "我的产品 · 功能演示",
voice: { name: "Cherry", apiKeyEnv: "AI_API_KEY", fallbackVoice: "Tingting" },
intro: { narration: "欢迎使用我的产品。" },
outro: { narration: "感谢观看。" },
steps: [
{
label: "搜索",
narration: "输入关键词即可快速定位。", // 同时作为字幕与配音
run: async ({ page, pause }) => {
await page.getByPlaceholder("搜索").fill("示例");
await pause(1500);
},
},
],
};
```
`run(ctx)``ctx = { page, pause(ms), say(text) }``page` 为 Playwright Page。
## CLI
```
demo-video <config.mjs> [--out dir] [--skip-tts] [--silent]
demo-video init [path]
demo-video --version | --help
```
- `--skip-tts`:复用 `out/voice/clips.json`(改了操作但没改文案时加速)
- `--silent`:跳过配音,仅出无声视频
## 程序化 API
```js
import { generateVoices, record, transcode, muxVoice } from "demo-video-kit";
```
## 设计原则:声画字三同步
解说常比单步操作长。本工具采用 **"画面等解说"**:先测每句语音时长 → 录制时每步至少停留该句时长 → 字幕显示解说词本身 → 配音按字幕真实时间点放置。三者天然对齐,不会漂移。
## 健壮性
- qwen-tts 自动重试 + 限速,失败回退系统 TTS
- 单步操作失败只记录、不中断整段录制
- 解说总时长超视频时,自动冻结末帧补足,保证解说完整
## 许可证
MIT
@@ -0,0 +1,36 @@
// 演示视频配置示例。复制到你的项目,按需修改 baseUrl 与 steps。
// 运行:node <kit>/src/cli.mjs path/to/demo.config.mjs --out out
export default {
baseUrl: "http://localhost:3000",
viewport: { width: 1440, height: 900 },
brand: "我的产品 · 功能演示",
outDir: "demo-video-out",
// 配音:DashScope qwen-ttsapiKeyEnv 指定从哪个环境变量/.env 键读取密钥
voice: { name: "Cherry", model: "qwen-tts", apiKeyEnv: "AI_API_KEY", fallbackVoice: "Tingting" },
intro: { narration: "欢迎使用我的产品。下面用一分钟带你看完核心功能。" },
outro: { narration: "以上就是核心功能演示,感谢观看。" },
// 每一步:label 用于日志/兜底字幕;narration 是字幕+配音文案;run 执行操作
// ctx = { page, pause(ms), say(text) }page 为 Playwright Page
steps: [
{
label: "搜索",
narration: "在顶部搜索框输入关键词,即可快速定位。",
run: async ({ page, pause }) => {
await page.getByPlaceholder("搜索").fill("示例");
await pause(1500);
},
},
{
label: "打开详情",
narration: "点击任意条目,右侧展开它的详细信息。",
run: async ({ page, pause }) => {
await page.getByRole("button", { name: "查看" }).first().click();
await pause(1500);
},
},
],
};
+53
View File
@@ -0,0 +1,53 @@
{
"name": "demo-video-kit",
"version": "1.0.0",
"description": "配置驱动的演示视频生成工具:Playwright 录屏 + 与配音逐句同步的字幕解说 + 中文 TTSDashScope qwen-tts,失败回退 macOS say)。",
"type": "module",
"bin": {
"demo-video": "src/cli.mjs"
},
"main": "src/index.mjs",
"exports": {
".": "./src/index.mjs",
"./tts": "./src/tts.mjs",
"./record": "./src/record.mjs",
"./mux": "./src/mux.mjs"
},
"files": [
"src",
"demo.config.example.mjs",
"README.md",
"LICENSE"
],
"scripts": {
"demo": "node src/cli.mjs"
},
"keywords": [
"playwright",
"screen-recording",
"demo",
"video",
"tts",
"subtitles",
"narration",
"ffmpeg",
"qwen-tts",
"dashscope"
],
"engines": {
"node": ">=20"
},
"dependencies": {
"playwright": ">=1.40"
},
"peerDependenciesMeta": {},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://example.com/your-org/demo-video-kit.git"
},
"homepage": "https://example.com/your-org/demo-video-kit#readme",
"publishConfig": {
"access": "public"
}
}
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { generateVoices } from "./tts.mjs";
import { record } from "./record.mjs";
import { transcode, muxVoice } from "./mux.mjs";
import { readApiKey } from "./util.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json"), "utf-8"));
const args = process.argv.slice(2);
const has = (n) => args.includes(n);
const getOpt = (n, def) => {
const i = args.indexOf(n);
return i >= 0 ? args[i + 1] : def;
};
const HELP = `demo-video v${PKG.version}
配置驱动的演示视频生成:Playwright 录屏 + 同步字幕 + 中文 TTS 配音。
用法:
demo-video <config.mjs> [选项] 生成视频
demo-video init [path] 生成配置模板(默认 ./demo.config.mjs
demo-video --version | --help
选项:
--out <dir> 输出目录(默认 config.outDir 或 demo-video-out
--skip-tts 复用已生成的 voice/clips.json
--silent 不配音,仅输出无声视频
依赖:playwright(含 chromium)、系统 ffmpeg/ffprobe、配音密钥 AI_API_KEY/DASHSCOPE_API_KEY。`;
async function cmdInit() {
const dest = path.resolve(args[1] && !args[1].startsWith("--") ? args[1] : "demo.config.mjs");
if (fs.existsSync(dest)) {
console.error(`已存在:${dest}`);
process.exit(1);
}
fs.copyFileSync(path.join(__dirname, "../demo.config.example.mjs"), dest);
console.log(`已生成配置模板:${dest}\n编辑后运行:demo-video ${path.relative(process.cwd(), dest)}`);
}
async function cmdRun(configArg) {
const configPath = path.resolve(configArg);
const config = (await import(pathToFileURL(configPath).href)).default;
const outDir = path.resolve(getOpt("--out", config.outDir ?? "demo-video-out"));
const voiceDir = path.join(outDir, "voice");
fs.mkdirSync(outDir, { recursive: true });
const voiceCfg = config.voice ?? {};
const apiKey = readApiKey(voiceCfg.apiKeyEnv ?? "AI_API_KEY");
const texts = [
config.intro?.narration,
...config.steps.map((s) => s.narration || s.label),
config.outro?.narration,
].filter(Boolean);
let clips;
const clipsJson = path.join(voiceDir, "clips.json");
if (has("--silent")) {
console.log("· 静默模式:跳过配音");
clips = {};
} else if (has("--skip-tts") && fs.existsSync(clipsJson)) {
console.log("· 复用已生成语音");
clips = JSON.parse(fs.readFileSync(clipsJson, "utf-8"));
} else {
console.log("· 生成配音(qwen-tts,失败回退 say)…");
clips = await generateVoices(texts, {
outDir: voiceDir,
apiKey,
voice: voiceCfg.name ?? "Cherry",
model: voiceCfg.model ?? "qwen-tts",
fallbackVoice: voiceCfg.fallbackVoice ?? "Tingting",
});
}
console.log("· 录屏中…");
const { webm, narrationPath } = await record(config, clips, { outDir });
console.log("· 转码 webm → mp4 …");
const mp4 = transcode(webm, path.join(outDir, "demo.mp4"));
if (has("--silent") || Object.keys(clips).length === 0) {
console.log(`完成(无配音):${mp4}`);
return;
}
console.log("· 合成配音…");
const voiced = muxVoice({ videoIn: mp4, narrationPath, clips, out: path.join(outDir, "demo-voiced.mp4") });
console.log(`完成:${voiced}`);
}
const main = async () => {
if (has("--version") || has("-v")) return console.log(PKG.version);
if (args.length === 0 || has("--help") || has("-h")) return console.log(HELP);
if (args[0] === "init") return cmdInit();
const configArg = args.find((a) => !a.startsWith("--"));
if (!configArg) return console.log(HELP);
return cmdRun(configArg);
};
main().catch((e) => {
console.error(e);
process.exit(1);
});
+5
View File
@@ -0,0 +1,5 @@
// 程序化 API:便于在脚本中直接调用,而不只是命令行。
export { generateVoices } from "./tts.mjs";
export { record } from "./record.mjs";
export { transcode, muxVoice } from "./mux.mjs";
export { clean, durationOf, readApiKey } from "./util.mjs";
+52
View File
@@ -0,0 +1,52 @@
import fs from "fs";
import path from "path";
import { clean, durationOf, run } from "./util.mjs";
export function transcode(webm, mp4) {
run(`ffmpeg -y -i "${webm}" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "${mp4}"`);
return mp4;
}
/**
* 按字幕时间轴把语音合成进视频。解说超长时冻结末帧补足,保证完整。
*/
export function muxVoice({ videoIn, narrationPath, clips, out }) {
const { narration } = JSON.parse(fs.readFileSync(narrationPath, "utf-8"));
const items = [];
for (const n of narration) {
const c = clips[clean(n.text)];
if (!c) {
console.warn(` × 缺少语音:${clean(n.text).slice(0, 22)}`);
continue;
}
items.push({ file: path.resolve(c.file), start: n.t, dur: c.dur });
}
if (items.length === 0) throw new Error("无可用解说语音");
const videoDur = durationOf(videoIn);
const audioEnd = Math.max(...items.map((c) => c.start / 1000 + c.dur)) + 0.6;
const pad = audioEnd - videoDur;
const inputs = items.map((c) => `-i "${c.file}"`).join(" ");
const aFilters = items
.map((c, i) => `[${i + 1}:a]adelay=${Math.round(c.start)}|${Math.round(c.start)}[a${i}]`)
.join(";");
const mixIn = items.map((_, i) => `[a${i}]`).join("");
const amix = `${mixIn}amix=inputs=${items.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";
}
run(
`ffmpeg -y -i "${videoIn}" ${inputs} ` +
`-filter_complex "${vfilter}${aFilters};${amix}" ` +
`-map ${videoMap} -map "[a]" ${vcodec} -c:a aac -b:a 160k "${out}"`
);
return out;
}
+110
View File
@@ -0,0 +1,110 @@
import fs from "fs";
import path from "path";
import { chromium } from "playwright";
import { clean } from "./util.mjs";
/**
* 按配置自动操作并录屏。字幕显示解说词本身,每步停留至该句解说播完(声画同步)。
* 返回 { webm, narrationPath }。
*/
export async function record(config, clips, { outDir }) {
const {
baseUrl,
viewport = { width: 1440, height: 900 },
brand = "演示",
steps = [],
intro,
outro,
gotoTimeout = 60000,
} = config;
const dwellMs = (t) => Math.round((clips[clean(t)]?.dur ?? 0) * 1000);
fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport, recordVideo: { dir: outDir, size: viewport } });
const page = await context.newPage();
page.on("console", (m) => m.type() === "error" && console.log(` console.error: ${m.text().slice(0, 100)}`));
const t0 = Date.now();
const narration = [];
const pause = (ms) => page.waitForTimeout(ms);
const say = async (display, sub = brand, voice = "") => {
narration.push({ t: Math.max(0, Date.now() - t0), text: clean(voice || display) });
await page
.evaluate(
({ display, sub }) => {
let el = document.getElementById("__demo_cap");
if (!el) {
el = document.createElement("div");
el.id = "__demo_cap";
el.style.cssText =
"position:fixed;left:50%;bottom:58px;transform:translateX(-50%);z-index:2147483647;" +
"max-width:86vw;padding:12px 26px;border-radius:9999px;text-align:center;" +
"background:rgba(8,7,5,0.84);border:1px solid rgba(214,170,91,0.5);" +
"box-shadow:0 10px 36px rgba(0,0,0,.55);backdrop-filter:blur(8px);" +
"font-family:'Noto Serif SC',serif;color:#f2cf83;transition:opacity .2s;pointer-events:none";
document.body.appendChild(el);
}
el.innerHTML =
`<div style="font-size:18px;line-height:1.65;letter-spacing:0.5px">${display}</div>` +
(sub ? `<div style="font-size:11px;color:#c7baa0;letter-spacing:2px;margin-top:4px">${sub}</div>` : "");
el.style.opacity = "1";
},
{ display, sub }
)
.catch(() => {});
};
const ctx = { page, pause, say };
console.log(`▶ 打开 ${baseUrl}`);
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: gotoTimeout });
await pause(2500);
if (intro?.narration) {
await say(intro.narration, intro.sub ?? brand, intro.narration);
await pause(Math.max(2500, dwellMs(intro.narration) + 500));
}
let i = 0;
for (const step of steps) {
i += 1;
const startT = Date.now();
const voice = step.narration || step.label || "";
await say(voice, brand, voice);
try {
await step.run(ctx);
console.log(` ok: ${step.label ?? voice.slice(0, 16)}`);
} catch (e) {
console.log(` skip: ${step.label ?? ""} (${String(e.message).split("\n")[0]})`);
}
const need = dwellMs(voice) + 500;
const elapsed = Date.now() - startT;
if (elapsed < need) await pause(need - elapsed);
}
if (outro?.narration) {
await pause(1200);
await say(outro.narration, outro.sub ?? brand, outro.narration);
await pause(Math.max(2500, dwellMs(outro.narration) + 700));
}
const video = page.video();
await context.close();
await browser.close();
await new Promise((r) => setTimeout(r, 500));
const webms = fs
.readdirSync(outDir)
.filter((f) => f.endsWith(".webm") && f !== "demo.webm")
.map((f) => ({ f, t: fs.statSync(path.join(outDir, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
const webm = path.join(outDir, "demo.webm");
if (webms.length) fs.copyFileSync(path.join(outDir, webms[0].f), webm);
const narrationPath = path.join(outDir, "narration.json");
fs.writeFileSync(narrationPath, JSON.stringify({ narration }, null, 2));
void video;
return { webm, narrationPath };
}
+70
View File
@@ -0,0 +1,70 @@
import fs from "fs";
import path from "path";
import { clean, durationOf, md5, sh, sleep } from "./util.mjs";
// DashScope qwen-ttsOpenAI 风格鉴权),返回音频 URL 再下载
async function qwenTts(text, { apiKey, voice, model = "qwen-tts" }, outFile) {
const r = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
{
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ model, input: { text, voice } }),
signal: AbortSignal.timeout(40000),
}
);
const j = await r.json();
const url = j?.output?.audio?.url;
if (!url) throw new Error(JSON.stringify(j).slice(0, 160));
const a = await fetch(url, { signal: AbortSignal.timeout(40000) });
if (!a.ok) throw new Error(`download ${a.status}`);
fs.writeFileSync(outFile, Buffer.from(await a.arrayBuffer()));
}
/**
* 为一组文本生成语音,返回 { 文本: { file, dur, engine } }。
* 优先 qwen-tts(自然语音),失败回退 macOS `say`。
*/
export async function generateVoices(texts, opts) {
const {
outDir,
apiKey,
voice = "Cherry",
model = "qwen-tts",
fallbackVoice = "Tingting",
throttleMs = 400,
retries = 3,
} = opts;
fs.mkdirSync(outDir, { recursive: true });
const clips = {};
const uniq = Array.from(new Set(texts.map(clean))).filter(Boolean);
for (const text of uniq) {
const id = md5(text);
const wav = path.join(outDir, `${id}.wav`);
let engine = "qwen-tts";
let ok = false;
if (apiKey) {
for (let a = 0; a < retries && !ok; a++) {
try {
await qwenTts(text, { apiKey, voice, model }, wav);
ok = true;
} catch {
if (a < retries - 1) await sleep(1500 * (a + 1));
}
}
}
if (!ok) {
engine = "say";
const aiff = path.join(outDir, `${id}.aiff`);
sh(`say -v ${fallbackVoice} -o "${aiff}" "${text.replace(/"/g, "")}"`);
sh(`ffmpeg -y -i "${aiff}" "${wav}"`);
fs.rmSync(aiff, { force: true });
}
clips[text] = { file: wav, dur: durationOf(wav), engine };
console.log(`${clips[text].dur.toFixed(1)}s [${engine}] ${text.slice(0, 24)}`);
await sleep(throttleMs);
}
fs.writeFileSync(path.join(outDir, "clips.json"), JSON.stringify(clips, null, 2));
return clips;
}
+34
View File
@@ -0,0 +1,34 @@
import { execSync } from "child_process";
import crypto from "crypto";
import fs from "fs";
import path from "path";
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const sh = (cmd) => execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString();
export const run = (cmd) => execSync(cmd, { stdio: "inherit" });
export const durationOf = (f) =>
parseFloat(sh(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${f}"`).trim());
export const md5 = (s) => crypto.createHash("md5").update(s).digest("hex").slice(0, 10);
// 文本归一化:去序号前缀、分隔点换停顿
export const clean = (t) =>
String(t)
.replace(/^\d+\.\s*/, "")
.replace(/·/g, "")
.trim();
// 读取 API Key:优先环境变量,其次项目根 .env
export function readApiKey(envName = "AI_API_KEY") {
if (process.env[envName]) return process.env[envName].trim();
if (process.env.DASHSCOPE_API_KEY) return process.env.DASHSCOPE_API_KEY.trim();
try {
const env = fs.readFileSync(path.resolve(".env"), "utf-8");
const m = env.match(new RegExp(`^${envName}=(.*)$`, "m")) || env.match(/^DASHSCOPE_API_KEY=(.*)$/m);
return m ? m[1].trim() : "";
} catch {
return "";
}
}