From 4190831081da525edaf478004b7942f97a973975 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: Thu, 17 Apr 2025 13:26:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=89=AC=E4=BA=86ffmpeg.wasm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 - src/common/ffmpeg-worker.ts | 308 ------------------------------------ src/common/ffmpeg.ts | 186 +++++++++++++++++++--- vite.config.ts | 6 +- 4 files changed, 164 insertions(+), 338 deletions(-) delete mode 100644 src/common/ffmpeg-worker.ts diff --git a/package.json b/package.json index dff66559..90a49876 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "@eslint/compat": "^1.2.2", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.14.0", - "@ffmpeg.wasm/main": "^0.13.1", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@log4js-node/log4js-api": "^1.0.2", "@napneko/nap-proto-core": "^0.0.4", @@ -63,7 +62,6 @@ "zod": "^3.24.2" }, "dependencies": { - "@ffmpeg.wasm/core-mt": "^0.13.2", "express": "^5.0.0", "silk-wasm": "^3.6.1", "ws": "^8.18.0" diff --git a/src/common/ffmpeg-worker.ts b/src/common/ffmpeg-worker.ts deleted file mode 100644 index 095776d1..00000000 --- a/src/common/ffmpeg-worker.ts +++ /dev/null @@ -1,308 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { FFmpeg } from '@ffmpeg.wasm/main'; -import { randomUUID } from 'crypto'; -import { readFileSync, statSync, writeFileSync } from 'fs'; -import type { VideoInfo } from './video'; -import { fileTypeFromFile } from 'file-type'; -import imageSize from 'image-size'; -import { parentPort } from 'worker_threads'; -export function recvTask(cb: (taskData: T) => Promise) { - parentPort?.on('message', async (taskData: T) => { - try { - let ret = await cb(taskData); - parentPort?.postMessage(ret); - } catch (error: unknown) { - parentPort?.postMessage({ error: (error as Error).message }); - } - }); -} -export function sendLog(_log: string) { - //parentPort?.postMessage({ log }); -} -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)); - const 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]; - const 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): 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]; - const 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) { - console.error('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(inputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - } - } - public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { - const startTime = Date.now(); - sendLog(`开始获取视频信息: ${videoPath}`); - - // 创建一个超时包装函数 - const withTimeout = (promise: Promise, timeoutMs: number, taskName: string): Promise => { - return Promise.race([ - promise, - new Promise((_, reject) => { - setTimeout(() => reject(new Error(`任务超时: ${taskName} (${timeoutMs}ms)`)), timeoutMs); - }) - ]); - }; - - // 并行执行多个任务 - const [fileInfo, durationInfo] = await Promise.all([ - // 任务1: 获取文件信息和提取缩略图 - (async () => { - sendLog('开始任务1: 获取文件信息和提取缩略图'); - - // 获取文件信息 (并行) - const fileInfoStartTime = Date.now(); - const [fileType, fileSize] = await Promise.all([ - withTimeout(fileTypeFromFile(videoPath), 10000, '获取文件类型') - .then(result => { - sendLog(`获取文件类型完成,耗时: ${Date.now() - fileInfoStartTime}ms`); - return result; - }), - (async () => { - const result = statSync(videoPath).size; - sendLog(`获取文件大小完成,耗时: ${Date.now() - fileInfoStartTime}ms`); - return result; - })() - ]); - - // 直接实现缩略图提取 (不调用extractThumbnail方法) - const thumbStartTime = Date.now(); - sendLog('开始提取缩略图'); - - const ffmpegInstance = await withTimeout( - FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }), - 15000, - '创建FFmpeg实例(缩略图)' - ); - - const videoFileName = `${randomUUID()}.mp4`; - const outputFileName = `${randomUUID()}.jpg`; - - try { - // 写入视频文件到FFmpeg - const writeFileStartTime = Date.now(); - ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath)); - sendLog(`写入视频文件到FFmpeg完成,耗时: ${Date.now() - writeFileStartTime}ms`); - - // 提取缩略图 - const extractStartTime = Date.now(); - const code = await withTimeout( - ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName), - 30000, - '提取缩略图' - ); - sendLog(`FFmpeg提取缩略图命令执行完成,耗时: ${Date.now() - extractStartTime}ms`); - - if (code !== 0) { - throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code); - } - - // 读取并保存缩略图 - const saveStartTime = Date.now(); - const thumbnail = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(thumbnailPath, thumbnail); - sendLog(`读取并保存缩略图完成,耗时: ${Date.now() - saveStartTime}ms`); - - // 获取缩略图尺寸 - const imageSizeStartTime = Date.now(); - const image = imageSize(thumbnailPath); - sendLog(`获取缩略图尺寸完成,耗时: ${Date.now() - imageSizeStartTime}ms`); - - sendLog(`提取缩略图完成,总耗时: ${Date.now() - thumbStartTime}ms`); - - return { - format: fileType?.ext ?? 'mp4', - size: fileSize, - width: image.width ?? 100, - height: image.height ?? 100 - }; - } finally { - // 清理资源 - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (error) { - sendLog(`清理输出文件失败: ${(error as Error).message}`); - } - - try { - ffmpegInstance.fs.unlink(videoFileName); - } catch (error) { - sendLog(`清理视频文件失败: ${(error as Error).message}`); - } - } - })(), - - // 任务2: 获取视频时长 - (async () => { - const task2StartTime = Date.now(); - sendLog('开始任务2: 获取视频时长'); - - // 创建FFmpeg实例 - const ffmpegCreateStartTime = Date.now(); - const ffmpegInstance = await withTimeout( - FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }), - 15000, - '创建FFmpeg实例(时长)' - ); - sendLog(`创建FFmpeg实例完成,耗时: ${Date.now() - ffmpegCreateStartTime}ms`); - - const inputFileName = `${randomUUID()}.mp4`; - - try { - // 写入文件 - const writeStartTime = Date.now(); - ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath)); - sendLog(`写入文件到FFmpeg完成,耗时: ${Date.now() - writeStartTime}ms`); - - 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] ?? '0', 10); - const minutes = parseInt(durationMatch[2] ?? '0', 10); - const seconds = parseFloat(durationMatch[3] ?? '0'); - duration = hours * 3600 + minutes * 60 + seconds; - } - }); - - // 执行FFmpeg - const runStartTime = Date.now(); - await withTimeout( - ffmpegInstance.run('-i', inputFileName), - 20000, - '获取视频时长' - ); - sendLog(`执行FFmpeg命令完成,耗时: ${Date.now() - runStartTime}ms`); - - sendLog(`任务2(获取视频时长)完成,总耗时: ${Date.now() - task2StartTime}ms`); - return { time: duration }; - } finally { - try { - ffmpegInstance.fs.unlink(inputFileName); - } catch (error) { - sendLog(`清理输入文件失败: ${(error as Error).message}`); - } - } - })() - ]); - - // 合并结果并返回 - const totalDuration = Date.now() - startTime; - sendLog(`获取视频信息完成,总耗时: ${totalDuration}ms`); - - return { - width: fileInfo.width, - height: fileInfo.height, - time: durationInfo.time, - format: fileInfo.format, - size: fileInfo.size, - 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]); - case 'getVideoInfo': - return await FFmpegService.getVideoInfo(...args as [string, string]); - default: - throw new Error(`Unknown method: ${method}`); - } -} -recvTask(async ({ method, args }: FFmpegTask) => { - return await handleFFmpegTask({ method, args }); -}); \ No newline at end of file diff --git a/src/common/ffmpeg.ts b/src/common/ffmpeg.ts index 22497d6e..31ae5740 100644 --- a/src/common/ffmpeg.ts +++ b/src/common/ffmpeg.ts @@ -1,36 +1,176 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { VideoInfo } from './video'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { runTask } from './worker'; - -type EncodeArgs = { - method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo'; - args: any[]; -}; - -type EncodeResult = any; - -function getWorkerPath() { - return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs'); -} +import { readFileSync, statSync, existsSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import type { VideoInfo } from './video'; +import { fileTypeFromFile } from 'file-type'; +import imageSize from 'image-size'; +const execFileAsync = promisify(execFile); +const FFMPEG_CMD = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'; +const FFPROBE_CMD = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; export class FFmpegService { + // 确保目标目录存在 + private static ensureDirExists(filePath: string): void { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { - await runTask(getWorkerPath(), { method: 'extractThumbnail', args: [videoPath, thumbnailPath] }); + try { + this.ensureDirExists(thumbnailPath); + + const { stderr } = await execFileAsync(FFMPEG_CMD, [ + '-i', videoPath, + '-ss', '00:00:01.000', + '-vframes', '1', + '-y', // 覆盖输出文件 + thumbnailPath + ]); + + if (!existsSync(thumbnailPath)) { + throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`); + } + } catch (error) { + console.error('Error extracting thumbnail:', error); + throw new Error(`提取缩略图失败: ${(error as Error).message}`); + } } public static async convertFile(inputFile: string, outputFile: string, format: string): Promise { - await runTask(getWorkerPath(), { method: 'convertFile', args: [inputFile, outputFile, format] }); + try { + this.ensureDirExists(outputFile); + + const params = format === 'amr' + ? [ + '-f', 's16le', + '-ar', '24000', + '-ac', '1', + '-i', inputFile, + '-ar', '8000', + '-b:a', '12.2k', + '-y', + outputFile + ] + : [ + '-f', 's16le', + '-ar', '24000', + '-ac', '1', + '-i', inputFile, + '-y', + outputFile + ]; + + await execFileAsync(FFMPEG_CMD, params); + + if (!existsSync(outputFile)) { + throw new Error('转换失败,输出文件不存在'); + } + } catch (error) { + console.error('Error converting file:', error); + throw new Error(`文件转换失败: ${(error as Error).message}`); + } } public static async convert(filePath: string, pcmPath: string): Promise { - const result = await runTask(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] }); - return result; + try { + this.ensureDirExists(pcmPath); + + await execFileAsync(FFMPEG_CMD, [ + '-y', + '-i', filePath, + '-ar', '24000', + '-ac', '1', + '-f', 's16le', + pcmPath + ]); + + if (!existsSync(pcmPath)) { + throw new Error('转换PCM失败,输出文件不存在'); + } + + return readFileSync(pcmPath); + } catch (error: any) { + throw new Error(`FFmpeg处理转换出错: ${error.message}`); + } } public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { - const result = await runTask(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] }); - return result; + try { + // 并行执行获取文件信息和提取缩略图 + const [fileInfo, duration] = await Promise.all([ + this.getFileInfo(videoPath, thumbnailPath), + this.getVideoDuration(videoPath) + ]); + + const result: VideoInfo = { + width: fileInfo.width, + height: fileInfo.height, + time: duration, + format: fileInfo.format, + size: fileInfo.size, + filePath: videoPath + }; + return result; + } catch (error) { + throw error; + } } -} + + private static async getFileInfo(videoPath: string, thumbnailPath: string): Promise<{ + format: string, + size: number, + width: number, + height: number + }> { + + // 获取文件大小和类型 + const [fileType, fileSize] = await Promise.all([ + fileTypeFromFile(videoPath).catch(() => { + return null; + }), + Promise.resolve(statSync(videoPath).size) + ]); + + + try { + await this.extractThumbnail(videoPath, thumbnailPath); + // 获取图片尺寸 + const dimensions = imageSize(thumbnailPath); + + return { + format: fileType?.ext ?? 'mp4', + size: fileSize, + width: dimensions.width ?? 100, + height: dimensions.height ?? 100 + }; + } catch (error) { + return { + format: fileType?.ext ?? 'mp4', + size: fileSize, + width: 100, + height: 100 + }; + } + } + + private static async getVideoDuration(videoPath: string): Promise { + try { + // 使用FFprobe获取时长 + const { stdout } = await execFileAsync(FFPROBE_CMD, [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + videoPath + ]); + + const duration = parseFloat(stdout.trim()); + + return isNaN(duration) ? 60 : duration; + } catch (error) { + return 60; // 默认时长 + } + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 9d9429b6..d2565857 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,8 +7,7 @@ import { builtinModules } from 'module'; const external = [ 'silk-wasm', 'ws', - 'express', - '@ffmpeg.wasm/core-mt' + 'express' ]; const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); @@ -97,7 +96,6 @@ const UniversalBaseConfig = () => entry: { napcat: 'src/universal/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', - 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], @@ -127,7 +125,6 @@ const ShellBaseConfig = () => entry: { napcat: 'src/shell/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', - 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], @@ -157,7 +154,6 @@ const FrameworkBaseConfig = () => entry: { napcat: 'src/framework/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', - 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'],