NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* FFmpeg Exec Adapter
|
||||
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
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';
|
||||
|
||||
constructor (
|
||||
private ffmpegPath: string = 'ffmpeg',
|
||||
private ffprobePath: string = 'ffprobe'
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 检查 FFmpeg 和 FFprobe 是否可用
|
||||
*/
|
||||
async isAvailable (): Promise<boolean> {
|
||||
// 首先检查当前路径
|
||||
try {
|
||||
await Promise.all([
|
||||
execFileAsync(this.ffmpegPath, ['-version'], { timeout: 10000, windowsHide: true }),
|
||||
execFileAsync(this.ffprobePath, ['-version'], { timeout: 10000, windowsHide: true }),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
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 [probe, frame] = await Promise.all([
|
||||
execFileAsync(this.ffprobePath, [
|
||||
'-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height:format=duration,format_name',
|
||||
'-of', 'json', videoPath,
|
||||
], { timeout: 30000, windowsHide: true }),
|
||||
execFileAsync(this.ffmpegPath, [
|
||||
'-v', 'error', '-i', videoPath, '-frames:v', '1',
|
||||
'-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1',
|
||||
], { encoding: 'buffer', maxBuffer: 16 * 1024 * 1024, timeout: 30000, windowsHide: true }),
|
||||
]);
|
||||
const metadata = JSON.parse(probe.stdout);
|
||||
const stream = metadata.streams[0];
|
||||
const duration = Number(metadata.format.duration);
|
||||
if (!stream || !Number.isFinite(duration) || duration < 0 || frame.stdout.length === 0) {
|
||||
throw new Error('FFmpeg 无法读取视频信息或首帧: ' + videoPath);
|
||||
}
|
||||
return {
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
duration,
|
||||
format: metadata.format.format_name,
|
||||
thumbnail: frame.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
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,
|
||||
], { timeout: 30000, windowsHide: true });
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
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,
|
||||
], { timeout: 120000, windowsHide: true });
|
||||
|
||||
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'
|
||||
? [
|
||||
'-i', inputFile,
|
||||
'-vn', '-ac', '1',
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile,
|
||||
]
|
||||
: [
|
||||
'-i', inputFile,
|
||||
'-vn', '-ac', '1',
|
||||
...(format === 'spx' ? ['-ar', '32000'] : []),
|
||||
'-y',
|
||||
outputFile,
|
||||
];
|
||||
|
||||
await execFileAsync(this.ffmpegPath, params, { timeout: 120000, windowsHide: true });
|
||||
|
||||
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,
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath,
|
||||
], { timeout: 30000, windowsHide: true });
|
||||
|
||||
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> {
|
||||
ensureDirExists(outputFile);
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-v', 'error', '-y', '-i', inputFile, '-vn', '-ac', '1', '-ar', '24000',
|
||||
'-c:a', 'ntsilk_s16le', '-f', 'ntsilk_s16le', outputFile,
|
||||
], { timeout: 120000, windowsHide: true });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user