diff --git a/src/common/ffmpeg-adapter-factory.ts b/src/common/ffmpeg-adapter-factory.ts new file mode 100644 index 00000000..53e1b9d7 --- /dev/null +++ b/src/common/ffmpeg-adapter-factory.ts @@ -0,0 +1,131 @@ +/** + * FFmpeg Adapter Factory + * 自动检测并选择最佳的 FFmpeg 适配器 + */ + +import { LogWrapper } from './log'; +import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter'; +import { FFmpegExecAdapter } from './ffmpeg-exec-adapter'; +import type { IFFmpegAdapter } from './ffmpeg-adapter-interface'; + +/** + * FFmpeg 适配器工厂 + */ +export class FFmpegAdapterFactory { + private static instance: IFFmpegAdapter | null = null; + private static initPromise: Promise | null = null; + + /** + * 初始化并获取最佳的 FFmpeg 适配器 + * @param logger 日志记录器 + * @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器) + * @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器) + * @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器) + */ + static async getAdapter( + logger: LogWrapper, + ffmpegPath: string = 'ffmpeg', + ffprobePath: string = 'ffprobe', + binaryPath?: string + ): Promise { + // 如果已经初始化,直接返回 + if (this.instance) { + return this.instance; + } + + // 如果正在初始化,等待初始化完成 + if (this.initPromise) { + return this.initPromise; + } + + // 开始初始化 + this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath); + + try { + this.instance = await this.initPromise; + return this.instance; + } finally { + this.initPromise = null; + } + } + + /** + * 初始化适配器 + */ + private static async initialize( + logger: LogWrapper, + ffmpegPath: string, + ffprobePath: string, + binaryPath?: string + ): Promise { + + // 1. 优先尝试使用 Native Addon + if (binaryPath) { + const addonAdapter = new FFmpegAddonAdapter(binaryPath); + + logger.log('[FFmpeg] 检查 Native Addon 可用性...'); + if (await addonAdapter.isAvailable()) { + logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器'); + return addonAdapter; + } + + logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具'); + } else { + logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测'); + } + + // 2. 降级到 execFile 实现 + const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger); + + logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`); + if (await execAdapter.isAvailable()) { + logger.log('[FFmpeg] 使用命令行工具适配器 ✓'); + return execAdapter; + } + + // 3. 都不可用,返回 execAdapter 但会在使用时报错 + logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败'); + return execAdapter; + } + + /** + * 重置适配器(用于测试或重新初始化) + */ + static reset(): void { + this.instance = null; + this.initPromise = null; + } + + /** + * 更新 FFmpeg 路径并重新初始化 + * @param logger 日志记录器 + * @param ffmpegPath FFmpeg 可执行文件路径 + * @param ffprobePath FFprobe 可执行文件路径 + */ + static async updateFFmpegPath( + logger: LogWrapper, + ffmpegPath: string, + ffprobePath: string + ): Promise { + // 如果当前使用的是 Exec 适配器,更新路径 + if (this.instance && this.instance instanceof FFmpegExecAdapter) { + logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`); + this.instance.setFFmpegPath(ffmpegPath); + this.instance.setFFprobePath(ffprobePath); + + // 验证新路径是否可用 + if (await this.instance.isAvailable()) { + logger.log('[FFmpeg] 新路径验证成功 ✓'); + } else { + logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用'); + } + } + } + + /** + * 获取当前适配器(不初始化) + */ + static getCurrentAdapter(): IFFmpegAdapter | null { + return this.instance; + } +} diff --git a/src/common/ffmpeg-adapter-interface.ts b/src/common/ffmpeg-adapter-interface.ts new file mode 100644 index 00000000..4e9a015c --- /dev/null +++ b/src/common/ffmpeg-adapter-interface.ts @@ -0,0 +1,68 @@ +/** + * FFmpeg Adapter Interface + * 定义统一的 FFmpeg 操作接口,支持多种实现方式 + */ + +/** + * 视频信息结果 + */ +export interface VideoInfoResult { + /** 视频宽度(像素) */ + width: number; + /** 视频高度(像素) */ + height: number; + /** 视频时长(秒) */ + duration: number; + /** 容器格式 */ + format: string; + /** 缩略图 Buffer */ + thumbnail?: Buffer; +} + +/** + * FFmpeg 适配器接口 + */ +export interface IFFmpegAdapter { + /** 适配器名称 */ + readonly name: string; + + /** 是否可用 */ + isAvailable(): Promise; + + /** + * 获取视频信息(包含缩略图) + * @param videoPath 视频文件路径 + * @returns 视频信息 + */ + getVideoInfo(videoPath: string): Promise; + + /** + * 获取音视频文件时长 + * @param filePath 文件路径 + * @returns 时长(秒) + */ + getDuration(filePath: string): Promise; + + /** + * 转换音频为 PCM 格式 + * @param filePath 输入文件路径 + * @param pcmPath 输出 PCM 文件路径 + * @returns PCM 数据 Buffer + */ + convertToPCM(filePath: string, pcmPath: string): Promise; + + /** + * 转换音频文件 + * @param inputFile 输入文件路径 + * @param outputFile 输出文件路径 + * @param format 目标格式 ('amr' | 'silk' 等) + */ + convertFile(inputFile: string, outputFile: string, format: string): Promise; + + /** + * 提取视频缩略图 + * @param videoPath 视频文件路径 + * @param thumbnailPath 缩略图输出路径 + */ + extractThumbnail(videoPath: string, thumbnailPath: string): Promise; +} diff --git a/src/common/ffmpeg-addon-adapter.ts b/src/common/ffmpeg-addon-adapter.ts new file mode 100644 index 00000000..acf649d1 --- /dev/null +++ b/src/common/ffmpeg-addon-adapter.ts @@ -0,0 +1,137 @@ +/** + * FFmpeg Native Addon Adapter + * 使用原生 Node.js Addon 实现的 FFmpeg 适配器 + */ + +import { platform, arch } from 'node:os'; +import path from 'node:path'; +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import type { FFmpeg } from './ffmpeg-addon'; +import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface'; +import { dlopen } from 'node:process'; + +/** + * 获取 Native Addon 路径 + * @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath) + */ +function getAddonPath(binaryPath: string): string { + const platformName = platform(); + const archName = arch(); + + let addonFileName: string; + + if (platformName === 'win32' && archName === 'x64') { + addonFileName = 'ffmpegAddon.win.x64.node'; + } else if (platformName === 'linux' && archName === 'x64') { + addonFileName = 'ffmpegAddon.linux.x64.node'; + } else if (platformName === 'linux' && archName === 'arm64') { + addonFileName = 'ffmpegAddon.linux.arm64.node'; + } else if (platformName === 'darwin' && archName === 'arm64') { + addonFileName = 'ffmpegAddon.darwin.arm64.node'; + } else { + throw new Error(`Unsupported platform: ${platformName} ${archName}`); + } + return path.join(binaryPath, "./nodeffmpeg/", addonFileName); +} + +/** + * FFmpeg Native Addon 适配器实现 + */ +export class FFmpegAddonAdapter implements IFFmpegAdapter { + public readonly name = 'FFmpegAddon'; + private addon: FFmpeg | null = null; + private binaryPath: string; + + constructor(binaryPath: string) { + this.binaryPath = binaryPath; + } + + /** + * 检查 Addon 是否可用 + */ + async isAvailable(): Promise { + try { + const addonPath = getAddonPath(this.binaryPath); + if (!existsSync(addonPath)) { + return false; + } + let temp_addon = { exports: {} }; + dlopen(temp_addon, addonPath); + this.addon = temp_addon.exports as FFmpeg; + return this.addon !== null; + } catch (error) { + console.error('[FFmpegAddonAdapter] Failed to load addon:', error); + return false; + } + } + + private ensureAddon(): FFmpeg { + if (!this.addon) { + throw new Error('FFmpeg Addon is not available'); + } + return this.addon; + } + + /** + * 获取视频信息 + */ + async getVideoInfo(videoPath: string): Promise { + const addon = this.ensureAddon(); + const info = await addon.getVideoInfo(videoPath, 'bmp24'); + + return { + width: info.width, + height: info.height, + duration: info.duration, + format: info.format, + thumbnail: info.image, + }; + } + + /** + * 获取时长 + */ + async getDuration(filePath: string): Promise { + const addon = this.ensureAddon(); + return addon.getDuration(filePath); + } + + /** + * 转换为 PCM + */ + async convertToPCM(filePath: string, pcmPath: string): Promise { + const addon = this.ensureAddon(); + const result = await addon.decodeAudioToPCM(filePath); + + // 写入文件 + await writeFile(pcmPath, result.pcm); + + return result.pcm; + } + + /** + * 转换文件 + */ + async convertFile(inputFile: string, outputFile: string, format: string): Promise { + const addon = this.ensureAddon(); + + if (format === 'silk' || format === 'ntsilk') { + // 使用 Addon 的 NTSILK 转换 + await addon.convertToNTSilkTct(inputFile, outputFile); + } else { + throw new Error(`Format '${format}' is not supported by FFmpeg Addon`); + } + } + + /** + * 提取缩略图 + */ + async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { + const addon = this.ensureAddon(); + const info = await addon.getVideoInfo(videoPath, 'bmp24'); + + // 将缩略图写入文件 + await writeFile(thumbnailPath, info.image); + } +} diff --git a/src/common/ffmpeg-exec-adapter.ts b/src/common/ffmpeg-exec-adapter.ts new file mode 100644 index 00000000..8c503701 --- /dev/null +++ b/src/common/ffmpeg-exec-adapter.ts @@ -0,0 +1,244 @@ +/** + * FFmpeg Exec Adapter + * 使用 execFile 调用 FFmpeg 命令行工具的适配器实现 + */ + +import { readFileSync, existsSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { fileTypeFromFile } from 'file-type'; +import { imageSizeFallBack } from '@/image-size'; +import { downloadFFmpegIfNotExists } from './download-ffmpeg'; +import { LogWrapper } from './log'; +import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface'; + +const execFileAsync = promisify(execFile); + +/** + * 确保目录存在 + */ +function ensureDirExists(filePath: string): void { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * FFmpeg 命令行适配器实现 + */ +export class FFmpegExecAdapter implements IFFmpegAdapter { + public readonly name = 'FFmpegExec'; + private downloadAttempted = false; + + constructor( + private ffmpegPath: string = 'ffmpeg', + private ffprobePath: string = 'ffprobe', + private binaryPath?: string, + private logger?: LogWrapper + ) {} + + /** + * 检查 FFmpeg 是否可用,如果不可用则尝试下载 + */ + async isAvailable(): Promise { + // 首先检查当前路径 + try { + await execFileAsync(this.ffmpegPath, ['-version']); + return true; + } catch { + // 如果失败且未尝试下载,尝试下载 + if (!this.downloadAttempted && this.binaryPath && this.logger) { + this.downloadAttempted = true; + + if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) { + return false; + } + + this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...'); + const result = await downloadFFmpegIfNotExists(this.logger); + + if (result.path && result.reset) { + // 更新路径 + if (process.platform === 'win32') { + this.ffmpegPath = join(result.path, 'ffmpeg.exe'); + this.ffprobePath = join(result.path, 'ffprobe.exe'); + this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath); + + // 再次检查 + try { + await execFileAsync(this.ffmpegPath, ['-version']); + return true; + } catch { + return false; + } + } + } + } + return false; + } + } + + /** + * 设置 FFmpeg 路径 + */ + setFFmpegPath(ffmpegPath: string): void { + this.ffmpegPath = ffmpegPath; + } + + /** + * 设置 FFprobe 路径 + */ + setFFprobePath(ffprobePath: string): void { + this.ffprobePath = ffprobePath; + } + + /** + * 获取视频信息 + */ + async getVideoInfo(videoPath: string): Promise { + // 获取文件大小和类型 + const [fileType, duration] = await Promise.all([ + fileTypeFromFile(videoPath).catch(() => null), + this.getDuration(videoPath) + ]); + + // 创建临时缩略图路径 + const thumbnailPath = `${videoPath}.thumbnail.bmp`; + let width = 100; + let height = 100; + let thumbnail: Buffer | undefined; + + try { + await this.extractThumbnail(videoPath, thumbnailPath); + + // 获取图片尺寸 + const dimensions = await imageSizeFallBack(thumbnailPath); + width = dimensions.width ?? 100; + height = dimensions.height ?? 100; + + // 读取缩略图 + if (existsSync(thumbnailPath)) { + thumbnail = readFileSync(thumbnailPath); + } + } catch (error) { + // 使用默认值 + } + + return { + width, + height, + duration, + format: fileType?.ext ?? 'mp4', + thumbnail, + }; + } + + /** + * 获取时长 + */ + async getDuration(filePath: string): Promise { + try { + const { stdout } = await execFileAsync(this.ffprobePath, [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + filePath + ]); + + const duration = parseFloat(stdout.trim()); + return isNaN(duration) ? 60 : duration; + } catch { + return 60; // 默认时长 + } + } + + /** + * 转换为 PCM + */ + async convertToPCM(filePath: string, pcmPath: string): Promise { + try { + ensureDirExists(pcmPath); + + await execFileAsync(this.ffmpegPath, [ + '-y', + '-i', filePath, + '-ar', '24000', + '-ac', '1', + '-f', 's16le', + pcmPath + ]); + + if (!existsSync(pcmPath)) { + throw new Error('转换PCM失败,输出文件不存在'); + } + + return readFileSync(pcmPath); + } catch (error: any) { + throw new Error(`FFmpeg处理转换出错: ${error.message}`); + } + } + + /** + * 转换文件 + */ + async convertFile(inputFile: string, outputFile: string, format: string): Promise { + try { + ensureDirExists(outputFile); + + const params = format === 'amr' + ? [ + '-f', 's16le', + '-ar', '24000', + '-ac', '1', + '-i', inputFile, + '-ar', '8000', + '-b:a', '12.2k', + '-y', + outputFile + ] + : [ + '-f', 's16le', + '-ar', '24000', + '-ac', '1', + '-i', inputFile, + '-y', + outputFile + ]; + + await execFileAsync(this.ffmpegPath, params); + + if (!existsSync(outputFile)) { + throw new Error('转换失败,输出文件不存在'); + } + } catch (error) { + console.error('Error converting file:', error); + throw new Error(`文件转换失败: ${(error as Error).message}`); + } + } + + /** + * 提取缩略图 + */ + async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { + try { + ensureDirExists(thumbnailPath); + + const { stderr } = await execFileAsync(this.ffmpegPath, [ + '-i', videoPath, + '-ss', '00:00:01.000', + '-vframes', '1', + '-y', // 覆盖输出文件 + thumbnailPath + ]); + + if (!existsSync(thumbnailPath)) { + throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`); + } + } catch (error) { + console.error('Error extracting thumbnail:', error); + throw new Error(`提取缩略图失败: ${(error as Error).message}`); + } + } +} diff --git a/src/common/ffmpeg.ts b/src/common/ffmpeg.ts index e6100f1d..e71fb254 100644 --- a/src/common/ffmpeg.ts +++ b/src/common/ffmpeg.ts @@ -1,195 +1,144 @@ -import { readFileSync, statSync, existsSync, mkdirSync } from 'fs'; -import path, { dirname } from 'path'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; +import { statSync, existsSync, writeFileSync } from 'fs'; +import path from 'path'; import type { VideoInfo } from './video'; import { fileTypeFromFile } from 'file-type'; -import { fileURLToPath } from 'node:url'; import { platform } from 'node:os'; import { LogWrapper } from './log'; -import { imageSizeFallBack } from '@/image-size'; -const currentPath = dirname(fileURLToPath(import.meta.url)); -const execFileAsync = promisify(execFile); -const getFFmpegPath = (tool: string): string => { - if (process.platform === 'win32') { +import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory'; +import type { IFFmpegAdapter } from './ffmpeg-adapter-interface'; + +const getFFmpegPath = (tool: string, binaryPath?: string): string => { + if (process.platform === 'win32' && binaryPath) { const exeName = `${tool}.exe`; - const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName)); - return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName; + const localPath = path.join(binaryPath, 'ffmpeg', exeName); + const isLocalExeExists = existsSync(localPath); + return isLocalExeExists ? localPath : exeName; } return tool; }; -export let FFMPEG_CMD = getFFmpegPath('ffmpeg'); -export let FFPROBE_CMD = getFFmpegPath('ffprobe'); + +export let FFMPEG_CMD = 'ffmpeg'; +export let FFPROBE_CMD = 'ffprobe'; export class FFmpegService { - // 确保目标目录存在 - public static setFfmpegPath(ffmpegPath: string,logger:LogWrapper): void { + private static adapter: IFFmpegAdapter | null = null; + private static initialized = false; + + /** + * 初始化 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); + + // 立即初始化适配器(会触发自动下载等逻辑) + this.adapter = await FFmpegAdapterFactory.getAdapter( + logger, + FFMPEG_CMD, + FFPROBE_CMD, + binaryPath + ); + + this.initialized = true; + } + + /** + * 获取 FFmpeg 适配器 + */ + private static async getAdapter(): Promise { + if (!this.adapter) { + throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.'); + } + return this.adapter; + } + + /** + * 设置 FFmpeg 路径并更新适配器 + * @deprecated 建议使用 init() 方法初始化 + */ + public static async setFfmpegPath(ffmpegPath: string, logger: LogWrapper): Promise { if (platform() === 'win32') { FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe'); FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe'); logger.log('[Check] ffmpeg:', FFMPEG_CMD); logger.log('[Check] ffprobe:', FFPROBE_CMD); - } - } - private static ensureDirExists(filePath: string): void { - const dir = dirname(filePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + + // 更新适配器路径 + await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD); } } + /** + * 提取视频缩略图 + */ public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { - try { - this.ensureDirExists(thumbnailPath); - - const { stderr } = await execFileAsync(FFMPEG_CMD, [ - '-i', videoPath, - '-ss', '00:00:01.000', - '-vframes', '1', - '-y', // 覆盖输出文件 - thumbnailPath - ]); - - if (!existsSync(thumbnailPath)) { - throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`); - } - } catch (error) { - console.error('Error extracting thumbnail:', error); - throw new Error(`提取缩略图失败: ${(error as Error).message}`); - } + const adapter = await this.getAdapter(); + await adapter.extractThumbnail(videoPath, thumbnailPath); } + /** + * 转换音频文件 + */ public static async convertFile(inputFile: string, outputFile: string, format: string): Promise { - try { - this.ensureDirExists(outputFile); - - const params = format === 'amr' - ? [ - '-f', 's16le', - '-ar', '24000', - '-ac', '1', - '-i', inputFile, - '-ar', '8000', - '-b:a', '12.2k', - '-y', - outputFile - ] - : [ - '-f', 's16le', - '-ar', '24000', - '-ac', '1', - '-i', inputFile, - '-y', - outputFile - ]; - - await execFileAsync(FFMPEG_CMD, params); - - if (!existsSync(outputFile)) { - throw new Error('转换失败,输出文件不存在'); - } - } catch (error) { - console.error('Error converting file:', error); - throw new Error(`文件转换失败: ${(error as Error).message}`); - } + const adapter = await this.getAdapter(); + await adapter.convertFile(inputFile, outputFile, format); } + /** + * 转换为 PCM 格式 + */ public static async convert(filePath: string, pcmPath: string): Promise { - try { - this.ensureDirExists(pcmPath); - - await execFileAsync(FFMPEG_CMD, [ - '-y', - '-i', filePath, - '-ar', '24000', - '-ac', '1', - '-f', 's16le', - pcmPath - ]); - - if (!existsSync(pcmPath)) { - throw new Error('转换PCM失败,输出文件不存在'); - } - - return readFileSync(pcmPath); - } catch (error: any) { - throw new Error(`FFmpeg处理转换出错: ${error.message}`); - } + const adapter = await this.getAdapter(); + return adapter.convertToPCM(filePath, pcmPath); } + /** + * 获取视频信息 + */ public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { + const adapter = await this.getAdapter(); + try { - // 并行执行获取文件信息和提取缩略图 - const [fileInfo, duration] = await Promise.all([ - this.getFileInfo(videoPath, thumbnailPath), - this.getVideoDuration(videoPath) - ]); + // 获取文件大小 + const fileSize = statSync(videoPath).size; + + // 使用适配器获取视频信息 + const videoInfo = await adapter.getVideoInfo(videoPath); + + // 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径 + if (thumbnailPath && videoInfo.thumbnail) { + writeFileSync(thumbnailPath, videoInfo.thumbnail); + } const result: VideoInfo = { - width: fileInfo.width, - height: fileInfo.height, - time: duration, - format: fileInfo.format, - size: fileInfo.size, + width: videoInfo.width, + height: videoInfo.height, + time: videoInfo.duration, + format: videoInfo.format, + size: fileSize, filePath: videoPath }; + return result; } catch (error) { - throw error; - } - } - - private static async getFileInfo(videoPath: string, thumbnailPath: string): Promise<{ - format: string, - size: number, - width: number, - height: number - }> { - - // 获取文件大小和类型 - const [fileType, fileSize] = await Promise.all([ - fileTypeFromFile(videoPath).catch(() => { - return null; - }), - Promise.resolve(statSync(videoPath).size) - ]); - - - try { - await this.extractThumbnail(videoPath, thumbnailPath); - // 获取图片尺寸 - const dimensions = await imageSizeFallBack(thumbnailPath); + // 降级处理:返回默认值 + const fileType = await fileTypeFromFile(videoPath).catch(() => null); + const fileSize = statSync(videoPath).size; return { - format: fileType?.ext ?? 'mp4', - size: fileSize, - width: dimensions.width ?? 100, - height: dimensions.height ?? 100 - }; - } catch (error) { - return { - format: fileType?.ext ?? 'mp4', - size: fileSize, width: 100, - height: 100 + height: 100, + time: 60, + format: fileType?.ext ?? 'mp4', + size: fileSize, + filePath: videoPath }; } } - - private static async getVideoDuration(videoPath: string): Promise { - try { - // 使用FFprobe获取时长 - const { stdout } = await execFileAsync(FFPROBE_CMD, [ - '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=noprint_wrappers=1:nokey=1', - videoPath - ]); - - const duration = parseFloat(stdout.trim()); - - return isNaN(duration) ? 60 : duration; - } catch (error) { - return 60; // 默认时长 - } - } } \ No newline at end of file diff --git a/src/core/helper/config.ts b/src/core/helper/config.ts index 1dfa057c..0c2540c1 100644 --- a/src/core/helper/config.ts +++ b/src/core/helper/config.ts @@ -4,7 +4,7 @@ import { Type, Static } from '@sinclair/typebox'; import { AnySchema } from 'ajv'; export const NapcatConfigSchema = Type.Object({ - fileLog: Type.Boolean({ default: true }), + fileLog: Type.Boolean({ default: false }), consoleLog: Type.Boolean({ default: true }), fileLogLevel: Type.String({ default: 'debug' }), consoleLogLevel: Type.String({ default: 'info' }), diff --git a/src/framework/napcat.ts b/src/framework/napcat.ts index 820f3587..65e4123e 100644 --- a/src/framework/napcat.ts +++ b/src/framework/napcat.ts @@ -9,7 +9,6 @@ import { NodeIKernelLoginService } from '@/core/services'; import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper'; import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui'; import { NapCatOneBot11Adapter } from '@/onebot'; -import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg'; import { FFmpegService } from '@/common/ffmpeg'; //Framework ES入口文件 @@ -38,15 +37,9 @@ export async function NCoreInitFramework( const logger = new LogWrapper(pathWrapper.logsPath); const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); - if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) { - downloadFFmpegIfNotExists(logger).then(({ path, reset }) => { - if (reset && path) { - FFmpegService.setFfmpegPath(path, logger); - } - }).catch(e => { - logger.logError('[Ffmpeg] Error:', e); - }); - } + + // 初始化 FFmpeg 服务 + await FFmpegService.init(pathWrapper.binaryPath, logger); //直到登录成功后,执行下一步 // const selfInfo = { // uid: 'u_FUSS0_x06S_9Tf4na_WpUg', diff --git a/src/shell/base.ts b/src/shell/base.ts index 708ac2a7..c6019aae 100644 --- a/src/shell/base.ts +++ b/src/shell/base.ts @@ -31,7 +31,6 @@ import { WebUiDataRuntime } from '@/webui/src/helper/Data'; import { napCatVersion } from '@/common/version'; import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener'; import { sleep } from '@/common/helper'; -import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg'; import { FFmpegService } from '@/common/ffmpeg'; import { connectToNamedPipe } from '@/shell/pipe'; // NapCat Shell App ES 入口文件 @@ -313,16 +312,11 @@ export async function NCoreInitShell() { const pathWrapper = new NapCatPathWrapper(); const logger = new LogWrapper(pathWrapper.logsPath); handleUncaughtExceptions(logger); + + // 初始化 FFmpeg 服务 + await FFmpegService.init(pathWrapper.binaryPath, logger); + await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); - if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) { - downloadFFmpegIfNotExists(logger).then(({ path, reset }) => { - if (reset && path) { - FFmpegService.setFfmpegPath(path, logger); - } - }).catch(e => { - logger.logError('[Ffmpeg] Error:', e); - }); - } const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); diff --git a/src/webui/index.ts b/src/webui/index.ts index 7c3388f3..730961f1 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -93,21 +93,18 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp if (config.token === 'napcat' || !config.token) { const randomToken = getRandomToken(8); await WebUiConfig.UpdateWebUIConfig({ token: randomToken }); - logger.log(`[NapCat] [WebUi] 🔐 检测到默认密码,已自动更新为安全密码`); + logger.log(`[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码`); // 存储token到全局变量,等待QQ登录成功后发送 setPendingTokenToSend(randomToken); - logger.log(`[NapCat] [WebUi] 📤 新密码将在QQ登录成功后发送给用户`); + logger.log(`[NapCat] [WebUi] 新密码将在QQ登录成功后发送给用户`); // 重新获取更新后的配置 config = await WebUiConfig.GetWebUIConfig(); - } else { - logger.log(`[NapCat] [WebUi] ✅ 当前使用安全密码`); } // 存储启动时的初始token用于鉴权 setInitialWebUiToken(config.token); - logger.log(`[NapCat] [WebUi] 🔑 已缓存启动时的token用于鉴权,运行时手动修改配置文件密码将不会生效`); // 检查是否禁用WebUI if (config.disableWebUI) { @@ -216,7 +213,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp // ------------启动服务------------ server.listen(port, host, async () => { let searchParams = { token: token }; - logger.log(`[NapCat] [WebUi] 🔑 token=${token}`); + logger.log(`[NapCat] [WebUi] WebUi Token: ${token}`); logger.log( `[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}` ); diff --git a/vite.config.ts b/vite.config.ts index 65e662d2..eaa81d62 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -82,6 +82,7 @@ const ShellBaseConfigPlugin: PluginOption[] = [ // }), cp({ targets: [ + { src: './src/native/ffmpeg', dest: 'dist/nodeffmpeg', flatten: false }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },