540 lines
18 KiB
JavaScript
540 lines
18 KiB
JavaScript
/**
|
||
* 视频编辑后处理(对齐 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 };
|
||
}
|
||
};
|