feat(video): add ffmpeg-backed video converter for mp4/webm/mov/avi/mkv
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { register } from './registry.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export const VIDEO_FORMATS = ['mp4', 'webm', 'mov', 'avi', 'mkv'];
|
||||||
|
|
||||||
|
const CODEC_ARGS = {
|
||||||
|
mp4: ['-c:v', 'libx264', '-c:a', 'aac'],
|
||||||
|
mov: ['-c:v', 'libx264', '-c:a', 'aac'],
|
||||||
|
mkv: ['-c:v', 'libx264', '-c:a', 'aac'],
|
||||||
|
webm: ['-c:v', 'libvpx-vp9', '-c:a', 'libopus'],
|
||||||
|
avi: ['-c:v', 'mpeg4', '-c:a', 'libmp3lame'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerVideoConverters() {
|
||||||
|
for (const sourceFormat of VIDEO_FORMATS) {
|
||||||
|
for (const targetFormat of VIDEO_FORMATS) {
|
||||||
|
if (sourceFormat === targetFormat) continue;
|
||||||
|
register({
|
||||||
|
family: 'video',
|
||||||
|
sourceFormat,
|
||||||
|
targetFormat,
|
||||||
|
convert: async (inputPath, outputPath, { quality, timeoutMs } = {}) => {
|
||||||
|
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||||
|
const args = ['-y', '-i', inputPath, ...CODEC_ARGS[targetFormat]];
|
||||||
|
if (quality != null) {
|
||||||
|
args.push('-vf', `scale=-2:${quality}`);
|
||||||
|
}
|
||||||
|
args.push(outputPath);
|
||||||
|
await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { registerVideoConverters, VIDEO_FORMATS } from '../../src/converters/video.js';
|
||||||
|
import { resolve, _resetForTests } from '../../src/converters/registry.js';
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
|
||||||
|
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTests();
|
||||||
|
execFileMock.mockReset();
|
||||||
|
execFileMock.mockImplementation((file, args, options, callback) => callback(null, '', ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerVideoConverters — registration', () => {
|
||||||
|
it('registers every pair among the 5 video formats, and nothing for source === target', () => {
|
||||||
|
registerVideoConverters();
|
||||||
|
|
||||||
|
for (const sourceFormat of VIDEO_FORMATS) {
|
||||||
|
for (const targetFormat of VIDEO_FORMATS) {
|
||||||
|
const entry = resolve(sourceFormat, targetFormat);
|
||||||
|
if (sourceFormat === targetFormat) {
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry.family).toBe('video');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes exactly the 5 requested formats', () => {
|
||||||
|
expect(VIDEO_FORMATS.sort()).toEqual(['avi', 'mkv', 'mov', 'mp4', 'webm'].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerVideoConverters — subprocess invocation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
registerVideoConverters();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls ffmpeg with -i, the codec args, and the output path, omitting -vf when no quality is given', async () => {
|
||||||
|
await resolve('mov', 'mp4').convert('/tmp/in.mov', '/tmp/out.mp4');
|
||||||
|
|
||||||
|
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [file, args, options] = execFileMock.mock.calls[0];
|
||||||
|
expect(file).toBe('ffmpeg');
|
||||||
|
expect(args).toEqual([
|
||||||
|
'-y', '-i', '/tmp/in.mov', '-c:v', 'libx264', '-c:a', 'aac', '/tmp/out.mp4',
|
||||||
|
]);
|
||||||
|
expect(options).toEqual({ timeout: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends -vf scale=-2:<height> when a resolution quality is given', async () => {
|
||||||
|
await resolve('mov', 'mp4').convert('/tmp/in.mov', '/tmp/out.mp4', { quality: 720 });
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).toEqual([
|
||||||
|
'-y', '-i', '/tmp/in.mov', '-c:v', 'libx264', '-c:a', 'aac', '-vf', 'scale=-2:720', '/tmp/out.mp4',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses libvpx-vp9 and libopus for a webm target', async () => {
|
||||||
|
await resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).toEqual([
|
||||||
|
'-y', '-i', '/tmp/in.mp4', '-c:v', 'libvpx-vp9', '-c:a', 'libopus', '/tmp/out.webm',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses mpeg4 and libmp3lame for an avi target', async () => {
|
||||||
|
await resolve('mp4', 'avi').convert('/tmp/in.mp4', '/tmp/out.avi');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).toEqual([
|
||||||
|
'-y', '-i', '/tmp/in.mp4', '-c:v', 'mpeg4', '-c:a', 'libmp3lame', '/tmp/out.avi',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses libx264 and aac for an mkv target', async () => {
|
||||||
|
await resolve('mp4', 'mkv').convert('/tmp/in.mp4', '/tmp/out.mkv');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).toEqual([
|
||||||
|
'-y', '-i', '/tmp/in.mp4', '-c:v', 'libx264', '-c:a', 'aac', '/tmp/out.mkv',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards options.timeoutMs to execFile as its timeout', async () => {
|
||||||
|
await resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm', { timeoutMs: 300000 });
|
||||||
|
|
||||||
|
const [, , options] = execFileMock.mock.calls[0];
|
||||||
|
expect(options).toEqual({ timeout: 300000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses FFMPEG_PATH from the environment when set', async () => {
|
||||||
|
const previous = process.env.FFMPEG_PATH;
|
||||||
|
process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';
|
||||||
|
|
||||||
|
await resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm');
|
||||||
|
|
||||||
|
expect(execFileMock.mock.calls[0][0]).toBe('/opt/ffmpeg/bin/ffmpeg');
|
||||||
|
|
||||||
|
if (previous === undefined) delete process.env.FFMPEG_PATH;
|
||||||
|
else process.env.FFMPEG_PATH = previous;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a rejection from execFile as a rejected promise', async () => {
|
||||||
|
execFileMock.mockImplementation((file, args, options, callback) =>
|
||||||
|
callback(new Error('ffmpeg exited with code 1'), '', 'Unknown encoder')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm')).rejects.toThrow(
|
||||||
|
/exited with code 1/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user