Skip to content

Commit 8bc790d

Browse files
committed
refactor(media-process): native ffmpeg spawn, remove optional sharp
1 parent 8185efb commit 8bc790d

5 files changed

Lines changed: 64 additions & 95 deletions

File tree

packages/media-process/package.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,10 @@
2727
"@ffmpeg-installer/ffmpeg": "^1.1.0",
2828
"@ffprobe-installer/ffprobe": "^2.1.2",
2929
"file-type": "^21.1.1",
30-
"fluent-ffmpeg": "^2.1.3",
3130
"jimp": "^1.6.0",
3231
"node-webpmux": "^3.2.1"
3332
},
34-
"optionalDependencies": {
35-
"sharp": "0.33.0"
36-
},
3733
"devDependencies": {
38-
"@types/fluent-ffmpeg": "^2.1.28",
3934
"tsup": "^8.5.1",
4035
"typescript": "^5.9.3"
4136
}

packages/media-process/src/ffmpeg/core.ts

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import ffmpeg from 'fluent-ffmpeg';
21
import fs from 'fs/promises';
32

43
import { tmpdir } from 'os';
54
import path from 'path';
5+
import { spawn } from 'child_process';
66

77
export const FFMPEG_CONSTANTS = {
88
OPUS: {
@@ -44,15 +44,18 @@ export interface FFmpegConfig {
4444
onError: (err: Error) => Promise<void>;
4545
}
4646

47+
let ffmpegPath = 'ffmpeg';
48+
let ffprobePath = 'ffprobe';
49+
4750
export const initializeFFmpeg = async (disable: boolean = false) => {
4851
if (disable) return;
4952

5053
try {
5154
const ffmpegInstaller = (await import('@ffmpeg-installer/ffmpeg')).default;
5255
const ffprobeInstaller = (await import('@ffprobe-installer/ffprobe')).default;
5356

54-
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
55-
ffmpeg.setFfprobePath(ffprobeInstaller.path);
57+
ffmpegPath = ffmpegInstaller.path;
58+
ffprobePath = ffprobeInstaller.path;
5659
} catch {}
5760
};
5861

@@ -152,45 +155,58 @@ export class MimeValidator {
152155
export class FFmpegProcessor {
153156
static async process(config: FFmpegConfig): Promise<void> {
154157
return new Promise((resolve, reject) => {
155-
const processor = ffmpeg(config.input).output(config.output);
156-
157-
for (let i = 0; i < config.options.length; i++) {
158-
const option = config.options[i];
159-
160-
if (option.startsWith('-') && i + 1 < config.options.length && !config.options[i + 1].startsWith('-')) {
161-
processor.outputOptions(option, config.options[i + 1]);
162-
i++;
163-
} else {
164-
processor.outputOptions(option);
165-
}
166-
}
158+
const args = ['-y', '-i', config.input, ...config.options, config.output];
159+
const child = spawn(ffmpegPath, args, { stdio: 'ignore' });
167160

168-
processor
169-
.on('end', async () => {
161+
child.on('close', async (code) => {
162+
if (code === 0) {
170163
try {
171164
await config.onEnd();
172165
resolve();
173166
} catch (error) {
174167
reject(error);
175168
}
176-
})
177-
.on('error', async (err: Error) => {
169+
} else {
178170
try {
171+
const err = new Error(`FFmpeg exited with code ${code}`);
179172
await config.onError(err);
180-
} finally {
181173
reject(err);
174+
} catch (error) {
175+
reject(error);
182176
}
183-
})
184-
.run();
177+
}
178+
});
179+
180+
child.on('error', async (err: Error) => {
181+
try {
182+
await config.onError(err);
183+
} finally {
184+
reject(err);
185+
}
186+
});
185187
});
186188
}
187189

188190
static async getDuration(filePath: string): Promise<number> {
189191
return new Promise((resolve, reject) => {
190-
ffmpeg.ffprobe(filePath, (err: Error | null, metadata: ffmpeg.FfprobeData) => {
191-
if (err) return reject(err);
192-
resolve(metadata.format.duration || 0);
192+
const child = spawn(ffprobePath, [
193+
'-v', 'error',
194+
'-show_entries', 'format=duration',
195+
'-of', 'default=noprint_wrappers=1:nokey=1',
196+
filePath
197+
]);
198+
199+
let output = '';
200+
child.stdout.on('data', (data) => output += data.toString());
201+
202+
child.on('close', (code) => {
203+
if (code === 0) {
204+
resolve(parseFloat(output.trim()) || 0);
205+
} else {
206+
reject(new Error(`ffprobe exited with code ${code}`));
207+
}
193208
});
209+
child.on('error', reject);
194210
});
195211
}
196212
}

packages/media-process/src/ffmpeg/image.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { Jimp } from 'jimp';
22
import { BufferConverter, FFMPEG_CONSTANTS, FFmpegProcessor, FileManager, type MediaInput } from './core';
33

44
// ─── Dynamic Sharp loader ───────────────────────────────────────────────────
5-
let _sharp: typeof import('sharp') | null = null;
5+
let _sharp: any | null = null;
66
let _sharpChecked = false;
77

8-
function getSharp(): typeof import('sharp') | null {
8+
function getSharp(): any | null {
99
if (_sharpChecked) return _sharp;
1010
_sharpChecked = true;
1111
try {
@@ -23,9 +23,9 @@ function getSharp(): typeof import('sharp') | null {
2323

2424
// ─── Sharp-based implementations ────────────────────────────────────────────
2525
class SharpImageProcessor {
26-
private sharp: typeof import('sharp');
26+
private sharp: any;
2727

28-
constructor(sharpModule: typeof import('sharp')) {
28+
constructor(sharpModule: any) {
2929
this.sharp = sharpModule;
3030
}
3131

@@ -276,7 +276,7 @@ function getProcessor(): SharpImageProcessor | JimpImageProcessor {
276276
if (sharpModule) {
277277
_processor = new SharpImageProcessor(sharpModule);
278278
} else {
279-
console.warn('[media-process] sharp not available, using jimp as fallback (slower but compatible)');
279+
console.warn('\x1b[33m%s\x1b[0m', '⚠️ [media-process] Jimp is slow. For faster performance, run: npm install sharp');
280280
_processor = new JimpImageProcessor();
281281
}
282282

packages/media-process/src/ffmpeg/video.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import ffmpeg from 'fluent-ffmpeg';
21
import { fileTypeFromBuffer } from 'file-type';
3-
import path from 'path';
42
import { BufferConverter, FFMPEG_CONSTANTS, FFmpegProcessor, FileManager, MimeValidator, type MediaInput } from './core';
53

64
export class VideoProcessor {
@@ -66,24 +64,24 @@ export class VideoProcessor {
6664
let thumbnailBase64: string;
6765

6866
try {
69-
await new Promise<void>((resolve, reject) => {
70-
ffmpeg(tempIn)
71-
.screenshots({
72-
timestamps: [FFMPEG_CONSTANTS.THUMBNAIL.TIMESTAMP],
73-
filename: path.basename(tempThumb),
74-
folder: path.dirname(tempThumb),
75-
size: `${FFMPEG_CONSTANTS.THUMBNAIL.SIZE}x${FFMPEG_CONSTANTS.THUMBNAIL.SIZE}`,
76-
})
77-
.on('end', async () => {
78-
try {
79-
const thumbBuffer = await FileManager.safeReadFile(tempThumb);
80-
thumbnailBase64 = thumbBuffer.toString('base64');
81-
resolve();
82-
} catch (error) {
83-
reject(error);
84-
}
85-
})
86-
.on('error', reject);
67+
const duration = await FFmpegProcessor.getDuration(tempIn);
68+
const targetTime = Math.max(0, duration * 0.1);
69+
70+
await FFmpegProcessor.process({
71+
input: tempIn,
72+
output: tempThumb,
73+
options: [
74+
'-ss', targetTime.toString(),
75+
'-vframes', '1',
76+
'-s', `${FFMPEG_CONSTANTS.THUMBNAIL.SIZE}x${FFMPEG_CONSTANTS.THUMBNAIL.SIZE}`
77+
],
78+
onEnd: async () => {
79+
const thumbBuffer = await FileManager.safeReadFile(tempThumb);
80+
thumbnailBase64 = thumbBuffer.toString('base64');
81+
},
82+
onError: async (error) => {
83+
throw error;
84+
}
8785
});
8886

8987
await FileManager.cleanup([tempIn, tempThumb]);

pnpm-lock.yaml

Lines changed: 0 additions & 40 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)