11
This commit is contained in:
288
scripts/nodejs/subtitle_bgm_generate.js
Normal file
288
scripts/nodejs/subtitle_bgm_generate.js
Normal file
@@ -0,0 +1,288 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 自动生成字幕并混 BGM(对齐 Electron autoGenerateSubtitleAndBGM)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
const desktopConfig = require("./desktop_config.js");
|
||||
|
||||
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-subtitle-bgm");
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
return OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function makeOutputPath(prefix, ext = "mp4") {
|
||||
return path.join(ensureDir(), `${prefix}_${Date.now()}.${ext}`);
|
||||
}
|
||||
|
||||
function execFfmpeg(args) {
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
if (!ffmpeg) {
|
||||
throw new Error("未找到 ffmpeg,请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
|
||||
}
|
||||
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
|
||||
if (r.status !== 0) {
|
||||
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function probeVideoDuration(videoPath) {
|
||||
const ffprobe = pipelineNative.locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
|
||||
if (ffprobe && fs.existsSync(ffprobe)) {
|
||||
const r = spawnSync(
|
||||
ffprobe,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (r.status === 0) {
|
||||
const n = parseFloat(String(r.stdout || "").trim());
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
}
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true });
|
||||
const stderr = r2.stderr || "";
|
||||
const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
if (m) {
|
||||
return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function splitScriptLines(text) {
|
||||
return String(text || "")
|
||||
.split(/(?<=[。!?.!?])\s*|\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function alignScriptToRecords(script, records) {
|
||||
const lines = splitScriptLines(script);
|
||||
if (!lines.length || !records.length) return records;
|
||||
|
||||
if (lines.length === records.length) {
|
||||
return records.map((r, i) => ({ ...r, text: lines[i] }));
|
||||
}
|
||||
|
||||
const startMs = records[0].start;
|
||||
const endMs = records[records.length - 1].end;
|
||||
const total = Math.max(endMs - startMs, lines.length * 2000);
|
||||
const step = total / lines.length;
|
||||
return lines.map((text, i) => ({
|
||||
text,
|
||||
start: Math.round(startMs + i * step),
|
||||
end: Math.round(startMs + (i + 1) * step),
|
||||
}));
|
||||
}
|
||||
|
||||
function recordsFromTextEvenly(text, durationSec) {
|
||||
const lines = splitScriptLines(text);
|
||||
if (!lines.length) return [];
|
||||
const totalMs = Math.max(Math.round(durationSec * 1000), lines.length * 1500);
|
||||
const step = totalMs / lines.length;
|
||||
return lines.map((line, i) => ({
|
||||
text: line,
|
||||
start: Math.round(i * step),
|
||||
end: Math.round((i + 1) * step),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatSrtTime(ms) {
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
const msPart = Math.floor(ms % 1000);
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(msPart).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function buildSrtContent(records) {
|
||||
return records
|
||||
.map((r, i) => {
|
||||
const start = formatSrtTime(r.start);
|
||||
const end = formatSrtTime(Math.max(r.end, r.start + 200));
|
||||
return `${i + 1}\n${start} --> ${end}\n${r.text}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function escapeSubPath(filePath) {
|
||||
return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function addSubtitleToVideo(inputVideo, srtPath, forceStyle) {
|
||||
const outputVideo = makeOutputPath("with_subtitle");
|
||||
const style = forceStyle || "FontName=Microsoft YaHei,FontSize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Alignment=2,MarginV=40";
|
||||
const sub = escapeSubPath(srtPath);
|
||||
const vf = `subtitles='${sub}':force_style='${style}'`;
|
||||
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
||||
return outputVideo;
|
||||
}
|
||||
|
||||
function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) {
|
||||
const outputVideo = makeOutputPath("with_bgm");
|
||||
const ratio = Number(bgmVolume) / 100;
|
||||
const filter = `[0:a]volume=1.0[a0];[1:a]volume=${ratio}[a1];[a0][a1]amix=inputs=2:duration=first`;
|
||||
execFfmpeg([
|
||||
"-y",
|
||||
"-i",
|
||||
inputVideo,
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
bgmPath,
|
||||
"-filter_complex",
|
||||
filter,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-shortest",
|
||||
outputVideo,
|
||||
]);
|
||||
return outputVideo;
|
||||
}
|
||||
|
||||
async function runAsr(audioPath, modelConfig, scriptContent) {
|
||||
const asrMode = modelConfig.asrMode || "online";
|
||||
if (asrMode === "online") {
|
||||
if (!modelConfig.aliyunApiKey) {
|
||||
throw new Error("在线 ASR 需配置 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY");
|
||||
}
|
||||
if (!modelConfig.ossConfig?.bucket) {
|
||||
throw new Error("在线 ASR 需配置 OSS(上传音频供识别)");
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在上传音频到 OSS…");
|
||||
const upload = await pipelineNative.aliyunOssUpload(audioPath, modelConfig.ossConfig);
|
||||
if (!upload?.success || !upload.url) {
|
||||
throw new Error(upload?.error || "OSS 上传失败");
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在进行语音识别…");
|
||||
const asr = await pipelineNative.aliyunAsrFiletrans(upload.url, {
|
||||
apiKey: modelConfig.aliyunApiKey,
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
});
|
||||
if (!asr?.success) {
|
||||
throw new Error(asr?.error || "语音识别失败");
|
||||
}
|
||||
let records = Array.isArray(asr.sentences) ? asr.sentences : [];
|
||||
if (!records.length && asr.text) {
|
||||
records = recordsFromTextEvenly(asr.text, 60);
|
||||
}
|
||||
return { text: asr.text || "", records };
|
||||
}
|
||||
|
||||
const info = modelConfig.asrServerInfo;
|
||||
if (!info) {
|
||||
throw new Error("本地 ASR 需配置 ASR_SERVER_URL 或 ASR_SERVER_INFO");
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在调用本地语音识别…");
|
||||
const asr = await pipelineNative.localAsr(audioPath, info);
|
||||
if (!asr?.success) {
|
||||
throw new Error(asr?.error || "本地语音识别失败");
|
||||
}
|
||||
let records = Array.isArray(asr.sentences) ? asr.sentences : [];
|
||||
if (!records.length && asr.text) {
|
||||
records = recordsFromTextEvenly(asr.text, 60);
|
||||
}
|
||||
return { text: asr.text || "", records };
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const inputVideo = String(p.inputVideo || "").trim();
|
||||
if (!inputVideo || !fs.existsSync(inputVideo)) {
|
||||
return { success: false, error: "缺少或找不到输入视频,请先在步骤 02/03 生成并处理视频" };
|
||||
}
|
||||
|
||||
const autoSubtitle = Boolean(p.autoSubtitle);
|
||||
const bgmEnabled = Boolean(p.bgmEnabled);
|
||||
if (!autoSubtitle && !bgmEnabled) {
|
||||
return { success: false, error: "请至少勾选「自动生成字幕」或「添加背景音乐」" };
|
||||
}
|
||||
if (bgmEnabled) {
|
||||
const bgm = String(p.bgmPath || "").trim();
|
||||
if (!bgm || !fs.existsSync(bgm)) {
|
||||
return { success: false, error: "已启用背景音乐,请先选择音乐文件" };
|
||||
}
|
||||
}
|
||||
|
||||
const modelConfig = desktopConfig.buildModelConfig();
|
||||
const check = desktopConfig.validateModelConfig(modelConfig);
|
||||
if (autoSubtitle && !check.ok) {
|
||||
return { success: false, error: check.error };
|
||||
}
|
||||
if (autoSubtitle && (modelConfig.asrMode === "online" || !modelConfig.asrMode)) {
|
||||
const appCheck = desktopConfig.validateModelConfig(modelConfig);
|
||||
if (!appCheck.ok && modelConfig.asrMode === "online") {
|
||||
return { success: false, error: appCheck.error };
|
||||
}
|
||||
}
|
||||
|
||||
let current = inputVideo;
|
||||
let srtPath = "";
|
||||
|
||||
try {
|
||||
if (autoSubtitle) {
|
||||
globalThis.__native?.emitProgress?.("正在提取音频…");
|
||||
const audioPath = await pipelineNative.ffmpegExtractAudio(current);
|
||||
const scriptContent = String(p.scriptContent || "").trim();
|
||||
const { records: rawRecords } = await runAsr(audioPath, modelConfig, scriptContent);
|
||||
|
||||
let records = rawRecords;
|
||||
if (p.smartSubtitle !== false && scriptContent) {
|
||||
records = alignScriptToRecords(scriptContent, records);
|
||||
}
|
||||
if (!records.length) {
|
||||
const duration = probeVideoDuration(current);
|
||||
const fallbackText = scriptContent || " ";
|
||||
records = recordsFromTextEvenly(fallbackText, duration || 30);
|
||||
}
|
||||
if (!records.length) {
|
||||
return { success: false, error: "未识别到语音内容,无法生成字幕" };
|
||||
}
|
||||
|
||||
srtPath = makeOutputPath("subtitle", "srt");
|
||||
fs.writeFileSync(srtPath, buildSrtContent(records), "utf8");
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在烧录字幕到视频…");
|
||||
const forceStyle = String(p.subtitleForceStyle || "").trim();
|
||||
current = addSubtitleToVideo(current, srtPath, forceStyle || undefined);
|
||||
pipelineNative.deleteFile(audioPath);
|
||||
}
|
||||
|
||||
if (bgmEnabled) {
|
||||
globalThis.__native?.emitProgress?.("正在混合背景音乐…");
|
||||
current = addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videoPath: current,
|
||||
srtPath: srtPath || null,
|
||||
message: autoSubtitle && bgmEnabled
|
||||
? "字幕与背景音乐处理完成"
|
||||
: autoSubtitle
|
||||
? "字幕生成完成"
|
||||
: "背景音乐添加完成",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.message || String(e),
|
||||
partialVideo: current !== inputVideo ? current : null,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user