This commit is contained in:
949036910@qq.com
2026-06-01 22:08:18 +08:00
parent 474f6823fe
commit 4dada0c1a0
2 changed files with 163 additions and 34 deletions

View File

@@ -6,7 +6,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const os = require("os"); const os = require("os");
const { spawnSync } = require("child_process"); const { spawn, spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js"); const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js"); const desktopConfig = require("./desktop_config.js");
@@ -31,14 +31,69 @@ function makeOutputPath(prefix, ext = "mp4") {
return path.join(ensureDir(), `${prefix}_${Date.now()}.${ext}`); return path.join(ensureDir(), `${prefix}_${Date.now()}.${ext}`);
} }
function execFfmpeg(args) { 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(); const ffmpeg = pipelineNative.locateFfmpeg();
if (!ffmpeg) { if (!ffmpeg) {
throw new Error("未找到 ffmpeg请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe"); throw new Error("未找到 ffmpeg请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
} }
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true }); const fullArgs = ["-hide_banner", "-loglevel", "warning", "-nostats", ...args];
if (r.status !== 0) { logSubtitle("info", `${label} 开始`, { args: fullArgs.slice(0, 12) });
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
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} 字节),可能已损坏`);
} }
} }
@@ -494,39 +549,100 @@ function formatAssTime(ms) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`; 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",
];
/** /**
* @param {string} [keywordDrawtextFilter] 逗号分隔的 drawtext 滤镜(关键词高亮 * 先烧录 ASS再单独叠加关键词 drawtext避免超长 -vf 与滤镜逗号冲突
* @param {string} [keywordDrawtextFilter]
*/ */
function addSubtitleToVideo(inputVideo, subtitlePath, keywordDrawtextFilter) { async function addSubtitleToVideo(inputVideo, subtitlePath, keywordDrawtextFilter) {
const outputVideo = makeOutputPath("with_subtitle"); const withSubtitle = makeOutputPath("with_subtitle");
const sub = escapeSubPath(subtitlePath); const sub = escapeSubPath(subtitlePath);
let vf = `subtitles='${sub}'`; await execFfmpeg(
if (keywordDrawtextFilter) { [
vf = `${vf},${keywordDrawtextFilter}`; "-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;
} }
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
return outputVideo;
} }
function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) { async function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) {
const outputVideo = makeOutputPath("with_bgm"); const outputVideo = makeOutputPath("with_bgm");
const ratio = Number(bgmVolume) / 100; 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`; const filter = `[0:a]volume=1.0[a0];[1:a]volume=${ratio}[a1];[a0][a1]amix=inputs=2:duration=first`;
execFfmpeg([ await execFfmpeg(
"-y", [
"-i", "-y",
inputVideo, "-i",
"-stream_loop", inputVideo,
"-1", "-stream_loop",
"-i", "-1",
bgmPath, "-i",
"-filter_complex", bgmPath,
filter, "-filter_complex",
"-c:v", filter,
"copy", "-c:v",
"-shortest", "copy",
outputVideo, "-shortest",
]); outputVideo,
],
"混合 BGM",
);
assertOutputVideo(outputVideo, "混合 BGM");
return outputVideo; return outputVideo;
} }
@@ -697,13 +813,13 @@ globalThis.__nodejsMain = async function main(params) {
? "正在烧录字幕并叠加关键词特效…" ? "正在烧录字幕并叠加关键词特效…"
: "正在烧录字幕到视频…", : "正在烧录字幕到视频…",
); );
current = addSubtitleToVideo(current, assPath, keywordDrawtextFilter); current = await addSubtitleToVideo(current, assPath, keywordDrawtextFilter);
pipelineNative.deleteFile(audioPath); pipelineNative.deleteFile(audioPath);
} }
if (bgmEnabled) { if (bgmEnabled) {
globalThis.__native?.emitProgress?.("正在混合背景音乐…"); globalThis.__native?.emitProgress?.("正在混合背景音乐…");
current = addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30); current = await addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30);
} }
return { return {

View File

@@ -278,11 +278,12 @@ function buildDrawtextForMatch(match, layout) {
const t0 = match.startSec.toFixed(3); const t0 = match.startSec.toFixed(3);
const t1 = match.endSec.toFixed(3); const t1 = match.endSec.toFixed(3);
const enable = `between(t\\,${t0}\\,${t1})`; /** 用 gte*lte 避免 between(t,a,b) 中逗号打断 -vf 滤镜链 */
const enable = `gte(t\\,${t0})*lte(t\\,${t1})`;
let fontsizePart = String(baseSize); let fontsizePart = String(baseSize);
if (match.effectType === "pulse-scale") { if (match.effectType === "pulse-scale") {
fontsizePart = `${baseSize}*(1+0.1*sin(2*PI*(t-${t0})*4))`; fontsizePart = `${baseSize}*(1+0.08*sin(8*PI*t))`;
} }
const parts = [ const parts = [
@@ -323,7 +324,19 @@ function buildKeywordDrawtextFilter(matches, opts) {
position: String(style.position || "bottom"), position: String(style.position || "bottom"),
}; };
return matches.map((m) => buildDrawtextForMatch(m, layout)).join(","); const filters = matches.map((m) => buildDrawtextForMatch(m, layout));
const chain = filters.join(",");
if (chain.length > 6000) {
logKeywordWarn("drawtext 滤镜过长,仅保留前 40 个关键词");
return filters.slice(0, 40).join(",");
}
return chain;
}
function logKeywordWarn(msg, fields) {
if (globalThis.__native?.log) {
globalThis.__native.log("warn", `[autoGenerateSubtitleAndBGM] ${msg}`, fields ?? null);
}
} }
/** /**