Files
2026-06-19 18:45:55 +08:00

362 lines
16 KiB
TypeScript

import { ipcMain } from "electron";
import shellApi, { buildRuntimeProcessEnv } from "./index";
import { Log } from "../log";
import { normalizePath } from "../../lib/path-util";
import { ProcessCleanupManager } from "../../lib/process-cleanup";
console.log("[shell/main.ts] Module loaded, registering IPC handlers...");
// 注册 showItemInFolder handler
ipcMain.handle("shell:showItemInFolder", async (event, filePath: string) => {
return shellApi.showItemInFolder(filePath);
});
// 注册 exec handler (用于执行系统命令如 python、nvidia-smi、taskkill 等)
ipcMain.handle("shell:exec", async (event, command: string) => {
console.log("[shell:exec] Handler called, command:", command.substring(0, 100) + (command.length > 100 ? '...' : ''));
Log.info("shell:exec.start", { command: command.substring(0, 200) + '...' });
try {
const { spawn } = await import('child_process');
const { AppEnv, waitAppEnvReady } = await import('../env');
// 等待AppEnv初始化完成
await waitAppEnvReady();
// 获取工作目录
const cwd = AppEnv.appRoot || process.cwd();
return new Promise((resolve, reject) => {
console.log('[shell.exec] 执行命令:', command);
const child = spawn(command, [], {
cwd,
shell: true,
env: buildRuntimeProcessEnv(),
stdio: ['pipe', 'pipe', 'pipe'],
});
// 注册子进程到清理管理器
if (child.pid) {
ProcessCleanupManager.registerChildProcess(child.pid);
}
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
// 进程结束时注销
if (child.pid) {
ProcessCleanupManager.unregisterChildProcess(child.pid);
}
console.log('[shell.exec] 命令完成,退出码:', code);
if (code === 0) {
resolve(stdout);
} else {
console.warn('[shell.exec] 命令执行失败,退出码:', code, 'stderr:', stderr);
resolve(stdout); // 返回stdout而不是抛出错误,保持与旧版本兼容
}
});
child.on('error', (error) => {
// 进程错误时注销
if (child.pid) {
ProcessCleanupManager.unregisterChildProcess(child.pid);
}
console.error('[shell.exec] 进程错误:', error);
reject(error);
});
});
} catch (error: any) {
Log.error("shell:exec.error", {
error: error?.message || String(error),
command: command.substring(0, 200) + '...'
});
throw error;
}
});
// 注册 getPythonPath handler
ipcMain.handle("shell:getPythonPath", async (event) => {
console.log("[shell:getPythonPath] Handler called");
Log.info("shell:getPythonPath.start");
try {
return await shellApi.getPythonPath();
} catch (error: any) {
Log.error("shell:getPythonPath.error", {
error: error?.message || String(error)
});
throw error;
}
});
// 注册 executeFFmpeg handler
ipcMain.handle("shell:executeFFmpeg", async (event, commandOrJson: string) => {
console.log("[shell:executeFFmpeg] Handler called, command length:", commandOrJson.length);
Log.info("shell:executeFFmpeg.start", { commandLength: commandOrJson.length });
try {
// 检查是否是JSON数组格式(args数组模式,避免路径拼接问题)
if (commandOrJson.startsWith('[')) {
const argsArray = JSON.parse(commandOrJson);
if (Array.isArray(argsArray)) {
Log.info("shell:executeFFmpeg.arrayMode", {
argsCount: argsArray.length
});
return await shellApi.execFFmpegCommand(argsArray);
}
}
// 检查是否是JSON格式(长过滤器模式)
if (commandOrJson.startsWith('{')) {
const data = JSON.parse(commandOrJson);
if (data.type === 'ffmpeg_long_filter') {
Log.info("shell:executeFFmpeg.longFilter", {
inputVideo: data.inputVideo,
outputVideo: data.outputVideo,
filterLength: data.filterComplex.length,
hasAudioMix: !!data.audioMixConfig // 🆕 记录是否有音效混音
});
// 🔧 转换输入输出路径
const { AppEnv, waitAppEnvReady } = await import('../env');
await waitAppEnvReady();
const ffmpegCwd = AppEnv.installRoot || AppEnv.appRoot || process.cwd();
const normalizedInputVideo = normalizePath(data.inputVideo);
const normalizedOutputVideo = normalizePath(data.outputVideo);
Log.info("shell:executeFFmpeg.pathNormalized", {
originalInput: data.inputVideo.substring(0, 100),
normalizedInput: normalizedInputVideo.substring(0, 100),
originalOutput: data.outputVideo.substring(0, 100),
normalizedOutput: normalizedOutputVideo.substring(0, 100)
});
// 【修复】如果filter_complex过长,写入文件使用新语法 -/filter_complex
// 避免Windows命令行长度限制(某些情况下单个参数>32KB会失败)
// 注意:FFmpeg N-122209 已弃用 -filter_complex_script,改用 -/filter_complex
const { promises: fs } = await import('fs');
const path = await import('path');
const os = await import('os');
// 🆕 处理音效混音配置
const audioMixConfig = data.audioMixConfig;
let finalFilterContent = data.filterComplex;
let audioInputArgs: string[] = [];
let useAudioMix = false;
if (audioMixConfig && audioMixConfig.inputFiles && audioMixConfig.inputFiles.length > 0) {
console.log(`[shell:executeFFmpeg] 🔊 检测到音效混音配置:`, {
inputFilesCount: audioMixConfig.inputFiles.length,
mixFilter: audioMixConfig.mixFilter
});
// 🔧 转换音效文件路径并添加为额外输入
for (const soundFile of audioMixConfig.inputFiles) {
const normalizedSoundFile = normalizePath(soundFile);
audioInputArgs.push('-i', normalizedSoundFile);
Log.info("shell:executeFFmpeg.soundFileNormalized", {
original: soundFile.substring(0, 100),
normalized: normalizedSoundFile.substring(0, 100)
});
}
// 🔧 关键修复:修改视频滤镜链的末尾,添加 [vout] 输出标签
// 原始滤镜链末尾没有输出标签,例如:[outN]drawtext=...
// 需要改为:[outN]drawtext=...[vout]
// 然后添加音频混音滤镜
let modifiedVideoFilter = data.filterComplex;
// 检查最后一个滤镜是否已有输出标签
// 如果没有,添加 [vout]
if (!modifiedVideoFilter.endsWith(']')) {
// 最后一个滤镜没有输出标签,添加 [vout]
modifiedVideoFilter = modifiedVideoFilter + '[vout]';
console.log(`[shell:executeFFmpeg] 🔊 添加视频输出标签 [vout]`);
} else {
// 最后一个滤镜已有输出标签(可能是 [out0]、[out1] 等)
// 需要提取这个标签名称,然后添加一个 null 滤镜连接到 [vout]
// 从滤镜链末尾提取最后一个输出标签,例如 [out5]
const lastLabelMatch = modifiedVideoFilter.match(/\[([^\[\]]+)\]$/);
if (lastLabelMatch) {
const lastLabel = lastLabelMatch[1];
modifiedVideoFilter = modifiedVideoFilter + `;[${lastLabel}]null[vout]`;
console.log(`[shell:executeFFmpeg] 🔊 通过 null 滤镜连接 [${lastLabel}] -> [vout]`);
} else {
// 无法提取标签,尝试直接添加 [vout]
modifiedVideoFilter = modifiedVideoFilter + '[vout]';
console.log(`[shell:executeFFmpeg] 🔊 无法提取最后标签,直接添加 [vout]`);
}
}
// 将音频混音滤镜添加到末尾
finalFilterContent = modifiedVideoFilter + ';' + audioMixConfig.mixFilter;
useAudioMix = true;
console.log(`[shell:executeFFmpeg] 🔊 组合滤镜长度: ${finalFilterContent.length}`);
}
const tempDir = os.tmpdir();
const filterScriptPath = path.join(tempDir, `ffmpeg_filter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.txt`);
console.log(`[shell:executeFFmpeg] 将filter_complex写入文件: ${filterScriptPath}`);
await fs.writeFile(filterScriptPath, finalFilterContent, 'utf-8');
// 🆕 根据是否有音效混音,构建不同的参数
let args: string[];
if (useAudioMix) {
// 有音效时:添加音效输入,映射视频和音频输出
// 使用新语法 -/filter_complex 而不是已弃用的 -filter_complex_script
args = [
'-y',
'-i', normalizedInputVideo, // 🔧 使用转换后的路径
...(data.stickerInputs || []), // 🆕 添加贴纸输入
...audioInputArgs, // 音效文件输入(已转换)
'-/filter_complex', filterScriptPath,
'-map', '[vout]',
'-map', '[aout]',
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '18',
'-c:a', 'aac', // 需要重新编码音频
'-b:a', '192k',
normalizedOutputVideo // 🔧 使用转换后的路径
];
console.log(`[shell:executeFFmpeg] 🔊 使用音效混音模式执行 FFmpeg`);
} else {
// 无音效时:原始逻辑
// 注意:新版 FFmpeg 使用 -/filter_complex 参数从文件读取
args = [
'-y',
'-i', normalizedInputVideo, // 🔧 使用转换后的路径
...(data.stickerInputs || []), // 🆕 添加贴纸输入
'-/filter_complex', filterScriptPath,
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '18',
'-c:a', 'copy',
normalizedOutputVideo // 🔧 使用转换后的路径
];
}
try {
const result = await shellApi.execFFmpegCommand(args, { cwd: ffmpegCwd });
try { await fs.unlink(filterScriptPath); } catch (e) {}
return result;
} catch (error: any) {
const exitCode = error?.message ? parseInt(error.message.match(/code\s+(\d+)/)?.[1] || '0') : 0;
if (exitCode !== 0) {
Log.warn("shell:executeFFmpeg.filterFileMode.failed", {
exitCode,
trying: "filter_complex_script fallback"
});
try {
const fallbackArgs = args.map(a => a === '-/filter_complex' ? '-filter_complex_script' : a);
const result2 = await shellApi.execFFmpegCommand(fallbackArgs, { cwd: ffmpegCwd });
try { await fs.unlink(filterScriptPath); } catch (e) {}
return result2;
} catch (error2: any) {
Log.warn("shell:executeFFmpeg.filter_complex_script.failed", {
error: error2?.message?.substring(0, 200),
trying: "inline filter_complex fallback"
});
try {
const inlineArgs = args
.filter(a => a !== '-/filter_complex' && a !== '-filter_complex_script' && a !== filterScriptPath)
;
const inlineIdx = inlineArgs.indexOf('-y') >= 0 ? 1 : 0;
inlineArgs.splice(inlineIdx, 0, '-filter_complex', finalFilterContent);
const result3 = await shellApi.execFFmpegCommand(inlineArgs, { cwd: ffmpegCwd });
try { await fs.unlink(filterScriptPath); } catch (e) {}
return result3;
} catch (error3) {
try { await fs.unlink(filterScriptPath); } catch (e) {}
throw error3;
}
}
}
try { await fs.unlink(filterScriptPath); } catch (e) {}
throw error;
}
}
}
// 普通命令模式:解析命令字符串为参数数组
const args = parseFFmpegCommand(commandOrJson);
Log.info("shell:executeFFmpeg.normalCommand", {
argsCount: args.length,
command: commandOrJson.substring(0, 200) + '...'
});
return await shellApi.execFFmpegCommand(args);
} catch (error: any) {
Log.error("shell:executeFFmpeg.error", {
error: error?.message || String(error),
command: commandOrJson.substring(0, 200) + '...'
});
throw error;
}
});
console.log("[shell/main.ts] IPC handlers registered!");
/**
* 解析FFmpeg命令字符串为参数数组
*/
function parseFFmpegCommand(command: string): string[] {
const args: string[] = [];
let current = '';
let inQuote = false;
let quoteChar = '';
const cmdStr = command.startsWith('ffmpeg ') ? command.substring(7) : command;
for (let i = 0; i < cmdStr.length; i++) {
const char = cmdStr[i];
if ((char === '"' || char === "'") && !inQuote) {
inQuote = true;
quoteChar = char;
} else if (char === quoteChar && inQuote) {
inQuote = false;
quoteChar = '';
} else if (char === ' ' && !inQuote) {
if (current.length > 0) {
args.push(current);
current = '';
}
} else {
current += char;
}
}
if (current.length > 0) {
args.push(current);
}
return args;
}
export const shell = {
execFFmpegCommand: shellApi.execFFmpegCommand,
extractAudioFromVideo: shellApi.extractAudioFromVideo,
getPythonPath: shellApi.getPythonPath,
showItemInFolder: shellApi.showItemInFolder,
};
export default shell;