437 lines
11 KiB
TypeScript
437 lines
11 KiB
TypeScript
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([]);
|
|
});
|
|
});
|