diff --git a/src/main.js b/src/main.js index 0a96569..8c96e80 100644 --- a/src/main.js +++ b/src/main.js @@ -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, { diff --git a/src/main/VideoOperations.js b/src/main/VideoOperations.js new file mode 100644 index 0000000..be1c7a9 --- /dev/null +++ b/src/main/VideoOperations.js @@ -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, +}; diff --git a/src/preload.js b/src/preload.js index a16b69e..5c00573 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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', diff --git a/tests/main/VideoOperations.test.js b/tests/main/VideoOperations.test.js new file mode 100644 index 0000000..4b1a121 --- /dev/null +++ b/tests/main/VideoOperations.test.js @@ -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(); + }); +});