feat: publish av-tools 0.1.0
This commit is contained in:
86
tests/unit/media/duration-timecode.test.ts
Normal file
86
tests/unit/media/duration-timecode.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
53
tests/unit/media/safe-file-name.test.ts
Normal file
53
tests/unit/media/safe-file-name.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
174
tests/unit/media/stream-playback.test.ts
Normal file
174
tests/unit/media/stream-playback.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user