11
This commit is contained in:
539
scripts/nodejs/video_edit_process.js
Normal file
539
scripts/nodejs/video_edit_process.js
Normal 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 };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user