Files
天天的鸟蛋蛋 b9655a5ab1 SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37
26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
2026-11-12 06:27:36 +08:00

267 lines
6.9 KiB
TypeScript

/**
* FFmpeg Exec Adapter
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
*/
import { readFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
import { dirname, join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileTypeFromFile } from 'file-type';
import { imageSizeFallBack } from 'snowcat-image-size/src/index';
import { downloadFFmpegIfNotExists } from './download-ffmpeg';
import { LogWrapper } from '@/snowcat-core/helper/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<boolean> {
// 首先检查当前路径
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<VideoInfoResult> {
// 获取文件大小和类型
const [fileType, duration] = await Promise.all([
fileTypeFromFile(videoPath).catch(() => null),
this.getDuration(videoPath).catch(() => 60),
]);
// 创建临时缩略图路径
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<number> {
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());
if (isNaN(duration)) {
throw new Error(`ffprobe 返回了无效的时长值: "${stdout.trim()}"`);
}
return duration;
}
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/**
* 转换为 PCM
*/
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
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 { result: true, sampleRate: 24000 };
} catch (error: any) {
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
}
}
/**
* 转换文件
*/
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
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<void> {
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}`);
}
}
async convertToNTSilkTct (_inputFile: string, _outputFile: string): Promise<void> {
throw new Error('convertToNTSilkTct is not implemented in FFmpegExecAdapter');
}
}