feat(video): implement ffmpeg-based video operations backend

Add src/main/VideoOperations.js with pure argument-builder functions
(buildConvertArgs, buildCompressArgs, buildTrimArgs, buildFramesArgs,
buildGifArgs) and a single executeOperation entry point that spawns
ffmpeg via dependency-injected execFileFn, mirroring AudioOperations.js.

Wire ipcMain.handle('process-video-operation', ...) in main.js using
getFFmpegPath() and sanitizeErrorMessage(). Update preload.js's
ALLOWED_SEND_CHANNELS: remove 6 stale video-* channel names, add
process-video-operation.

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent a353a695b5
commit 8dc1ae1c45
4 changed files with 316 additions and 6 deletions
+15
View File
@@ -7,6 +7,7 @@ const WordTemplateExporter = require('./wordTemplateExporter');
const PDFOperations = require('./main/PDFOperations');
const ImageOperations = require('./main/ImageOperations');
const AudioOperations = require('./main/AudioOperations');
const VideoOperations = require('./main/VideoOperations');
const GitOperations = require('./main/GitOperations');
const PdfFontHeader = require('./main/PdfFontHeader');
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
@@ -4651,6 +4652,20 @@ ipcMain.handle('process-audio-operation', async (event, { operation, data }) =>
}
});
// ========================================
// VIDEO OPERATIONS — delegates to main/VideoOperations.js
// ========================================
ipcMain.handle('process-video-operation', async (event, { operation, data }) => {
try {
return await VideoOperations.executeOperation(operation, data, {
ffmpegPath: getFFmpegPath(),
});
} catch (error) {
return { success: false, error: sanitizeErrorMessage(error.message) };
}
});
// IPC Handler for folder selection (for batch image operations)
ipcMain.on('select-image-folder', (event, inputId) => {
const folder = dialog.showOpenDialogSync(mainWindow, {
+145
View File
@@ -0,0 +1,145 @@
/**
* Video Operations Module
*
* Handles video manipulation via `ffmpeg`: format conversion, compression, trim,
* frame extraction, and GIF conversion. Because ffmpeg is an external binary, this
* module is split into pure/testable argument-builder functions and a single
* `executeOperation` that is the only piece which actually spawns ffmpeg — the
* ffmpeg binary path and the `execFile` implementation are both injected so tests
* can replace them with fakes, without invoking a real binary.
*
* @module VideoOperations
*/
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
/**
* Build args for a straight format conversion. ffmpeg infers the output format
* from outputPath's extension.
*/
function buildConvertArgs({ inputPath, outputPath }) {
return ['-i', inputPath, '-y', outputPath];
}
/**
* Build args to re-encode inputPath with libx264 at the given CRF (Constant Rate
* Factor). Lower CRF = higher quality/larger file, per libx264 convention. crf
* must be an integer in [0, 51].
*/
function buildCompressArgs({ inputPath, outputPath, crf = 28 }) {
if (!Number.isInteger(crf) || crf < 0 || crf > 51) {
throw new Error('Invalid crf: must be an integer between 0 and 51');
}
return ['-i', inputPath, '-vcodec', 'libx264', '-crf', String(crf), '-y', outputPath];
}
/**
* Build args to trim inputPath to [startTime, startTime + duration) seconds.
* startTime/duration must be finite, non-negative numbers — they become argv
* elements passed straight to execFile with no shell involved, so there's no
* injection risk, but malformed values should still fail fast rather than reach
* ffmpeg with garbage.
*/
function buildTrimArgs({ inputPath, outputPath, startTime, duration }) {
const isValid = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
if (!isValid(startTime) || !isValid(duration)) {
throw new Error('Invalid trim range');
}
return ['-i', inputPath, '-ss', String(startTime), '-t', String(duration), '-y', outputPath];
}
/**
* Build args to extract frames from inputPath at fps frames-per-second, written
* as sequentially numbered PNGs into outputDir. fps must be a positive finite
* number.
*/
function buildFramesArgs({ inputPath, outputDir, fps = 1 }) {
if (typeof fps !== 'number' || !Number.isFinite(fps) || fps <= 0) {
throw new Error('Invalid fps: must be a positive finite number');
}
return ['-i', inputPath, '-vf', `fps=${fps}`, path.join(outputDir, 'frame-%04d.png')];
}
/**
* Build args to convert inputPath to an animated GIF at the given fps and width
* (height scales automatically via -1), using the lanczos scaling filter.
*/
function buildGifArgs({ inputPath, outputPath, fps = 10, width = 480 }) {
return ['-i', inputPath, '-vf', `fps=${fps},scale=${width}:-1:flags=lanczos`, '-y', outputPath];
}
/**
* Run ffmpeg with the given args via the injected execFileFn, wrapped in a Promise.
*/
function runFfmpeg(ffmpegPath, args, execFileFn) {
return new Promise((resolve, reject) => {
execFileFn(ffmpegPath, args, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
return;
}
resolve();
});
});
}
async function executeOperation(operation, data, { ffmpegPath, execFileFn } = {}) {
const resolvedFfmpegPath = ffmpegPath || 'ffmpeg';
const resolvedExecFileFn = execFileFn || execFile;
switch (operation) {
case 'convert': {
const { inputPath, outputPath } = data || {};
const args = buildConvertArgs({ inputPath, outputPath });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputPath };
}
case 'compress': {
const { inputPath, outputPath, crf } = data || {};
const args = buildCompressArgs({ inputPath, outputPath, crf });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputPath };
}
case 'trim': {
const { inputPath, outputPath, startTime, duration } = data || {};
const args = buildTrimArgs({ inputPath, outputPath, startTime, duration });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputPath };
}
case 'frames': {
const { inputPath, outputDir, fps } = data || {};
const args = buildFramesArgs({ inputPath, outputDir, fps });
fs.mkdirSync(outputDir, { recursive: true });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputDir };
}
case 'gif': {
const { inputPath, outputPath, fps, width } = data || {};
const args = buildGifArgs({ inputPath, outputPath, fps, width });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputPath };
}
default:
throw new Error(`Unknown operation: ${operation}`);
}
}
module.exports = {
executeOperation,
buildConvertArgs,
buildCompressArgs,
buildTrimArgs,
buildFramesArgs,
buildGifArgs,
};
+1 -6
View File
@@ -52,12 +52,7 @@ const ALLOWED_SEND_CHANNELS = [
'process-audio-operation',
// Video converter
'video-convert',
'video-batch-convert',
'video-compress',
'video-trim',
'video-frames',
'video-gif',
'process-video-operation',
// Header/Footer
'get-header-footer-settings',
+155
View File
@@ -0,0 +1,155 @@
const fs = require('fs');
const path = require('path');
const VideoOperations = require('../../src/main/VideoOperations');
describe('VideoOperations argument builders', () => {
test('buildConvertArgs builds correct ffmpeg args', () => {
const args = VideoOperations.buildConvertArgs({ inputPath: '/a.mov', outputPath: '/b.mp4' });
expect(args).toEqual(['-i', '/a.mov', '-y', '/b.mp4']);
});
test('buildCompressArgs builds correct compress args with given crf', () => {
const args = VideoOperations.buildCompressArgs({
inputPath: '/a.mp4',
outputPath: '/b.mp4',
crf: 23,
});
expect(args).toEqual(['-i', '/a.mp4', '-vcodec', 'libx264', '-crf', '23', '-y', '/b.mp4']);
});
test('buildCompressArgs defaults crf to 28', () => {
const args = VideoOperations.buildCompressArgs({ inputPath: '/a.mp4', outputPath: '/b.mp4' });
expect(args).toEqual(['-i', '/a.mp4', '-vcodec', 'libx264', '-crf', '28', '-y', '/b.mp4']);
});
test('buildCompressArgs rejects out-of-range crf', () => {
expect(() =>
VideoOperations.buildCompressArgs({ inputPath: '/a.mp4', outputPath: '/b.mp4', crf: 52 })
).toThrow();
});
test('buildCompressArgs rejects non-integer crf', () => {
expect(() =>
VideoOperations.buildCompressArgs({ inputPath: '/a.mp4', outputPath: '/b.mp4', crf: 12.5 })
).toThrow();
});
test('buildTrimArgs builds correct trim args', () => {
const args = VideoOperations.buildTrimArgs({
inputPath: '/a.mp4',
outputPath: '/b.mp4',
startTime: 5,
duration: 10,
});
expect(args).toEqual(['-i', '/a.mp4', '-ss', '5', '-t', '10', '-y', '/b.mp4']);
});
test('buildTrimArgs rejects non-finite startTime', () => {
expect(() =>
VideoOperations.buildTrimArgs({
inputPath: '/a.mp4',
outputPath: '/b.mp4',
startTime: NaN,
duration: 10,
})
).toThrow('Invalid trim range');
});
test('buildFramesArgs builds correct frame extraction args with given fps', () => {
const args = VideoOperations.buildFramesArgs({
inputPath: '/a.mp4',
outputDir: '/out',
fps: 2,
});
expect(args).toEqual(['-i', '/a.mp4', '-vf', 'fps=2', path.join('/out', 'frame-%04d.png')]);
});
test('buildFramesArgs defaults fps to 1', () => {
const args = VideoOperations.buildFramesArgs({ inputPath: '/a.mp4', outputDir: '/out' });
expect(args).toEqual(['-i', '/a.mp4', '-vf', 'fps=1', path.join('/out', 'frame-%04d.png')]);
});
test('buildFramesArgs rejects non-positive fps', () => {
expect(() =>
VideoOperations.buildFramesArgs({ inputPath: '/a.mp4', outputDir: '/out', fps: 0 })
).toThrow();
});
test('buildFramesArgs rejects non-finite fps', () => {
expect(() =>
VideoOperations.buildFramesArgs({ inputPath: '/a.mp4', outputDir: '/out', fps: Infinity })
).toThrow();
});
test('buildGifArgs builds correct gif args with defaults', () => {
const args = VideoOperations.buildGifArgs({ inputPath: '/a.mp4', outputPath: '/b.gif' });
expect(args).toEqual([
'-i',
'/a.mp4',
'-vf',
'fps=10,scale=480:-1:flags=lanczos',
'-y',
'/b.gif',
]);
});
test('buildGifArgs builds correct gif args with given fps and width', () => {
const args = VideoOperations.buildGifArgs({
inputPath: '/a.mp4',
outputPath: '/b.gif',
fps: 15,
width: 320,
});
expect(args).toEqual([
'-i',
'/a.mp4',
'-vf',
'fps=15,scale=320:-1:flags=lanczos',
'-y',
'/b.gif',
]);
});
});
describe('VideoOperations.executeOperation', () => {
test('convert calls execFileFn with ffmpeg path and args, resolves success', async () => {
const execFileFn = (cmd, args, opts, cb) => cb(null, '', '');
const result = await VideoOperations.executeOperation(
'convert',
{ inputPath: '/a.mov', outputPath: '/b.mp4' },
{ ffmpegPath: '/usr/bin/ffmpeg', execFileFn }
);
expect(result.success).toBe(true);
expect(result.outputPath).toBe('/b.mp4');
});
test('frames creates the output directory before spawning ffmpeg and resolves success', async () => {
const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {});
const execFileFn = jest.fn((cmd, args, opts, cb) => cb(null, '', ''));
const result = await VideoOperations.executeOperation(
'frames',
{ inputPath: '/a.mp4', outputDir: '/out', fps: 1 },
{ ffmpegPath: '/usr/bin/ffmpeg', execFileFn }
);
expect(mkdirSpy).toHaveBeenCalledWith('/out', { recursive: true });
expect(mkdirSpy.mock.invocationCallOrder[0]).toBeLessThan(
execFileFn.mock.invocationCallOrder[0]
);
expect(result.success).toBe(true);
expect(result.outputDir).toBe('/out');
mkdirSpy.mockRestore();
});
test('unknown operation rejects', async () => {
await expect(
VideoOperations.executeOperation(
'bogus',
{},
{ ffmpegPath: '/usr/bin/ffmpeg', execFileFn: () => {} }
)
).rejects.toThrow();
});
});