Files
yaoyaoai/scripts/nodejs/subtitle_bgm_generate.js
949036910@qq.com 4dada0c1a0 1
2026-06-01 22:08:18 +08:00

843 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* 自动生成字幕并混 BGM对齐 Electron autoGenerateSubtitleAndBGM
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawn, spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js");
const { correctTextWithLevenshtein, similarity: textSimilarity } = require("./subtitle_levenshtein.js");
const {
buildKeywordGroups,
matchKeywordsInRecords,
buildKeywordDrawtextFilter,
normalizeDisplayText,
stripKeywordsFromDisplay,
filterMatchesForRecord,
} = require("./subtitle_keyword_drawtext.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}`);
}
const FFMPEG_TIMEOUT_MS = 15 * 60 * 1000;
function tailFfmpegLog(text, maxLines = 24) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length <= maxLines) return lines.join("\n");
return lines.slice(-maxLines).join("\n");
}
/**
* 异步执行 ffmpeg避免 spawnSync 默认 1MB maxBuffer 在重编码时撑爆)
* @param {string[]} args
* @param {string} [label]
*/
function execFfmpeg(args, label = "ffmpeg") {
const ffmpeg = pipelineNative.locateFfmpeg();
if (!ffmpeg) {
throw new Error("未找到 ffmpeg请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
}
const fullArgs = ["-hide_banner", "-loglevel", "warning", "-nostats", ...args];
logSubtitle("info", `${label} 开始`, { args: fullArgs.slice(0, 12) });
return new Promise((resolve, reject) => {
const child = spawn(ffmpeg, fullArgs, { windowsHide: true });
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-256 * 1024);
});
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`${label} 超时(超过 15 分钟),请缩短视频或降低分辨率后重试`));
}, FFMPEG_TIMEOUT_MS);
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) {
resolve();
return;
}
const tail = tailFfmpegLog(stderr);
reject(
new Error(
tail
? `${label} 失败(退出码 ${code}\n${tail}`
: `${label} 失败,退出码 ${code}`,
),
);
});
});
}
function assertOutputVideo(outputPath, label) {
if (!outputPath || !fs.existsSync(outputPath)) {
throw new Error(`${label} 未生成输出文件`);
}
const stat = fs.statSync(outputPath);
if (stat.size < 1024) {
throw new Error(`${label} 输出文件过小(${stat.size} 字节),可能已损坏`);
}
}
function locateFfprobe() {
const fromEnv = process.env.AICLIENT_FFPROBE_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
const ffmpeg = pipelineNative.locateFfmpeg();
if (!ffmpeg) return null;
const sibling = ffmpeg.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
return fs.existsSync(sibling) ? sibling : null;
}
function probeVideoDuration(videoPath) {
const ffprobe = locateFfprobe();
if (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;
}
/** @returns {{ width: number, height: number }} */
function probeVideoSize(videoPath) {
const ffprobe = locateFfprobe();
if (ffprobe) {
const r = spawnSync(
ffprobe,
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
videoPath,
],
{ encoding: "utf8", windowsHide: true },
);
if (r.status === 0) {
const parts = String(r.stdout || "").trim().split("x");
const width = parseInt(parts[0], 10);
const height = parseInt(parts[1], 10);
if (width > 0 && height > 0) return { width, height };
}
}
const ffmpeg = pipelineNative.locateFfmpeg();
const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true });
const stderr = r2.stderr || "";
const m = stderr.match(/,\s*(\d{2,5})x(\d{2,5})/);
if (m) {
return { width: Number(m[1]), height: Number(m[2]) };
}
return { width: 1920, height: 1080 };
}
function splitScriptLines(text) {
return String(text || "")
.split(/(?<=[。!?.!?])\s*|\n+/)
.map((s) => s.trim())
.filter(Boolean);
}
function normalizeAsrTimeToMs(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
if (n > 100) return Math.round(n);
return Math.round(n * 1000);
}
/** 对齐 ZimuShengcheng.shouldTreatTimelineAsSeconds */
function shouldTreatTimelineAsSeconds(records) {
const valid = records.filter(
(r) => Number.isFinite(Number(r?.start)) && Number.isFinite(Number(r?.end)),
);
if (valid.length === 0) return false;
const maxEnd = Math.max(...valid.map((r) => Number(r.end)));
const maxDur = Math.max(...valid.map((r) => Number(r.end) - Number(r.start)));
return maxEnd <= 600 && maxDur <= 30;
}
function normalizeFunasrSegmentsToMilliseconds(records) {
if (!Array.isArray(records) || records.length === 0) return [];
const mapped = records
.map((record) => {
const text = String(record?.text || record?.sentence || "").trim();
if (!text) return null;
return {
text,
start: Number(record.start ?? record.begin ?? record.beginTime ?? 0),
end: Number(record.end ?? record.endTime ?? 0),
words: record.words,
};
})
.filter(Boolean);
if (shouldTreatTimelineAsSeconds(mapped)) {
logSubtitle("info", "检测到秒级时间轴,统一转换为毫秒");
return mapped.map((r) => ({
...r,
start: Math.round(r.start * 1000),
end: Math.round((r.end > r.start ? r.end : r.start + 2) * 1000),
words: Array.isArray(r.words)
? r.words.map((w) => ({
...w,
start: Math.round(Number(w.start ?? w.begin_time ?? 0) * 1000),
end: Math.round(Number(w.end ?? w.end_time ?? 0) * 1000),
}))
: r.words,
}));
}
return mapped.map((r) => {
let start = normalizeAsrTimeToMs(r.start);
let end = normalizeAsrTimeToMs(r.end || start + 2);
if (end <= start) end = start + 2000;
return { text: r.text, start, end, words: r.words };
});
}
function normalizeAsrRecords(records) {
if (!Array.isArray(records)) return [];
const normalized = normalizeFunasrSegmentsToMilliseconds(records)
.map((record) => {
let { start, end } = record;
if (end <= start) end = start + 2000;
return { text: record.text, start, end, words: record.words };
})
.filter(Boolean);
const MIN_GAP_MS = 50;
for (let i = 0; i < normalized.length - 1; i++) {
const current = normalized[i];
const next = normalized[i + 1];
if (current.end >= next.start) {
current.end = Math.max(current.start + MIN_GAP_MS, next.start - MIN_GAP_MS);
}
if (current.end <= current.start) {
current.end = current.start + MIN_GAP_MS;
}
}
return normalized;
}
/** 对齐 Electron smartLineBreak最多 2 行,优先在标点处换行 */
function smartLineBreak(text, maxCharsPerLine = 18) {
const lines = [];
const punctuations = ["", "。", "", "", "", "", ",", ".", "!", "?", ";", ":"];
const MAX_LINES = 2;
let currentLine = "";
let pureTextLength = 0;
let i = 0;
const max = Math.max(8, Math.min(30, Number(maxCharsPerLine) || 18));
while (i < text.length) {
if (lines.length >= MAX_LINES) {
currentLine += text[i];
i += 1;
continue;
}
currentLine += text[i];
pureTextLength += 1;
const isPunctuation = punctuations.includes(text[i]);
const isNearMaxLength = pureTextLength >= max * 0.8;
if (isPunctuation && isNearMaxLength && lines.length < MAX_LINES - 1) {
lines.push(currentLine.trim());
currentLine = "";
pureTextLength = 0;
} else if (pureTextLength >= max && lines.length < MAX_LINES - 1) {
lines.push(currentLine.trim());
currentLine = "";
pureTextLength = 0;
}
i += 1;
}
if (currentLine.trim()) lines.push(currentLine.trim());
return lines.slice(0, MAX_LINES);
}
function cleanSubtitleDisplayText(text) {
return String(text || "")
.replace(/[,。!?;:、""''()《》【】…—·,.!?;:'"()[\]{}<>]/g, "")
.trim();
}
/**
* 智能字幕Levenshtein 校对ZimuShengcheng失败时回退按句均分
*/
function applySmartSubtitle(script, records, videoDurationSec = 0) {
const normalized = normalizeAsrRecords(records);
const scriptTrim = String(script || "").trim();
if (!scriptTrim) return normalized;
const asrPlain = normalized.map((r) => r.text).join("");
const sim = textSimilarity(
scriptTrim.replace(/\s+/g, ""),
asrPlain.replace(/\s+/g, ""),
);
if (normalized.length > 0 && sim >= 0.25) {
const { segments, similarity } = correctTextWithLevenshtein(scriptTrim, normalized);
if (segments.length > 0) {
logSubtitle("info", "Levenshtein 文案校对", {
count: segments.length,
similarity: Math.round(similarity * 1000) / 1000,
scriptSimilarity: Math.round(sim * 1000) / 1000,
});
return segments;
}
}
logSubtitle("info", "Levenshtein 未产出有效段落,回退按句对齐", { similarity: sim });
return alignScriptToRecords(scriptTrim, normalized, videoDurationSec);
}
function alignScriptToRecords(script, records, videoDurationSec = 0) {
const lines = splitScriptLines(script);
const normalized = normalizeAsrRecords(records);
if (!lines.length) return normalized;
if (!normalized.length) {
return recordsFromTextEvenly(script, videoDurationSec || 30);
}
if (lines.length === normalized.length) {
return normalized.map((r, i) => ({ ...r, text: lines[i] }));
}
const startMs = normalized[0].start;
let endMs = normalized[normalized.length - 1].end;
const durationMs = Math.max(0, Math.round(Number(videoDurationSec) * 1000));
if (durationMs > 0 && (endMs - startMs < 1000 || endMs < durationMs * 0.2)) {
endMs = durationMs;
}
const totalMs = Math.max(endMs - startMs, lines.length * 2000);
const totalChars = lines.reduce((sum, line) => sum + line.length, 0) || lines.length;
let cursor = startMs;
return lines.map((text, index) => {
const isLast = index === lines.length - 1;
const weight = text.length / totalChars;
const dur = isLast
? Math.max(1500, endMs - cursor)
: Math.max(1500, Math.round(totalMs * weight));
const start = cursor;
const end = isLast ? endMs : Math.min(cursor + dur, endMs);
cursor = end;
return { text, start, end: Math.max(end, start + 500) };
});
}
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, maxCharsPerLine = 18) {
return records
.map((r, i) => {
const start = formatSrtTime(r.start);
const end = formatSrtTime(Math.max(r.end, r.start + 500));
const text = wrapSubtitleText(r.text, maxCharsPerLine);
if (!text) return "";
return `${i + 1}\n${start} --> ${end}\n${text}\n`;
})
.filter(Boolean)
.join("\n");
}
function escapeSubPath(filePath) {
return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'");
}
/** 对齐 Electron maxCharsPerLine + smartLineBreakSRT 用换行ASS 经 assEscapeText 转 \\N */
function wrapSubtitleText(text, maxCharsPerLine = 18) {
const cleaned = cleanSubtitleDisplayText(text);
if (!cleaned) return "";
return smartLineBreak(cleaned, maxCharsPerLine).join("\n");
}
function logSubtitle(level, msg, fields) {
const tag = `[autoGenerateSubtitleAndBGM] ${msg}`;
if (globalThis.__native?.log) {
globalThis.__native.log(level, tag, fields ?? null);
return;
}
if (fields != null) {
if (level === "error") console.error(tag, fields);
else console.log(tag, fields);
} else if (level === "error") console.error(tag);
else console.log(tag);
}
function hexToAssColor(hex) {
const h = String(hex || "#FFFFFF").replace("#", "");
const base = h.length >= 6 ? h.slice(0, 6) : "FFFFFF";
const r = base.substring(0, 2);
const g = base.substring(2, 4);
const b = base.substring(4, 6);
return `&H00${b}${g}${r}`.toUpperCase();
}
function assEscapeText(text) {
return String(text || "")
.replace(/\r/g, "")
.replace(/\n/g, "\\N")
.replace(/{/g, "(")
.replace(/}/g, ")");
}
function normalizeBurnStyle(style) {
const s = style && typeof style === "object" ? style : {};
const outlineWidth = Number(s.outlineWidth);
const hasBg =
s.backgroundColor &&
s.backgroundColor !== "transparent" &&
(Number(s.backgroundOpacity) ?? 0) > 0;
return {
fontName: String(s.fontName || "Microsoft YaHei"),
fontSize: Number(s.fontSize) || 35,
fontColor: String(s.fontColor || "#FFFFFF"),
outlineColor: String(s.outlineColor || "#000000"),
outlineWidth: Number.isFinite(outlineWidth) ? outlineWidth : 2,
backgroundColor: String(s.backgroundColor || "transparent"),
backgroundOpacity: Number(s.backgroundOpacity) ?? 0,
position: String(s.position || "bottom"),
fontSizeScale: Number(s.fontSizeScale) || 100,
maxCharsPerLine: Number(s.maxCharsPerLine) || 18,
hasBg,
};
}
function resolveAssLayout(position, videoHeight) {
const h = Math.max(360, Number(videoHeight) || 1080);
const marginV = 80;
if (position === "top") {
return { alignment: 8, marginL: 120, marginR: 120, marginV };
}
if (position === "center") {
return { alignment: 5, marginL: 120, marginR: 120, marginV: 0 };
}
return { alignment: 2, marginL: 120, marginR: 120, marginV };
}
function buildAssContent(records, styleInput, videoWidth, videoHeight, keywordMatches = []) {
const style = normalizeBurnStyle(styleInput);
const width = Math.max(320, Number(videoWidth) || 1920);
const height = Math.max(320, Number(videoHeight) || 1080);
const scale = Math.max(0.2, Math.min(2, style.fontSizeScale / 100));
const fontSize = Math.max(14, Math.round(style.fontSize * scale));
const outline = Math.max(0, Math.round(style.outlineWidth));
const { alignment, marginL, marginR, marginV } = resolveAssLayout(
style.position,
height,
);
const hasBg = style.hasBg;
let borderStyle = 1;
let finalOutline = outline;
let finalShadow = 0;
let backColour = "&H00000000";
const primary = hexToAssColor(style.fontColor);
let outlineColour = hexToAssColor(style.outlineColor);
if (hasBg) {
borderStyle = 3;
finalOutline = 1;
finalShadow = 0;
backColour = hexToAssColor(style.backgroundColor);
outlineColour = backColour;
} else {
borderStyle = 1;
finalOutline = outline || 3;
finalShadow = 0;
backColour = "&H00000000";
outlineColour = hexToAssColor(style.outlineColor);
}
const header = [
"[Script Info]",
"Title: aiclient",
"ScriptType: v4.00+",
"WrapStyle: 0",
"ScaledBorderAndShadow: yes",
`PlayResX: ${width}`,
`PlayResY: ${height}`,
"",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
`Style: Default,${style.fontName},${fontSize},${primary},&H000000FF,${outlineColour},${backColour},1,0,0,0,100,100,0,0,${borderStyle},${finalOutline},${finalShadow},${alignment},${marginL},${marginR},${marginV},1`,
"",
"[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
];
const events = records.map((r) => {
const start = formatAssTime(r.start);
const end = formatAssTime(Math.max(r.end, r.start + 500));
const display = normalizeDisplayText(r.text);
const segMatches = filterMatchesForRecord(r, keywordMatches);
const baseDisplay =
segMatches.length > 0
? stripKeywordsFromDisplay(display, segMatches)
: display;
const text = assEscapeText(wrapSubtitleText(baseDisplay || " ", style.maxCharsPerLine));
if (!text.replace(/\\N/g, "").trim()) return "";
return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`;
}).filter(Boolean);
return `${header.join("\n")}\n${events.join("\n")}\n`;
}
function formatAssTime(ms) {
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
const cs = Math.floor((ms % 1000) / 10);
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`;
}
const VIDEO_ENCODE_ARGS = [
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
];
/**
* 先烧录 ASS再单独叠加关键词 drawtext避免超长 -vf 与滤镜逗号冲突)
* @param {string} [keywordDrawtextFilter]
*/
async function addSubtitleToVideo(inputVideo, subtitlePath, keywordDrawtextFilter) {
const withSubtitle = makeOutputPath("with_subtitle");
const sub = escapeSubPath(subtitlePath);
await execFfmpeg(
[
"-y",
"-i",
inputVideo,
"-vf",
`subtitles='${sub}'`,
...VIDEO_ENCODE_ARGS,
"-c:a",
"copy",
withSubtitle,
],
"烧录字幕",
);
assertOutputVideo(withSubtitle, "烧录字幕");
if (!keywordDrawtextFilter) {
return withSubtitle;
}
const withKeyword = makeOutputPath("with_keyword");
try {
await execFfmpeg(
[
"-y",
"-i",
withSubtitle,
"-vf",
keywordDrawtextFilter,
...VIDEO_ENCODE_ARGS,
"-c:a",
"copy",
withKeyword,
],
"关键词特效",
);
assertOutputVideo(withKeyword, "关键词特效");
try {
fs.unlinkSync(withSubtitle);
} catch {
/* ignore */
}
return withKeyword;
} catch (err) {
logSubtitle("warn", "关键词 drawtext 失败,已保留无特效字幕版", {
error: err instanceof Error ? err.message : String(err),
});
return withSubtitle;
}
}
async 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`;
await execFfmpeg(
[
"-y",
"-i",
inputVideo,
"-stream_loop",
"-1",
"-i",
bgmPath,
"-filter_complex",
filter,
"-c:v",
"copy",
"-shortest",
outputVideo,
],
"混合 BGM",
);
assertOutputVideo(outputVideo, "混合 BGM");
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 videoDuration = probeVideoDuration(current);
logSubtitle("info", "视频时长(秒)", { videoDuration });
const { records: rawRecords } = await runAsr(audioPath, modelConfig, scriptContent);
logSubtitle("info", "ASR 原始句数", { count: rawRecords?.length ?? 0 });
let records = normalizeAsrRecords(rawRecords);
logSubtitle("info", "时间轴归一化后", {
count: records.length,
spanMs:
records.length > 1
? records[records.length - 1].end - records[0].start
: 0,
});
if (p.smartSubtitle !== false && scriptContent) {
records = applySmartSubtitle(scriptContent, records, videoDuration);
logSubtitle("info", "智能字幕对齐后", { count: records.length });
}
if (!records.length) {
const fallbackText = scriptContent || " ";
records = recordsFromTextEvenly(fallbackText, videoDuration || 30);
logSubtitle("info", "按文案均分时间轴", { count: records.length });
}
if (!records.length) {
return { success: false, error: "未识别到语音内容,无法生成字幕" };
}
const burnStyle =
p.subtitleStyle && typeof p.subtitleStyle === "object"
? p.subtitleStyle
: null;
const maxChars = burnStyle?.maxCharsPerLine || 18;
srtPath = makeOutputPath("subtitle", "srt");
fs.writeFileSync(srtPath, buildSrtContent(records, maxChars), "utf8");
if (p.recognizeOnly) {
pipelineNative.deleteFile(audioPath);
return {
success: true,
records,
srtPath,
message: `字幕识别完成,共 ${records.length}`,
};
}
const { width, height } = probeVideoSize(current);
let keywordDrawtextFilter = null;
let keywordMatches = [];
const enableKeywords = p.enableKeywordEffects !== false;
const keywordGroups = buildKeywordGroups(p);
if (enableKeywords && keywordGroups.length > 0) {
keywordMatches = matchKeywordsInRecords(records, keywordGroups);
logSubtitle("info", "关键词匹配", {
groups: keywordGroups.length,
matches: keywordMatches.length,
keywords: keywordMatches.slice(0, 8).map((m) => m.keyword),
});
if (keywordMatches.length > 0) {
keywordDrawtextFilter = buildKeywordDrawtextFilter(keywordMatches, {
videoWidth: width,
videoHeight: height,
subtitleStyle: burnStyle,
});
if (!keywordDrawtextFilter) {
logSubtitle("warn", "未找到可用字体,跳过关键词 drawtext 特效");
keywordMatches = [];
}
}
}
const assPath = makeOutputPath("subtitle", "ass");
fs.writeFileSync(
assPath,
buildAssContent(records, burnStyle, width, height, keywordMatches),
"utf8",
);
globalThis.__native?.emitProgress?.(
keywordDrawtextFilter
? "正在烧录字幕并叠加关键词特效…"
: "正在烧录字幕到视频…",
);
current = await addSubtitleToVideo(current, assPath, keywordDrawtextFilter);
pipelineNative.deleteFile(audioPath);
}
if (bgmEnabled) {
globalThis.__native?.emitProgress?.("正在混合背景音乐…");
current = await 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,
};
}
};