import { describe, expect, it } from 'vitest'; import { ProbeParseError, normalizeFfprobeReport, parseFfprobeJson, parseFraction, parseFractionDetailed, parseFrameRate, } from '../../../src/media/probe-parser'; describe('fraction parsing', () => { it.each([ ['30000/1001', 30000 / 1001], ['25/1', 25], ['-1/2', -0.5], [' 1 / 4 ', 0.25], ['23.976', 23.976], [5, 5], ])('parses %j safely', (input, expected) => { expect(parseFraction(input)).toBeCloseTo(expected); }); it.each([ ['0/0', 'division-by-zero'], ['1/0', 'division-by-zero'], ['NaN', 'invalid-syntax'], ['Infinity', 'invalid-syntax'], ['1e3', 'invalid-syntax'], ['1/2/3', 'invalid-syntax'], ['999999999999999999/1', 'component-too-large'], ])('rejects %j with a useful reason', (input, reason) => { expect(parseFractionDetailed(input)).toEqual({ ok: false, reason }); }); it('rejects implausible, zero and negative frame rates', () => { expect(parseFrameRate('0/0')).toBeUndefined(); expect(parseFrameRate('0/1')).toBeUndefined(); expect(parseFrameRate('-25/1')).toBeUndefined(); expect(parseFrameRate('1001/1')).toBeUndefined(); }); }); describe('ffprobe normalization', () => { it('normalizes format, streams, dispositions, tags and chapters', () => { const report = { format: { duration: '12.500000', start_time: '-0.021', bit_rate: '192000', format_name: 'mov,mp4,m4a,3gp,3g2,mj2', format_long_name: 'QuickTime / MOV', tags: { title: 'Demo' }, }, streams: [ { index: 2, codec_type: 'audio', codec_name: 'aac', sample_rate: '48000', channels: 2, channel_layout: 'stereo', disposition: { default: 1, forced: 0 }, tags: { language: 'deu', title: 'German' }, }, { index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080, coded_width: 1920, coded_height: 1088, pix_fmt: 'yuv420p', sample_aspect_ratio: '8:6', display_aspect_ratio: '16/9', time_base: '1/30000', avg_frame_rate: '30000/1001', r_frame_rate: '30/1', side_data_list: [ { side_data_type: 'Display Matrix', rotation: -90, }, ], disposition: { default: true }, tags: {}, }, ], chapters: [ { id: 4, time_base: '1/1000', start: 1000, end: 2500, tags: { title: 'Opening' }, }, ], }; const probe = normalizeFfprobeReport(report); expect(probe).toMatchObject({ durationSeconds: 12.5, startTimeSeconds: -0.021, bitRate: 192000, formatNames: ['mov', 'mp4', 'm4a', '3gp', '3g2', 'mj2'], formatLongName: 'QuickTime / MOV', tags: { title: 'Demo' }, warnings: [], }); expect(probe.streams.map((stream) => stream.index)).toEqual([0, 2]); expect(probe.streams[0]).toMatchObject({ type: 'video', codecName: 'h264', width: 1920, height: 1080, sampleAspectRatio: '4:3', displayAspectRatio: '16:9', rotationDegrees: -90, frameRate: 30000 / 1001, timeBase: '1/30000', disposition: { default: true }, }); expect(probe.streams[1]).toMatchObject({ type: 'audio', sampleRate: 48000, channels: 2, language: 'deu', title: 'German', disposition: { default: true, forced: false }, }); expect(probe.chapters).toEqual([ { id: 4, startSeconds: 1, endSeconds: 2.5, timeBase: '1/1000', title: 'Opening', tags: { title: 'Opening' }, }, ]); }); it('falls back to r_frame_rate and records malformed data as warnings', () => { const probe = normalizeFfprobeReport({ format: { duration: 'not-a-number', tags: { title: 42 } }, streams: [ { index: 0, codec_type: 'video', avg_frame_rate: '0/0', r_frame_rate: '25/1', disposition: { default: 'sometimes' }, tags: [], }, { index: 1, codec_type: 'telepathy', tags: {}, disposition: {} }, { index: 1, codec_type: 'audio', tags: {}, disposition: {} }, { codec_type: 'audio' }, 'not a stream', ], chapters: [{ id: 0, start_time: '2', end_time: '1', tags: {} }], }); expect(probe.durationSeconds).toBeUndefined(); expect(probe.streams).toHaveLength(2); expect(probe.streams[0]?.frameRate).toBe(25); expect(probe.streams[1]?.type).toBe('unknown'); expect(probe.chapters).toEqual([]); expect(probe.warnings.map((warning) => warning.code)).toEqual( expect.arrayContaining([ 'invalid-field', 'invalid-fraction', 'unknown-stream-type', 'duplicate-stream-index', 'missing-stream-index', 'invalid-stream', 'invalid-chapter', ]) ); }); it('parses legacy rotation and rejects misleading aspect ratios', () => { const probe = normalizeFfprobeReport({ streams: [ { index: 0, codec_type: 'video', sample_aspect_ratio: '0:0', display_aspect_ratio: 'not-available', side_data_list: [{ rotation: 'not-a-number' }], disposition: {}, tags: { rotate: '270' }, }, { index: 1, codec_type: 'video', sample_aspect_ratio: ' 32 / 18 ', display_aspect_ratio: '64:36', disposition: {}, tags: { rotate: '540' }, }, ], }); expect(probe.streams[0]).toMatchObject({ rotationDegrees: -90, }); expect(probe.streams[0]).not.toHaveProperty('sampleAspectRatio'); expect(probe.streams[0]).not.toHaveProperty('displayAspectRatio'); expect(probe.streams[1]).toMatchObject({ sampleAspectRatio: '16:9', displayAspectRatio: '16:9', rotationDegrees: 180, }); expect(probe.warnings).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'invalid-fraction', path: 'streams[0].sample_aspect_ratio', }), expect.objectContaining({ code: 'invalid-fraction', path: 'streams[0].display_aspect_ratio', }), expect.objectContaining({ code: 'invalid-field', path: 'streams[0].side_data_list[0].rotation', }), ]) ); }); it('keeps raw diagnostics separate from the normalized model', () => { const rawReport = '{"format":{"format_name":"wav"},"streams":[]}'; const result = parseFfprobeJson(rawReport); expect(result.rawReport).toBe(rawReport); expect(result.probe.formatNames).toEqual(['wav']); expect(result.probe).not.toHaveProperty('rawReport'); }); it('throws a typed error for malformed JSON or a non-object root', () => { expect(() => parseFfprobeJson('{')).toThrow(ProbeParseError); expect(() => normalizeFfprobeReport([])).toThrow( 'The ffprobe report must be a JSON object.' ); }); });