1
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user