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,86 @@
import { describe, expect, it } from 'vitest';
import {
addDurations,
clampTime,
roundSeconds,
validateSourceRange,
} from '../../../src/media/duration';
import {
formatFrameTimecode,
formatTimecode,
parseTimecode,
parseTimecodeDetailed,
} from '../../../src/media/timecode';
describe('duration arithmetic', () => {
it('adds durations without exposing common binary drift', () => {
expect(addDurations([0.1, 0.2, 1.0000004])).toBe(1.3);
expect(roundSeconds(-0.0000001, 3)).toBe(0);
});
it('rejects invalid durations and clamps preview times', () => {
expect(() => addDurations([1, Number.NaN])).toThrow();
expect(() => addDurations([-1])).toThrow();
expect(clampTime(-2, 10)).toBe(0);
expect(clampTime(12, 10)).toBe(10);
});
it('validates source in/out ranges and source bounds', () => {
expect(validateSourceRange(1, 3, 5)).toEqual({
valid: true,
durationSeconds: 2,
issues: [],
});
expect(validateSourceRange(3, 1, 5).issues[0]?.code).toBe(
'source-out-before-in'
);
expect(validateSourceRange(1, 1, 5).issues[0]?.code).toBe('clip-too-short');
expect(
validateSourceRange(1, 6, 5).issues.some(
(issue) => issue.code === 'source-out-after-duration'
)
).toBe(true);
});
});
describe('timecode parsing and formatting', () => {
it.each([
['12.5', 12.5, undefined],
['01:02.500', 62.5, undefined],
['02:03:04.125', 7_384.125, undefined],
['00:00:10:12', 10.5, { frameRate: 24 }],
])('parses %s', (text, expected, options) => {
expect(parseTimecode(text, options)).toBe(expected);
});
it('reports malformed components', () => {
expect(parseTimecodeDetailed('01:60')).toEqual({
ok: false,
reason: 'seconds-out-of-range',
});
expect(parseTimecodeDetailed('00:00:01:10')).toEqual({
ok: false,
reason: 'frame-rate-required',
});
expect(parseTimecodeDetailed('-00:01', { allowNegative: false })).toEqual({
ok: false,
reason: 'negative-not-allowed',
});
expect(parseTimecode('anything')).toBeUndefined();
expect(parseTimecode('1.5:02:03')).toBeUndefined();
});
it('formats carry, precision, negative values and nominal frames', () => {
expect(formatTimecode(59.9996)).toBe('00:01:00.000');
expect(
formatTimecode(62.5, {
decimalPlaces: 2,
alwaysShowHours: false,
})
).toBe('01:02.50');
expect(formatTimecode(-1.25, { decimalPlaces: 2 })).toBe('-00:00:01.25');
expect(formatFrameTimecode(10.5, 24)).toBe('00:00:10:12');
expect(() => formatFrameTimecode(5, 0.5)).toThrow();
});
});

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

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
createSafeVirtualFileName,
safeFileName,
safeOutputFileName,
splitFileName,
} from '../../../src/media/safe-file-name';
describe('safe filenames', () => {
it('removes path traversal, control characters and shell-like punctuation', () => {
expect(safeFileName('../../My $ video\u0000 (final).MP4')).toBe(
'My-video-final.mp4'
);
expect(safeFileName('..\\..\\evil.webm')).toBe('evil.webm');
expect(safeFileName('../')).toBe('file');
});
it('handles Unicode, hidden names and reserved device names', () => {
expect(safeFileName('Crème brûlée.mov')).toBe('Creme-brulee.mov');
expect(safeFileName('.env')).toBe('env');
expect(safeFileName('CON.mp4')).toBe('_CON.mp4');
});
it('preserves a short extension while enforcing the requested maximum', () => {
const result = safeFileName(`${'a'.repeat(200)}.mp4`, {
maxLength: 32,
});
expect(result).toHaveLength(32);
expect(result.endsWith('.mp4')).toBe(true);
});
it('creates generated virtual names without leaking the original stem', () => {
expect(createSafeVirtualFileName('private-holiday.mov', 3)).toBe(
'source-3.mov'
);
expect(() => createSafeVirtualFileName('x.mp4', -1)).toThrow();
});
it('creates deterministic download names and splits compound extensions', () => {
expect(safeOutputFileName('My export (1)', '.WEBM')).toBe(
'My-export-1.webm'
);
expect(safeOutputFileName('Project', 'avproject.json')).toBe(
'Project.avproject.json'
);
expect(safeOutputFileName('Project.v2', 'mp4')).toBe('Project.v2.mp4');
expect(splitFileName('archive.tar.gz')).toEqual({
stem: 'archive.tar',
extension: '.gz',
});
});
});

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import {
assessBrowserPlayback,
assessGeneratedResultPreview,
buildPlaybackMimeCandidates,
} from '../../../src/media/browser-playback';
import type {
MediaProbe,
MediaStreamProbe,
} from '../../../src/media/media.types';
import {
selectDefaultStreams,
streamSelectionMapArgs,
validateStreamSelection,
} from '../../../src/media/stream-selection';
function stream(
index: number,
type: MediaStreamProbe['type'],
overrides: Partial<MediaStreamProbe> = {}
): MediaStreamProbe {
return {
index,
type,
disposition: {},
tags: {},
...overrides,
};
}
function probe(streams: MediaStreamProbe[]): MediaProbe {
return {
formatNames: ['mov', 'mp4'],
tags: {},
streams,
chapters: [],
warnings: [],
};
}
describe('stream selection', () => {
const media = probe([
stream(0, 'video', { codecName: 'h264' }),
stream(1, 'audio', {
language: 'eng',
disposition: { default: true },
}),
stream(2, 'audio', {
language: 'de-DE',
}),
stream(3, 'subtitle', { language: 'deu' }),
stream(4, 'video', { disposition: { attached_pic: true } }),
]);
it('chooses default streams, avoids cover art and can include subtitles', () => {
expect(
selectDefaultStreams(media, {
preferredAudioLanguages: ['de'],
preferredSubtitleLanguages: ['deu'],
includeSubtitles: true,
})
).toEqual({
videoStreamIndex: 0,
audioStreamIndexes: [2],
subtitleStreamIndexes: [3],
});
});
it('validates stream existence, type and duplicate mappings', () => {
const result = validateStreamSelection(media, {
videoStreamIndex: 1,
audioStreamIndexes: [2, 2, 99],
subtitleStreamIndexes: [],
});
expect(result.valid).toBe(false);
expect(result.issues.map((issue) => issue.code)).toEqual([
'wrong-stream-type',
'duplicate-stream',
'stream-not-found',
]);
});
it('generates structured map arguments in deterministic order', () => {
expect(
streamSelectionMapArgs({
videoStreamIndex: 0,
audioStreamIndexes: [2],
subtitleStreamIndexes: [3],
})
).toEqual(['-map', '0:0', '-map', '0:2', '-map', '0:3']);
});
});
describe('browser playback assessment', () => {
const mp4 = probe([
stream(0, 'video', { codecName: 'h264' }),
stream(1, 'audio', { codecName: 'aac' }),
]);
it('builds codec-qualified and container-only MIME candidates', () => {
expect(buildPlaybackMimeCandidates(mp4)).toEqual([
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
'video/mp4',
]);
});
it('keeps browser support separate from FFmpeg readability', () => {
expect(
assessBrowserPlayback(mp4, (mime) =>
mime.includes('codecs') ? 'probably' : ''
)
).toMatchObject({
support: 'probably',
needsPreviewProxy: false,
});
expect(assessBrowserPlayback(mp4, () => '')).toMatchObject({
support: 'unsupported',
needsPreviewProxy: true,
});
});
it('does not treat an audio/video MIME prefix as playable when the browser rejects it', () => {
expect(
assessGeneratedResultPreview(
{
fileName: 'generated.flac',
mimeType: 'audio/flac',
},
() => ''
)
).toMatchObject({
kind: 'audio',
previewable: false,
support: 'unsupported',
});
expect(
assessGeneratedResultPreview(
{
fileName: 'generated.mp4',
mimeType: 'video/mp4',
probe: mp4,
},
(mime) => (mime.includes('codecs') ? 'probably' : '')
)
).toMatchObject({
kind: 'video',
previewable: true,
support: 'probably',
});
expect(
assessGeneratedResultPreview(
{
fileName: 'frame.png',
mimeType: 'image/png',
},
() => ''
)
).toMatchObject({ kind: 'image', previewable: true });
expect(
assessGeneratedResultPreview(
{
fileName: 'frame.tiff',
mimeType: 'image/tiff',
},
() => ''
)
).toMatchObject({
kind: 'image',
previewable: false,
support: 'unsupported',
});
});
});