1
This commit is contained in:
@@ -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,25 +549,83 @@ 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}`;
|
||||
}
|
||||
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
||||
return outputVideo;
|
||||
await execFfmpeg(
|
||||
[
|
||||
"-y",
|
||||
"-i",
|
||||
inputVideo,
|
||||
"-vf",
|
||||
`subtitles='${sub}'`,
|
||||
...VIDEO_ENCODE_ARGS,
|
||||
"-c:a",
|
||||
"copy",
|
||||
withSubtitle,
|
||||
],
|
||||
"烧录字幕",
|
||||
);
|
||||
assertOutputVideo(withSubtitle, "烧录字幕");
|
||||
|
||||
if (!keywordDrawtextFilter) {
|
||||
return withSubtitle;
|
||||
}
|
||||
|
||||
function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) {
|
||||
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`;
|
||||
execFfmpeg([
|
||||
await execFfmpeg(
|
||||
[
|
||||
"-y",
|
||||
"-i",
|
||||
inputVideo,
|
||||
@@ -526,7 +639,10 @@ function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) {
|
||||
"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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user