Files
yaoyaoai/scripts/nodejs/video_edit_process.js
949036910@qq.com 620b022af0 1
2026-05-31 02:29:18 +08:00

551 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 视频编辑后处理(对齐 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 };
}
};