feat: ffmpeg

This commit is contained in:
手瓜一十雪
2025-01-26 16:26:21 +08:00
parent 6e261f30c2
commit d278e9d8bc
7 changed files with 119 additions and 53 deletions

View File

@@ -55,6 +55,8 @@
"winston": "^3.17.0"
},
"dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2",
"@ffmpeg.wasm/main": "^0.13.1",
"express": "^5.0.0",
"fluent-ffmpeg": "^2.1.2",
"piscina": "^4.7.0",

View File

@@ -2,14 +2,12 @@ import Piscina from 'piscina';
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
import { spawn } from 'node:child_process';
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
import { LogWrapper } from '@/common/log';
import { EncodeArgs } from "@/common/audio-worker";
import { ffmpegService } from "@/common/ffmpeg";
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
const EXIT_CODES = [0, 255];
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
async function getWorkerPath() {
return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
@@ -26,30 +24,6 @@ async function guessDuration(pttPath: string, logger: LogWrapper) {
return duration;
}
async function convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const cp = spawn(FFMPEG_PATH, ['-y', '-i', filePath, '-ar', '24000', '-ac', '1', '-f', 's16le', pcmPath]);
cp.on('error', (err: Error) => {
logger.log('FFmpeg处理转换出错: ', err.message);
reject(err);
});
cp.on('exit', async (code, signal) => {
if (code == null || EXIT_CODES.includes(code)) {
try {
const data = await fsPromise.readFile(pcmPath);
await fsPromise.unlink(pcmPath);
resolve(data);
} catch (err) {
reject(err);
}
} else {
logger.log(`FFmpeg exit: code=${code ?? 'unknown'} sig=${signal ?? 'unknown'}`);
reject(new Error('FFmpeg处理转换失败'));
}
});
});
}
async function handleWavFile(
file: Buffer,
filePath: string,
@@ -58,7 +32,7 @@ async function handleWavFile(
): Promise<{ input: Buffer; sampleRate: number }> {
const { fmt } = getWavFileInfo(file);
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
return { input: await convert(filePath, pcmPath, logger), sampleRate: 24000 };
return { input: await ffmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
}
return { input: file, sampleRate: fmt.sampleRate };
}
@@ -72,7 +46,7 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
const pcmPath = `${pttPath}.pcm`;
const { input, sampleRate } = isWav(file)
? (await handleWavFile(file, filePath, pcmPath, logger))
: { input: await convert(filePath, pcmPath, logger), sampleRate: 24000 };
: { input: await ffmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);

104
src/common/ffmpeg.ts Normal file
View File

@@ -0,0 +1,104 @@
import { FFmpeg } from '@ffmpeg.wasm/main';
import { randomUUID } from 'crypto';
import { readFileSync, writeFileSync } from 'fs';
import { LogWrapper } from './log';
class FFmpegService {
private ffmpegRef: FFmpeg;
constructor(ffmpegRef: FFmpeg) {
this.ffmpegRef = ffmpegRef;
}
public async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const videoFileName = `${randomUUID()}.mp4`;
const outputFileName = `${randomUUID()}.jpg`;
try {
this.ffmpegRef.fs.writeFile(videoFileName, readFileSync(videoPath));
let code = await this.ffmpegRef.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
if (code! === 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
const thumbnail = this.ffmpegRef.fs.readFile(outputFileName);
writeFileSync(thumbnailPath, thumbnail);
} catch (error) {
console.error('Error extracting thumbnail:', error);
throw error;
} finally {
try {
this.ffmpegRef.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
this.ffmpegRef.fs.unlink(videoFileName);
} catch (unlinkError) {
console.error('Error unlinking video file:', unlinkError);
}
}
}
public async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
const inputFileName = `${randomUUID()}.pcm`;
const outputFileName = `${randomUUID()}.${format}`;
try {
this.ffmpegRef.fs.writeFile(inputFileName, readFileSync(inputFile));
const params = format === 'amr'
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
let code = await this.ffmpegRef.run(...params);
if (code! === 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
const outputData = this.ffmpegRef.fs.readFile(outputFileName);
writeFileSync(outputFile, outputData);
} catch (error) {
console.error('Error converting file:', error);
throw error;
} finally {
try {
this.ffmpegRef.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
this.ffmpegRef.fs.unlink(inputFileName);
} catch (unlinkError) {
console.error('Error unlinking input file:', unlinkError);
}
}
}
public async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
const inputFileName = `${randomUUID()}.input`;
const outputFileName = `${randomUUID()}.pcm`;
try {
this.ffmpegRef.fs.writeFile(inputFileName, readFileSync(filePath));
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
let code = await this.ffmpegRef.run(...params);
if (code! === 0) {
throw new Error('FFmpeg process exited with code ' + code);
}
const outputData = this.ffmpegRef.fs.readFile(outputFileName);
writeFileSync(pcmPath, outputData);
return Buffer.from(outputData);
} catch (error: any) {
logger.log('FFmpeg处理转换出错: ', error.message);
throw error;
} finally {
try {
this.ffmpegRef.fs.unlink(outputFileName);
} catch (unlinkError) {
logger.log('Error unlinking output file:', unlinkError);
}
try {
this.ffmpegRef.fs.unlink(inputFileName);
} catch (unlinkError) {
logger.log('Error unlinking input file:', unlinkError);
}
}
}
}
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
export const ffmpegService = new FFmpegService(ffmpegInstance);

