feat: publish av-tools 0.1.0
This commit is contained in:
249
tests/unit/media/probe-parser.test.ts
Normal file
249
tests/unit/media/probe-parser.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user