mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+15
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user