"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, }; } };