/** * 视频编辑后处理(对齐 Electron ipAgent:autoProcessVideo) * - processSilenceDetection:自动剪气口 * - processVideoMixCut:画中画 / 混剪 * - processGreenScreen:绿幕替换 */ const fs = require("fs"); const path = require("path"); const os = require("os"); const { spawnSync } = require("child_process"); const { buildReplacementsFromMixCutSettings, } = require("./mixcut_replacements.js"); const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]); function locateFfmpegBinary() { const fromEnv = process.env.AICLIENT_FFMPEG_PATH; if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; } function locateFfprobeBinary() { const fromEnv = process.env.AICLIENT_FFPROBE_PATH; if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; const ffmpeg = locateFfmpegBinary(); const dir = path.dirname(ffmpeg); const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe"); if (fs.existsSync(sibling)) return sibling; return process.platform === "win32" ? "ffprobe.exe" : "ffprobe"; } function execFfmpeg(args) { const ffmpeg = locateFfmpegBinary(); const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true }); if (r.status !== 0) { const err = (r.stderr || r.stdout || "").trim(); throw new Error(err || `ffmpeg 退出码 ${r.status}`); } } function ensureOutputDir() { const dir = path.join(os.tmpdir(), "aiclient-video-edit"); fs.mkdirSync(dir, { recursive: true }); return dir; } function makeOutputPath(prefix, ext = "mp4") { return path.join(ensureOutputDir(), `${prefix}_${Date.now()}.${ext}`); } function parseDurationHms(text) { const marker = "Duration:"; const idx = text.indexOf(marker); if (idx < 0) return 0; const rest = text.slice(idx + marker.length).trim(); const timePart = rest.split(",")[0].trim(); const parts = timePart.split(":").map(Number); if (parts.length < 3) return 0; const [h, m, s] = parts; if (![h, m, s].every((n) => Number.isFinite(n))) return 0; return h * 3600 + m * 60 + s; } function probeVideoDuration(videoPath) { const ffprobe = locateFfprobeBinary(); const r = spawnSync( ffprobe, [ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath, ], { encoding: "utf8", windowsHide: true }, ); if (r.status === 0) { const n = parseFloat(String(r.stdout || "").trim()); if (Number.isFinite(n) && n > 0) return n; } const ffmpeg = locateFfmpegBinary(); const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true, }); return parseDurationHms(r2.stderr || ""); } function probeVideoResolution(videoPath) { const ffprobe = locateFfprobeBinary(); const r = spawnSync( ffprobe, [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", videoPath, ], { encoding: "utf8", windowsHide: true }, ); if (r.status === 0) { const line = String(r.stdout || "").trim(); const m = line.match(/^(\d+)x(\d+)$/); if (m) { return { width: Number(m[1]), height: Number(m[2]) }; } } return { width: 1920, height: 1080 }; } function isImageFile(filePath) { return IMAGE_EXT.has(path.extname(filePath).toLowerCase()); } function processSilenceDetection(inputVideo, opts = {}) { const silenceThreshold = Number(opts.silenceThreshold ?? -40); const silenceDuration = Number(opts.silenceDuration ?? 1); const minPauseDuration = Number(opts.minPauseDuration ?? 0.15); const minPauseDurationMs = Math.round(minPauseDuration * 1000); const outputVideo = makeOutputPath("silence"); const audioFilter = `silenceremove=stop_periods=-1:stop_duration=${silenceDuration}:stop_threshold=${silenceThreshold}dB,adelay=${minPauseDurationMs}ms|${minPauseDurationMs}ms`; execFfmpeg([ "-i", inputVideo, "-af", audioFilter, "-c:v", "copy", "-y", outputVideo, ]); return { outputVideo, message: `已删除超过 ${silenceDuration} 秒且低于 ${silenceThreshold}dB 的停顿`, }; } function processGreenScreen(inputVideo, backgroundImage) { const outputVideo = makeOutputPath("greenscreen"); const filterComplex = [ "[1:v][0:v]scale2ref=flags=lanczos[bg][vid]", "[vid]format=yuva444p,chromakey=0x00FF00:0.28:0.05,chromakey=0x40FF40:0.12:0.15[fg]", "[bg][fg]overlay=0:0:shortest=1,unsharp=3:3:0.5:3:3:0.5,format=yuv420p[out]", ].join(";"); execFfmpeg([ "-i", inputVideo, "-loop", "1", "-i", backgroundImage, "-filter_complex", filterComplex, "-map", "[out]", "-map", "0:a?", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "128k", "-shortest", "-y", outputVideo, ]); return { outputVideo, message: "绿幕替换完成" }; } /** 对齐 Electron ipAgent:processVideoMixCut */ function processVideoMixCut({ inputVideo, replacements, outputPath, videoResolution, videoDuration, displayMode = "pip", }) { if (!replacements?.length) { throw new Error("混剪替换点为空"); } for (const r of replacements) { if (!r.materialPath || !fs.existsSync(r.materialPath)) { throw new Error(`素材文件不存在: ${r.materialPath || "(空)"}`); } } let filterComplex = ""; let inputFiles = [inputVideo]; let currentStream = "[0:v]"; const adjustedReplacements = replacements.map((r, i) => { let actualEndTime = r.startTime + Math.min(r.duration, r.materialDuration); for (let j = i + 1; j < replacements.length; j++) { const nextR = replacements[j]; if (nextR.startTime < actualEndTime) { actualEndTime = nextR.startTime; } } return { ...r, actualEndTime }; }); for (let i = 0; i < adjustedReplacements.length; i++) { const r = adjustedReplacements[i]; const inputIndex = i + 1; const materialStream = `[${inputIndex}:v]`; const trimmedMaterialStream = `[m_trim${i}]`; const processedMaterialStream = `[m${i}]`; const mode = r.displayMode || displayMode || "pip"; let scaleFilter = ""; let overlayParam = ""; if (mode === "fullscreen") { scaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height},boxblur=25:2`; overlayParam = "0:0"; } else { const pipSizePercent = r.pipSizePercent || 30; const pipWidth = Math.round((videoResolution.width * pipSizePercent) / 100); const pipHeight = Math.round((videoResolution.height * pipSizePercent) / 100); const pipScaleMode = r.pipScaleMode || "fit"; let pipScaleFilter = ""; if (pipScaleMode === "fit") { pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=decrease`; } else if (pipScaleMode === "fill") { pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=increase,crop=${pipWidth}:${pipHeight}`; } else { pipScaleFilter = `scale=${pipWidth}:${pipHeight}`; } const pipPos = r.pipPosition || "bottom-right"; let pipX = 0; let pipY = 0; switch (pipPos) { case "top-left": pipX = 10; pipY = 10; break; case "top-center": pipX = (videoResolution.width - pipWidth) / 2; pipY = 10; break; case "top-right": pipX = videoResolution.width - pipWidth - 10; pipY = 10; break; case "center-left": pipX = 10; pipY = (videoResolution.height - pipHeight) / 2; break; case "center": pipX = (videoResolution.width - pipWidth) / 2; pipY = (videoResolution.height - pipHeight) / 2; break; case "center-right": pipX = videoResolution.width - pipWidth - 10; pipY = (videoResolution.height - pipHeight) / 2; break; case "bottom-left": pipX = 10; pipY = videoResolution.height - pipHeight - 10; break; case "bottom-center": pipX = (videoResolution.width - pipWidth) / 2; pipY = videoResolution.height - pipHeight - 10; break; case "bottom-right": default: pipX = videoResolution.width - pipWidth - 10; pipY = videoResolution.height - pipHeight - 10; break; } scaleFilter = pipScaleFilter; overlayParam = `${Math.round(pipX)}:${Math.round(pipY)}`; } const actualDuration = Math.min(r.duration, r.materialDuration); const isImage = r.isImage ?? isImageFile(r.materialPath); if (isImage) { const fpsFilter = "fps=25"; const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`; filterComplex += `${materialStream}${fpsFilter},${setptsFilter}${trimmedMaterialStream};`; if (mode === "fullscreen") { const originalMinimized = r.originalVideoMinimized; if (originalMinimized?.enabled) { const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`; filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`; } else { const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`; filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`; } } else { filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`; } } else { const trimFilter = `trim=start=${(r.materialStartOffset || 0).toFixed(3)}:duration=${actualDuration.toFixed(3)}`; const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`; filterComplex += `${materialStream}${trimFilter},${setptsFilter}${trimmedMaterialStream};`; if (mode === "fullscreen") { const originalMinimized = r.originalVideoMinimized; if (originalMinimized?.enabled) { const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`; filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`; } else { const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`; filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`; } } else { filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`; } } const outputStream = `[v${i}]`; const enableExpr = `gte(t,${r.startTime.toFixed(3)})*lt(t,${r.actualEndTime.toFixed(3)})`; if (mode === "fullscreen") { const originalMinimized = r.originalVideoMinimized; if (originalMinimized?.enabled) { const { shape, position, sizePercent } = originalMinimized; const originalSize = Math.floor(videoResolution.width * (sizePercent / 100)); const splitStream1 = `[split${i}_1]`; const splitStream2 = `[split${i}_2]`; filterComplex += `${currentStream}split=2${splitStream1}${splitStream2};`; const origStream = `[orig${i}]`; let originalScaled = `${splitStream1}scale=${originalSize}:${originalSize}:force_original_aspect_ratio=increase,crop=${originalSize}:${originalSize}`; if (shape === "circle") { const radius = originalSize / 2; originalScaled += `,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='if(lte(hypot(X-${radius},Y-${radius}),${radius}),255,0)'`; } originalScaled += origStream; filterComplex += `${originalScaled};`; const tmpStream = `[tmp${i}]`; filterComplex += `${splitStream2}${processedMaterialStream}overlay=0:0:enable='${enableExpr}'${tmpStream};`; const padding = 50; let x = 0; let y = 0; switch (position) { case "top-left": x = padding; y = padding; break; case "top-right": x = videoResolution.width - originalSize - padding; y = padding; break; case "bottom-left": x = padding; y = videoResolution.height - originalSize - padding; break; case "bottom-right": x = videoResolution.width - originalSize - padding; y = videoResolution.height - originalSize - padding; break; case "center": x = (videoResolution.width - originalSize) / 2; y = (videoResolution.height - originalSize) / 2; break; default: break; } filterComplex += `${tmpStream}${origStream}overlay=${Math.round(x)}:${Math.round(y)}:enable='${enableExpr}'${outputStream};`; } else { const blurredBgStream = `[blurred_bg${i}]`; filterComplex += `${currentStream}boxblur=25:2:enable='${enableExpr}'${blurredBgStream};`; filterComplex += `${blurredBgStream}${processedMaterialStream}overlay=(W-w)/2:(H-h)/2:enable='${enableExpr}'${outputStream};`; } } else { filterComplex += `${currentStream}${processedMaterialStream}overlay=${overlayParam}:enable='${enableExpr}'${outputStream};`; } inputFiles.push(r.materialPath); currentStream = outputStream; } filterComplex = filterComplex.slice(0, -1); filterComplex += `; ${currentStream}trim=start=0:duration=${videoDuration}[final_video]`; const ffmpegArgs = [ ...inputFiles.flatMap((f) => ["-i", f]), "-filter_complex", filterComplex, "-map", "[final_video]", "-map", "0:a?", "-c:a", "copy", "-c:v", "libx264", "-preset", "medium", "-shortest", "-y", outputPath, ]; execFfmpeg(ffmpegArgs); return { outputVideo: outputPath, message: "混剪处理完成" }; } function normalizeReplacements(raw) { if (!Array.isArray(raw)) return []; return raw .map((r) => ({ startTime: Number(r.startTime), duration: Number(r.duration), materialPath: String(r.materialPath || "").trim(), materialDuration: Number(r.materialDuration), materialStartOffset: Number(r.materialStartOffset || 0), displayMode: r.displayMode, pipPosition: r.pipPosition, pipSizePercent: r.pipSizePercent, pipScaleMode: r.pipScaleMode, isImage: r.isImage, originalVideoMinimized: r.originalVideoMinimized, })) .filter( (r) => Number.isFinite(r.startTime) && Number.isFinite(r.duration) && r.duration > 0 && r.materialPath && Number.isFinite(r.materialDuration) && r.materialDuration > 0, ); } function pickReplacementsFromSettings(settings, videoDuration) { if (!settings || typeof settings !== "object") return []; const fromMixCut = buildReplacementsFromMixCutSettings( settings, videoDuration, probeVideoDuration, ); if (fromMixCut.length) { return normalizeReplacements(fromMixCut); } if (Array.isArray(settings.replacements) && settings.replacements.length) { return normalizeReplacements(settings.replacements); } const simple = settings.simplePip; if (simple?.materialPath && fs.existsSync(simple.materialPath)) { const matDur = Number(simple.materialDuration) > 0 ? Number(simple.materialDuration) : isImageFile(simple.materialPath) ? videoDuration : probeVideoDuration(simple.materialPath); const startTime = Number(simple.startTime || 0); const duration = Number(simple.duration || videoDuration); return normalizeReplacements([ { startTime, duration, materialPath: simple.materialPath, materialDuration: matDur, materialStartOffset: Number(simple.materialStartOffset || 0), displayMode: simple.displayMode || settings.displayMode || "pip", pipPosition: simple.pipPosition || "bottom-right", pipSizePercent: simple.pipSizePercent ?? 30, pipScaleMode: simple.pipScaleMode || "fit", isImage: simple.isImage ?? isImageFile(simple.materialPath), }, ]); } return []; } globalThis.__nodejsMain = async function main(params) { const p = params || {}; const inputVideo = String(p.inputVideo || "").trim(); if (!inputVideo || !fs.existsSync(inputVideo)) { return { success: false, error: "缺少或找不到输入视频" }; } const steps = []; let current = inputVideo; try { if (p.autoCutBreath) { globalThis.__native?.emitProgress?.("正在剪气口…"); const r = processSilenceDetection(current, { silenceThreshold: p.silenceThreshold, silenceDuration: p.silenceDuration, minPauseDuration: p.minPauseDuration, }); current = r.outputVideo; steps.push({ step: "silence", ...r }); } if (p.pipInPicture) { const settings = p.mixCutSettings || null; const replacements = pickReplacementsFromSettings( settings, probeVideoDuration(current), ); if (!replacements.length) { return { success: false, error: "已启用画中画,但未配置混剪素材。请在本地配置 videoMixCutSettings(含 replacements 或 simplePip),或使用「选择画中画素材」", }; } globalThis.__native?.emitProgress?.("正在混剪/画中画…"); const videoDuration = probeVideoDuration(current); const videoResolution = probeVideoResolution(current); const outputPath = makeOutputPath("mixcut"); const r = processVideoMixCut({ inputVideo: current, replacements, outputPath, videoResolution, videoDuration, displayMode: settings?.displayMode || "pip", }); current = r.outputVideo; steps.push({ step: "mixcut", ...r }); } if (p.greenScreen) { const bg = String(p.backgroundImage || "").trim(); if (!bg || !fs.existsSync(bg)) { return { success: false, error: "已启用绿幕切换,请先上传替换背景图" }; } globalThis.__native?.emitProgress?.("正在绿幕替换…"); const r = processGreenScreen(current, bg); current = r.outputVideo; steps.push({ step: "greenscreen", ...r }); } return { success: true, videoPath: current, steps, message: steps.length ? steps[steps.length - 1].message : "未执行任何处理", }; } catch (e) { return { success: false, error: e.message || String(e), partialVideo: current }; } };