import { statSync, existsSync, writeFileSync } from 'fs'; import path from 'path'; import type { VideoInfo } from './video'; import { LogWrapper } from '@/napcat-core/helper/log'; import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory'; import type { IFFmpegAdapter } from './ffmpeg-adapter-interface'; import { FFmpegExecAdapter } from './ffmpeg-exec-adapter'; import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter'; export const getFFmpegPath = (tool: 'ffmpeg' | 'ffprobe', binaryPath?: string): string => { const configuredPath = process.env[tool === 'ffmpeg' ? 'FFMPEG_PATH' : 'FFPROBE_PATH']; if (configuredPath) return configuredPath; const executable = tool + (process.platform === 'win32' ? '.exe' : ''); if (binaryPath) { const localPath = path.join(binaryPath, 'ffmpeg', executable); if (existsSync(localPath)) return localPath; } if (process.platform === 'darwin') { const directories = (process.env['PATH'] ?? '').split(':').filter(Boolean); directories.push(process.arch === 'arm64' ? '/opt/homebrew/bin' : '/usr/local/bin'); for (const directory of directories) { const candidate = path.join(directory, executable); if (existsSync(candidate)) return candidate; } } return executable; }; export let FFMPEG_CMD = 'ffmpeg'; export let FFPROBE_CMD = 'ffprobe'; export class FFmpegService { private static adapter: IFFmpegAdapter | null = null; private static initialized = false; private static initializationError: unknown; /** * 初始化 FFmpeg 服务 * @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath) * @param logger 日志记录器 */ public static async init (binaryPath: string, logger: LogWrapper): Promise { if (this.initialized) { return; } // 检查本地 ffmpeg 路径 FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath); FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath); try { this.adapter = await FFmpegAdapterFactory.getAdapter( logger, FFMPEG_CMD, FFPROBE_CMD, binaryPath ); } catch (error) { this.initializationError = error; logger.logError('[FFmpeg] 媒体功能初始化失败:', error); } this.initialized = true; } public static getAdapterName (): string { if (!this.adapter) { throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.'); } return this.adapter.name; } /** * 获取 FFmpeg 适配器 */ private static async getAdapter (): Promise { if (!this.adapter) { throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.'); } return this.adapter; } public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise { const adapter = await this.getAdapter(); await adapter.convertToNTSilkTct(inputFile, outputFile); } /** * 设置 FFmpeg 路径并更新适配器 * @deprecated 建议使用 init() 方法初始化 */ public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise { const suffix = process.platform === 'win32' ? '.exe' : ''; const ffmpegCommand = path.join(ffmpegPath, 'ffmpeg' + suffix); const ffprobeCommand = path.join(ffmpegPath, 'ffprobe' + suffix); this.adapter = await FFmpegAdapterFactory.updateFFmpegPath(logger, ffmpegCommand, ffprobeCommand); FFMPEG_CMD = ffmpegCommand; FFPROBE_CMD = ffprobeCommand; this.initializationError = undefined; this.initialized = true; } /** * 提取视频缩略图 */ public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise { const adapter = await this.getAdapter(); await adapter.extractThumbnail(videoPath, thumbnailPath); } /** * 直接发送到 QQ kernel 的旧视频元素使用 PNG 缩略图。 * Packet/forward 路径继续使用当前适配器生成的 JPEG。 */ public static async extractLegacyVideoThumbnail (videoPath: string, thumbnailPath: string): Promise { const currentAdapter = await this.getAdapter(); if (currentAdapter instanceof FFmpegAddonAdapter) { await currentAdapter.extractThumbnail(videoPath, thumbnailPath, 'png'); return; } const adapter = new FFmpegExecAdapter(FFMPEG_CMD, FFPROBE_CMD); await adapter.extractThumbnail(videoPath, thumbnailPath); } /** * 转换音频文件 */ public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise { const adapter = await this.getAdapter(); await adapter.convertFile(inputFile, outputFile, format); } /** * 获取音频时长 */ public static async getDuration (filePath: string): Promise { const adapter = await this.getAdapter(); return adapter.getDuration(filePath); } /** * 判断是否为 Silk 格式 */ public static async isSilk (filePath: string): Promise { const adapter = await this.getAdapter(); return adapter.isSilk(filePath); } /** * 转换为 PCM 格式 */ public static async convert (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> { const adapter = await this.getAdapter(); return adapter.convertToPCM(filePath, pcmPath); } /** * 获取视频信息 */ public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise { const adapter = await this.getAdapter(); const fileSize = statSync(videoPath).size; const videoInfo = await adapter.getVideoInfo(videoPath); if (thumbnailPath && videoInfo.thumbnail) { writeFileSync(thumbnailPath, videoInfo.thumbnail); } return { width: videoInfo.width, height: videoInfo.height, time: videoInfo.duration, format: videoInfo.format, size: fileSize, filePath: videoPath, }; } }