chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# 只发布 package.json files 字段列出的内容;忽略本地产物
|
||||
demo-video-out/
|
||||
*.webm
|
||||
*.mp4
|
||||
voice/
|
||||
node_modules/
|
||||
@@ -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.
|
||||
@@ -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-tts;apiKeyEnv 指定从哪个环境变量/.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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "demo-video-kit",
|
||||
"version": "1.0.0",
|
||||
"description": "配置驱动的演示视频生成工具:Playwright 录屏 + 与配音逐句同步的字幕解说 + 中文 TTS(DashScope 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"
|
||||
}
|
||||
}
|
||||
Executable
+106
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { clean, durationOf, md5, sh, sleep } from "./util.mjs";
|
||||
|
||||
// DashScope qwen-tts(OpenAI 风格鉴权),返回音频 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;
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user