import { describe, expect, it } from 'vitest'; import { buildConvertPlan, buildFastConcatPlan, buildPreviewProxyPlan, buildRemuxPlan, buildSplitPlan, buildStreamRemovalPlan, buildTimelineExportPlan, buildTrimPlan, calculateResize, createConcatList, createPlannedInput, createVideoFiltergraph, displayArguments, sourceVirtualPath, validateConcatCompatibility, validateCrop, validateSplitMarkers, } from '../../src/commands'; import { findBuiltInPreset } from '../../src/presets'; const SOURCE = { id: 'source', sourceIndex: 0, fileName: "../../My holiday's clip.mp4", extension: 'mp4', } as const; describe('structured command plans', () => { it('generates safe deterministic virtual paths without source path disclosure', () => { expect(sourceVirtualPath('../job id', SOURCE)).toBe( '/input/job-job-id/source-0.mp4' ); expect(createPlannedInput('abc', SOURCE)).toEqual({ id: 'source', sourceIndex: 0, kind: 'media', path: '/input/job-abc/source-0.mp4', }); }); it('renders shell-like diagnostics without changing structured arguments', () => { const args = [ '-i', "/input/a file's.mp4", '-metadata', 'title=hello world', ]; expect(displayArguments(args)).toBe( "-i '/input/a file'\\''s.mp4' -metadata 'title=hello world'" ); expect(args).toEqual([ '-i', "/input/a file's.mp4", '-metadata', 'title=hello world', ]); }); it('escapes generated concat paths and rejects line injection', () => { expect(createConcatList(['/input/source-0.mp4', "/input/it's.mp4"])).toBe( "file '/input/source-0.mp4'\nfile '/input/it'\\''s.mp4'\n" ); expect(() => createConcatList(["/input/a.mp4\nfile '/etc/passwd'"]) ).toThrow(/line breaks/u); }); it('composes conversion filters into one -vf argument', () => { const preset = findBuiltInPreset('mp4-h264-balanced'); expect(preset).toBeDefined(); const plan = buildConvertPlan({ jobId: 'convert-1', source: SOURCE, preset: preset!, videoFiltergraph: 'crop=640:360:0:0,fade=t=in:st=0:d=1', audioFiltergraph: 'afade=t=in:st=0:d=1', expectedDurationSeconds: 10, }); expect(plan.args.filter((argument) => argument === '-vf')).toHaveLength(1); expect(plan.args).toContain('crop=640:360:0:0,fade=t=in:st=0:d=1'); expect(plan.args).toContain('afade=t=in:st=0:d=1'); expect(plan.args.at(-1)).toMatch(/\.mp4$/u); expect(Object.isFrozen(plan.args)).toBe(true); }); it('distinguishes fast and accurate trim argument placement', () => { const fast = buildTrimPlan({ jobId: 'trim-fast', source: SOURCE, mode: 'fast', startSeconds: 1.25, endSeconds: 3.5, sourceDurationSeconds: 10, targetExtension: 'mp4', }); expect(fast.args.slice(0, 7)).toEqual([ '-ss', '1.25', '-to', '3.5', '-i', '/input/job-trim-fast/source-0.mp4', '-map', ]); expect(fast.args).toContain('copy'); expect(fast.diagnostics[0]?.severity).toBe('warning'); const preset = findBuiltInPreset('mp4-h264-balanced'); const accurate = buildTrimPlan({ jobId: 'trim-accurate', source: SOURCE, mode: 'accurate', startSeconds: 1.25, endSeconds: 3.5, preset: preset!, }); expect(accurate.args.slice(0, 6)).toEqual([ '-ss', '1.25', '-to', '3.5', '-i', '/input/job-trim-accurate/source-0.mp4', ]); expect(accurate.args).toContain('libx264'); }); it('validates and expands every supported split definition', () => { expect(validateSplitMarkers([8, 2, 5], 10)).toEqual([2, 5, 8]); expect(() => validateSplitMarkers([2, 2.01], 10)).toThrow(/apart/u); const plan = buildSplitPlan({ jobId: 'split', source: SOURCE, durationSeconds: 10, definition: { type: 'part-count', parts: 3 }, mode: 'fast', targetExtension: 'mp4', }); expect(plan.outputs.map((output) => output.fileName)).toEqual([ 'My-holiday-s-clip-split-001.mp4', 'My-holiday-s-clip-split-002.mp4', 'My-holiday-s-clip-split-003.mp4', ]); expect(plan.outputs.map((output) => output.timeRange)).toEqual([ { startSeconds: 0, endSeconds: 10 / 3 }, { startSeconds: 10 / 3, endSeconds: 20 / 3 }, { startSeconds: 20 / 3, endSeconds: 10 }, ]); expect( plan.args.filter((argument) => argument === '-progress') ).toHaveLength(1); expect(plan.args.filter((argument) => argument === 'copy')).toHaveLength(3); }); it('blocks incompatible remuxes before returning a plan', () => { expect(() => buildRemuxPlan({ jobId: 'remux', source: SOURCE, targetContainer: 'mp4', fileExtension: 'mp4', description: { streams: [{ index: 0, kind: 'video', codec: 'vp9' }], }, }) ).toThrow(/cannot be remuxed/u); }); it('maps selected attachment and data streams during compatible remux', () => { const plan = buildRemuxPlan({ jobId: 'remux-auxiliary', source: SOURCE, targetContainer: 'matroska', fileExtension: 'mkv', description: { streams: [ { index: 2, kind: 'attachment', codec: 'ttf' }, { index: 3, kind: 'data', codec: 'bin_data' }, ], }, streamSelection: { attachments: [2], data: [3], }, }); expect(plan.args).toEqual( expect.arrayContaining(['-map', '0:2', '-map', '0:3', '-c', 'copy']) ); }); it('maps only explicitly retained streams for stream removal', () => { const plan = buildStreamRemovalPlan({ jobId: 'streams', source: SOURCE, selection: { video: [0], audio: [1], subtitles: [] }, targetExtension: 'mkv', targetMuxer: 'matroska', }); expect(plan.args).toEqual([ '-i', '/input/job-streams/source-0.mp4', '-map', '0:0', '-map', '0:1', '-c', 'copy', '-map_metadata', '0', '-map_chapters', '0', '/work/job-streams/My-holiday-s-clip-streams.mkv', ]); }); }); describe('concat and transform validation', () => { const STREAMS = [ { kind: 'video' as const, codec: 'h264', width: 640, height: 360, pixelFormat: 'yuv420p', timeBase: '1/12800', }, { kind: 'audio' as const, codec: 'aac', sampleRate: 48_000, channelLayout: 'stereo', timeBase: '1/48000', }, ]; const SOURCES = [ { ...SOURCE, id: 'one', sourceIndex: 0, durationSeconds: 2, streams: STREAMS, }, { ...SOURCE, id: 'two', sourceIndex: 1, fileName: 'two.mp4', durationSeconds: 3, streams: STREAMS, }, ]; it('checks exact fast-concat stream arrangement and properties', () => { expect(validateConcatCompatibility(SOURCES)).toEqual([]); expect( validateConcatCompatibility([ SOURCES[0]!, { ...SOURCES[1]!, streams: [{ ...STREAMS[0]!, width: 1280 }, STREAMS[1]!], }, ])[0] ).toMatchObject({ code: 'stream-property', severity: 'error' }); const plan = buildFastConcatPlan({ jobId: 'concat', sources: SOURCES, targetExtension: 'mp4', targetMuxer: 'mp4', }); expect(plan.temporaryFiles[0]?.content).toBe( "file '/input/job-concat/source-0.mp4'\nfile '/input/job-concat/source-1.mp4'\n" ); expect(plan.expectedDurationSeconds).toBe(5); }); it('corrects crop to even bounds and calculates aspect-preserving resize', () => { expect( validateCrop({ x: 3, y: 5, width: 101, height: 51, sourceWidth: 200, sourceHeight: 100, requireEvenDimensions: true, }) ).toMatchObject({ x: 2, y: 4, width: 100, height: 50 }); expect( calculateResize({ sourceWidth: 1920, sourceHeight: 1080, width: 640, height: 640, mode: 'contain', }) ).toEqual({ width: 640, height: 360 }); }); it('orders typed filter stages independently of insertion order', () => { const builder = createVideoFiltergraph() .add({ id: 'fade', stage: 'fade', expression: 'fade=t=in:d=1' }) .add({ id: 'crop', stage: 'crop', expression: 'crop=100:100:0:0' }) .add({ id: 'fps', stage: 'frame-rate', expression: 'fps=25' }); expect(builder.build().graph).toBe('crop=100:100:0:0,fps=25,fade=t=in:d=1'); }); it('builds a bounded preview proxy with optional audio mapping', () => { const plan = buildPreviewProxyPlan({ jobId: 'preview', source: SOURCE, sourceDurationSeconds: 120, startSeconds: 10, }); expect(plan.expectedDurationSeconds).toBe(30); expect(plan.args).toContain('0:a:0?'); expect(plan.args).toContain("scale=w='max(2,trunc(min(960,iw)/2)*2)':h=-2"); }); it('builds a bounded AAC/M4A proxy for an audio-only source', () => { const plan = buildPreviewProxyPlan({ jobId: 'audio-preview', source: SOURCE, sourceDurationSeconds: 90, maxDurationSeconds: 20, hasVideo: false, }); expect(plan.expectedDurationSeconds).toBe(20); expect(plan.outputs[0]?.fileName).toMatch(/-preview\.m4a$/u); expect(plan.outputs[0]?.mediaType).toBe('audio/mp4'); expect(plan.args).toContain('0:a:0'); expect(plan.args).toContain('aac'); expect(plan.args).not.toContain('0:v:0'); expect(plan.args).not.toContain('libx264'); expect(plan.args).not.toContain('-vf'); expect(plan.requiredCapabilities).toMatchObject({ muxers: ['ipod'], encoders: ['aac'], filters: [], }); }); it('builds one normalized timeline filter graph', () => { const preset = findBuiltInPreset('mp4-h264-balanced'); const plan = buildTimelineExportPlan({ jobId: 'timeline', preset: preset!, outputWidth: 640, outputHeight: 360, frameRate: 25, missingAudioPolicy: 'insert-silence', outputFileName: '../../My final cut.mov', clips: [ { id: 'one', source: SOURCES[0]!, sourceInSeconds: 0.25, sourceOutSeconds: 1.75, hasVideo: true, hasAudio: true, audioFadeInSeconds: 0.25, audioFadeCurve: 'qsin', videoFadeOutSeconds: 0.25, videoFadeCurve: 'exp', }, { id: 'two', source: SOURCES[1]!, sourceInSeconds: 0, sourceOutSeconds: 2, hasVideo: true, hasAudio: false, }, ], }); expect( plan.args.filter((argument) => argument === '-filter_complex') ).toHaveLength(1); const graph = plan.args[plan.args.indexOf('-filter_complex') + 1]; expect(graph).toContain('anullsrc='); expect(graph).toContain('curve=qsin'); expect(graph).toContain("geq=lum_expr='lum(X,Y)*((exp(5*"); expect(graph).toContain('concat=n=2:v=1:a=1[vout][aout]'); expect(plan.requiredCapabilities.filters).toContain('geq'); expect(plan.expectedDurationSeconds).toBe(3.5); expect(plan.outputs[0]?.fileName).toBe('My-final-cut.mp4'); expect(plan.outputs[0]?.path).not.toContain('..'); }); });