mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: ffmpeg
This commit is contained in:
@@ -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",
|
||||
|
@@ -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
104
src/common/ffmpeg.ts
Normal 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);
|
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -1,3 +1,3 @@
|
||||
import { NCoreInitShell } from "./base";
|
||||
|
||||
NCoreInitShell();
|
||||
NCoreInitShell();
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user