import { describe, expect, it } from 'vitest'; import { buildContactSheetPlan, buildConvertPlan, buildFadePlan, buildLoudnessAnalysisPlan, buildLoudnessApplyPlan, buildMetadataEditPlan, buildPeakAnalysisPlan, buildPeakNormalizationFilter, buildPeakNormalizationPlan, buildSubtitleBurnPlan, buildSubtitleExtractionPlan, buildSubtitleMuxPlan, buildStaticWaveformPlan, buildSingleThumbnailPlan, buildThumbnailSeriesPlan, buildTransformPlan, classifySubtitleCodec, createChaptersAtIntervals, createChaptersFromMarkers, escapeFFMetadata, escapeFilterValue, metadataArguments, parseChapterJson, parseLoudnessMeasurement, parsePeakMeasurement, parseMetadataJson, resolveThumbnailTimes, serializeChapterJson, serializeFFMetadata, validateChapters, } from '../../src/commands'; import { findBuiltInPreset } from '../../src/presets'; const SOURCE = { id: 'media', sourceIndex: 0, fileName: 'source.mp4', extension: 'mp4', } as const; describe('metadata and chapter commands', () => { it('serializes deterministic metadata args without a command string', () => { expect( metadataArguments({ format: { title: 'A title', artist: 'An artist' }, streams: { 1: { language: 'deu', title: null } }, }) ).toEqual([ '-metadata', 'artist=An artist', '-metadata', 'title=A title', '-metadata:s:1', 'language=deu', '-metadata:s:1', 'title=', ]); expect( parseMetadataJson( '{"format":{"title":"Demo"},"streams":{"0":{"language":"eng"}},"dispositions":{"0":["forced","default"]}}' ) ).toEqual({ format: { title: 'Demo' }, streams: { 0: { language: 'eng' } }, dispositions: { 0: ['default', 'forced'] }, }); expect( metadataArguments({ dispositions: { 0: [], 1: ['forced', 'default'] }, }) ).toEqual(['-disposition:0', '0', '-disposition:1', 'default+forced']); expect(() => parseMetadataJson('{"dispositions":{"0":["default","default"]}}') ).toThrow(/duplicates/iu); expect(() => parseMetadataJson('{"args":["-i","remote"]}')).toThrow( /unsupported/iu ); }); it('escapes every FFMETADATA delimiter and round-trips chapter JSON', () => { expect(escapeFFMetadata('a=b;c#d\\e\nf')).toBe('a\\=b\\;c\\#d\\\\e\\\nf'); const chapters = [ { id: 'intro', title: 'Intro #1', startSeconds: 0, endSeconds: 2 }, { id: 'main', title: 'Main', startSeconds: 2, endSeconds: 5 }, ]; expect(serializeFFMetadata(chapters)).toContain('title=Intro \\#1'); const json = serializeChapterJson(chapters); expect(parseChapterJson(json).chapters).toEqual(chapters); expect( validateChapters([chapters[0]!, { ...chapters[1]!, startSeconds: 1 }])[0] ).toMatchObject({ severity: 'error' }); expect(createChaptersFromMarkers([2, 5], 8)).toHaveLength(3); expect( createChaptersAtIntervals(10, 4).map((chapter) => chapter.startSeconds) ).toEqual([0, 4, 8]); }); it('preserves validated per-chapter time bases and converts seconds to ticks', () => { const metadata = serializeFFMetadata([ { id: 'milliseconds', title: 'Milliseconds', startSeconds: 0, endSeconds: 0.5, timeBase: '1/1000', }, { id: 'samples', title: 'Samples', startSeconds: 0.5, endSeconds: 1, timeBase: '1/48000', }, ]); expect(metadata).toContain('TIMEBASE=1/1000\nSTART=0\nEND=500'); expect(metadata).toContain('TIMEBASE=1/48000\nSTART=24000\nEND=48000'); expect(() => serializeFFMetadata([ { id: 'invalid', title: 'Invalid', startSeconds: 0, endSeconds: 1, timeBase: '0/1000', }, ]) ).toThrow(/time base/iu); expect(() => serializeFFMetadata([ { id: 'coarse', title: 'Coarse', startSeconds: 0, endSeconds: 0.1, timeBase: '1/1', }, ]) ).toThrow(/too coarse/iu); }); it('builds a stream-copy metadata edit with explicit removal diagnostics', () => { const plan = buildMetadataEditPlan({ jobId: 'meta', source: SOURCE, edits: { format: { title: 'Edited' } }, targetExtension: 'mp4', targetMuxer: 'mp4', sourceMetadataPolicy: 'remove', chapterPolicy: 'remove', }); expect(plan.args).toContain('title=Edited'); expect(plan.args).toContain('copy'); expect(plan.diagnostics).toContainEqual( expect.objectContaining({ code: 'metadata-removal-scope' }) ); }); }); describe('loudness and subtitle commands', () => { const LOG = ` [Parsed_loudnorm_0] { "input_i" : "-19.70", "input_tp" : "-2.10", "input_lra" : "4.20", "input_thresh" : "-30.00", "target_offset" : "-0.30" }`; it('parses complete loudnorm JSON and rejects invalid measurements', () => { expect(parseLoudnessMeasurement(LOG)).toEqual({ inputIntegratedLufs: -19.7, inputTruePeakDbtp: -2.1, inputLoudnessRangeLu: 4.2, inputThresholdLufs: -30, targetOffsetLu: -0.3, }); expect(() => parseLoudnessMeasurement( '{"input_i":"-inf","input_tp":"0","input_lra":"0","input_thresh":"0"}' ) ).toThrow(/finite/u); expect(buildPeakNormalizationFilter(-1, { inputPeakDbfs: -6 })).toMatch( /^volume=1\.778/u ); }); it('builds distinct first and second loudness passes', () => { const targets = { integratedLufs: -16, truePeakDbtp: -1, loudnessRangeLu: 11, }; const measurement = parseLoudnessMeasurement(LOG); const analysis = buildLoudnessAnalysisPlan({ jobId: 'loud', source: SOURCE, targets, audioStreamIndex: 1, }); expect(analysis.outputs).toEqual([]); expect(analysis.args).toContain('-f'); expect(analysis.args).toContain('null'); expect(analysis.args).toContain('0:1'); expect( analysis.args.find((value) => value.startsWith('loudnorm=')) ).toContain('print_format=json'); const apply = buildLoudnessApplyPlan({ jobId: 'loud', source: SOURCE, targets, measurement, audioStreamIndex: 1, preset: findBuiltInPreset('audio-m4a-aac')!, }); expect(apply.args.find((value) => value.startsWith('loudnorm='))).toContain( 'measured_I=-19.7' ); const peakAnalysis = buildPeakAnalysisPlan({ jobId: 'peak', source: SOURCE, audioStreamIndex: 1, preset: findBuiltInPreset('audio-m4a-aac')!, }); expect( peakAnalysis.args.find((value) => value.includes('volumedetect')) ).toContain('aformat=channel_layouts=stereo'); const peakMeasurement = parsePeakMeasurement('max_volume: -6.0 dB'); const peak = buildPeakNormalizationPlan({ jobId: 'peak', source: SOURCE, audioStreamIndex: 1, targetPeakDb: -1, measurement: peakMeasurement, preset: findBuiltInPreset('audio-m4a-aac')!, }); expect(peak.args.find((value) => value.includes('volume='))).toContain( 'volume=1.778' ); }); it('classifies text and bitmap subtitles and blocks bitmap-to-text extraction', () => { expect(classifySubtitleCodec('subrip')).toBe('text'); expect(classifySubtitleCodec('hdmv_pgs_subtitle')).toBe('bitmap'); expect(() => buildSubtitleExtractionPlan({ jobId: 'sub', source: SOURCE, stream: { index: 2, codec: 'hdmv_pgs_subtitle' }, outputFormat: 'srt', }) ).toThrow(/bitmap/iu); }); it('converts text subtitles for target containers and safely builds burn-in', () => { const subtitle = { id: 'subtitle', sourceIndex: 1, fileName: "my:sub's.srt", extension: 'srt', } as const; const mux = buildSubtitleMuxPlan({ jobId: 'mux', source: SOURCE, subtitle, subtitleCodec: 'subrip', sourceStreams: [ { index: 0, kind: 'video', codec: 'h264' }, { index: 1, kind: 'audio', codec: 'aac' }, ], targetContainer: 'mp4', targetExtension: 'mp4', language: 'eng', default: true, }); expect(mux.args).toContain('mov_text'); const burn = buildSubtitleBurnPlan({ jobId: 'burn', source: SOURCE, subtitle, preset: findBuiltInPreset('mp4-h264-balanced')!, }); expect(burn.inputs).toHaveLength(2); expect(burn.args.find((value) => value.startsWith('subtitles='))).toContain( '/input/job-burn/source-1.srt' ); expect(escapeFilterValue("/path/a:b,c's.ass")).toBe( "/path/a\\:b\\,c\\'s.ass" ); }); }); describe('waveform plans', () => { it('builds a bounded static derivative for an absolute audio stream', () => { const plan = buildStaticWaveformPlan({ jobId: 'wave', source: SOURCE, audioStreamIndex: 1, width: 640, height: 160, }); expect(plan.args.join(' ')).toContain( '[0:1]aformat=channel_layouts=mono,showwavespic=s=640x160' ); expect(plan.outputs[0]).toMatchObject({ role: 'analysis', mediaType: 'image/png', }); }); }); describe('thumbnail and contact sheet plans', () => { it('supports explicit stretched single-frame dimensions and quality', () => { const plan = buildSingleThumbnailPlan({ jobId: 'single-thumb', source: SOURCE, timeSeconds: 1.25, size: { width: 640, height: 360, maintainAspect: false }, format: 'jpeg', quality: 85, }); expect(plan.args).toContain('scale=640:360'); expect(plan.args).not.toContain( 'scale=640:360:force_original_aspect_ratio=decrease' ); expect(plan.args).toEqual( expect.arrayContaining(['-q:v', '6', '-frames:v', '1']) ); }); it('generates ordered unique times from interval, count, and explicit selections', () => { expect( resolveThumbnailTimes({ durationSeconds: 10, intervalSeconds: 4 }) ).toEqual([0, 4, 8]); expect(resolveThumbnailTimes({ durationSeconds: 10, count: 3 })).toEqual([ 2.5, 5, 7.5, ]); expect( resolveThumbnailTimes({ durationSeconds: 10, timesSeconds: [5, 1, 5] }) ).toEqual([1, 5]); }); it('builds one invocation for a series and declares every output', () => { const plan = buildThumbnailSeriesPlan({ jobId: 'thumbs', source: SOURCE, durationSeconds: 10, count: 3, format: 'png', size: { width: 320 }, }); expect(plan.outputs).toHaveLength(3); expect(plan.args.filter((value) => value === '-i')).toHaveLength(1); expect(plan.args).toContain('-filter_complex'); expect(plan.args.filter((value) => value === '-frames:v')).toHaveLength(3); for (const output of plan.outputs) { expect(plan.args).toContain(output.path); } expect(plan.args.some((value) => value.includes('%03d'))).toBe(false); }); it('degrades contact-sheet labels when drawtext is unavailable', () => { const plan = buildContactSheetPlan({ jobId: 'sheet', source: SOURCE, durationSeconds: 10, frameCount: 6, rows: 2, columns: 3, cellWidth: 240, timestampLabels: true, format: 'jpeg', drawtextAvailable: false, }); expect(plan.diagnostics).toContainEqual( expect.objectContaining({ code: 'labels-unavailable', severity: 'warning', }) ); expect(plan.requiredCapabilities.filters).not.toContain('drawtext'); }); }); describe('composed visual operations', () => { it('creates palette-based GIF conversion instead of a raw encoder pass', () => { const plan = buildConvertPlan({ jobId: 'gif', source: SOURCE, preset: findBuiltInPreset('animated-gif')!, }); expect(plan.args).toContain('-filter_complex'); expect(plan.args.find((value) => value.includes('palettegen'))).toContain( 'paletteuse' ); expect(plan.args).not.toContain('-vf'); }); it('wraps transforms and A/V fades into executable conversion plans', () => { const preset = findBuiltInPreset('mp4-h264-balanced')!; const transformed = buildTransformPlan({ jobId: 'transform', source: SOURCE, preset, settings: { crop: { x: 0, y: 0, width: 640, height: 360, sourceWidth: 1280, sourceHeight: 720, requireEvenDimensions: true, }, frameRate: 25, }, }); expect(transformed.args).toContain('crop=640:360:0:0,fps=25'); const faded = buildFadePlan({ jobId: 'fade', source: SOURCE, preset, durationSeconds: 10, audioFadeInSeconds: 1, videoFadeOutSeconds: 2, audioCurve: 'qsin', videoCurve: 'exp', }); expect(faded.args.find((value) => value.startsWith('afade='))).toContain( 'curve=qsin' ); expect(faded.args.find((value) => value.startsWith('geq='))).toContain( 'exp(5*' ); expect(faded.requiredCapabilities.filters).toContain('geq'); expect(() => buildFadePlan({ jobId: 'overlap', source: SOURCE, preset, durationSeconds: 2, audioFadeInSeconds: 1.5, audioFadeOutSeconds: 1, }) ).toThrow(/overlap/u); expect(() => buildFadePlan({ jobId: 'unsupported-curve', source: SOURCE, preset, durationSeconds: 2, audioFadeInSeconds: 1, audioCurve: 'log' as 'tri', }) ).toThrow(/tri, qsin, or exp/u); }); });