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

727 lines
26 KiB
TypeScript
Raw Permalink 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.
import { spawn } from 'node:child_process';
import path from 'node:path';
import * as fs from 'fs';
import { shell } from 'electron';
import { AppEnv, waitAppEnvReady } from '../env';
import { Log } from '../log';
import { getFFmpegExecutablePath, getFFprobeExecutablePath, getPythonExecutablePath, resourceExists, isDevelopment } from '../../lib/resource-path';
import { normalizePath, normalizeOutputPath } from '../../lib/path-util';
import { getMacOSPythonPath } from '../../lib/python-setup';
const isWin = process.platform === 'win32';
const isMac = process.platform === 'darwin';
// Python路径缓存,避免重复验证
let cachedPythonPath: string | null = null;
const ensureWritableDirectory = (candidates: Array<string | null | undefined>): string => {
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'string') {
continue;
}
try {
fs.mkdirSync(candidate, { recursive: true });
return candidate;
} catch (error) {
Log.warn('ensureWritableDirectory: failed to create directory', {
path: candidate,
error: (error as Error).message,
});
}
}
const fallback = process.env.TEMP || process.env.TMP || process.cwd();
fs.mkdirSync(fallback, { recursive: true });
return fallback;
};
export const getRuntimeTempRoot = (): string => {
const appRoot = AppEnv.appRoot || process.env.APP_ROOT || process.cwd();
return ensureWritableDirectory([
AppEnv.tempRoot,
AppEnv.dataRoot ? path.join(AppEnv.dataRoot, 'temp') : null,
AppEnv.userData ? path.join(AppEnv.userData, 'temp') : null,
appRoot ? path.join(appRoot, 'data', 'temp') : null,
path.join(process.cwd(), 'data', 'temp'),
]);
};
export const getRuntimeFontconfigCacheDir = (): string => {
return ensureWritableDirectory([
path.join(getRuntimeTempRoot(), 'fontconfig-cache'),
AppEnv.userData ? path.join(AppEnv.userData, 'fontconfig-cache') : null,
path.join(process.cwd(), 'data', 'temp', 'fontconfig-cache'),
]);
};
export const buildRuntimeProcessEnv = (extraEnv?: Record<string, string | undefined>): NodeJS.ProcessEnv => {
const tempRoot = getRuntimeTempRoot();
const fontCacheDir = getRuntimeFontconfigCacheDir();
return {
...process.env,
TEMP: tempRoot,
TMP: tempRoot,
TMPDIR: tempRoot,
FONTCONFIG_FILE: '',
FONTCONFIG_PATH: '',
FONTCONFIG_CACHE: fontCacheDir,
XDG_CACHE_HOME: tempRoot,
...(extraEnv || {}),
};
};
/**
* 动态检测 Python 可执行文件路径
* 优先级:系统 PATH 中的 python > py > python3
* @returns Promise<string> Python 可执行文件路径
*/
export const getPythonPath = async (): Promise<string> => {
// 如果已经缓存了有效路径,直接返回
if (cachedPythonPath) {
Log.debug('getPythonPath: 使用缓存路径', { path: cachedPythonPath });
return cachedPythonPath;
}
Log.info('getPythonPath: 开始检测 Python 路径');
if (!isWin) {
const standalonePython = getMacOSPythonPath();
if (standalonePython) {
Log.info('getPythonPath: ✅ 使用独立 Python', { path: standalonePython });
cachedPythonPath = standalonePython;
return standalonePython;
}
throw new Error('macOS Python 环境尚未初始化,请等待应用完成初始化');
}
await waitAppEnvReady();
const bundledPythonPath = getPythonExecutablePath();
if (resourceExists(bundledPythonPath)) {
try {
Log.info('getPythonPath: trying bundled Python', { path: bundledPythonPath });
const isAvailable = await verifyPythonExecutable(bundledPythonPath);
if (isAvailable) {
Log.info('getPythonPath: using bundled Python', { path: bundledPythonPath });
cachedPythonPath = bundledPythonPath;
return bundledPythonPath;
}
} catch (error) {
Log.warn('getPythonPath: bundled Python unavailable', {
path: bundledPythonPath,
error: (error as Error).message,
});
}
}
const pythonCommands = [];
pythonCommands.push('py', 'python', 'python3');
// 逐个测试 Python 命令是否可用
for (const command of pythonCommands) {
try {
Log.info('getPythonPath: 尝试命令', { command });
const isAvailable = await verifyPythonExecutable(command);
if (isAvailable) {
Log.info('getPythonPath: ✅ 找到可用的 Python', { command });
cachedPythonPath = command;
return command;
}
} catch (error) {
Log.warn('getPythonPath: 命令不可用', { command, error: (error as Error).message });
}
}
// 所有尝试都失败
const errorMsg = `❌ 无法找到可用的 Python\n` +
`尝试的命令: ${pythonCommands.join(', ')}\n` +
`请安装 Python 或确保其在系统 PATH 中。`;
Log.error('getPythonPath', errorMsg);
throw new Error(errorMsg);
};
/**
* 验证 Python 命令是否可执行
* @param pythonCommand Python 命令(python, py, python3 等)
* @returns Promise<boolean> 是否可执行
*/
const verifyPythonExecutable = async (pythonCommand: string): Promise<boolean> => {
return new Promise((resolve) => {
try {
Log.info('verifyPythonExecutable: 开始验证', { pythonCommand });
const child = spawn(pythonCommand, ['--version'], {
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
});
let hasOutput = false;
let isResolved = false;
const cleanup = () => {
if (!isResolved) {
isResolved = true;
try {
child.kill('SIGTERM');
} catch (e) {}
}
};
const timeout = setTimeout(() => {
Log.warn('verifyPythonExecutable: 超时', { pythonCommand });
cleanup();
resolve(false);
}, 3000); // 3秒超时
child.stdout?.on('data', () => {
hasOutput = true;
Log.debug('verifyPythonExecutable: 获得输出', { pythonCommand });
});
child.stderr?.on('data', () => {
hasOutput = true;
Log.debug('verifyPythonExecutable: 获得错误输出', { pythonCommand });
});
child.on('close', (code) => {
clearTimeout(timeout);
if (!isResolved) {
isResolved = true;
const success = code === 0 && hasOutput;
Log.info('verifyPythonExecutable: 完成', { pythonCommand, code, hasOutput, success });
resolve(success);
}
});
child.on('error', (error) => {
clearTimeout(timeout);
if (!isResolved) {
isResolved = true;
Log.warn('verifyPythonExecutable: 错误', { pythonCommand, error: error.message });
cleanup();
resolve(false);
}
});
} catch (error: any) {
Log.error('verifyPythonExecutable: 异常', { pythonCommand, error: error.message });
resolve(false);
}
});
};
// FFmpeg路径缓存,避免重复验证
let cachedFFmpegPath: string | null = null;
const invalidFFmpegPaths = new Set<string>();
const invalidFFprobePaths = new Set<string>();
const ILLEGAL_INSTRUCTION_EXIT_CODES = new Set([3221225501]);
const FFMPEG_RUNTIME_VERIFY_ARGS = [
'-hide_banner',
'-loglevel', 'error',
'-f', 'lavfi',
'-i', 'color=size=16x16:rate=1:color=black',
'-frames:v', '1',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
'-f', 'null',
'-'
];
const getBundledExecutableCandidates = (primaryPath: string): string[] => {
const binaryName = path.basename(primaryPath);
const candidates = [
primaryPath,
AppEnv.resourceBundleRoot ? path.join(AppEnv.resourceBundleRoot, 'ffmpeg', binaryName) : null,
process.resourcesPath ? path.join(process.resourcesPath, 'resources-bundles', 'ffmpeg', binaryName) : null,
process.resourcesPath ? path.join(path.dirname(process.resourcesPath), 'resources-bundles', 'ffmpeg', binaryName) : null,
];
if (isWin) {
const parentDir = path.dirname(primaryPath);
const backupDir = `${parentDir}-backup`;
const backupPath = path.join(backupDir, path.basename(primaryPath));
candidates.push(backupPath);
}
return Array.from(new Set(candidates.filter(Boolean)));
};
const markExecutableAsInvalid = (
invalidSet: Set<string>,
executablePath: string,
tag: 'FFmpeg' | 'FFprobe',
reason: string
) => {
if (!executablePath || invalidSet.has(executablePath)) {
return;
}
invalidSet.add(executablePath);
Log.warn(`${tag}: invalid executable path`, {
executablePath,
reason,
});
};
const verifyRuntimeExecutable = async (
executablePath: string,
args: string[],
tag: 'FFmpeg' | 'FFprobe',
options: {
requireOutput?: boolean;
} = {}
): Promise<boolean> => {
return new Promise((resolve) => {
try {
const child = spawn(executablePath, args, {
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
});
let hasOutput = false;
let settled = false;
const requireOutput = options.requireOutput ?? true;
const finish = (result: boolean) => {
if (!settled) {
settled = true;
resolve(result);
}
};
const timer = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch (e) {}
Log.warn(`verify${tag}Executable: timeout`, { executablePath, args });
finish(false);
}, 4000);
child.stdout?.on('data', () => {
hasOutput = true;
});
child.stderr?.on('data', () => {
hasOutput = true;
});
child.on('close', (code) => {
clearTimeout(timer);
const success = code === 0 && (!requireOutput || hasOutput);
Log.info(`verify${tag}Executable: complete`, {
executablePath,
args,
code,
hasOutput,
requireOutput,
success,
});
finish(success);
});
child.on('error', (error) => {
clearTimeout(timer);
Log.warn(`verify${tag}Executable: error`, { executablePath, args, error: error.message });
finish(false);
});
} catch (error: any) {
Log.error(`verify${tag}Executable: exception`, { executablePath, args, error: error.message });
resolve(false);
}
});
};
const verifyBundledFFmpegCandidate = async (ffmpegPath: string): Promise<boolean> => {
return verifyRuntimeExecutable(ffmpegPath, FFMPEG_RUNTIME_VERIFY_ARGS, 'FFmpeg', {
requireOutput: false,
});
};
const verifyBundledFFprobeCandidate = async (ffprobePath: string): Promise<boolean> => {
return verifyRuntimeExecutable(ffprobePath, ['-version'], 'FFprobe');
};
const findFallbackFFmpegPath = async (excludePaths: string[]): Promise<string | null> => {
const excluded = new Set(excludePaths);
const bundledCandidates = getBundledExecutableCandidates(getFFmpegExecutablePath());
for (const candidate of bundledCandidates) {
if (!candidate || excluded.has(candidate) || invalidFFmpegPaths.has(candidate) || !resourceExists(candidate)) {
continue;
}
if (await verifyBundledFFmpegCandidate(candidate)) {
return candidate;
}
markExecutableAsInvalid(invalidFFmpegPaths, candidate, 'FFmpeg', 'fallback verification failed');
}
if (isDevelopment()) {
const systemFFmpegPath = isWin ? 'ffmpeg.exe' : 'ffmpeg';
if (!excluded.has(systemFFmpegPath) && !invalidFFmpegPaths.has(systemFFmpegPath)) {
if (await verifyFFmpegExecutable(systemFFmpegPath)) {
return systemFFmpegPath;
}
markExecutableAsInvalid(invalidFFmpegPaths, systemFFmpegPath, 'FFmpeg', 'dev system fallback verification failed');
}
}
return null;
};
// FFprobe路径缓存,避免重复验证
let cachedFFprobePath: string | null = null;
/**
* Execute FFmpeg command with the given parameters
* @param params Array of FFmpeg command parameters
* @param options Optional execution options
* @returns Promise that resolves when the command completes
*/
export const execFFmpegCommand = async (
params: string[],
options?: {
cwd?: string;
outputEncoding?: string;
onProgress?: (progress: any) => void;
env?: Record<string, string>;
}
): Promise<string> => {
console.log('[execFFmpegCommand] ========== START ==========');
console.log('[execFFmpegCommand] Called with params count:', params.length);
const ffmpegPath = await getFFmpegPath();
let cwd: string;
if (options?.cwd && typeof options.cwd === 'string') {
cwd = options.cwd;
} else {
await waitAppEnvReady();
cwd = AppEnv.appRoot || process.cwd();
}
const outputEncoding = options?.outputEncoding || 'utf8';
if (!Array.isArray(params)) {
const error = new Error('params must be an array');
Log.error('execFFmpegCommand: Invalid params type', { params, type: typeof params });
throw error;
}
for (let i = 0; i < params.length; i++) {
if (typeof params[i] !== 'string') {
const error = new Error(`params[${i}] must be a string, got ${typeof params[i]}: ${params[i]}`);
Log.error('execFFmpegCommand: Invalid param type', { index: i, value: params[i], type: typeof params[i] });
throw error;
}
}
const pathParamFlags = ['-i'];
const normalizedParams = params.map((param, index) => {
if (index > 0 && pathParamFlags.includes(params[index - 1])) {
const normalized = normalizePath(param);
if (normalized !== param) {
Log.info('execFFmpegCommand: normalized input path', {
original: param.substring(0, 100),
normalized: normalized.substring(0, 100)
});
}
return normalized;
}
if (index === params.length - 1) {
const looksLikeOutputPath = param.includes('\\') || param.includes('/') || (
param.includes('.') && param.length > 3 && !param.startsWith('-')
);
if (looksLikeOutputPath) {
const outputDir = path.dirname(param);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const normalized = normalizeOutputPath(param);
if (normalized !== param) {
Log.info('execFFmpegCommand: normalized output path', {
original: param.substring(0, 100),
normalized: normalized.substring(0, 100)
});
}
return normalized;
}
}
return param;
});
if (typeof cwd !== 'string') {
const error = new Error(`cwd must be a string, got ${typeof cwd}`);
Log.error('execFFmpegCommand: Invalid cwd type', { cwd, type: typeof cwd });
throw error;
}
if (typeof ffmpegPath !== 'string') {
const error = new Error(`ffmpegPath must be a string, got ${typeof ffmpegPath}`);
Log.error('execFFmpegCommand: Invalid ffmpegPath type', { ffmpegPath, type: typeof ffmpegPath });
throw error;
}
Log.info('execFFmpegCommand', { ffmpegPath, params: normalizedParams, cwd, outputEncoding });
const env = buildRuntimeProcessEnv(options?.env);
const runProcess = (executablePath: string) => {
return new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
try {
const child = spawn(executablePath, normalizedParams, {
cwd,
shell: false,
env,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
const output = data.toString(outputEncoding as BufferEncoding);
stdout += output;
if (options?.onProgress) {
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
if (timeMatch) {
const [, hours, minutes, seconds] = timeMatch;
const totalSeconds = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds);
options.onProgress({ currentTime: totalSeconds });
}
}
});
child.stderr?.on('data', (data) => {
const output = data.toString(outputEncoding as BufferEncoding);
stderr += output;
console.log('[FFmpeg stderr]', output.substring(0, 500));
});
child.on('close', (code) => {
Log.info('FFmpeg process completed', {
executablePath,
code,
stderrLength: stderr.length
});
console.log('[FFmpeg] exited with code:', code);
if (stderr.length > 0) {
console.log('[FFmpeg] stderr:\n', stderr.substring(0, 2000));
}
resolve({ stdout, stderr, code });
});
child.on('error', (error) => {
Log.error('FFmpeg process error', { executablePath, error });
reject(error);
});
} catch (error) {
Log.error('execFFmpegCommand: spawn error', { executablePath, error });
reject(error);
}
});
};
let activeFFmpegPath = ffmpegPath;
let result = await runProcess(activeFFmpegPath);
if (result.code !== 0 && result.code !== null && ILLEGAL_INSTRUCTION_EXIT_CODES.has(result.code)) {
markExecutableAsInvalid(invalidFFmpegPaths, activeFFmpegPath, 'FFmpeg', `runtime exit code ${result.code}`);
if (cachedFFmpegPath === activeFFmpegPath) {
cachedFFmpegPath = null;
}
const fallbackPath = await findFallbackFFmpegPath([activeFFmpegPath]);
if (fallbackPath) {
Log.warn('execFFmpegCommand: retrying with fallback bundled FFmpeg', {
failedPath: activeFFmpegPath,
fallbackPath,
code: result.code
});
cachedFFmpegPath = fallbackPath;
activeFFmpegPath = fallbackPath;
result = await runProcess(activeFFmpegPath);
}
}
if (result.code === 0) {
return result.stdout;
}
Log.error(`FFmpeg process exited with code ${result.code}`, result.stderr);
throw new Error(`FFmpeg process exited with code ${result.code}: ${result.stderr}`);
};
/**
* Extract audio from video file using FFmpeg
* @param inputPath Path to the input video file
* @param outputPath Path to save the extracted audio
* @param options Audio extraction options
* @returns Promise that resolves when audio extraction completes
*/
export const extractAudioFromVideo = async (
inputPath: string,
outputPath: string,
options?: {
audioCodec?: string;
sampleRate?: number;
channels?: number;
bitrate?: string;
onProgress?: (progress: any) => void;
}
): Promise<string> => {
const {
audioCodec = 'mp3',
sampleRate = 16000,
channels = 1,
bitrate = '128k',
onProgress
} = options || {};
// 🔧 路径已经在 execFFmpegCommand 中自动转换,这里直接传递即可
const params = [
'-i', inputPath,
'-vn', // No video
'-acodec', audioCodec,
'-ar', sampleRate.toString(),
'-ac', channels.toString(),
'-ab', bitrate,
'-y', // Overwrite output
outputPath
];
return execFFmpegCommand(params, { onProgress });
};
/**
* 验证FFmpeg是否可执行
* @param ffmpegPath FFmpeg路径
* @returns Promise<boolean> 是否可执行
*/
const verifyFFmpegExecutable = async (ffmpegPath: string): Promise<boolean> => {
return verifyRuntimeExecutable(ffmpegPath, ['-version'], 'FFmpeg');
};
/**
* Get the path to the FFmpeg executable
* ⚠️ 优先使用程序自带的 FFmpeg,如果无法执行则回退到系统 FFmpeg
* @returns Promise that resolves to the FFmpeg path
*/
export const getFFmpegPath = async (): Promise<string> => {
if (cachedFFmpegPath && !invalidFFmpegPaths.has(cachedFFmpegPath)) {
Log.debug('getFFmpegPath: using cached bundled path', { path: cachedFFmpegPath });
return cachedFFmpegPath;
}
await waitAppEnvReady();
cachedFFmpegPath = null;
const bundledCandidates = getBundledExecutableCandidates(getFFmpegExecutablePath());
Log.info('getFFmpegPath: checking bundled candidates', { bundledCandidates });
for (const candidate of bundledCandidates) {
if (!candidate || invalidFFmpegPaths.has(candidate) || !resourceExists(candidate)) {
continue;
}
Log.info('getFFmpegPath: verifying bundled candidate', { candidate });
if (await verifyBundledFFmpegCandidate(candidate)) {
cachedFFmpegPath = candidate;
Log.info('getFFmpegPath: using bundled candidate', { candidate });
return candidate;
}
markExecutableAsInvalid(invalidFFmpegPaths, candidate, 'FFmpeg', 'startup verification failed');
}
if (isDevelopment()) {
const systemFFmpegPath = isWin ? 'ffmpeg.exe' : 'ffmpeg';
Log.warn('getFFmpegPath: bundled FFmpeg unavailable in dev, trying system FFmpeg', {
systemFFmpegPath,
});
if (!invalidFFmpegPaths.has(systemFFmpegPath) && await verifyFFmpegExecutable(systemFFmpegPath)) {
cachedFFmpegPath = systemFFmpegPath;
Log.info('getFFmpegPath: using system FFmpeg in dev mode', { systemFFmpegPath });
return systemFFmpegPath;
}
markExecutableAsInvalid(invalidFFmpegPaths, systemFFmpegPath, 'FFmpeg', 'dev system fallback verification failed');
}
const errorMsg = `无法找到可用的 FFmpeg: ${bundledCandidates.join(', ')}`;
Log.error('getFFmpegPath', errorMsg);
throw new Error(errorMsg);
};
/**
* Get FFprobe path with fallback support
* 1. Try bundled ffprobe
* 2. Fallback to system ffprobe
*/
export const getFFprobePath = async (): Promise<string> => {
if (cachedFFprobePath && !invalidFFprobePaths.has(cachedFFprobePath)) {
Log.debug('getFFprobePath: using cached bundled path', { path: cachedFFprobePath });
return cachedFFprobePath;
}
await waitAppEnvReady();
cachedFFprobePath = null;
const bundledCandidates = getBundledExecutableCandidates(getFFprobeExecutablePath());
Log.info('getFFprobePath: checking bundled candidates', { bundledCandidates });
for (const candidate of bundledCandidates) {
if (!candidate || invalidFFprobePaths.has(candidate) || !resourceExists(candidate)) {
continue;
}
Log.info('getFFprobePath: verifying bundled candidate', { candidate });
if (await verifyBundledFFprobeCandidate(candidate)) {
cachedFFprobePath = candidate;
Log.info('getFFprobePath: using bundled candidate', { candidate });
return candidate;
}
markExecutableAsInvalid(invalidFFprobePaths, candidate, 'FFprobe', 'startup verification failed');
}
if (isDevelopment()) {
const systemFFprobePath = isWin ? 'ffprobe.exe' : 'ffprobe';
Log.warn('getFFprobePath: bundled FFprobe unavailable in dev, trying system FFprobe', {
systemFFprobePath,
});
if (!invalidFFprobePaths.has(systemFFprobePath) && await verifyRuntimeExecutable(systemFFprobePath, ['-version'], 'FFprobe')) {
cachedFFprobePath = systemFFprobePath;
Log.info('getFFprobePath: using system FFprobe in dev mode', { systemFFprobePath });
return systemFFprobePath;
}
markExecutableAsInvalid(invalidFFprobePaths, systemFFprobePath, 'FFprobe', 'dev system fallback verification failed');
}
const errorMsg = `无法找到可用的 FFprobe: ${bundledCandidates.join(', ')}`;
Log.error('getFFprobePath', errorMsg);
throw new Error(errorMsg);
};
/**
* Show item in folder using the system's file manager
* @param filePath Path to the file to show
* @returns boolean indicating success
*/
export const showItemInFolder = async (filePath: string): Promise<boolean> => {
try {
Log.info('showItemInFolder', { filePath });
shell.showItemInFolder(filePath);
return true;
} catch (error) {
Log.error('showItemInFolder error', error);
return false;
}
};
export default {
execFFmpegCommand,
extractAudioFromVideo,
getFFmpegPath,
getPythonPath,
showItemInFolder,
};