diff --git a/src/common/ffmpeg-worker.ts b/src/common/ffmpeg-worker.ts new file mode 100644 index 00000000..5803c125 --- /dev/null +++ b/src/common/ffmpeg-worker.ts @@ -0,0 +1,151 @@ +import { FFmpeg } from '@ffmpeg.wasm/main'; +import { randomUUID } from 'crypto'; +import { readFileSync, statSync, writeFileSync } from 'fs'; +import type { LogWrapper } from './log'; +import type { VideoInfo } from './video'; +import { fileTypeFromFile } from 'file-type'; +import imageSize from 'image-size'; + class FFmpegService { + public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { + const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); + const videoFileName = `${randomUUID()}.mp4`; + const outputFileName = `${randomUUID()}.jpg`; + try { + ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath)); + let code = await ffmpegInstance.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 = ffmpegInstance.fs.readFile(outputFileName); + writeFileSync(thumbnailPath, thumbnail); + } catch (error) { + console.error('Error extracting thumbnail:', error); + throw error; + } finally { + try { + ffmpegInstance.fs.unlink(outputFileName); + } catch (unlinkError) { + console.error('Error unlinking output file:', unlinkError); + } + try { + ffmpegInstance.fs.unlink(videoFileName); + } catch (unlinkError) { + console.error('Error unlinking video file:', unlinkError); + } + } + } + + public static async convertFile(inputFile: string, outputFile: string, format: string): Promise { + const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); + const inputFileName = `${randomUUID()}.pcm`; + const outputFileName = `${randomUUID()}.${format}`; + try { + ffmpegInstance.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 ffmpegInstance.run(...params); + if (code !== 0) { + throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code); + } + const outputData = ffmpegInstance.fs.readFile(outputFileName); + writeFileSync(outputFile, outputData); + } catch (error) { + console.error('Error converting file:', error); + throw error; + } finally { + try { + ffmpegInstance.fs.unlink(outputFileName); + } catch (unlinkError) { + console.error('Error unlinking output file:', unlinkError); + } + try { + ffmpegInstance.fs.unlink(inputFileName); + } catch (unlinkError) { + console.error('Error unlinking input file:', unlinkError); + } + } + } + + public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise { + const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); + const inputFileName = `${randomUUID()}.input`; + const outputFileName = `${randomUUID()}.pcm`; + try { + ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath)); + const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName]; + let code = await ffmpegInstance.run(...params); + if (code !== 0) { + throw new Error('FFmpeg process exited with code ' + code); + } + const outputData = ffmpegInstance.fs.readFile(outputFileName); + writeFileSync(pcmPath, outputData); + return Buffer.from(outputData); + } catch (error: any) { + throw new Error('FFmpeg处理转换出错: ' + error.message); + } finally { + try { + ffmpegInstance.fs.unlink(outputFileName); + } catch (unlinkError) { + logger.log('Error unlinking output file:', unlinkError); + } + try { + ffmpegInstance.fs.unlink(inputFileName); + } catch (unlinkError) { + logger.log('Error unlinking input file:', unlinkError); + } + } + } + + public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { + await FFmpegService.extractThumbnail(videoPath, thumbnailPath); + let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4'; + const inputFileName = `${randomUUID()}.${fileType}`; + const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); + ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath)); + ffmpegInstance.setLogging(true); + let duration = 60; + ffmpegInstance.setLogger((level, ...msg) => { + const message = msg.join(' '); + const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/); + if (durationMatch) { + const hours = parseInt(durationMatch[1], 10); + const minutes = parseInt(durationMatch[2], 10); + const seconds = parseFloat(durationMatch[3]); + duration = hours * 3600 + minutes * 60 + seconds; + } + }); + await ffmpegInstance.run('-i', inputFileName); + let image = imageSize(thumbnailPath); + ffmpegInstance.fs.unlink(inputFileName); + const fileSize = statSync(videoPath).size; + return { + width: image.width ?? 100, + height: image.height ?? 100, + time: duration, + format: fileType, + size: fileSize, + filePath: videoPath + } + } +} +type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo'; + +interface FFmpegTask { + method: FFmpegMethod; + args: any[]; +} +export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise { + switch (method) { + case 'extractThumbnail': + return await FFmpegService.extractThumbnail(...args as [string, string]); + case 'convertFile': + return await FFmpegService.convertFile(...args as [string, string, string]); + case 'convert': + return await FFmpegService.convert(...args as [string, string, LogWrapper]); + case 'getVideoInfo': + return await FFmpegService.getVideoInfo(...args as [string, string]); + default: + throw new Error(`Unknown method: ${method}`); + } +} \ No newline at end of file diff --git a/src/common/ffmpeg.ts b/src/common/ffmpeg.ts index efc37e20..e026a2b9 100644 --- a/src/common/ffmpeg.ts +++ b/src/common/ffmpeg.ts @@ -1,131 +1,50 @@ -import { FFmpeg } from '@ffmpeg.wasm/main'; -import { randomUUID } from 'crypto'; -import { readFileSync, statSync, writeFileSync } from 'fs'; -import { LogWrapper } from './log'; -import { VideoInfo } from './video'; -import { fileTypeFromFile } from 'file-type'; -import imageSize from 'image-size'; +import Piscina from "piscina"; +import { VideoInfo } from "./video"; +import type { LogWrapper } from "./log"; + +type EncodeArgs = { + method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo'; + args: any[]; +}; + +type EncodeResult = any; + +async function getWorkerPath() { + return new URL(/* @vite-ignore */ './ffmpeg-worker.mjs', import.meta.url).href; +} + export class FFmpegService { public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - const videoFileName = `${randomUUID()}.mp4`; - const outputFileName = `${randomUUID()}.jpg`; - try { - ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath)); - let code = await ffmpegInstance.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 = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(thumbnailPath, thumbnail); - } catch (error) { - console.error('Error extracting thumbnail:', error); - throw error; - } finally { - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(videoFileName); - } catch (unlinkError) { - console.error('Error unlinking video file:', unlinkError); - } - } + const piscina = new Piscina({ + filename: await getWorkerPath(), + }); + await piscina.run({ method: 'extractThumbnail', args: [videoPath, thumbnailPath] }); + await piscina.destroy(); } public static async convertFile(inputFile: string, outputFile: string, format: string): Promise { - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - const inputFileName = `${randomUUID()}.pcm`; - const outputFileName = `${randomUUID()}.${format}`; - try { - ffmpegInstance.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 ffmpegInstance.run(...params); - if (code !== 0) { - throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code); - } - const outputData = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(outputFile, outputData); - } catch (error) { - console.error('Error converting file:', error); - throw error; - } finally { - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(inputFileName); - } catch (unlinkError) { - console.error('Error unlinking input file:', unlinkError); - } - } + const piscina = new Piscina({ + filename: await getWorkerPath(), + }); + await piscina.run({ method: 'convertFile', args: [inputFile, outputFile, format] }); + await piscina.destroy(); } public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise { - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - const inputFileName = `${randomUUID()}.input`; - const outputFileName = `${randomUUID()}.pcm`; - try { - ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath)); - const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName]; - let code = await ffmpegInstance.run(...params); - if (code !== 0) { - throw new Error('FFmpeg process exited with code ' + code); - } - const outputData = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(pcmPath, outputData); - return Buffer.from(outputData); - } catch (error: any) { - throw new Error('FFmpeg处理转换出错: ' + error.message); - } finally { - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (unlinkError) { - logger.log('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(inputFileName); - } catch (unlinkError) { - logger.log('Error unlinking input file:', unlinkError); - } - } - } - - public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { - await FFmpegService.extractThumbnail(videoPath, thumbnailPath); - let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4'; - const inputFileName = `${randomUUID()}.${fileType}`; - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath)); - ffmpegInstance.setLogging(true); - let duration = 60; - ffmpegInstance.setLogger((level, ...msg) => { - const message = msg.join(' '); - const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/); - if (durationMatch) { - const hours = parseInt(durationMatch[1], 10); - const minutes = parseInt(durationMatch[2], 10); - const seconds = parseFloat(durationMatch[3]); - duration = hours * 3600 + minutes * 60 + seconds; - } + const piscina = new Piscina({ + filename: await getWorkerPath(), }); - await ffmpegInstance.run('-i', inputFileName); - let image = imageSize(thumbnailPath); - ffmpegInstance.fs.unlink(inputFileName); - const fileSize = statSync(videoPath).size; - return { - width: image.width ?? 100, - height: image.height ?? 100, - time: duration, - format: fileType, - size: fileSize, - filePath: videoPath - } + const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath, logger] }); + await piscina.destroy(); + return result; + } + + public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { + const piscina = new Piscina({ + filename: await getWorkerPath(), + }); + const result = await piscina.run({ method: 'getVideoInfo', args: [videoPath, thumbnailPath] }); + await piscina.destroy(); + return result; } } \ No newline at end of file diff --git a/src/onebot/network/http-client.ts b/src/onebot/network/http-client.ts index 4dbc6924..a346d0d8 100644 --- a/src/onebot/network/http-client.ts +++ b/src/onebot/network/http-client.ts @@ -7,6 +7,7 @@ import { RequestUtil } from '@/common/request'; import { HttpClientConfig } from '@/onebot/config/config'; import { ActionMap } from '@/onebot/action'; import { IOB11NetworkAdapter } from "@/onebot/network/adapter"; +import json5 from 'json5'; export class OB11HttpClientAdapter extends IOB11NetworkAdapter { constructor( @@ -34,7 +35,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter } const data = await RequestUtil.HttpGetText(this.config.url, 'POST', msgStr, headers); - const resJson: QuickAction = data ? JSON.parse(data) : {}; + const resJson: QuickAction = data ? json5.parse(data) : {}; await this.obContext.apis.QuickActionApi.handleQuickOperation(event as QuickActionEvent, resJson); } diff --git a/src/onebot/network/websocket-client.ts b/src/onebot/network/websocket-client.ts index fec0fffc..3f6cc1d3 100644 --- a/src/onebot/network/websocket-client.ts +++ b/src/onebot/network/websocket-client.ts @@ -9,6 +9,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li import { WebsocketClientConfig } from '@/onebot/config/config'; import { NapCatOneBot11Adapter } from "@/onebot"; import { IOB11NetworkAdapter } from "@/onebot/network/adapter"; +import json5 from 'json5'; export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter { private connection: WebSocket | null = null; @@ -129,7 +130,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter { wsServer: WebSocketServer; @@ -162,7 +163,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter entry: { napcat: 'src/universal/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', + 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`, @@ -119,6 +120,7 @@ const ShellBaseConfig = () => entry: { napcat: 'src/shell/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', + 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`, @@ -147,6 +149,7 @@ const FrameworkBaseConfig = () => entry: { napcat: 'src/framework/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', + 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`,