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 path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
const { spawn, spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js");
@@ -31,14 +31,69 @@ function makeOutputPath(prefix, ext = "mp4") {
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();
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}`);
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} 字节),可能已损坏`);
}
}
@@ -494,39 +549,100 @@ function formatAssTime(ms) {
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) {
const outputVideo = makeOutputPath("with_subtitle");
async function addSubtitleToVideo(inputVideo, subtitlePath, keywordDrawtextFilter) {
const withSubtitle = makeOutputPath("with_subtitle");
const sub = escapeSubPath(subtitlePath);
let vf = `subtitles='${sub}'`;
if (keywordDrawtextFilter) {
vf = `${vf},${keywordDrawtextFilter}`;
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;
}
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 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,
]);
await execFfmpeg(
[
"-y",
"-i",
inputVideo,
"-stream_loop",
"-1",
"-i",
bgmPath,
"-filter_complex",
filter,
"-c:v",
"copy",
"-shortest",
outputVideo,
],
"混合 BGM",
);
assertOutputVideo(outputVideo, "混合 BGM");
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);
}
if (bgmEnabled) {
globalThis.__native?.emitProgress?.("正在混合背景音乐…");
current = addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30);
current = await addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30);
}
return {

View File

@@ -278,11 +278,12 @@ function buildDrawtextForMatch(match, layout) {
const t0 = match.startSec.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);
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 = [
@@ -323,7 +324,19 @@ function buildKeywordDrawtextFilter(matches, opts) {
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);
}
}
/**