mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-24 07:20:16 +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 PDFOperations = require('./main/PDFOperations');
|
||||||
const ImageOperations = require('./main/ImageOperations');
|
const ImageOperations = require('./main/ImageOperations');
|
||||||
const AudioOperations = require('./main/AudioOperations');
|
const AudioOperations = require('./main/AudioOperations');
|
||||||
|
const VideoOperations = require('./main/VideoOperations');
|
||||||
const GitOperations = require('./main/GitOperations');
|
const GitOperations = require('./main/GitOperations');
|
||||||
const PdfFontHeader = require('./main/PdfFontHeader');
|
const PdfFontHeader = require('./main/PdfFontHeader');
|
||||||
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
|
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)
|
// IPC Handler for folder selection (for batch image operations)
|
||||||
ipcMain.on('select-image-folder', (event, inputId) => {
|
ipcMain.on('select-image-folder', (event, inputId) => {
|
||||||
const folder = dialog.showOpenDialogSync(mainWindow, {
|
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',
|
'process-audio-operation',
|
||||||
|
|
||||||
// Video converter
|
// Video converter
|
||||||
'video-convert',
|
'process-video-operation',
|
||||||
'video-batch-convert',
|
|
||||||
'video-compress',
|
|
||||||
'video-trim',
|
|
||||||
'video-frames',
|
|
||||||
'video-gif',
|
|
||||||
|
|
||||||
// Header/Footer
|
// Header/Footer
|
||||||
'get-header-footer-settings',
|
'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