This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:11 +08:00
parent 1325ff92e5
commit 7f57dfa9ed
31 changed files with 10437 additions and 12 deletions

View File

@@ -0,0 +1,539 @@
/**
* 视频编辑后处理(对齐 Electron ipAgent:autoProcessVideo
* - processSilenceDetection自动剪气口
* - processVideoMixCut画中画 / 混剪
* - processGreenScreen绿幕替换
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
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 [];
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 };
}
};