From d278e9d8bc2ca34a6bbf2101bd1dbf23e70060e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 26 Jan 2025 16:26:21 +0800 Subject: [PATCH] feat: ffmpeg --- package.json | 2 + src/common/audio.ts | 32 +-------- src/common/ffmpeg.ts | 104 ++++++++++++++++++++++++++++ src/onebot/action/file/GetRecord.ts | 24 +------ src/shell/base.ts | 6 ++ src/shell/napcat.ts | 2 +- vite.config.ts | 2 +- 7 files changed, 119 insertions(+), 53 deletions(-) create mode 100644 src/common/ffmpeg.ts diff --git a/package.json b/package.json index 0e279604..621813cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/audio.ts b/src/common/audio.ts index ade00e69..9358173e 100644 --- a/src/common/audio.ts +++ b/src/common/audio.ts @@ -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 { - return new Promise((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); diff --git a/src/common/ffmpeg.ts b/src/common/ffmpeg.ts new file mode 100644 index 00000000..fadbf58b --- /dev/null +++ b/src/common/ffmpeg.ts @@ -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 { + 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 { + 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 { + 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); \ No newline at end of file diff --git a/src/onebot/action/file/GetRecord.ts b/src/onebot/action/file/GetRecord.ts index 644ba995..65bced45 100644 --- a/src/onebot/action/file/GetRecord.ts +++ b/src/onebot/action/file/GetRecord.ts @@ -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 { - 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); - }); - }); - } } diff --git a/src/shell/base.ts b/src/shell/base.ts index 7d1a15e8..72d98d17 100644 --- a/src/shell/base.ts +++ b/src/shell/base.ts @@ -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); diff --git a/src/shell/napcat.ts b/src/shell/napcat.ts index ae05d794..f2a05426 100644 --- a/src/shell/napcat.ts +++ b/src/shell/napcat.ts @@ -1,3 +1,3 @@ import { NCoreInitShell } from "./base"; -NCoreInitShell(); +NCoreInitShell(); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index fe592985..6a0c5871 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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;