From 4dada0c1a055477052b056c9159a4e36b8de5d89 Mon Sep 17 00:00:00 2001 From: "949036910@qq.com" <> Date: Mon, 1 Jun 2026 22:08:18 +0800 Subject: [PATCH] 1 --- scripts/nodejs/subtitle_bgm_generate.js | 178 ++++++++++++++++---- scripts/nodejs/subtitle_keyword_drawtext.js | 19 ++- 2 files changed, 163 insertions(+), 34 deletions(-) diff --git a/scripts/nodejs/subtitle_bgm_generate.js b/scripts/nodejs/subtitle_bgm_generate.js index a1be9d7..655f28c 100644 --- a/scripts/nodejs/subtitle_bgm_generate.js +++ b/scripts/nodejs/subtitle_bgm_generate.js @@ -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 { diff --git a/scripts/nodejs/subtitle_keyword_drawtext.js b/scripts/nodejs/subtitle_keyword_drawtext.js index 1c831f3..662366b 100644 --- a/scripts/nodejs/subtitle_keyword_drawtext.js +++ b/scripts/nodejs/subtitle_keyword_drawtext.js @@ -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); + } } /**