View File

@@ -1,9 +1,8 @@
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile';
import { ActionName } from '@/onebot/action/router';
import { spawn } from 'node:child_process';
import { promises as fs } from 'fs';
import { decode } from 'silk-wasm';
const FFMPEG_PATH = process.env.FFMPEG_PATH || 'ffmpeg';
import { ffmpegService } from '@/common/ffmpeg';
const out_format = ['mp3' , 'amr' , 'wma' , 'm4a' , 'spx' , 'ogg' , 'wav' , 'flac'];
@@ -30,7 +29,7 @@ export default class GetRecord extends GetFileBase {
await fs.access(outputFile);
} catch (error) {
await this.decodeFile(inputFile, pcmFile);
await this.convertFile(pcmFile, outputFile, payload.out_format);
await ffmpegService.convertFile(pcmFile, outputFile, payload.out_format);
}
const base64Data = await fs.readFile(outputFile, { encoding: 'base64' });
res.file = outputFile;
@@ -54,23 +53,4 @@ export default class GetRecord extends GetFileBase {
throw error; // 重新抛出错误以便调用者可以处理
}
}
private convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
return new Promise((resolve, reject) => {
const params = format === 'amr' ? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFile, '-ar', '8000', '-b:a', '12.2k', outputFile] : ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFile, outputFile];
const ffmpeg = spawn(FFMPEG_PATH, params);
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`ffmpeg process exited with code ${code}`));
}
});
ffmpeg.on('error', (error: Error) => {
reject(error);
});
});
}
}

View File

@@ -29,6 +29,7 @@ import { InitWebUi } from '@/webui';
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { napCatVersion } from '@/common/version';
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
import { ffmpegService } from '@/common/ffmpeg';
// NapCat Shell App ES 入口文件
async function handleUncaughtExceptions(logger: LogWrapper) {
process.on('uncaughtException', (err) => {
@@ -262,6 +263,11 @@ async function initializeSession(
}
export async function NCoreInitShell() {
try {
await ffmpegService.extractThumbnail("F:\\BVideo\\123.mp4","F:\\BVideo\\123.jpg");
} catch (error) {
console.log(error);
}
console.log('NapCat Shell App Loading...');
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);

View File

@@ -1,3 +1,3 @@
import { NCoreInitShell } from "./base";
NCoreInitShell();
NCoreInitShell();

View File

@@ -4,7 +4,7 @@ import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
//依赖排除
const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'fluent-ffmpeg', 'piscina'];
const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'fluent-ffmpeg', 'piscina', '@ffmpeg.wasm/core-mt', "@ffmpeg.wasm/main"];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
let startScripts: string[] | undefined = undefined;