feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions

View 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.'
);
});
});