feat: publish av-tools 0.1.0
This commit is contained in:
436
tests/unit/presets.test.ts
Normal file
436
tests/unit/presets.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AUDIO_PRESETS,
|
||||
BUILT_IN_PRESETS,
|
||||
MemoryUserPresetPersistence,
|
||||
UserPresetRegistry,
|
||||
VIDEO_PRESETS,
|
||||
migrateUserPreset,
|
||||
parseUserPresetJson,
|
||||
presetAvailability,
|
||||
presetEncodingArguments,
|
||||
serializeUserPreset,
|
||||
validateUserPreset,
|
||||
type UserExportPreset,
|
||||
} from '../../src/presets';
|
||||
|
||||
const USER_PRESET: UserExportPreset = {
|
||||
schemaVersion: 1,
|
||||
id: 'my-podcast',
|
||||
name: 'My podcast',
|
||||
description: 'Speech export',
|
||||
kind: 'audio',
|
||||
container: 'mp3',
|
||||
fileExtension: 'mp3',
|
||||
audio: {
|
||||
codec: 'libmp3lame',
|
||||
bitrateKbps: 128,
|
||||
sampleRate: 44_100,
|
||||
channels: 2,
|
||||
},
|
||||
metadataPolicy: 'copy',
|
||||
chapterPolicy: 'remove',
|
||||
subtitlePolicy: 'none',
|
||||
};
|
||||
|
||||
describe('built-in preset registry', () => {
|
||||
it('contains immutable, uniquely identified video and audio definitions', () => {
|
||||
expect(VIDEO_PRESETS.length).toBeGreaterThanOrEqual(6);
|
||||
expect(AUDIO_PRESETS.length).toBeGreaterThanOrEqual(6);
|
||||
expect(new Set(BUILT_IN_PRESETS.map((preset) => preset.id)).size).toBe(
|
||||
BUILT_IN_PRESETS.length
|
||||
);
|
||||
expect(Object.isFrozen(BUILT_IN_PRESETS[0])).toBe(true);
|
||||
expect(Object.isFrozen(BUILT_IN_PRESETS[0]?.settingsSummary)).toBe(true);
|
||||
});
|
||||
|
||||
it('disables a preset with exact missing capability reasons', () => {
|
||||
const preset = BUILT_IN_PRESETS.find(
|
||||
(candidate) => candidate.id === 'webm-vp9-opus'
|
||||
)!;
|
||||
const unavailable = presetAvailability(preset, {
|
||||
muxers: new Set(['webm']),
|
||||
encoders: new Set(['libvpx-vp9']),
|
||||
decoders: new Set(),
|
||||
filters: new Set(),
|
||||
});
|
||||
expect(unavailable).toEqual({
|
||||
status: 'unavailable',
|
||||
reasons: ['Missing encoders: libopus'],
|
||||
});
|
||||
expect(
|
||||
presetAvailability(preset, {
|
||||
muxers: new Set(['webm']),
|
||||
encoders: new Set(['libvpx-vp9', 'libopus']),
|
||||
decoders: new Set(),
|
||||
filters: new Set(),
|
||||
})
|
||||
).toEqual({ status: 'available', reasons: [] });
|
||||
expect(
|
||||
presetAvailability(
|
||||
preset,
|
||||
{
|
||||
muxers: new Set(['webm']),
|
||||
encoders: new Set(['libvpx-vp9', 'libopus']),
|
||||
decoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
{ filters: ['drawtext'] }
|
||||
)
|
||||
).toEqual({
|
||||
status: 'degraded',
|
||||
reasons: ['Optional filters unavailable: drawtext'],
|
||||
});
|
||||
});
|
||||
|
||||
it('turns allowlisted settings into structured encoder arguments', () => {
|
||||
expect(presetEncodingArguments(USER_PRESET)).toEqual([
|
||||
'-vn',
|
||||
'-c:a',
|
||||
'libmp3lame',
|
||||
'-b:a',
|
||||
'128k',
|
||||
'-ar',
|
||||
'44100',
|
||||
'-ac',
|
||||
'2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user preset validation and persistence', () => {
|
||||
it('round-trips a versioned preset as JSON', () => {
|
||||
const json = serializeUserPreset(USER_PRESET);
|
||||
expect(parseUserPresetJson(json)).toEqual(USER_PRESET);
|
||||
expect(validateUserPreset(USER_PRESET)).toMatchObject({ valid: true });
|
||||
});
|
||||
|
||||
it('migrates an allowlisted legacy schema without accepting raw fields', () => {
|
||||
expect(
|
||||
migrateUserPreset({
|
||||
schemaVersion: 0,
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
kind: 'audio',
|
||||
format: 'ipod',
|
||||
audio: { codec: 'aac', bitrateKbps: 128 },
|
||||
includeChapters: true,
|
||||
})
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
kind: 'audio',
|
||||
container: 'ipod',
|
||||
fileExtension: 'm4a',
|
||||
audio: { codec: 'aac', bitrateKbps: 128 },
|
||||
metadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'none',
|
||||
});
|
||||
expect(() =>
|
||||
migrateUserPreset({
|
||||
schemaVersion: 0,
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
kind: 'audio',
|
||||
format: 'mp3',
|
||||
args: ['-i', 'remote'],
|
||||
})
|
||||
).toThrow(/unsupported field/iu);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['args', ['-i', 'https://example.invalid/a']],
|
||||
['filter', 'movie=https://example.invalid'],
|
||||
['inputPath', '/etc/passwd'],
|
||||
['protocol', 'https'],
|
||||
])('rejects imported arbitrary %s fields', (field, value) => {
|
||||
expect(
|
||||
validateUserPreset({ ...USER_PRESET, [field]: value })
|
||||
).toMatchObject({
|
||||
valid: false,
|
||||
errors: [expect.stringContaining(`"${field}"`)],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown codecs and unsafe identifiers', () => {
|
||||
expect(
|
||||
validateUserPreset({
|
||||
...USER_PRESET,
|
||||
id: '../../unsafe',
|
||||
audio: { codec: 'shell', bitrateKbps: 128 },
|
||||
})
|
||||
).toMatchObject({ valid: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'MP4 H.264/AAC video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: { codec: 'libx264', qualityMode: 'balanced', crf: 23 },
|
||||
audio: { codec: 'aac', bitrateKbps: 160 },
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'convert',
|
||||
fastStart: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'WebM VP9/Opus video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'webm',
|
||||
fileExtension: 'webm',
|
||||
video: { codec: 'libvpx-vp9', qualityMode: 'smaller-file', crf: 32 },
|
||||
audio: { codec: 'libopus', bitrateKbps: 128 },
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'convert',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Matroska VP8/FLAC video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'matroska',
|
||||
fileExtension: 'mkv',
|
||||
video: { codec: 'libvpx', qualityMode: 'compatibility', crf: 18 },
|
||||
audio: { codec: 'flac' },
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'copy-compatible',
|
||||
},
|
||||
],
|
||||
[
|
||||
'animated WebP derivative',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'derivative',
|
||||
container: 'webp',
|
||||
fileExtension: 'webp',
|
||||
video: {
|
||||
codec: 'libwebp_anim',
|
||||
qualityMode: 'balanced',
|
||||
crf: 60,
|
||||
},
|
||||
audio: undefined,
|
||||
metadataPolicy: 'remove',
|
||||
chapterPolicy: 'remove',
|
||||
subtitlePolicy: 'none',
|
||||
},
|
||||
],
|
||||
[
|
||||
'GIF derivative',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'derivative',
|
||||
container: 'gif',
|
||||
fileExtension: 'gif',
|
||||
video: { codec: 'gif', qualityMode: 'compatibility' },
|
||||
audio: undefined,
|
||||
metadataPolicy: 'remove',
|
||||
chapterPolicy: 'remove',
|
||||
subtitlePolicy: 'none',
|
||||
},
|
||||
],
|
||||
['MP3 audio', USER_PRESET],
|
||||
[
|
||||
'M4A/AAC audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'ipod',
|
||||
fileExtension: 'm4a',
|
||||
audio: { codec: 'aac', bitrateKbps: 160 },
|
||||
fastStart: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Ogg/Vorbis audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'ogg',
|
||||
fileExtension: 'ogg',
|
||||
audio: { codec: 'libvorbis', bitrateKbps: 160 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'Opus audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'opus',
|
||||
fileExtension: 'opus',
|
||||
audio: { codec: 'libopus', bitrateKbps: 128 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'WAV/PCM audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'wav',
|
||||
fileExtension: 'wav',
|
||||
audio: { codec: 'pcm_s16le' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'FLAC audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'flac',
|
||||
fileExtension: 'flac',
|
||||
audio: { codec: 'flac' },
|
||||
},
|
||||
],
|
||||
])('accepts the reviewed %s combination', (_label, preset) => {
|
||||
expect(validateUserPreset(preset)).toMatchObject({
|
||||
valid: true,
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unknown container',
|
||||
{ ...USER_PRESET, container: 'avi', fileExtension: 'avi' },
|
||||
/container "avi".*allowlist/iu,
|
||||
],
|
||||
[
|
||||
'a mismatched extension',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'ipod',
|
||||
fileExtension: 'mp4',
|
||||
audio: { codec: 'aac' },
|
||||
},
|
||||
/fileExtension must be "m4a"/u,
|
||||
],
|
||||
[
|
||||
'the wrong media kind',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'audio',
|
||||
container: 'webm',
|
||||
fileExtension: 'webm',
|
||||
video: undefined,
|
||||
audio: { codec: 'libopus' },
|
||||
},
|
||||
/container "webm".*kind "audio"/u,
|
||||
],
|
||||
[
|
||||
'an incompatible video codec',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: { codec: 'libvpx-vp9', qualityMode: 'balanced' },
|
||||
audio: { codec: 'aac' },
|
||||
},
|
||||
/video codec "libvpx-vp9".*container "mp4"/u,
|
||||
],
|
||||
[
|
||||
'an incompatible audio codec',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'webm',
|
||||
fileExtension: 'webm',
|
||||
video: { codec: 'libvpx', qualityMode: 'balanced' },
|
||||
audio: { codec: 'aac' },
|
||||
},
|
||||
/audio codec "aac".*container "webm"/u,
|
||||
],
|
||||
[
|
||||
'audio in an image derivative',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'derivative',
|
||||
container: 'gif',
|
||||
fileExtension: 'gif',
|
||||
video: { codec: 'gif', qualityMode: 'compatibility' },
|
||||
},
|
||||
/container "gif" does not support audio settings/u,
|
||||
],
|
||||
[
|
||||
'a video container without video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
audio: { codec: 'aac' },
|
||||
video: undefined,
|
||||
},
|
||||
/container "mp4" requires video settings/u,
|
||||
],
|
||||
[
|
||||
'fast-start outside MP4/M4A',
|
||||
{ ...USER_PRESET, fastStart: true },
|
||||
/fastStart is only supported.*not "mp3"/u,
|
||||
],
|
||||
])('rejects %s with a clear compatibility error', (_label, preset, error) => {
|
||||
const result = validateUserPreset(preset);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join('; ')).toMatch(error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['metadataPolicy', 'edit', /metadataPolicy "edit".*Quick presets/u],
|
||||
['chapterPolicy', 'replace', /chapterPolicy "replace".*Quick presets/u],
|
||||
[
|
||||
'subtitlePolicy',
|
||||
'burn-in',
|
||||
/subtitlePolicy "burn-in".*explicit subtitle source/u,
|
||||
],
|
||||
])(
|
||||
'rejects the unsupported Quick preset policy %s=%s',
|
||||
(field, value, error) => {
|
||||
const result = validateUserPreset({
|
||||
...USER_PRESET,
|
||||
[field]: value,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join('; ')).toMatch(error);
|
||||
}
|
||||
);
|
||||
|
||||
it('applies relational compatibility checks to imported JSON', () => {
|
||||
expect(() =>
|
||||
parseUserPresetJson(
|
||||
JSON.stringify({
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: { codec: 'libvpx-vp9', qualityMode: 'balanced' },
|
||||
audio: { codec: 'aac' },
|
||||
})
|
||||
)
|
||||
).toThrow(/video codec "libvpx-vp9".*container "mp4"/iu);
|
||||
});
|
||||
|
||||
it('supports create, clone, rename, export, import, delete and reset', async () => {
|
||||
const persistence = new MemoryUserPresetPersistence();
|
||||
const registry = new UserPresetRegistry(persistence);
|
||||
await registry.initialize();
|
||||
await registry.save(USER_PRESET);
|
||||
const clone = await registry.clone('my-podcast', 'my-podcast-copy', 'Copy');
|
||||
expect(clone.name).toBe('Copy');
|
||||
await registry.rename(clone.id, 'Renamed');
|
||||
expect(registry.get(clone.id)?.name).toBe('Renamed');
|
||||
const exported = registry.export('my-podcast');
|
||||
await registry.delete('my-podcast');
|
||||
expect(registry.get('my-podcast')).toBeUndefined();
|
||||
await registry.import(exported);
|
||||
expect(registry.get('my-podcast')).toEqual(USER_PRESET);
|
||||
await registry.reset();
|
||||
expect(registry.list()).toEqual([]);
|
||||
|
||||
const reloaded = new UserPresetRegistry(persistence);
|
||||
await reloaded.initialize();
|
||||
expect(reloaded.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user