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"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
||||||
|
"@ffmpeg.wasm/main": "^0.13.1",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"piscina": "^4.7.0",
|
"piscina": "^4.7.0",
|
||||||
|
@@ -2,14 +2,12 @@ import Piscina from 'piscina';
|
|||||||
import fsPromise from 'fs/promises';
|
import fsPromise from 'fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
import { EncodeArgs } from "@/common/audio-worker";
|
import { EncodeArgs } from "@/common/audio-worker";
|
||||||
|
import { ffmpegService } from "@/common/ffmpeg";
|
||||||
|
|
||||||
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
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() {
|
async function getWorkerPath() {
|
||||||
return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
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;
|
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(
|
async function handleWavFile(
|
||||||
file: Buffer,
|
file: Buffer,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
@@ -58,7 +32,7 @@ async function handleWavFile(
|
|||||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||||
const { fmt } = getWavFileInfo(file);
|
const { fmt } = getWavFileInfo(file);
|
||||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
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 };
|
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 pcmPath = `${pttPath}.pcm`;
|
||||||
const { input, sampleRate } = isWav(file)
|
const { input, sampleRate } = isWav(file)
|
||||||
? (await handleWavFile(file, filePath, pcmPath, logger))
|
? (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 });
|
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
|
||||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
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 { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile';
|
||||||
import { ActionName } from '@/onebot/action/router';
|
import { ActionName } from '@/onebot/action/router';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { decode } from 'silk-wasm';
|
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'];
|
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);
|
await fs.access(outputFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.decodeFile(inputFile, pcmFile);
|
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' });
|
const base64Data = await fs.readFile(outputFile, { encoding: 'base64' });
|
||||||
res.file = outputFile;
|
res.file = outputFile;
|
||||||
@@ -54,23 +53,4 @@ export default class GetRecord extends GetFileBase {
|
|||||||
throw error; // 重新抛出错误以便调用者可以处理
|
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 { WebUiDataRuntime } from '@/webui/src/helper/Data';
|
||||||
import { napCatVersion } from '@/common/version';
|
import { napCatVersion } from '@/common/version';
|
||||||
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
|
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
|
||||||
|
import { ffmpegService } from '@/common/ffmpeg';
|
||||||
// NapCat Shell App ES 入口文件
|
// NapCat Shell App ES 入口文件
|
||||||
async function handleUncaughtExceptions(logger: LogWrapper) {
|
async function handleUncaughtExceptions(logger: LogWrapper) {
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
@@ -262,6 +263,11 @@ async function initializeSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function NCoreInitShell() {
|
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...');
|
console.log('NapCat Shell App Loading...');
|
||||||
const pathWrapper = new NapCatPathWrapper();
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
|
@@ -4,7 +4,7 @@ import { resolve } from 'path';
|
|||||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||||
import { builtinModules } from 'module';
|
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();
|
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||||
|
|
||||||
let startScripts: string[] | undefined = undefined;
|
let startScripts: string[] | undefined = undefined;
|
||||||
|
Reference in New Issue
Block a user