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,555 @@
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
AdvancedOperations,
type AdvancedCapabilityKey,
type AdvancedCapabilityStatus,
type AdvancedOperationsProps,
} from '../../src/components/AdvancedOperations';
const AVAILABLE: AdvancedCapabilityStatus = { available: true };
function capabilities(
...keys: readonly AdvancedCapabilityKey[]
): AdvancedOperationsProps['availability'] {
return Object.fromEntries(keys.map((key) => [key, AVAILABLE]));
}
const SOURCE: NonNullable<AdvancedOperationsProps['source']> = {
durationSeconds: 90,
sourceExtension: 'mp4',
hasAudio: true,
hasVideo: true,
selectedAudioStreamIndex: 1,
};
const EXPORT_PRESETS: NonNullable<AdvancedOperationsProps['exportPresets']> = [
{
id: 'mp4-h264-balanced',
name: 'MP4 · H.264 + AAC',
fileExtension: 'mp4',
supportsAudio: true,
supportsVideo: true,
},
{
id: 'audio-mp3',
name: 'MP3',
fileExtension: 'mp3',
supportsAudio: true,
supportsVideo: false,
},
];
afterEach(cleanup);
describe('AdvancedOperations', () => {
it('fails closed when a runtime capability was not explicitly verified', () => {
render(
<AdvancedOperations
source={SOURCE}
splitMarkers={[30]}
onSplit={vi.fn()}
/>
);
const action = screen.getByRole('button', { name: 'Queue 2 segments' });
expect(action).toBeDisabled();
expect(
screen.getByText(
'The required browser and FFmpeg capabilities have not been verified.'
)
).toBeInTheDocument();
});
it('builds a validated marker request and explains fast versus accurate mode', async () => {
const user = userEvent.setup();
const onSplit = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
splitMarkers={[60, 30]}
exportPresets={EXPORT_PRESETS}
availability={capabilities('split-fast', 'split-accurate')}
onSplit={onSplit}
/>
);
expect(
screen.getByText(/boundaries may move to nearby keyframes/iu)
).toBeInTheDocument();
expect(screen.getByText(/practical frame accuracy/iu)).toBeInTheDocument();
const ranges = screen.getByLabelText('Exact planned segment ranges');
expect(within(ranges).getByText('0 s30 s')).toBeVisible();
expect(within(ranges).getByText('30 s60 s')).toBeVisible();
expect(within(ranges).getByText('60 s90 s')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Queue 3 segments' }));
await waitFor(() =>
expect(onSplit).toHaveBeenCalledWith({
mode: 'fast',
definition: { type: 'markers', markers: [30, 60] },
targetExtension: 'mp4',
})
);
await user.click(
screen.getByRole('radio', { name: /Accurate re-encode/iu })
);
await user.click(screen.getByRole('button', { name: 'Queue 3 segments' }));
await waitFor(() =>
expect(onSplit).toHaveBeenLastCalledWith({
mode: 'accurate',
definition: { type: 'markers', markers: [30, 60] },
targetExtension: 'mp4',
presetId: 'mp4-h264-balanced',
})
);
});
it('shows exact latest-batch status and individual segment failure details', () => {
render(
<AdvancedOperations
source={SOURCE}
splitMarkers={[30]}
splitBatch={{
planId: 'split-plan',
segments: [
{
outputId: 'segment-1',
fileName: 'source-split-001.mp4',
startSeconds: 0,
endSeconds: 30,
state: 'verified',
},
{
outputId: 'segment-2',
fileName: 'source-split-002.mp4',
startSeconds: 30,
endSeconds: 90,
state: 'failed',
message: 'FFmpeg did not return this expected output.',
},
],
}}
/>
);
const status = screen.getByLabelText('Split segment status');
expect(within(status).getByText('source-split-001.mp4')).toBeVisible();
expect(within(status).getByText('0 s30 s')).toBeVisible();
expect(within(status).getByText('verified')).toBeVisible();
expect(
within(status).getByText('FFmpeg did not return this expected output.')
).toBeVisible();
});
it('supports keyboard tab navigation and emits typed loudness targets', async () => {
const user = userEvent.setup();
const onAudioAction = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
exportPresets={EXPORT_PRESETS}
availability={capabilities(
'loudness-measure',
'loudness-normalize',
'peak-normalize',
'waveform-static',
'waveform-peaks'
)}
onAudioAction={onAudioAction}
/>
);
const splitTab = screen.getByRole('tab', { name: 'Split media' });
splitTab.focus();
await user.keyboard('{ArrowRight}');
expect(
screen.getByRole('tab', { name: 'Waveform and loudness' })
).toHaveAttribute('aria-selected', 'true');
await user.selectOptions(
screen.getByLabelText(/^Target profile/iu),
'podcast'
);
await user.click(screen.getByRole('button', { name: 'Measure EBU R128' }));
await waitFor(() =>
expect(onAudioAction).toHaveBeenCalledWith({
type: 'loudness-measurement',
audioStreamIndex: 1,
targets: {
integratedLufs: -16,
truePeakDbtp: -1,
loudnessRangeLu: 11,
},
})
);
const normalize = screen.getByRole('button', {
name: 'Apply measured normalization',
});
expect(normalize).toBeDisabled();
expect(
screen.getByText(
'Run the first-pass loudness measurement before normalization.'
)
).toBeInTheDocument();
});
it('uses an audio-only preset for measured normalization', async () => {
const user = userEvent.setup();
const onAudioAction = vi.fn();
const measurement = {
inputIntegratedLufs: -19.7,
inputTruePeakDbtp: -2.1,
inputLoudnessRangeLu: 4.2,
inputThresholdLufs: -30,
targetOffsetLu: -0.3,
};
render(
<AdvancedOperations
source={SOURCE}
exportPresets={EXPORT_PRESETS}
loudnessMeasurement={measurement}
availability={capabilities('loudness-normalize')}
onAudioAction={onAudioAction}
/>
);
await user.click(
screen.getByRole('tab', { name: 'Waveform and loudness' })
);
await user.click(
screen.getByRole('button', { name: 'Apply measured normalization' })
);
await waitFor(() =>
expect(onAudioAction).toHaveBeenCalledWith({
type: 'loudness-normalization',
audioStreamIndex: 1,
targets: {
integratedLufs: -14,
truePeakDbtp: -1,
loudnessRangeLu: 11,
},
measurement,
presetId: 'audio-mp3',
})
);
});
it('keeps unverified contact-sheet labels off and emits a bounded series request', async () => {
const user = userEvent.setup();
const onThumbnailSeries = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
availability={capabilities('thumbnail-series', 'contact-sheet')}
imageFormatAvailability={{ jpeg: AVAILABLE }}
onThumbnailSeries={onThumbnailSeries}
onContactSheet={vi.fn()}
/>
);
await user.click(
screen.getByRole('tab', {
name: 'Thumbnails and contact sheets',
})
);
expect(screen.getByLabelText('Timestamp labels')).toBeDisabled();
expect(screen.getByLabelText('Source filename label')).toBeDisabled();
expect(
screen.getByText(
'Labels stay off until drawtext and a safe local font are verified.'
)
).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Generate thumbnail series' })
);
await waitFor(() =>
expect(onThumbnailSeries).toHaveBeenCalledWith({
distribution: { type: 'evenly-spaced', count: 12 },
width: 480,
format: 'jpeg',
quality: 85,
})
);
});
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
const user = userEvent.setup();
const onSubtitleAction = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
subtitleStreams={[
{ index: 2, codec: 'dvd_subtitle', language: 'deu' },
{ index: 3, codec: 'subrip', language: 'eng' },
]}
availability={capabilities('subtitle-extract')}
onSubtitleAction={onSubtitleAction}
/>
);
await user.click(screen.getByRole('tab', { name: 'Subtitle operations' }));
expect(
screen.getByRole('button', { name: 'Extract subtitle' })
).toBeDisabled();
expect(
screen.getByText(/bitmap subtitle extraction is not text conversion/iu)
).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Subtitle stream'), '3');
await user.click(screen.getByRole('button', { name: 'Extract subtitle' }));
await waitFor(() =>
expect(onSubtitleAction).toHaveBeenCalledWith({
type: 'extract',
stream: { index: 3, codec: 'subrip', language: 'eng' },
outputFormat: 'srt',
})
);
});
it('retains validated stream metadata JSON for apply and export', async () => {
const user = userEvent.setup();
const onMetadataAction = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
availability={capabilities('metadata-write')}
onMetadataAction={onMetadataAction}
/>
);
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
await user.upload(
screen.getByLabelText('Import metadata JSON'),
new File(
[
JSON.stringify({
format: { title: 'Imported title', genre: 'Synthetic' },
streams: {
1: { language: 'deu', title: 'German dialogue' },
},
}),
],
'metadata.json',
{ type: 'application/json' }
)
);
await waitFor(() =>
expect(onMetadataAction).toHaveBeenCalledWith({
type: 'import',
edits: {
format: { title: 'Imported title', genre: 'Synthetic' },
streams: {
1: { language: 'deu', title: 'German dialogue' },
},
},
})
);
expect(screen.getByText(/1 stream metadata edit loaded/iu)).toBeVisible();
await user.click(
screen.getByRole('button', { name: 'Apply metadata policies' })
);
await waitFor(() =>
expect(onMetadataAction).toHaveBeenLastCalledWith({
type: 'apply',
edits: {
format: { genre: 'Synthetic', title: 'Imported title' },
streams: {
1: { language: 'deu', title: 'German dialogue' },
},
},
sourceMetadataPolicy: 'copy',
chapterPolicy: 'keep',
coverArtPolicy: 'keep',
})
);
});
it('edits probed stream language, title, and typed dispositions directly', async () => {
const user = userEvent.setup();
const onMetadataAction = vi.fn();
render(
<AdvancedOperations
source={{ ...SOURCE, sourceId: 'source-a' }}
metadataStreams={[
{
index: 1,
type: 'audio',
codec: 'aac',
language: 'eng',
title: 'Original',
disposition: { default: false, forced: false },
},
]}
availability={capabilities('metadata-write')}
onMetadataAction={onMetadataAction}
/>
);
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
const language = screen.getByLabelText('Stream #1 language');
await user.clear(language);
await user.type(language, 'deu');
const title = screen.getByLabelText('Stream #1 title');
await user.clear(title);
await user.type(title, 'German dialogue');
await user.click(screen.getByLabelText('Default stream #1'));
await user.click(
screen.getByRole('button', { name: 'Apply metadata policies' })
);
await waitFor(() =>
expect(onMetadataAction).toHaveBeenCalledWith({
type: 'apply',
edits: {
format: {},
streams: {
1: { language: 'deu', title: 'German dialogue' },
},
dispositions: { 1: ['default'] },
},
sourceMetadataPolicy: 'copy',
chapterPolicy: 'keep',
coverArtPolicy: 'keep',
})
);
});
it('does not clear untouched source container tags', async () => {
const user = userEvent.setup();
const onMetadataAction = vi.fn();
render(
<AdvancedOperations
source={{ ...SOURCE, sourceId: 'source-tags' }}
metadataFormatTags={{
title: 'Original title',
artist: 'Original artist',
album: 'Original album',
}}
availability={capabilities('metadata-write')}
onMetadataAction={onMetadataAction}
/>
);
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
expect(screen.getByLabelText('Artist')).toHaveValue('Original artist');
const title = screen.getByLabelText('Title');
await user.clear(title);
await user.type(title, 'Replacement title');
await user.click(
screen.getByRole('button', { name: 'Apply metadata policies' })
);
await waitFor(() =>
expect(onMetadataAction).toHaveBeenCalledWith({
type: 'apply',
edits: { format: { title: 'Replacement title' } },
sourceMetadataPolicy: 'copy',
chapterPolicy: 'keep',
coverArtPolicy: 'keep',
})
);
});
it('creates only an allowlisted typed user preset', async () => {
const user = userEvent.setup();
const onUserPresetAction = vi.fn();
render(
<AdvancedOperations
availability={capabilities('preset-storage')}
onUserPresetAction={onUserPresetAction}
/>
);
await user.click(screen.getByRole('tab', { name: 'User export presets' }));
const editor = screen
.getByRole('heading', { name: 'Create typed preset' })
.closest('section');
expect(editor).not.toBeNull();
const editorQueries = within(editor as HTMLElement);
await user.type(editorQueries.getByLabelText('Safe ID'), 'my-web-export');
await user.type(editorQueries.getByLabelText('Name'), 'My web export');
await user.click(
editorQueries.getByRole('button', { name: 'Create preset' })
);
await waitFor(() =>
expect(onUserPresetAction).toHaveBeenCalledWith({
type: 'create',
preset: {
schemaVersion: 1,
id: 'my-web-export',
name: 'My web export',
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: {
codec: 'libx264',
qualityMode: 'balanced',
crf: 23,
},
audio: {
codec: 'aac',
bitrateKbps: 160,
sampleRate: 48000,
channels: 2,
},
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'none',
fastStart: true,
},
})
);
expect(JSON.stringify(onUserPresetAction.mock.calls)).not.toMatch(
/(?:args|filter|protocol|inputPath)/u
);
});
it('does not offer preset policies that require explicit advanced inputs', async () => {
const user = userEvent.setup();
render(
<AdvancedOperations
availability={capabilities('preset-storage')}
onUserPresetAction={vi.fn()}
/>
);
await user.click(screen.getByRole('tab', { name: 'User export presets' }));
const editor = screen
.getByRole('heading', { name: 'Create typed preset' })
.closest('section');
expect(editor).not.toBeNull();
const editorQueries = within(editor as HTMLElement);
expect(
within(editorQueries.getByLabelText('Metadata')).queryByRole('option', {
name: 'Apply edits',
})
).not.toBeInTheDocument();
expect(
within(editorQueries.getByLabelText('Chapters')).queryByRole('option', {
name: 'Replace',
})
).not.toBeInTheDocument();
expect(
within(editorQueries.getByLabelText('Subtitles')).queryByRole('option', {
name: 'Burn in',
})
).not.toBeInTheDocument();
expect(
editorQueries.getByText(/remain explicit operations/iu)
).toBeVisible();
});
});

View File

@@ -0,0 +1,458 @@
import { describe, expect, it } from 'vitest';
import {
buildContactSheetPlan,
buildConvertPlan,
buildFadePlan,
buildLoudnessAnalysisPlan,
buildLoudnessApplyPlan,
buildMetadataEditPlan,
buildPeakAnalysisPlan,
buildPeakNormalizationFilter,
buildPeakNormalizationPlan,
buildSubtitleBurnPlan,
buildSubtitleExtractionPlan,
buildSubtitleMuxPlan,
buildStaticWaveformPlan,
buildSingleThumbnailPlan,
buildThumbnailSeriesPlan,
buildTransformPlan,
classifySubtitleCodec,
createChaptersAtIntervals,
createChaptersFromMarkers,
escapeFFMetadata,
escapeFilterValue,
metadataArguments,
parseChapterJson,
parseLoudnessMeasurement,
parsePeakMeasurement,
parseMetadataJson,
resolveThumbnailTimes,
serializeChapterJson,
serializeFFMetadata,
validateChapters,
} from '../../src/commands';
import { findBuiltInPreset } from '../../src/presets';
const SOURCE = {
id: 'media',
sourceIndex: 0,
fileName: 'source.mp4',
extension: 'mp4',
} as const;
describe('metadata and chapter commands', () => {
it('serializes deterministic metadata args without a command string', () => {
expect(
metadataArguments({
format: { title: 'A title', artist: 'An artist' },
streams: { 1: { language: 'deu', title: null } },
})
).toEqual([
'-metadata',
'artist=An artist',
'-metadata',
'title=A title',
'-metadata:s:1',
'language=deu',
'-metadata:s:1',
'title=',
]);
expect(
parseMetadataJson(
'{"format":{"title":"Demo"},"streams":{"0":{"language":"eng"}},"dispositions":{"0":["forced","default"]}}'
)
).toEqual({
format: { title: 'Demo' },
streams: { 0: { language: 'eng' } },
dispositions: { 0: ['default', 'forced'] },
});
expect(
metadataArguments({
dispositions: { 0: [], 1: ['forced', 'default'] },
})
).toEqual(['-disposition:0', '0', '-disposition:1', 'default+forced']);
expect(() =>
parseMetadataJson('{"dispositions":{"0":["default","default"]}}')
).toThrow(/duplicates/iu);
expect(() => parseMetadataJson('{"args":["-i","remote"]}')).toThrow(
/unsupported/iu
);
});
it('escapes every FFMETADATA delimiter and round-trips chapter JSON', () => {
expect(escapeFFMetadata('a=b;c#d\\e\nf')).toBe('a\\=b\\;c\\#d\\\\e\\\nf');
const chapters = [
{ id: 'intro', title: 'Intro #1', startSeconds: 0, endSeconds: 2 },
{ id: 'main', title: 'Main', startSeconds: 2, endSeconds: 5 },
];
expect(serializeFFMetadata(chapters)).toContain('title=Intro \\#1');
const json = serializeChapterJson(chapters);
expect(parseChapterJson(json).chapters).toEqual(chapters);
expect(
validateChapters([chapters[0]!, { ...chapters[1]!, startSeconds: 1 }])[0]
).toMatchObject({ severity: 'error' });
expect(createChaptersFromMarkers([2, 5], 8)).toHaveLength(3);
expect(
createChaptersAtIntervals(10, 4).map((chapter) => chapter.startSeconds)
).toEqual([0, 4, 8]);
});
it('preserves validated per-chapter time bases and converts seconds to ticks', () => {
const metadata = serializeFFMetadata([
{
id: 'milliseconds',
title: 'Milliseconds',
startSeconds: 0,
endSeconds: 0.5,
timeBase: '1/1000',
},
{
id: 'samples',
title: 'Samples',
startSeconds: 0.5,
endSeconds: 1,
timeBase: '1/48000',
},
]);
expect(metadata).toContain('TIMEBASE=1/1000\nSTART=0\nEND=500');
expect(metadata).toContain('TIMEBASE=1/48000\nSTART=24000\nEND=48000');
expect(() =>
serializeFFMetadata([
{
id: 'invalid',
title: 'Invalid',
startSeconds: 0,
endSeconds: 1,
timeBase: '0/1000',
},
])
).toThrow(/time base/iu);
expect(() =>
serializeFFMetadata([
{
id: 'coarse',
title: 'Coarse',
startSeconds: 0,
endSeconds: 0.1,
timeBase: '1/1',
},
])
).toThrow(/too coarse/iu);
});
it('builds a stream-copy metadata edit with explicit removal diagnostics', () => {
const plan = buildMetadataEditPlan({
jobId: 'meta',
source: SOURCE,
edits: { format: { title: 'Edited' } },
targetExtension: 'mp4',
targetMuxer: 'mp4',
sourceMetadataPolicy: 'remove',
chapterPolicy: 'remove',
});
expect(plan.args).toContain('title=Edited');
expect(plan.args).toContain('copy');
expect(plan.diagnostics).toContainEqual(
expect.objectContaining({ code: 'metadata-removal-scope' })
);
});
});
describe('loudness and subtitle commands', () => {
const LOG = `
[Parsed_loudnorm_0] {
"input_i" : "-19.70",
"input_tp" : "-2.10",
"input_lra" : "4.20",
"input_thresh" : "-30.00",
"target_offset" : "-0.30"
}`;
it('parses complete loudnorm JSON and rejects invalid measurements', () => {
expect(parseLoudnessMeasurement(LOG)).toEqual({
inputIntegratedLufs: -19.7,
inputTruePeakDbtp: -2.1,
inputLoudnessRangeLu: 4.2,
inputThresholdLufs: -30,
targetOffsetLu: -0.3,
});
expect(() =>
parseLoudnessMeasurement(
'{"input_i":"-inf","input_tp":"0","input_lra":"0","input_thresh":"0"}'
)
).toThrow(/finite/u);
expect(buildPeakNormalizationFilter(-1, { inputPeakDbfs: -6 })).toMatch(
/^volume=1\.778/u
);
});
it('builds distinct first and second loudness passes', () => {
const targets = {
integratedLufs: -16,
truePeakDbtp: -1,
loudnessRangeLu: 11,
};
const measurement = parseLoudnessMeasurement(LOG);
const analysis = buildLoudnessAnalysisPlan({
jobId: 'loud',
source: SOURCE,
targets,
audioStreamIndex: 1,
});
expect(analysis.outputs).toEqual([]);
expect(analysis.args).toContain('-f');
expect(analysis.args).toContain('null');
expect(analysis.args).toContain('0:1');
expect(
analysis.args.find((value) => value.startsWith('loudnorm='))
).toContain('print_format=json');
const apply = buildLoudnessApplyPlan({
jobId: 'loud',
source: SOURCE,
targets,
measurement,
audioStreamIndex: 1,
preset: findBuiltInPreset('audio-m4a-aac')!,
});
expect(apply.args.find((value) => value.startsWith('loudnorm='))).toContain(
'measured_I=-19.7'
);
const peakAnalysis = buildPeakAnalysisPlan({
jobId: 'peak',
source: SOURCE,
audioStreamIndex: 1,
preset: findBuiltInPreset('audio-m4a-aac')!,
});
expect(
peakAnalysis.args.find((value) => value.includes('volumedetect'))
).toContain('aformat=channel_layouts=stereo');
const peakMeasurement = parsePeakMeasurement('max_volume: -6.0 dB');
const peak = buildPeakNormalizationPlan({
jobId: 'peak',
source: SOURCE,
audioStreamIndex: 1,
targetPeakDb: -1,
measurement: peakMeasurement,
preset: findBuiltInPreset('audio-m4a-aac')!,
});
expect(peak.args.find((value) => value.includes('volume='))).toContain(
'volume=1.778'
);
});
it('classifies text and bitmap subtitles and blocks bitmap-to-text extraction', () => {
expect(classifySubtitleCodec('subrip')).toBe('text');
expect(classifySubtitleCodec('hdmv_pgs_subtitle')).toBe('bitmap');
expect(() =>
buildSubtitleExtractionPlan({
jobId: 'sub',
source: SOURCE,
stream: { index: 2, codec: 'hdmv_pgs_subtitle' },
outputFormat: 'srt',
})
).toThrow(/bitmap/iu);
});
it('converts text subtitles for target containers and safely builds burn-in', () => {
const subtitle = {
id: 'subtitle',
sourceIndex: 1,
fileName: "my:sub's.srt",
extension: 'srt',
} as const;
const mux = buildSubtitleMuxPlan({
jobId: 'mux',
source: SOURCE,
subtitle,
subtitleCodec: 'subrip',
sourceStreams: [
{ index: 0, kind: 'video', codec: 'h264' },
{ index: 1, kind: 'audio', codec: 'aac' },
],
targetContainer: 'mp4',
targetExtension: 'mp4',
language: 'eng',
default: true,
});
expect(mux.args).toContain('mov_text');
const burn = buildSubtitleBurnPlan({
jobId: 'burn',
source: SOURCE,
subtitle,
preset: findBuiltInPreset('mp4-h264-balanced')!,
});
expect(burn.inputs).toHaveLength(2);
expect(burn.args.find((value) => value.startsWith('subtitles='))).toContain(
'/input/job-burn/source-1.srt'
);
expect(escapeFilterValue("/path/a:b,c's.ass")).toBe(
"/path/a\\:b\\,c\\'s.ass"
);
});
});
describe('waveform plans', () => {
it('builds a bounded static derivative for an absolute audio stream', () => {
const plan = buildStaticWaveformPlan({
jobId: 'wave',
source: SOURCE,
audioStreamIndex: 1,
width: 640,
height: 160,
});
expect(plan.args.join(' ')).toContain(
'[0:1]aformat=channel_layouts=mono,showwavespic=s=640x160'
);
expect(plan.outputs[0]).toMatchObject({
role: 'analysis',
mediaType: 'image/png',
});
});
});
describe('thumbnail and contact sheet plans', () => {
it('supports explicit stretched single-frame dimensions and quality', () => {
const plan = buildSingleThumbnailPlan({
jobId: 'single-thumb',
source: SOURCE,
timeSeconds: 1.25,
size: { width: 640, height: 360, maintainAspect: false },
format: 'jpeg',
quality: 85,
});
expect(plan.args).toContain('scale=640:360');
expect(plan.args).not.toContain(
'scale=640:360:force_original_aspect_ratio=decrease'
);
expect(plan.args).toEqual(
expect.arrayContaining(['-q:v', '6', '-frames:v', '1'])
);
});
it('generates ordered unique times from interval, count, and explicit selections', () => {
expect(
resolveThumbnailTimes({ durationSeconds: 10, intervalSeconds: 4 })
).toEqual([0, 4, 8]);
expect(resolveThumbnailTimes({ durationSeconds: 10, count: 3 })).toEqual([
2.5, 5, 7.5,
]);
expect(
resolveThumbnailTimes({ durationSeconds: 10, timesSeconds: [5, 1, 5] })
).toEqual([1, 5]);
});
it('builds one invocation for a series and declares every output', () => {
const plan = buildThumbnailSeriesPlan({
jobId: 'thumbs',
source: SOURCE,
durationSeconds: 10,
count: 3,
format: 'png',
size: { width: 320 },
});
expect(plan.outputs).toHaveLength(3);
expect(plan.args.filter((value) => value === '-i')).toHaveLength(1);
expect(plan.args).toContain('-filter_complex');
expect(plan.args.filter((value) => value === '-frames:v')).toHaveLength(3);
for (const output of plan.outputs) {
expect(plan.args).toContain(output.path);
}
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
});
it('degrades contact-sheet labels when drawtext is unavailable', () => {
const plan = buildContactSheetPlan({
jobId: 'sheet',
source: SOURCE,
durationSeconds: 10,
frameCount: 6,
rows: 2,
columns: 3,
cellWidth: 240,
timestampLabels: true,
format: 'jpeg',
drawtextAvailable: false,
});
expect(plan.diagnostics).toContainEqual(
expect.objectContaining({
code: 'labels-unavailable',
severity: 'warning',
})
);
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
});
});
describe('composed visual operations', () => {
it('creates palette-based GIF conversion instead of a raw encoder pass', () => {
const plan = buildConvertPlan({
jobId: 'gif',
source: SOURCE,
preset: findBuiltInPreset('animated-gif')!,
});
expect(plan.args).toContain('-filter_complex');
expect(plan.args.find((value) => value.includes('palettegen'))).toContain(
'paletteuse'
);
expect(plan.args).not.toContain('-vf');
});
it('wraps transforms and A/V fades into executable conversion plans', () => {
const preset = findBuiltInPreset('mp4-h264-balanced')!;
const transformed = buildTransformPlan({
jobId: 'transform',
source: SOURCE,
preset,
settings: {
crop: {
x: 0,
y: 0,
width: 640,
height: 360,
sourceWidth: 1280,
sourceHeight: 720,
requireEvenDimensions: true,
},
frameRate: 25,
},
});
expect(transformed.args).toContain('crop=640:360:0:0,fps=25');
const faded = buildFadePlan({
jobId: 'fade',
source: SOURCE,
preset,
durationSeconds: 10,
audioFadeInSeconds: 1,
videoFadeOutSeconds: 2,
audioCurve: 'qsin',
videoCurve: 'exp',
});
expect(faded.args.find((value) => value.startsWith('afade='))).toContain(
'curve=qsin'
);
expect(faded.args.find((value) => value.startsWith('geq='))).toContain(
'exp(5*'
);
expect(faded.requiredCapabilities.filters).toContain('geq');
expect(() =>
buildFadePlan({
jobId: 'overlap',
source: SOURCE,
preset,
durationSeconds: 2,
audioFadeInSeconds: 1.5,
audioFadeOutSeconds: 1,
})
).toThrow(/overlap/u);
expect(() =>
buildFadePlan({
jobId: 'unsupported-curve',
source: SOURCE,
preset,
durationSeconds: 2,
audioFadeInSeconds: 1,
audioCurve: 'log' as 'tri',
})
).toThrow(/tri, qsin, or exp/u);
});
});

392
tests/unit/commands.test.ts Normal file
View File

@@ -0,0 +1,392 @@
import { describe, expect, it } from 'vitest';
import {
buildConvertPlan,
buildFastConcatPlan,
buildPreviewProxyPlan,
buildRemuxPlan,
buildSplitPlan,
buildStreamRemovalPlan,
buildTimelineExportPlan,
buildTrimPlan,
calculateResize,
createConcatList,
createPlannedInput,
createVideoFiltergraph,
displayArguments,
sourceVirtualPath,
validateConcatCompatibility,
validateCrop,
validateSplitMarkers,
} from '../../src/commands';
import { findBuiltInPreset } from '../../src/presets';
const SOURCE = {
id: 'source',
sourceIndex: 0,
fileName: "../../My holiday's clip.mp4",
extension: 'mp4',
} as const;
describe('structured command plans', () => {
it('generates safe deterministic virtual paths without source path disclosure', () => {
expect(sourceVirtualPath('../job id', SOURCE)).toBe(
'/input/job-job-id/source-0.mp4'
);
expect(createPlannedInput('abc', SOURCE)).toEqual({
id: 'source',
sourceIndex: 0,
kind: 'media',
path: '/input/job-abc/source-0.mp4',
});
});
it('renders shell-like diagnostics without changing structured arguments', () => {
const args = [
'-i',
"/input/a file's.mp4",
'-metadata',
'title=hello world',
];
expect(displayArguments(args)).toBe(
"-i '/input/a file'\\''s.mp4' -metadata 'title=hello world'"
);
expect(args).toEqual([
'-i',
"/input/a file's.mp4",
'-metadata',
'title=hello world',
]);
});
it('escapes generated concat paths and rejects line injection', () => {
expect(createConcatList(['/input/source-0.mp4', "/input/it's.mp4"])).toBe(
"file '/input/source-0.mp4'\nfile '/input/it'\\''s.mp4'\n"
);
expect(() =>
createConcatList(["/input/a.mp4\nfile '/etc/passwd'"])
).toThrow(/line breaks/u);
});
it('composes conversion filters into one -vf argument', () => {
const preset = findBuiltInPreset('mp4-h264-balanced');
expect(preset).toBeDefined();
const plan = buildConvertPlan({
jobId: 'convert-1',
source: SOURCE,
preset: preset!,
videoFiltergraph: 'crop=640:360:0:0,fade=t=in:st=0:d=1',
audioFiltergraph: 'afade=t=in:st=0:d=1',
expectedDurationSeconds: 10,
});
expect(plan.args.filter((argument) => argument === '-vf')).toHaveLength(1);
expect(plan.args).toContain('crop=640:360:0:0,fade=t=in:st=0:d=1');
expect(plan.args).toContain('afade=t=in:st=0:d=1');
expect(plan.args.at(-1)).toMatch(/\.mp4$/u);
expect(Object.isFrozen(plan.args)).toBe(true);
});
it('distinguishes fast and accurate trim argument placement', () => {
const fast = buildTrimPlan({
jobId: 'trim-fast',
source: SOURCE,
mode: 'fast',
startSeconds: 1.25,
endSeconds: 3.5,
sourceDurationSeconds: 10,
targetExtension: 'mp4',
});
expect(fast.args.slice(0, 7)).toEqual([
'-ss',
'1.25',
'-to',
'3.5',
'-i',
'/input/job-trim-fast/source-0.mp4',
'-map',
]);
expect(fast.args).toContain('copy');
expect(fast.diagnostics[0]?.severity).toBe('warning');
const preset = findBuiltInPreset('mp4-h264-balanced');
const accurate = buildTrimPlan({
jobId: 'trim-accurate',
source: SOURCE,
mode: 'accurate',
startSeconds: 1.25,
endSeconds: 3.5,
preset: preset!,
});
expect(accurate.args.slice(0, 6)).toEqual([
'-ss',
'1.25',
'-to',
'3.5',
'-i',
'/input/job-trim-accurate/source-0.mp4',
]);
expect(accurate.args).toContain('libx264');
});
it('validates and expands every supported split definition', () => {
expect(validateSplitMarkers([8, 2, 5], 10)).toEqual([2, 5, 8]);
expect(() => validateSplitMarkers([2, 2.01], 10)).toThrow(/apart/u);
const plan = buildSplitPlan({
jobId: 'split',
source: SOURCE,
durationSeconds: 10,
definition: { type: 'part-count', parts: 3 },
mode: 'fast',
targetExtension: 'mp4',
});
expect(plan.outputs.map((output) => output.fileName)).toEqual([
'My-holiday-s-clip-split-001.mp4',
'My-holiday-s-clip-split-002.mp4',
'My-holiday-s-clip-split-003.mp4',
]);
expect(plan.outputs.map((output) => output.timeRange)).toEqual([
{ startSeconds: 0, endSeconds: 10 / 3 },
{ startSeconds: 10 / 3, endSeconds: 20 / 3 },
{ startSeconds: 20 / 3, endSeconds: 10 },
]);
expect(
plan.args.filter((argument) => argument === '-progress')
).toHaveLength(1);
expect(plan.args.filter((argument) => argument === 'copy')).toHaveLength(3);
});
it('blocks incompatible remuxes before returning a plan', () => {
expect(() =>
buildRemuxPlan({
jobId: 'remux',
source: SOURCE,
targetContainer: 'mp4',
fileExtension: 'mp4',
description: {
streams: [{ index: 0, kind: 'video', codec: 'vp9' }],
},
})
).toThrow(/cannot be remuxed/u);
});
it('maps selected attachment and data streams during compatible remux', () => {
const plan = buildRemuxPlan({
jobId: 'remux-auxiliary',
source: SOURCE,
targetContainer: 'matroska',
fileExtension: 'mkv',
description: {
streams: [
{ index: 2, kind: 'attachment', codec: 'ttf' },
{ index: 3, kind: 'data', codec: 'bin_data' },
],
},
streamSelection: {
attachments: [2],
data: [3],
},
});
expect(plan.args).toEqual(
expect.arrayContaining(['-map', '0:2', '-map', '0:3', '-c', 'copy'])
);
});
it('maps only explicitly retained streams for stream removal', () => {
const plan = buildStreamRemovalPlan({
jobId: 'streams',
source: SOURCE,
selection: { video: [0], audio: [1], subtitles: [] },
targetExtension: 'mkv',
targetMuxer: 'matroska',
});
expect(plan.args).toEqual([
'-i',
'/input/job-streams/source-0.mp4',
'-map',
'0:0',
'-map',
'0:1',
'-c',
'copy',
'-map_metadata',
'0',
'-map_chapters',
'0',
'/work/job-streams/My-holiday-s-clip-streams.mkv',
]);
});
});
describe('concat and transform validation', () => {
const STREAMS = [
{
kind: 'video' as const,
codec: 'h264',
width: 640,
height: 360,
pixelFormat: 'yuv420p',
timeBase: '1/12800',
},
{
kind: 'audio' as const,
codec: 'aac',
sampleRate: 48_000,
channelLayout: 'stereo',
timeBase: '1/48000',
},
];
const SOURCES = [
{
...SOURCE,
id: 'one',
sourceIndex: 0,
durationSeconds: 2,
streams: STREAMS,
},
{
...SOURCE,
id: 'two',
sourceIndex: 1,
fileName: 'two.mp4',
durationSeconds: 3,
streams: STREAMS,
},
];
it('checks exact fast-concat stream arrangement and properties', () => {
expect(validateConcatCompatibility(SOURCES)).toEqual([]);
expect(
validateConcatCompatibility([
SOURCES[0]!,
{
...SOURCES[1]!,
streams: [{ ...STREAMS[0]!, width: 1280 }, STREAMS[1]!],
},
])[0]
).toMatchObject({ code: 'stream-property', severity: 'error' });
const plan = buildFastConcatPlan({
jobId: 'concat',
sources: SOURCES,
targetExtension: 'mp4',
targetMuxer: 'mp4',
});
expect(plan.temporaryFiles[0]?.content).toBe(
"file '/input/job-concat/source-0.mp4'\nfile '/input/job-concat/source-1.mp4'\n"
);
expect(plan.expectedDurationSeconds).toBe(5);
});
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
expect(
validateCrop({
x: 3,
y: 5,
width: 101,
height: 51,
sourceWidth: 200,
sourceHeight: 100,
requireEvenDimensions: true,
})
).toMatchObject({ x: 2, y: 4, width: 100, height: 50 });
expect(
calculateResize({
sourceWidth: 1920,
sourceHeight: 1080,
width: 640,
height: 640,
mode: 'contain',
})
).toEqual({ width: 640, height: 360 });
});
it('orders typed filter stages independently of insertion order', () => {
const builder = createVideoFiltergraph()
.add({ id: 'fade', stage: 'fade', expression: 'fade=t=in:d=1' })
.add({ id: 'crop', stage: 'crop', expression: 'crop=100:100:0:0' })
.add({ id: 'fps', stage: 'frame-rate', expression: 'fps=25' });
expect(builder.build().graph).toBe('crop=100:100:0:0,fps=25,fade=t=in:d=1');
});
it('builds a bounded preview proxy with optional audio mapping', () => {
const plan = buildPreviewProxyPlan({
jobId: 'preview',
source: SOURCE,
sourceDurationSeconds: 120,
startSeconds: 10,
});
expect(plan.expectedDurationSeconds).toBe(30);
expect(plan.args).toContain('0:a:0?');
expect(plan.args).toContain("scale=w='max(2,trunc(min(960,iw)/2)*2)':h=-2");
});
it('builds a bounded AAC/M4A proxy for an audio-only source', () => {
const plan = buildPreviewProxyPlan({
jobId: 'audio-preview',
source: SOURCE,
sourceDurationSeconds: 90,
maxDurationSeconds: 20,
hasVideo: false,
});
expect(plan.expectedDurationSeconds).toBe(20);
expect(plan.outputs[0]?.fileName).toMatch(/-preview\.m4a$/u);
expect(plan.outputs[0]?.mediaType).toBe('audio/mp4');
expect(plan.args).toContain('0:a:0');
expect(plan.args).toContain('aac');
expect(plan.args).not.toContain('0:v:0');
expect(plan.args).not.toContain('libx264');
expect(plan.args).not.toContain('-vf');
expect(plan.requiredCapabilities).toMatchObject({
muxers: ['ipod'],
encoders: ['aac'],
filters: [],
});
});
it('builds one normalized timeline filter graph', () => {
const preset = findBuiltInPreset('mp4-h264-balanced');
const plan = buildTimelineExportPlan({
jobId: 'timeline',
preset: preset!,
outputWidth: 640,
outputHeight: 360,
frameRate: 25,
missingAudioPolicy: 'insert-silence',
outputFileName: '../../My final cut.mov',
clips: [
{
id: 'one',
source: SOURCES[0]!,
sourceInSeconds: 0.25,
sourceOutSeconds: 1.75,
hasVideo: true,
hasAudio: true,
audioFadeInSeconds: 0.25,
audioFadeCurve: 'qsin',
videoFadeOutSeconds: 0.25,
videoFadeCurve: 'exp',
},
{
id: 'two',
source: SOURCES[1]!,
sourceInSeconds: 0,
sourceOutSeconds: 2,
hasVideo: true,
hasAudio: false,
},
],
});
expect(
plan.args.filter((argument) => argument === '-filter_complex')
).toHaveLength(1);
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1];
expect(graph).toContain('anullsrc=');
expect(graph).toContain('curve=qsin');
expect(graph).toContain("geq=lum_expr='lum(X,Y)*((exp(5*");
expect(graph).toContain('concat=n=2:v=1:a=1[vout][aout]');
expect(plan.requiredCapabilities.filters).toContain('geq');
expect(plan.expectedDurationSeconds).toBe(3.5);
expect(plan.outputs[0]?.fileName).toBe('My-final-cut.mp4');
expect(plan.outputs[0]?.path).not.toContain('..');
});
});

View File

@@ -0,0 +1,139 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CapabilityPanel } from '../../../src/components/CapabilityPanel';
import type {
EngineState,
FFmpegCapabilities,
} from '../../../src/ffmpeg/ffmpeg.types';
import { createResourcePolicy } from '../../../src/storage';
afterEach(cleanup);
describe('CapabilityPanel', () => {
it('shows the active device-adjusted resource policy', async () => {
const user = userEvent.setup();
const policy = createResourcePolicy({
softInputBytes: 128 * 1024 * 1024,
hardSingleInputBytes: 512 * 1024 * 1024,
hardTotalInputBytes: 768 * 1024 * 1024,
softOutputEstimateBytes: 256 * 1024 * 1024,
hardOutputEstimateBytes: 512 * 1024 * 1024,
});
render(
<CapabilityPanel
state={{ status: 'idle' }}
preference="automatic"
resourcePolicy={policy}
deviceMemoryGiB={2}
onPreferenceChange={vi.fn()}
onInitialize={vi.fn()}
/>
);
await user.click(screen.getByText('Engine & browser'));
expect(screen.getByText('Active resource limits')).toBeVisible();
expect(screen.getByText('Adjusted for 2 GiB device memory')).toBeVisible();
expect(screen.getByText('128 MiB / 512 MiB')).toBeVisible();
expect(
screen.getByText(/conservative estimates, not media format limits/i)
).toBeVisible();
});
it('reports the exact FFmpeg version, build flags, and capability counts', async () => {
const user = userEvent.setup();
render(
<CapabilityPanel
state={readyEngine()}
preference="automatic"
resourcePolicy={createResourcePolicy()}
onPreferenceChange={vi.fn()}
onInitialize={vi.fn()}
/>
);
await user.click(screen.getByText('Engine & browser'));
expect(screen.getByTitle(/ffmpeg version n5\.1\.4/iu)).toHaveTextContent(
'ffmpeg version n5.1.4'
);
expect(valueFor('Demuxers')).toHaveTextContent('3');
expect(valueFor('Muxers')).toHaveTextContent('2');
expect(valueFor('Decoders')).toHaveTextContent('4');
expect(valueFor('Encoders')).toHaveTextContent('5');
expect(valueFor('Filters')).toHaveTextContent('6');
expect(
screen.getByText('Detected capability inventory (20 entries)')
).toBeInTheDocument();
expect(
screen.getByText(
/Demuxers \(3\)\s+matroska, mov, wav[\s\S]+Encoders \(5\)\s+aac, libvorbis, libvpx, libx264, png/u
)
).toBeInTheDocument();
expect(
screen.getByText('FFmpeg build configuration (2 flags)')
).toBeInTheDocument();
expect(
screen.getByText(/--enable-gpl\s+--enable-libx264/u)
).toBeInTheDocument();
});
it('exposes controlled initialization failure details and retries', async () => {
const user = userEvent.setup();
const onInitialize = vi.fn();
const state: EngineState = {
status: 'error',
error: {
code: 'load-failed',
message: 'The compatibility core could not be loaded.',
details: 'worker bootstrap failed',
exitCode: 1,
},
};
render(
<CapabilityPanel
state={state}
preference="automatic"
resourcePolicy={createResourcePolicy()}
onPreferenceChange={vi.fn()}
onInitialize={onInitialize}
/>
);
await user.click(screen.getByText('Engine & browser'));
expect(screen.getByRole('alert')).toHaveTextContent(
'The compatibility core could not be loaded.'
);
expect(screen.getByText('load-failed')).toBeVisible();
expect(screen.getByText('worker bootstrap failed')).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Retry initialization' })
);
expect(onInitialize).toHaveBeenCalledOnce();
});
});
function valueFor(label: string): HTMLElement {
const container = screen.getByText(label).closest('div');
if (!container) throw new Error(`Missing capability cell for ${label}`);
return within(container).getByRole('definition');
}
function readyEngine(): EngineState {
const capabilities: FFmpegCapabilities = {
versionText:
'ffmpeg version n5.1.4 Copyright the FFmpeg developers\nconfiguration: test',
buildConfiguration: ['--enable-gpl', '--enable-libx264'],
demuxers: new Set(['mov', 'matroska', 'wav']),
muxers: new Set(['mp4', 'webm']),
decoders: new Set(['h264', 'aac', 'vp8', 'vorbis']),
encoders: new Set(['libx264', 'aac', 'libvpx', 'libvorbis', 'png']),
filters: new Set(['scale', 'crop', 'fps', 'format', 'aresample', 'volume']),
};
return {
status: 'ready',
mode: 'single-thread',
capabilities,
};
}

View File

@@ -0,0 +1,149 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CropOverlay } from '../../../src/components/CropOverlay';
describe('CropOverlay', () => {
afterEach(cleanup);
it('maps a clockwise-oriented preview back to encoded source pixels', () => {
const onChange = vi.fn();
const { container } = render(
<CropOverlay
sourceWidth={100}
sourceHeight={80}
orientation={90}
value={{ x: 10, y: 20, width: 30, height: 40 }}
onChange={onChange}
>
<img src="blob:local-preview" alt="Local preview" />
</CropOverlay>
);
const rectangle = container.querySelector('.crop-editor__rectangle');
expect(rectangle).toHaveStyle({
left: '25%',
top: '10%',
width: '50%',
height: '30%',
});
fireEvent.keyDown(
screen.getByRole('button', { name: 'Move crop rectangle' }),
{ key: 'ArrowRight' }
);
expect(onChange).toHaveBeenLastCalledWith({
x: 10,
y: 19,
width: 30,
height: 40,
});
});
it('supports pointer dragging with bounded percentage-to-pixel math', () => {
const onChange = vi.fn();
const { container } = render(
<CropOverlay
sourceWidth={200}
sourceHeight={100}
value={{ x: 20, y: 10, width: 100, height: 60 }}
onChange={onChange}
>
<div>Preview</div>
</CropOverlay>
);
const stage = container.querySelector('.crop-editor__stage');
if (!(stage instanceof HTMLElement)) throw new Error('Missing crop stage');
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 200,
bottom: 100,
width: 200,
height: 100,
toJSON: () => ({}),
});
const move = screen.getByRole('button', { name: 'Move crop rectangle' });
fireEvent.pointerDown(move, {
pointerId: 7,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(move, {
pointerId: 7,
clientX: 60,
clientY: 30,
});
fireEvent.pointerUp(move, { pointerId: 7 });
expect(onChange).toHaveBeenLastCalledWith({
x: 70,
y: 30,
width: 100,
height: 60,
});
});
it('locks common display ratios without confusing rotated dimensions', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<CropOverlay
sourceWidth={100}
sourceHeight={80}
orientation={90}
value={{ x: 0, y: 0, width: 100, height: 80 }}
onChange={onChange}
>
<div>Preview</div>
</CropOverlay>
);
await user.selectOptions(
screen.getByRole('combobox', { name: 'Common ratio' }),
'16:9'
);
const crop = onChange.mock.lastCall?.[0] as
{ width: number; height: number } | undefined;
expect(crop).toBeDefined();
expect((crop?.width ?? 0) / (crop?.height ?? 1)).toBeCloseTo(9 / 16, 2);
expect(
screen.getByRole('checkbox', { name: 'Lock aspect ratio' })
).toBeChecked();
});
it('rejects invalid numeric edits and emits even reset bounds when required', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<CropOverlay
sourceWidth={101}
sourceHeight={99}
requireEvenDimensions
value={{ x: 0, y: 0, width: 100, height: 98 }}
onChange={onChange}
>
<div>Preview</div>
</CropOverlay>
);
await user.clear(screen.getByRole('spinbutton', { name: 'Width' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'All crop fields are required.'
);
expect(onChange).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Reset crop' }));
expect(onChange).toHaveBeenLastCalledWith({
x: 0,
y: 0,
width: 100,
height: 98,
});
});
});

View File

@@ -0,0 +1,363 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ImportedMediaAsset } from '../../../src/app/application-state';
import { Inspector } from '../../../src/components/Inspector';
import { Timeline } from '../../../src/components/Timeline';
import type { AvProjectV1, TimelineClip } from '../../../src/project';
import { aggregatePcmPeaks } from '../../../src/waveform';
describe('visual editor integration', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
createCanvasContext()
);
});
afterEach(cleanup);
it('shows encoded dimensions separately from normalized display metadata', () => {
const project = createProject();
render(
<Inspector
asset={createImportedAsset()}
project={project}
clip={project.timeline[0]}
onClipUpdate={vi.fn()}
onMetadataChange={vi.fn()}
onChaptersChange={vi.fn()}
onSubtitlesChange={vi.fn()}
onThumbnail={vi.fn()}
/>
);
expect(
screen.getByText('Encoded pixels 101×99 · coded 102×100 · 25.000 fps')
).toBeVisible();
expect(
screen.getByText('SAR 4:3 · DAR 16:9 · rotation -90°')
).toBeVisible();
});
it('uses the local video object URL in the rotation-aware crop editor', async () => {
const user = userEvent.setup();
const onClipUpdate = vi.fn();
const project = createProject();
const clip = {
...project.timeline[0]!,
crop: { x: 0, y: 0, width: 100, height: 98 },
rotation: 90 as const,
};
const asset = createImportedAsset();
render(
<Inspector
asset={asset}
project={project}
clip={clip}
disabled
onClipUpdate={onClipUpdate}
onMetadataChange={vi.fn()}
onChaptersChange={vi.fn()}
onSubtitlesChange={vi.fn()}
onThumbnail={vi.fn()}
/>
);
await user.click(screen.getByRole('tab', { name: 'Transform' }));
const cropEditor = screen.getByRole('region', {
name: 'Crop local-video.mp4',
});
expect(cropEditor).toHaveAttribute('data-orientation', '90');
expect(
screen.getByLabelText('Local preview of local-video.mp4')
).toHaveAttribute('src', 'blob:local-video');
expect(
screen.getByRole('button', { name: 'Move crop rectangle' })
).toBeDisabled();
expect(
screen.getByRole('group', { name: 'Encoded source pixels' })
).toBeVisible();
});
it('keeps numeric crop inputs when a visual video source is unavailable', async () => {
const user = userEvent.setup();
const onClipUpdate = vi.fn();
const project = createProject();
render(
<Inspector
project={project}
clip={project.timeline[0]}
onClipUpdate={onClipUpdate}
onMetadataChange={vi.fn()}
onChaptersChange={vi.fn()}
onSubtitlesChange={vi.fn()}
onThumbnail={vi.fn()}
/>
);
await user.click(screen.getByRole('tab', { name: 'Transform' }));
expect(
screen.getByText(/visual crop preview is unavailable/i)
).toBeVisible();
fireEvent.change(screen.getByRole('spinbutton', { name: 'WIDTH' }), {
target: { value: '101' },
});
expect(onClipUpdate).toHaveBeenLastCalledWith({
crop: { x: 0, y: 0, width: 100, height: 2 },
});
});
it('edits allowlisted fade curves and persists chapter time bases', async () => {
const user = userEvent.setup();
const onClipUpdate = vi.fn();
const onChaptersChange = vi.fn();
const baseProject = createProject();
const project: AvProjectV1 = {
...baseProject,
chapters: [
{
id: 'chapter-1',
title: 'Intro',
startSeconds: 0,
endSeconds: 1,
timeBase: '1/1000',
},
],
};
render(
<Inspector
project={project}
clip={project.timeline[0]}
onClipUpdate={onClipUpdate}
onMetadataChange={vi.fn()}
onChaptersChange={onChaptersChange}
onSubtitlesChange={vi.fn()}
onThumbnail={vi.fn()}
/>
);
await user.click(screen.getByRole('tab', { name: 'Transform' }));
await user.selectOptions(
screen.getByRole('combobox', { name: 'Video fade curve' }),
'exp'
);
expect(onClipUpdate).toHaveBeenLastCalledWith({
video: { enabled: true, fadeCurve: 'exp' },
});
await user.click(screen.getByRole('tab', { name: 'Audio' }));
await user.selectOptions(
screen.getByRole('combobox', { name: 'Audio fade curve' }),
'qsin'
);
expect(onClipUpdate).toHaveBeenLastCalledWith({
audio: { enabled: true, fadeCurve: 'qsin' },
});
await user.click(screen.getByRole('tab', { name: 'Chapters' }));
fireEvent.change(screen.getByLabelText('Chapter 1 time base'), {
target: { value: '1/48000' },
});
expect(onChaptersChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'chapter-1', timeBase: '1/48000' }),
]);
});
it('updates the selected clip and forwards validated waveform selections', () => {
const project = createProject();
const onUpdate = vi.fn();
const onSelectionChange = vi.fn();
render(
<Timeline
project={project}
selectedClipId="clip-1"
onSelect={vi.fn()}
onMove={vi.fn()}
onUpdate={onUpdate}
onRemove={vi.fn()}
waveformPeaks={createPeaks()}
currentTimeSeconds={4}
splitMarkers={[3]}
onSelectionChange={onSelectionChange}
/>
);
expect(screen.getByText('local-video.mp4')).toBeVisible();
expect(screen.getByText('Single sequential audio track')).toBeVisible();
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
key: 'ArrowRight',
});
expect(onUpdate).toHaveBeenLastCalledWith('clip-1', {
sourceInSeconds: 1.1,
sourceOutSeconds: 9,
});
expect(onSelectionChange).toHaveBeenLastCalledWith({
inSeconds: 1.1,
outSeconds: 9,
markers: [3],
});
});
it('preserves the original clip-card timeline when waveform data is omitted', () => {
const project = createProject();
render(
<Timeline
project={project}
selectedClipId="clip-1"
onSelect={vi.fn()}
onMove={vi.fn()}
onUpdate={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.getByText('local-video.mp4')).toBeVisible();
expect(
screen.queryByText('Single sequential audio track')
).not.toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'In' })).toHaveValue(1);
expect(screen.getByRole('spinbutton', { name: 'Out' })).toHaveValue(9);
});
it('resets the selected clip trim to its full probed source duration', async () => {
const user = userEvent.setup();
const project = createProject();
const onUpdate = vi.fn();
render(
<Timeline
project={project}
selectedClipId="clip-1"
onSelect={vi.fn()}
onMove={vi.fn()}
onUpdate={onUpdate}
onRemove={vi.fn()}
/>
);
await user.click(
screen.getByRole('button', { name: 'Reset trim to full source' })
);
expect(onUpdate).toHaveBeenCalledWith('clip-1', {
sourceInSeconds: 0,
sourceOutSeconds: 10,
});
});
});
function createProject(): AvProjectV1 {
const clip: TimelineClip = {
id: 'clip-1',
assetId: 'asset-1',
sourceInSeconds: 1,
sourceOutSeconds: 9,
};
return {
schemaVersion: 1,
id: 'project-1',
name: 'Test project',
createdAt: '2026-07-24T12:00:00.000Z',
updatedAt: '2026-07-24T12:00:00.000Z',
assets: [
{
id: 'asset-1',
originalName: 'local-video.mp4',
size: 4,
lastModified: 0,
mimeType: 'video/mp4',
sourceStatus: 'attached',
probe: createProbe(),
},
],
timeline: [clip],
output: {
presetId: 'video-h264',
fileName: 'output.mp4',
container: 'mp4',
videoEnabled: true,
audioEnabled: true,
},
metadata: { custom: {} },
chapters: [],
subtitles: [],
};
}
function createImportedAsset(): ImportedMediaAsset {
return {
id: 'asset-1',
file: new File(['test'], 'local-video.mp4', { type: 'video/mp4' }),
objectUrl: 'blob:local-video',
phase: 'ready',
probe: createProbe(),
};
}
function createProbe() {
return {
durationSeconds: 10,
formatNames: ['mov', 'mp4'],
tags: {},
chapters: [],
warnings: [],
streams: [
{
index: 0,
type: 'video' as const,
codecName: 'h264',
width: 101,
height: 99,
codedWidth: 102,
codedHeight: 100,
sampleAspectRatio: '4:3',
displayAspectRatio: '16:9',
rotationDegrees: -90,
frameRate: 25,
disposition: {},
tags: {},
},
],
};
}
function createPeaks() {
return aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array(
Array.from({ length: 100 }, (_, index) =>
Math.round(Math.sin(index / 5) * 20_000)
)
),
encoding: 's16le',
sampleRate: 10,
},
{ bucketCount: 100 }
);
}
function createCanvasContext(): CanvasRenderingContext2D {
return {
setTransform: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D;
}

View File

@@ -0,0 +1,48 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HelpDialog } from '../../../src/components/HelpDialog';
afterEach(cleanup);
describe('HelpDialog', () => {
it('exposes bundled engine, licence, source and privacy context', () => {
render(<HelpDialog open={false} onClose={vi.fn()} />);
expect(
screen.getByRole('heading', {
name: 'About & licences',
hidden: true,
})
).toBeInTheDocument();
expect(screen.getByText('n5.1.4')).toBeInTheDocument();
expect(screen.getByText('0.12.15')).toBeInTheDocument();
expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument();
expect(
screen.getByText(/copyright © 2026 Albrecht Degering/i)
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'Application GPL', hidden: true })
).toHaveAttribute('href', expect.stringContaining('LICENSE'));
expect(
screen.getByRole('link', { name: 'MIT text', hidden: true })
).toHaveAttribute('href', expect.stringContaining('ffmpeg.wasm-MIT'));
expect(
screen.getByRole('link', { name: 'GPL text', hidden: true })
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
expect(
screen.getByRole('link', {
name: 'Complete inventory',
hidden: true,
})
).toHaveAttribute('href', expect.stringContaining('THIRD_PARTY_NOTICES'));
expect(
screen.getByRole('link', {
name: 'Source & reproducibility record',
hidden: true,
})
).toHaveAttribute('href', expect.stringContaining('SOURCE'));
expect(
screen.getByText(/source files stay inside the browser session/i)
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,196 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { JobPanel } from '../../../src/components/JobPanel';
import type { ExportResult } from '../../../src/export';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('JobPanel result selection', () => {
it('downloads only the selected generated results as ZIP', async () => {
const user = userEvent.setup();
const onSaveZip = vi.fn();
const first = result('one', 'one.png');
const second = result('two', 'two.png');
render(
<JobPanel
engineState={{
status: 'ready',
mode: 'single-thread',
capabilities: {
versionText: 'test',
buildConfiguration: [],
demuxers: new Set(),
muxers: new Set(),
decoders: new Set(),
encoders: new Set(),
filters: new Set(),
},
}}
jobs={[]}
results={[first, second]}
verifications={{}}
logs={[]}
onCancel={vi.fn()}
onCancelJob={vi.fn()}
onRetryJob={vi.fn()}
onClearJobHistory={vi.fn()}
onSave={vi.fn()}
onSaveZip={onSaveZip}
onSaveReport={vi.fn()}
onPreview={vi.fn()}
onRemove={vi.fn()}
onClearResults={vi.fn()}
/>
);
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'Download selected as ZIP (2)',
})
).toBeEnabled()
);
await user.click(
screen.getByRole('checkbox', { name: 'Select two.png for ZIP' })
);
await user.click(
screen.getByRole('button', { name: 'Download selected as ZIP (1)' })
);
expect(onSaveZip).toHaveBeenCalledWith([first]);
});
it('marks browser-rejected media as download-only and does not offer Preview', () => {
vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('');
const output = mediaResult('audio', 'archive.flac', 'audio/flac');
render(
<JobPanel
engineState={{
status: 'ready',
mode: 'single-thread',
capabilities: {
versionText: 'test',
buildConfiguration: [],
demuxers: new Set(),
muxers: new Set(),
decoders: new Set(),
encoders: new Set(),
filters: new Set(),
},
}}
jobs={[]}
results={[output]}
verifications={{}}
logs={[]}
onCancel={vi.fn()}
onCancelJob={vi.fn()}
onRetryJob={vi.fn()}
onClearJobHistory={vi.fn()}
onSave={vi.fn()}
onSaveZip={vi.fn()}
onSaveReport={vi.fn()}
onPreview={vi.fn()}
onRemove={vi.fn()}
onClearResults={vi.fn()}
/>
);
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull();
expect(
screen.getByText(/Download only: The browser reports no native/iu)
).toBeVisible();
expect(screen.getByRole('button', { name: 'Save result' })).toBeEnabled();
});
it('shows the exact split interval and per-result verification state', () => {
const output = {
...mediaResult('segment', 'clip-001.mp4', 'video/mp4'),
timeRange: { startSeconds: 1.25, endSeconds: 3.5 },
};
render(
<JobPanel
engineState={{
status: 'ready',
mode: 'single-thread',
capabilities: {
versionText: 'test',
buildConfiguration: [],
demuxers: new Set(),
muxers: new Set(),
decoders: new Set(),
encoders: new Set(),
filters: new Set(),
},
}}
jobs={[]}
results={[output]}
verifications={{
segment: {
warning: 'Output was created, but its verification probe failed.',
},
}}
logs={[]}
onCancel={vi.fn()}
onCancelJob={vi.fn()}
onRetryJob={vi.fn()}
onClearJobHistory={vi.fn()}
onSave={vi.fn()}
onSaveZip={vi.fn()}
onSaveReport={vi.fn()}
onPreview={vi.fn()}
onRemove={vi.fn()}
onClearResults={vi.fn()}
/>
);
expect(
screen.getByText(
'Segment range 00:00:01.25000:00:03.500 · 00:00:02.250 duration'
)
).toBeVisible();
expect(screen.getByText(/created · verification warning/iu)).toBeVisible();
expect(
screen.getByText('Output was created, but its verification probe failed.')
).toBeVisible();
});
});
function result(id: string, fileName: string): ExportResult {
const blob = new Blob([id], { type: 'image/png' });
return {
id,
planId: `plan-${id}`,
outputId: `output-${id}`,
fileName,
blob,
mimeType: blob.type,
role: 'thumbnail',
size: blob.size,
createdAt: '2026-01-01T00:00:00.000Z',
};
}
function mediaResult(
id: string,
fileName: string,
mimeType: string
): ExportResult {
const blob = new Blob([id], { type: mimeType });
return {
id,
planId: `plan-${id}`,
outputId: `output-${id}`,
fileName,
blob,
mimeType,
role: 'media',
size: blob.size,
createdAt: '2026-01-01T00:00:00.000Z',
};
}

View File

@@ -0,0 +1,259 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ImportedMediaAsset } from '../../../src/app/application-state';
import {
MediaPreview,
type PreviewExportResult,
} from '../../../src/components/MediaPreview';
import { aggregatePcmPeaks } from '../../../src/waveform';
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(
'probably'
);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('MediaPreview', () => {
it('offers bounded configurable proxy settings after playback fails', async () => {
const user = userEvent.setup();
const onCreateProxy = vi.fn();
const onProxySettingsChange = vi.fn();
const { container } = render(
<MediaPreview
asset={videoAsset()}
onCreateProxy={onCreateProxy}
proxySettings={{ maxDurationSeconds: 30, maxWidth: 960 }}
onProxySettingsChange={onProxySettingsChange}
/>
);
fireEvent.error(requiredElement(container.querySelector('video')));
expect(screen.getByText('Create a disposable preview proxy')).toBeVisible();
fireEvent.change(
screen.getByRole('spinbutton', { name: 'Maximum seconds' }),
{ target: { value: '60' } }
);
expect(onProxySettingsChange).toHaveBeenLastCalledWith({
maxDurationSeconds: 60,
maxWidth: 960,
});
await user.selectOptions(
screen.getByRole('combobox', { name: 'Maximum width' }),
'1280'
);
expect(onProxySettingsChange).toHaveBeenLastCalledWith({
maxDurationSeconds: 30,
maxWidth: 1280,
});
await user.click(screen.getByRole('button', { name: 'Create proxy' }));
expect(onCreateProxy).toHaveBeenCalledOnce();
});
it('seeks a source preview externally and reports its actual playback time', () => {
const onCurrentTimeChange = vi.fn();
const { container, rerender } = render(
<MediaPreview
asset={videoAsset()}
currentTimeSeconds={3.25}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
const video = requiredElement(container.querySelector('video'));
expect(video.currentTime).toBeCloseTo(3.25);
video.currentTime = 7.75;
fireEvent.timeUpdate(video);
expect(onCurrentTimeChange).toHaveBeenLastCalledWith(7.75);
rerender(
<MediaPreview
asset={videoAsset()}
currentTimeSeconds={1.5}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
expect(video.currentTime).toBeCloseTo(1.5);
});
it('uses the result media kind, honors result seeks, and reports result time', () => {
const onCurrentTimeChange = vi.fn();
const result: PreviewExportResult = {
name: 'audio-result.mp3',
mimeType: 'audio/mpeg',
objectUrl: 'blob:audio-result',
};
const { container, rerender } = render(
<MediaPreview
asset={videoAsset()}
result={result}
currentTimeSeconds={2}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
expect(container.querySelector('video')).toBeNull();
const audio = requiredElement(container.querySelector('audio'));
expect(audio).toHaveAttribute('src', result.objectUrl);
expect(audio.currentTime).toBeCloseTo(2);
rerender(
<MediaPreview
asset={videoAsset()}
result={result}
currentTimeSeconds={5.5}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
expect(audio.currentTime).toBeCloseTo(5.5);
audio.currentTime = 8.125;
fireEvent.timeUpdate(audio);
expect(onCurrentTimeChange).toHaveBeenLastCalledWith(8.125);
});
it('uses bounded peaks for an audio-only source and offers an AAC/M4A proxy', async () => {
vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('');
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
canvasContext()
);
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([-32_768, 0, 16_384, 32_767]),
encoding: 's16le',
sampleRate: 2,
},
{ bucketCount: 4 }
);
const { container } = render(
<MediaPreview
asset={audioAsset()}
waveformPeaks={peaks}
currentTimeSeconds={1}
onCreateProxy={vi.fn()}
proxySettings={{ maxDurationSeconds: 30, maxWidth: 960 }}
/>
);
await waitFor(() =>
expect(container.querySelector('canvas')).toHaveAttribute(
'aria-label',
'Bounded waveform preview for source.flac.'
)
);
expect(container.querySelector('.audio-preview__disc')).toBeNull();
expect(container.querySelector('audio')).toBeInTheDocument();
expect(screen.getByText(/AAC\/M4A/iu)).toBeVisible();
expect(
screen.queryByRole('combobox', { name: 'Maximum width' })
).toBeNull();
});
it('shows an explicit download-only result instead of mounting unsupported playback', () => {
vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('');
const result: PreviewExportResult = {
name: 'archive.flac',
mimeType: 'audio/flac',
objectUrl: 'blob:unsupported-result',
};
const { container } = render(<MediaPreview result={result} />);
expect(container.querySelector('audio')).toBeNull();
expect(
screen.getByText(
'Download only: this browser cannot preview this result.'
)
).toBeVisible();
expect(screen.getByText('Download only · archive.flac')).toBeVisible();
});
});
function videoAsset(): ImportedMediaAsset {
return {
id: 'asset-video',
file: new File(['video'], 'source.mp4', { type: 'video/mp4' }),
objectUrl: 'blob:source-video',
phase: 'ready',
probe: {
durationSeconds: 10,
formatNames: ['mov', 'mp4'],
tags: {},
chapters: [],
warnings: [],
streams: [
{
index: 0,
type: 'video',
codecName: 'h264',
width: 640,
height: 360,
disposition: {},
tags: {},
},
],
},
};
}
function audioAsset(): ImportedMediaAsset {
return {
id: 'asset-audio',
file: new File(['audio'], 'source.flac', { type: 'audio/flac' }),
objectUrl: 'blob:source-audio',
phase: 'ready',
probe: {
durationSeconds: 2,
formatNames: ['flac'],
tags: {},
chapters: [],
warnings: [],
streams: [
{
index: 0,
type: 'audio',
codecName: 'flac',
sampleRate: 2,
channels: 1,
disposition: {},
tags: {},
},
],
},
};
}
function canvasContext(): CanvasRenderingContext2D {
return {
setTransform: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D;
}
function requiredElement<T extends Element>(element: T | null): T {
if (!element) {
throw new Error('Expected media element to be rendered');
}
return element;
}

View File

@@ -0,0 +1,335 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ImportedMediaAsset } from '../../../src/app/application-state';
import {
QuickConvert,
type QuickExportConfiguration,
} from '../../../src/components/QuickConvert';
import type {
EngineState,
FFmpegCapabilities,
} from '../../../src/ffmpeg/ffmpeg.types';
import { BUILT_IN_PRESETS, validateUserPreset } from '../../../src/presets';
import type { UserExportPreset } from '../../../src/presets';
afterEach(cleanup);
describe('QuickConvert', () => {
it('labels a submission honestly when another operation is active', () => {
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={readyEngine()}
busy={false}
queueing
onExport={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Add to queue' })).toBeEnabled();
});
it('preserves an explicitly empty stream selection and blocks export', async () => {
const user = userEvent.setup();
const onExport = vi.fn();
const asset = createAsset('asset-a', 'source-a.mp4');
const { rerender } = render(
<QuickConvert
asset={asset}
engineState={readyEngine()}
busy={false}
onExport={onExport}
/>
);
const videoStream = screen.getByRole('checkbox', {
name: /video.*#0/iu,
});
const audioStream = screen.getByRole('checkbox', {
name: /audio.*#1/iu,
});
await user.click(videoStream);
await user.click(audioStream);
expect(videoStream).not.toBeChecked();
expect(audioStream).not.toBeChecked();
expect(
screen.getByText('Select at least one output stream.')
).toBeVisible();
expect(screen.getByRole('button', { name: 'Convert' })).toBeDisabled();
rerender(
<QuickConvert
asset={{ ...asset }}
engineState={readyEngine()}
busy={false}
onExport={onExport}
/>
);
expect(
screen.getByRole('checkbox', { name: /video.*#0/iu })
).not.toBeChecked();
expect(
screen.getByRole('checkbox', { name: /audio.*#1/iu })
).not.toBeChecked();
expect(onExport).not.toHaveBeenCalled();
});
it('starts a newly selected asset with all of its streams selected', async () => {
const user = userEvent.setup();
const firstAsset = createAsset('asset-a', 'source-a.mp4');
const secondAsset = createAsset('asset-b', 'source-b.mp4');
const { rerender } = render(
<QuickConvert
asset={firstAsset}
engineState={readyEngine()}
busy={false}
onExport={vi.fn()}
/>
);
await user.click(screen.getByRole('checkbox', { name: /video.*#0/iu }));
expect(
screen.getByRole('checkbox', { name: /video.*#0/iu })
).not.toBeChecked();
rerender(
<QuickConvert
asset={secondAsset}
engineState={readyEngine()}
busy={false}
onExport={vi.fn()}
/>
);
expect(screen.getByText('source-b.mp4')).toBeVisible();
expect(screen.getByRole('checkbox', { name: /video.*#0/iu })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /audio.*#1/iu })).toBeChecked();
});
it('disables unavailable presets and incompatible remux targets with reasons', async () => {
const user = userEvent.setup();
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={readyEngine()}
busy={false}
onExport={vi.fn()}
/>
);
expect(
screen.getByRole('option', {
name: /WebM · VP8 \+ Vorbis unavailable: Missing muxers: webm/iu,
})
).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Fast remux' }));
expect(
screen.getByText('The loaded core is missing muxer matroska.')
).toBeVisible();
expect(screen.getByRole('button', { name: 'Remux' })).toBeDisabled();
expect(
screen.getByRole('option', {
name: /WebM.*unavailable: missing muxer webm.*video codec h264.*audio codec aac/iu,
})
).toBeDisabled();
});
it('exposes a validated user preset and returns it unchanged on export', async () => {
const user = userEvent.setup();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
const userPreset = validatedUserPreset();
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={readyEngine()}
busy={false}
presets={[...BUILT_IN_PRESETS, userPreset]}
onExport={onExport}
/>
);
const presetSelect = screen.getByRole('combobox', {
name: /^Export preset/iu,
});
expect(
screen.getByRole('option', { name: 'My local H.264 export' })
).toBeInTheDocument();
await user.selectOptions(presetSelect, userPreset.id);
expect(
screen.getByText('A validated user-created local export preset.')
).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Convert' }));
await waitFor(() => expect(onExport).toHaveBeenCalledOnce());
expect(onExport).toHaveBeenCalledWith({
operation: 'convert',
preset: userPreset,
remuxContainer: 'matroska',
streamSelection: {
video: [0],
audio: [1],
subtitles: [],
attachments: [],
data: [],
},
removeMetadata: false,
removeChapters: false,
});
});
it('disables unsupported auxiliary streams for conversion and maps them for Matroska remux', async () => {
const user = userEvent.setup();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
const asset = createAsset('asset-a', 'source-a.mkv');
asset.probe?.streams.push(
{
index: 2,
type: 'attachment',
codecName: 'ttf',
disposition: {},
tags: {},
},
{
index: 3,
type: 'data',
codecName: 'bin_data',
disposition: {},
tags: {},
}
);
render(
<QuickConvert
asset={asset}
engineState={readyEngine(['mp4', 'matroska'])}
busy={false}
onExport={onExport}
/>
);
const attachment = screen.getByRole('checkbox', {
name: /attachment.*#2/iu,
});
const data = screen.getByRole('checkbox', { name: /data.*#3/iu });
expect(attachment).toBeDisabled();
expect(attachment).not.toBeChecked();
expect(data).toBeDisabled();
expect(data).not.toBeChecked();
expect(
screen.getByText(/attachment streams are only preserved.*Matroska/iu)
).toBeVisible();
expect(
screen.getByText(/data streams are only preserved.*Matroska/iu)
).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Fast remux' }));
expect(attachment).toBeEnabled();
expect(attachment).toBeChecked();
expect(data).toBeEnabled();
expect(data).toBeChecked();
await user.click(screen.getByRole('button', { name: 'Remux' }));
await waitFor(() => expect(onExport).toHaveBeenCalledOnce());
expect(onExport.mock.calls[0]?.[0].streamSelection).toEqual({
video: [0],
audio: [1],
subtitles: [],
attachments: [2],
data: [3],
});
});
});
function createAsset(id: string, name: string): ImportedMediaAsset {
return {
id,
file: new File(['media'], name, { type: 'video/mp4' }),
objectUrl: `blob:${id}`,
phase: 'ready',
probe: {
durationSeconds: 12,
formatNames: ['mov', 'mp4'],
tags: {},
chapters: [],
warnings: [],
streams: [
{
index: 0,
type: 'video',
codecName: 'h264',
width: 640,
height: 360,
disposition: {},
tags: {},
},
{
index: 1,
type: 'audio',
codecName: 'aac',
sampleRate: 48_000,
channels: 2,
disposition: {},
tags: {},
},
],
},
};
}
function readyEngine(muxers: readonly string[] = ['mp4']): EngineState {
return {
status: 'ready',
mode: 'single-thread',
capabilities: capabilities(muxers),
};
}
function capabilities(muxers: readonly string[] = ['mp4']): FFmpegCapabilities {
return {
versionText: 'test',
buildConfiguration: [],
demuxers: new Set(['mov']),
muxers: new Set(muxers),
decoders: new Set(['h264', 'aac']),
encoders: new Set(['libx264', 'aac']),
filters: new Set(),
};
}
function validatedUserPreset(): UserExportPreset {
const candidate: UserExportPreset = {
schemaVersion: 1,
id: 'my-local-h264',
name: 'My local H.264 export',
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: {
codec: 'libx264',
qualityMode: 'balanced',
crf: 22,
pixelFormat: 'yuv420p',
},
audio: {
codec: 'aac',
bitrateKbps: 160,
sampleRate: 48_000,
channels: 2,
},
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'none',
fastStart: true,
};
const result = validateUserPreset(candidate);
if (!result.valid || !result.preset) {
throw new Error(`Test preset is invalid: ${result.errors.join('; ')}`);
}
return result.preset;
}

View File

@@ -0,0 +1,376 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
StructuralOperations,
type StructuralCapabilityKey,
type StructuralCapabilityStatus,
type StructuralOperationsProps,
} from '../../../src/components/StructuralOperations';
const AVAILABLE: StructuralCapabilityStatus = { available: true };
const SELECTED_CLIP: NonNullable<StructuralOperationsProps['selectedClip']> = {
id: 'clip-a',
label: 'Opening shot',
sourceInSeconds: 5.25,
sourceOutSeconds: 18.75,
sourceDurationSeconds: 60,
sourceExtension: '.MP4',
hasAudio: true,
hasVideo: true,
};
const TIMELINE: NonNullable<StructuralOperationsProps['timeline']> = {
clips: [
{
id: 'clip-a',
label: 'Opening shot',
hasAudio: true,
hasVideo: true,
hasSubtitles: false,
},
{
id: 'clip-b',
label: 'Silent cutaway',
hasAudio: false,
hasVideo: true,
hasSubtitles: false,
},
],
fastTargetExtension: 'mp4',
fastTargetMuxer: 'mp4',
fastCompatibilityDiagnostics: [],
};
const PRESETS: NonNullable<StructuralOperationsProps['exportPresets']> = [
{
id: 'video-ready',
name: 'MP4 · H.264 + AAC',
fileExtension: 'mp4',
supportsAudio: true,
supportsVideo: true,
},
{
id: 'video-disabled',
name: 'WebM · VP9 + Opus',
fileExtension: 'webm',
supportsAudio: true,
supportsVideo: true,
disabledReason: 'The Opus encoder is not available.',
},
{
id: 'audio-only',
name: 'MP3 audio',
fileExtension: 'mp3',
supportsAudio: true,
supportsVideo: false,
},
];
function capabilities(
...keys: readonly StructuralCapabilityKey[]
): StructuralOperationsProps['availability'] {
return Object.fromEntries(keys.map((key) => [key, AVAILABLE]));
}
afterEach(cleanup);
describe('StructuralOperations', () => {
it('uses the selected clip range for a fast stream-copy trim', async () => {
const user = userEvent.setup();
const onTrim = vi.fn();
render(
<StructuralOperations
selectedClip={SELECTED_CLIP}
availability={capabilities('trim-fast')}
onTrim={onTrim}
/>
);
expect(screen.getByText('00:00:05.25000:00:18.750')).toBeInTheDocument();
expect(
screen.getByText(/first frame may move to a nearby keyframe/iu)
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Export fast trim' }));
await waitFor(() =>
expect(onTrim).toHaveBeenCalledWith({
operation: 'trim',
mode: 'fast',
clipId: 'clip-a',
startSeconds: 5.25,
endSeconds: 18.75,
targetExtension: 'mp4',
})
);
});
it('requires and emits an available typed preset for an accurate trim', async () => {
const user = userEvent.setup();
const onTrim = vi.fn();
render(
<StructuralOperations
selectedClip={SELECTED_CLIP}
exportPresets={PRESETS}
availability={capabilities('trim-accurate')}
onTrim={onTrim}
/>
);
await user.click(
screen.getByRole('radio', { name: /Accurate re-encode/iu })
);
const action = screen.getByRole('button', { name: 'Export exact trim' });
expect(action).toBeDisabled();
expect(
screen.getByText(
'Choose an available typed preset for the accurate trim.'
)
).toBeInTheDocument();
const preset = screen.getByLabelText('Accurate trim preset');
expect(
screen.getByRole('option', { name: /WebM · VP9 \+ Opus/iu })
).toBeDisabled();
expect(screen.getByRole('option', { name: /MP3 audio/iu })).toBeDisabled();
await user.selectOptions(preset, 'video-ready');
await user.click(action);
await waitFor(() =>
expect(onTrim).toHaveBeenCalledWith({
operation: 'trim',
mode: 'accurate',
clipId: 'clip-a',
startSeconds: 5.25,
endSeconds: 18.75,
presetId: 'video-ready',
})
);
});
it('fails closed when a capability was not explicitly verified', () => {
render(
<StructuralOperations selectedClip={SELECTED_CLIP} onTrim={vi.fn()} />
);
expect(
screen.getByRole('button', { name: 'Export fast trim' })
).toBeDisabled();
expect(
screen.getByText(
'The required browser and FFmpeg capabilities have not been verified.'
)
).toBeInTheDocument();
});
it('shows concrete incompatibilities and blocks fast concat', () => {
render(
<StructuralOperations
timeline={{
...TIMELINE,
fastCompatibilityDiagnostics: [
{
code: 'stream-property',
severity: 'error',
message: 'Clip 2 has incompatible width: 1280 instead of 1920.',
path: 'sources.1.streams.0.width',
},
],
}}
availability={capabilities('concat-fast')}
onConcat={vi.fn()}
/>
);
expect(
screen.getByRole('heading', { name: 'Fast compatibility failed' })
).toBeInTheDocument();
expect(
screen.getByText('Clip 2 has incompatible width: 1280 instead of 1920.')
).toBeInTheDocument();
expect(
screen.getByText('Checked field: sources.1.streams.0.width')
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Export fast concat' })
).toBeDisabled();
});
it('emits the verified sequential clip order for fast concat', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={TIMELINE}
availability={capabilities('concat-fast')}
onConcat={onConcat}
/>
);
expect(
screen.getByRole('heading', { name: 'Fast compatibility passed' })
).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Export fast concat' })
);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'fast',
clipIds: ['clip-a', 'clip-b'],
targetExtension: 'mp4',
targetMuxer: 'mp4',
})
);
});
it('requires an explicit missing-audio policy for normalized concat', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={TIMELINE}
exportPresets={PRESETS}
availability={capabilities('concat-normalized')}
onConcat={onConcat}
/>
);
expect(screen.getByText('No audio: Silent cutaway')).toBeInTheDocument();
await user.click(
screen.getByRole('radio', { name: /Normalized re-encode/iu })
);
await user.selectOptions(
screen.getByLabelText('Normalized concat preset'),
'video-ready'
);
const action = screen.getByRole('button', {
name: 'Export normalized concat',
});
expect(action).toBeDisabled();
expect(
screen.getByText('Choose an explicit missing-audio policy.')
).toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
expect(action).toBeDisabled();
expect(
screen.getByText('Choose an explicit subtitle policy.')
).toBeInTheDocument();
await user.click(
screen.getByRole('radio', { name: /Reject subtitle input/iu })
);
await user.click(action);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'normalized',
clipIds: ['clip-a', 'clip-b'],
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'reject',
})
);
});
it('requires destructive subtitle loss to be explicit for normalized concat', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={{
...TIMELINE,
clips: TIMELINE.clips.map((clip, index) =>
index === 0 ? { ...clip, hasSubtitles: true } : clip
),
}}
exportPresets={PRESETS}
availability={capabilities('concat-normalized')}
onConcat={onConcat}
/>
);
await user.click(
screen.getByRole('radio', { name: /Normalized re-encode/iu })
);
await user.selectOptions(
screen.getByLabelText('Normalized concat preset'),
'video-ready'
);
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
await user.click(
screen.getByRole('radio', { name: /Reject subtitle input/iu })
);
const action = screen.getByRole('button', {
name: 'Export normalized concat',
});
expect(action).toBeDisabled();
expect(
screen.getByText(/sequence contains subtitles.*explicitly drop all/iu)
).toBeVisible();
await user.click(
screen.getByRole('radio', { name: /Drop all subtitles/iu })
);
await user.click(action);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'normalized',
clipIds: ['clip-a', 'clip-b'],
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'drop-all',
})
);
});
it('treats an absent fast compatibility result as unchecked', () => {
render(
<StructuralOperations
timeline={{ ...TIMELINE, fastCompatibilityDiagnostics: undefined }}
availability={capabilities('concat-fast')}
onConcat={vi.fn()}
/>
);
expect(
screen.getByRole('heading', { name: 'Fast compatibility not checked' })
).toBeInTheDocument();
expect(
screen.getByText(
'Fast concat remains disabled until every input stream is checked.'
)
).toBeInTheDocument();
});
it('surfaces a synchronous runner rejection and releases pending state', async () => {
const user = userEvent.setup();
render(
<StructuralOperations
selectedClip={SELECTED_CLIP}
availability={capabilities('trim-fast')}
onTrim={() => {
throw new Error('Queue capacity was reached.');
}}
/>
);
const action = screen.getByRole('button', { name: 'Export fast trim' });
await user.click(action);
expect(
await screen.findByText(
'The operation could not be queued: Queue capacity was reached.'
)
).toBeInTheDocument();
await waitFor(() => expect(action).toBeEnabled());
});
});

View File

@@ -0,0 +1,216 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WaveformEditor } from '../../../src/components/WaveformEditor';
import { aggregatePcmPeaks } from '../../../src/waveform';
describe('WaveformEditor', () => {
afterEach(cleanup);
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
createCanvasContext()
);
});
it('renders a bounded canvas with accessible trim and marker controls', async () => {
render(
<WaveformEditor
peaks={createPeaks()}
inSeconds={1}
outSeconds={9}
markers={[3]}
currentTimeSeconds={5}
onChange={vi.fn()}
/>
);
const canvas = document.querySelector('canvas');
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error('Missing waveform canvas');
}
await waitFor(() =>
expect(canvas).toHaveAttribute(
'aria-label',
expect.stringContaining('Selected 00:00:01.00000:00:09.000')
)
);
expect(
screen.getByRole('slider', { name: 'In trim handle' })
).toHaveAttribute('aria-valuenow', '1');
expect(
screen.getByRole('slider', { name: 'Out trim handle' })
).toHaveAttribute('aria-valuenow', '9');
expect(
screen.getByRole('slider', { name: 'Split marker 1' })
).toHaveAttribute('aria-valuenow', '3');
expect(screen.getByText('Single sequential audio track')).toBeVisible();
});
it('emits validated keyboard edits and current-time actions', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<WaveformEditor
peaks={createPeaks()}
inSeconds={1}
outSeconds={9}
markers={[3]}
currentTimeSeconds={4}
onChange={onChange}
/>
);
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
key: 'ArrowRight',
});
expect(onChange).toHaveBeenLastCalledWith({
inSeconds: 1.1,
outSeconds: 9,
markers: [3],
});
await user.click(
screen.getByRole('button', { name: 'Add split at current time' })
);
expect(onChange).toHaveBeenLastCalledWith({
inSeconds: 1,
outSeconds: 9,
markers: [3, 4],
});
fireEvent.keyDown(screen.getByRole('slider', { name: 'Split marker 1' }), {
key: 'Delete',
});
expect(onChange).toHaveBeenLastCalledWith({
inSeconds: 1,
outSeconds: 9,
markers: [],
});
});
it('drags split markers and seeks using the visible zoomed range', async () => {
const onChange = vi.fn();
const onSeek = vi.fn();
const { container } = render(
<WaveformEditor
peaks={createPeaks()}
inSeconds={0}
outSeconds={10}
markers={[2]}
onChange={onChange}
onSeek={onSeek}
/>
);
const stage = container.querySelector('.waveform-editor__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing waveform stage');
}
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 100,
bottom: 168,
width: 100,
height: 168,
toJSON: () => ({}),
});
const marker = screen.getByRole('slider', { name: 'Split marker 1' });
fireEvent.pointerDown(marker, {
pointerId: 11,
clientX: 20,
clientY: 20,
});
fireEvent.pointerMove(marker, {
pointerId: 11,
clientX: 50,
clientY: 20,
});
fireEvent.pointerUp(marker, { pointerId: 11 });
expect(onChange).toHaveBeenLastCalledWith({
inSeconds: 0,
outSeconds: 10,
markers: [5],
});
fireEvent.pointerDown(stage, {
button: 0,
clientX: 75,
clientY: 30,
});
expect(onSeek).toHaveBeenLastCalledWith(7.5);
fireEvent.change(screen.getByRole('slider', { name: 'Zoom waveform' }), {
target: { value: '2' },
});
expect(
screen.getByRole('slider', { name: 'Scroll waveform' })
).toBeEnabled();
});
it('normalizes untrusted selection values and caps marker count', () => {
render(
<WaveformEditor
peaks={createPeaks()}
inSeconds={Number.NaN}
outSeconds={999}
markers={[
Number.NaN,
-1,
2,
2.0004,
...Array.from({ length: 510 }, (_, index) => 2.01 + index * 0.01),
]}
onChange={vi.fn()}
/>
);
expect(
screen.getByText(/Selected 00:00:00.00000:00:10.000/u)
).toBeVisible();
expect(screen.getByText('254/254')).toBeVisible();
expect(
screen.getAllByRole('slider', { name: /Split marker/u })
).toHaveLength(254);
});
});
function createPeaks() {
return aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array(
Array.from({ length: 100 }, (_, index) =>
Math.round(Math.sin(index / 5) * 20_000)
)
),
encoding: 's16le',
sampleRate: 10,
},
{ bucketCount: 100 }
);
}
function createCanvasContext(): CanvasRenderingContext2D {
return {
setTransform: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D;
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
cacheDerivative,
readCachedDerivative,
} from '../../src/app/derived-cache';
describe('application derivative cache', () => {
it('reuses only an exact source and settings identity', async () => {
const file = new File(['source'], 'preview-source.mp4', {
type: 'video/mp4',
lastModified: 123,
});
const settings = {
maxDurationSeconds: 30,
maxWidth: 960,
includeAudio: true,
};
const derivative = new Blob(['proxy'], { type: 'video/mp4' });
await cacheDerivative(
file,
'preview-proxy-test',
'preview-proxy',
'project-preview-cache-test',
derivative,
settings
);
expect(
await readCachedDerivative(file, 'preview-proxy-test', settings)
).toEqual(derivative);
expect(
await readCachedDerivative(file, 'preview-proxy-test', {
...settings,
maxWidth: 1280,
})
).toBeUndefined();
expect(
await readCachedDerivative(
new File(['other'], file.name, {
type: file.type,
lastModified: file.lastModified,
}),
'preview-proxy-test',
settings
)
).toBeUndefined();
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { DeferredResourceLifecycle } from '../../src/ffmpeg/engine-lifecycle';
describe('singleton engine lifecycle', () => {
it('cancels deferred disposal when StrictMode immediately reacquires', () => {
const dispose = vi.fn();
const deferred: Array<() => void> = [];
const lifecycle = new DeferredResourceLifecycle({ dispose }, (callback) =>
deferred.push(callback)
);
const releaseFirstMount = lifecycle.acquire();
releaseFirstMount();
const releaseStrictModeRemount = lifecycle.acquire();
deferred.shift()?.();
expect(dispose).not.toHaveBeenCalled();
expect(lifecycle.leaseCount).toBe(1);
releaseStrictModeRemount();
deferred.shift()?.();
expect(dispose).toHaveBeenCalledOnce();
expect(lifecycle.leaseCount).toBe(0);
});
it('disposes only after the last lease and ignores duplicate releases', () => {
const dispose = vi.fn();
const deferred: Array<() => void> = [];
const lifecycle = new DeferredResourceLifecycle({ dispose }, (callback) =>
deferred.push(callback)
);
const releaseOne = lifecycle.acquire();
const releaseTwo = lifecycle.acquire();
releaseOne();
releaseOne();
expect(deferred).toHaveLength(0);
expect(lifecycle.leaseCount).toBe(1);
releaseTwo();
expect(deferred).toHaveLength(1);
deferred[0]?.();
expect(dispose).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import {
createExportReport,
deriveOutputFileName,
exportReportBlob,
isBrowserPreviewMimeType,
isSafeArchiveLeafName,
mimeTypeFromOutputName,
normalizeOutputMimeType,
preferredExtensionForMimeType,
redactPrivatePaths,
safeExportFileName,
serializeExportReport,
uniqueExportFileNames,
type ExportResult,
} from '../../src/export';
describe('safe export output names', () => {
it('removes directory traversal, device names, and unsafe punctuation', () => {
expect(safeExportFileName('../../CON.mp4')).toBe('_CON.mp4');
expect(safeExportFileName('..\\..\\holiday 🎬.MP4')).toBe('holiday.mp4');
expect(
safeExportFileName('/private/video.mov', { extension: '.webm' })
).toBe('video.webm');
expect(isSafeArchiveLeafName('../unsafe.mp4')).toBe(false);
expect(isSafeArchiveLeafName('safe-output.mp4')).toBe(true);
});
it('derives numbered names and rejects invalid part positions', () => {
expect(
deriveOutputFileName({
sourceName: 'My recording.mov',
operation: 'split',
extension: 'mp4',
partIndex: 1,
partCount: 12,
})
).toBe('My-recording-split-002.mp4');
expect(() =>
deriveOutputFileName({
sourceName: 'source.mp4',
operation: 'split',
partIndex: 2,
partCount: 2,
})
).toThrow(/part index/iu);
});
it('deduplicates names for case-insensitive filesystems', () => {
expect(
uniqueExportFileNames([
'Clip.mp4',
'clip.mp4',
'../../CLIP.mp4',
'other.mp4',
])
).toEqual(['Clip.mp4', 'clip-2.mp4', 'CLIP-3.mp4', 'other.mp4']);
});
});
describe('output MIME helpers', () => {
it('normalizes base types and falls back to known extensions', () => {
expect(normalizeOutputMimeType('Video/MP4; codecs=avc1')).toBe('video/mp4');
expect(normalizeOutputMimeType('not a mime', 'frame.png')).toBe(
'image/png'
);
expect(normalizeOutputMimeType(undefined, 'unknown.xyz')).toBe(
'application/octet-stream'
);
expect(mimeTypeFromOutputName('captions.vtt')).toBe('text/vtt');
expect(preferredExtensionForMimeType('image/jpeg')).toBe('.jpg');
expect(isBrowserPreviewMimeType('audio/mpeg')).toBe(true);
expect(isBrowserPreviewMimeType('application/json')).toBe(false);
});
});
describe('privacy-safe JSON export reports', () => {
const result = {
id: 'result',
planId: 'plan',
outputId: 'media',
fileName: '../../converted.mp4',
blob: new Blob(['private bytes']),
mimeType: 'video/mp4',
role: 'media',
size: 13,
createdAt: '2026-01-01T00:00:00.000Z',
} satisfies ExportResult;
it('includes plan diagnostics and output metadata without source data', async () => {
const privateRuntimeResult = {
...result,
// Extra runtime fields must never be copied into the report.
sourceBlob: new Blob(['source secret']),
hostPath: '/home/zemion/private/source.mp4',
};
const report = createExportReport({
plan: {
id: 'plan-/home/zemion/private/source.mp4',
operation: 'convert',
diagnostics: [
{
code: 'codec-warning',
severity: 'warning',
message:
'Read /mnt/DATA/Videos/secret.mov via blob:https://local/id',
field: 'C:\\Users\\Person\\Videos\\secret.mov',
},
],
},
results: [privateRuntimeResult],
execution: {
status: 'completed',
engineMode: 'single-thread',
ffmpegVersion: '6.0',
elapsedSeconds: 1.25,
conversionMilliseconds: 1_000,
outputReadMilliseconds: 25,
cleanupMilliseconds: 5,
exitCode: 0,
},
generatedAt: '2026-07-24T12:00:00+02:00',
});
expect(report.generatedAt).toBe('2026-07-24T10:00:00.000Z');
expect(report.plan.id).toContain('[local path]');
expect(report.outputs[0]).toEqual({
id: 'result',
outputId: 'media',
fileName: 'converted.mp4',
mimeType: 'video/mp4',
role: 'media',
sizeBytes: 13,
});
expect(report.diagnostics[0]?.message).toBe(
'Read [local path] via [object URL]'
);
expect(report.diagnostics[0]?.field).toBe('[local path]');
expect(report.privacy).toEqual({
includesSourceMedia: false,
includesCommandArguments: false,
privatePathsRedacted: true,
});
expect(report.execution).toMatchObject({
conversionMilliseconds: 1_000,
outputReadMilliseconds: 25,
cleanupMilliseconds: 5,
});
const serialized = serializeExportReport(report);
expect(serialized.endsWith('\n')).toBe(true);
expect(serialized).not.toContain('sourceBlob');
expect(serialized).not.toContain('hostPath');
expect(serialized).not.toContain('/home/zemion');
const blob = exportReportBlob(report);
expect(blob.type).toBe('application/json');
expect(await blob.text()).toBe(serialized);
});
it('redacts common local path and URL forms', () => {
expect(
redactPrivatePaths(
'file:///home/me/a.mp4 C:\\fakepath\\b.mp4 /Users/me/c.mp4'
)
).toBe('[local path] [local path] [local path]');
});
it('rejects invalid numeric report metadata', () => {
expect(() =>
createExportReport({
plan: { id: 'plan', operation: 'convert', diagnostics: [] },
results: [{ ...result, size: Number.NaN }],
})
).toThrow(/output size/iu);
});
});

View File

@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import {
collectCommandOutputs,
DEFAULT_RESULT_COLLECTION_LIMITS,
DEFAULT_RESULT_ZIP_LIMITS,
ExportResultCollection,
ExportResultLimitError,
MissingCommandOutputError,
saveCollectionResult,
type BlobSaver,
type ExportResultInput,
} from '../../src/export';
import type { FFmpegCommandPlan } from '../../src/commands';
function input(
id: string,
fileName = 'result.mp4',
contents = 'result'
): ExportResultInput {
return {
id,
planId: 'plan',
outputId: id,
fileName,
blob: new Blob([contents]),
mimeType: 'video/mp4',
role: 'media',
};
}
describe('export result collection', () => {
it('reserves one ZIP entry for the optional report', () => {
expect(DEFAULT_RESULT_COLLECTION_LIMITS.maxResults).toBe(255);
expect(DEFAULT_RESULT_ZIP_LIMITS.maxEntries).toBe(
DEFAULT_RESULT_COLLECTION_LIMITS.maxResults + 1
);
});
it('owns result Blobs and revokes every replaced or released object URL', () => {
let urlIndex = 0;
const revokeObjectURL = vi.fn();
const collection = new ExportResultCollection({
objectUrlApi: {
createObjectURL: () => `blob:result-${urlIndex++}`,
revokeObjectURL,
},
now: () => new Date('2026-07-24T10:00:00.000Z'),
});
const first = collection.add(input('one'));
expect(first).toMatchObject({
fileName: 'result.mp4',
mimeType: 'video/mp4',
size: 6,
createdAt: '2026-07-24T10:00:00.000Z',
});
expect(collection.objectUrl('one')).toBe('blob:result-0');
expect(collection.objectUrl('one')).toBe('blob:result-0');
collection.add(input('two', 'RESULT.mp4'));
expect(collection.get('two')?.fileName).toBe('RESULT-2.mp4');
collection.replace(input('one', 'replacement.mp4', 'new'));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-0');
expect(collection.totalBytes).toBe(9);
collection.objectUrl('two');
expect(collection.remove('two')).toBe(true);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-1');
collection.dispose();
expect(collection.totalBytes).toBe(0);
expect(() => collection.list()).toThrow(/disposed/iu);
expect(() => collection.dispose()).not.toThrow();
});
it('adds a batch atomically and enforces count and byte limits', () => {
const collection = new ExportResultCollection({
limits: {
maxResults: 2,
maxSingleResultBytes: 5,
maxTotalBytes: 8,
},
});
expect(() =>
collection.addMany([
input('one', 'one.bin', '1234'),
input('bad', 'bad.bin', '123456'),
])
).toThrowError(ExportResultLimitError);
expect(collection.size).toBe(0);
collection.add(input('one', 'one.bin', '1234'));
expect(() => collection.add(input('two', 'two.bin', '12345'))).toThrowError(
expect.objectContaining({ limit: 'total-result-bytes' })
);
expect(collection.size).toBe(1);
});
it('collects all planned outputs atomically from IDs or virtual paths', () => {
const collection = new ExportResultCollection();
const plan: Pick<FFmpegCommandPlan, 'id' | 'outputs'> = {
id: 'split-plan',
outputs: [
{
id: 'one',
path: '/work/job/one.mp4',
fileName: 'clip-001.mp4',
mediaType: 'video/mp4',
role: 'media',
timeRange: { startSeconds: 0, endSeconds: 5 },
},
{
id: 'two',
path: '/work/job/two.mp4',
fileName: 'clip-002.mp4',
mediaType: 'video/mp4',
role: 'media',
},
],
};
const results = collectCommandOutputs(
plan,
new Map<string, Blob | Uint8Array>([
['one', Uint8Array.of(1, 2)],
['/work/job/two.mp4', new Blob(['two'])],
]),
collection
);
expect(results.map((result) => result.outputId)).toEqual(['one', 'two']);
expect(results[0]?.blob.type).toBe('video/mp4');
expect(results[0]?.timeRange).toEqual({
startSeconds: 0,
endSeconds: 5,
});
const empty = new ExportResultCollection();
expect(() =>
collectCommandOutputs(plan, new Map([['one', new Blob(['one'])]]), empty)
).toThrowError(MissingCommandOutputError);
expect(empty.size).toBe(0);
});
});
describe('browser result saving', () => {
it('sanitizes the download name and releases a successfully saved result', async () => {
const collection = new ExportResultCollection();
collection.add(input('one', 'result.mp4'));
const saver = vi.fn<BlobSaver>(async () => 'downloaded');
await expect(
saveCollectionResult(collection, 'one', {
fileName: '../../My result.mp4',
saver,
})
).resolves.toBe('downloaded');
expect(saver).toHaveBeenCalledWith(
expect.any(Blob),
'My-result.mp4',
'video/mp4'
);
expect(collection.size).toBe(0);
});
it('retains the result when the save picker is cancelled', async () => {
const collection = new ExportResultCollection();
collection.add(input('one'));
const saver: BlobSaver = async () => 'cancelled';
await saveCollectionResult(collection, 'one', { saver });
expect(collection.size).toBe(1);
});
});

View File

@@ -0,0 +1,109 @@
import { unzipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import {
createResultZip,
saveResultsZip,
type BlobSaver,
type ExportResult,
} from '../../src/export';
function result(id: string, fileName: string, contents: string): ExportResult {
const blob = new Blob([contents], { type: 'text/plain' });
return {
id,
planId: 'plan',
outputId: id,
fileName,
blob,
mimeType: 'text/plain',
role: 'analysis',
size: blob.size,
createdAt: '2026-07-24T10:00:00.000Z',
};
}
describe('deterministic result ZIP creation', () => {
it('sorts and deduplicates safe paths with fixed archive metadata', async () => {
const entries = [
{ fileName: '../../Z.txt', blob: new Blob(['z']) },
{ fileName: 'a.txt', blob: new Blob(['first']) },
{ fileName: 'A.TXT', blob: new Blob(['second']) },
];
const first = await createResultZip(entries, {
fileName: '../My outputs.any',
});
const second = await createResultZip(entries, {
fileName: '../My outputs.any',
});
expect(first.fileName).toBe('My-outputs.zip');
expect(first.entryNames).toEqual(['A-2.txt', 'Z.txt', 'a.txt']);
expect(first.totalInputBytes).toBe(12);
expect(new Uint8Array(await first.blob.arrayBuffer())).toEqual(
new Uint8Array(await second.blob.arrayBuffer())
);
const extracted = unzipSync(new Uint8Array(await first.blob.arrayBuffer()));
expect(Object.keys(extracted)).toEqual(['A-2.txt', 'Z.txt', 'a.txt']);
expect(new TextDecoder().decode(extracted['a.txt'])).toBe('first');
expect(new TextDecoder().decode(extracted['A-2.txt'])).toBe('second');
});
it('rejects empty, oversized, and pre-cancelled batches', async () => {
await expect(createResultZip([])).rejects.toThrow(/at least one/iu);
await expect(
createResultZip([{ fileName: 'large.bin', blob: new Blob(['12345']) }], {
limits: {
maxEntries: 2,
maxEntryBytes: 4,
maxTotalInputBytes: 4,
maxArchiveBytes: 100,
},
})
).rejects.toThrowError(
expect.objectContaining({
limit: 'entry-bytes',
})
);
const controller = new AbortController();
controller.abort();
await expect(
createResultZip([{ fileName: 'one.txt', blob: new Blob(['one']) }], {
signal: controller.signal,
})
).rejects.toMatchObject({ name: 'AbortError' });
});
it('packages an optional report and passes the ZIP to the save adapter', async () => {
const saverCalls: { fileName: string; type: string }[] = [];
const saver: BlobSaver = async (blob, fileName) => {
saverCalls.push({ fileName, type: blob.type });
return 'saved';
};
const saved = await saveResultsZip([result('one', 'one.txt', 'contents')], {
fileName: 'batch.zip',
saver,
report: {
schemaVersion: 1,
generatedAt: '2026-07-24T10:00:00.000Z',
plan: { id: 'plan', operation: 'convert' },
outputs: [],
diagnostics: [],
privacy: {
includesSourceMedia: false,
includesCommandArguments: false,
privatePathsRedacted: true,
},
},
});
expect(saved.outcome).toBe('saved');
expect(saved.archive.entryNames).toEqual([
'convert-export-report.json',
'one.txt',
]);
expect(saverCalls).toEqual([
{ fileName: 'batch.zip', type: 'application/zip' },
]);
});
});

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
deserializeCapabilities,
parseBuildConfiguration,
parseCodecs,
parseFilters,
parseFormats,
parseNamedComponents,
serializeCapabilities,
} from '../../src/ffmpeg/capability-parser';
describe('FFmpeg capability parsing', () => {
it('parses demuxer and muxer flags including aliases', () => {
const parsed = parseFormats(`
File formats:
D. = Demuxing supported
.E = Muxing supported
DE matroska,webm Matroska / WebM
D mov,mp4,m4a QuickTime / MOV
E mp3 MP3
`);
expect(parsed.demuxers).toEqual(
new Set(['matroska', 'webm', 'mov', 'mp4', 'm4a'])
);
expect(parsed.muxers).toEqual(new Set(['matroska', 'webm', 'mp3']));
});
it('parses codecs, encoders, filters and configuration flags', () => {
const codecs = parseCodecs(`
Codecs:
D..... = Decoding supported
.E.... = Encoding supported
DEV.LS h264 H.264
D.A.L. aac AAC
.EV.L. vp9 VP9
`);
expect(codecs.decoders).toEqual(new Set(['h264', 'aac']));
expect(codecs.encoders).toEqual(new Set(['h264', 'vp9']));
expect(
parseNamedComponents(`
Encoders:
V..... libx264 H.264
A..... aac AAC
`)
).toEqual(new Set(['libx264', 'aac']));
expect(
parseFilters(`
Filters:
..C scale V->V Scale the input video size.
T.. loudnorm A->A EBU R128 loudness normalization
... concat N->N Concatenate audio and video streams.
`)
).toEqual(new Set(['scale', 'loudnorm', 'concat']));
expect(
parseBuildConfiguration(
'ffmpeg version x\nconfiguration: --enable-gpl --disable-doc'
)
).toEqual(['--enable-gpl', '--disable-doc']);
});
it('serializes Set values for durable caches without losing data', () => {
const original = {
versionText: 'ffmpeg version n6.1',
buildConfiguration: ['--enable-gpl'],
demuxers: new Set(['mov']),
muxers: new Set(['mp4']),
decoders: new Set(['h264']),
encoders: new Set(['libx264']),
filters: new Set(['scale']),
};
expect(deserializeCapabilities(serializeCapabilities(original))).toEqual(
original
);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
MAX_CONTACT_SHEETS_PER_ENGINE,
MAX_EXECUTIONS_PER_ENGINE,
MULTITHREAD_CODEC_THREAD_LIMIT,
engineJobTimeoutMilliseconds,
prepareEngineArguments,
shouldRecycleExecutionCore,
} from '../../src/ffmpeg/EngineManager';
describe('prepareEngineArguments', () => {
it('leaves single-thread commands unchanged', () => {
const args = ['-i', '/input/source.mp4', '/work/output.mp4'];
expect(
prepareEngineArguments(
args,
'single-thread',
['/input/source.mp4'],
['/work/output.mp4']
)
).toEqual(args);
});
it('bounds multithread input and output codec threads', () => {
expect(
prepareEngineArguments(
['-i', '/input/source.mp4', '-c:v', 'libx264', '/work/output.mp4'],
'multithread',
['/input/source.mp4'],
['/work/output.mp4']
)
).toEqual([
'-threads',
String(MULTITHREAD_CODEC_THREAD_LIMIT),
'-i',
'/input/source.mp4',
'-c:v',
'libx264',
'-threads',
String(MULTITHREAD_CODEC_THREAD_LIMIT),
'/work/output.mp4',
]);
});
it('bounds a generated output pattern in the final output position', () => {
expect(
prepareEngineArguments(
['-i', '/input/source.mp4', '-f', 'segment', '/work/part-%03d.mp4'],
'multithread',
['/input/source.mp4'],
['/work/part-001.mp4', '/work/part-002.mp4']
)
).toEqual([
'-threads',
'2',
'-i',
'/input/source.mp4',
'-f',
'segment',
'-threads',
'2',
'/work/part-%03d.mp4',
]);
});
});
describe('engineJobTimeoutMilliseconds', () => {
it('uses a bounded duration-aware watchdog', () => {
expect(engineJobTimeoutMilliseconds({ expectedDurationSeconds: 2 })).toBe(
120_000
);
expect(
engineJobTimeoutMilliseconds({ expectedDurationSeconds: 10_000 })
).toBe(DEFAULT_JOB_TIMEOUT_MILLISECONDS);
});
it('honors a shorter explicit timeout and caps a longer one', () => {
expect(
engineJobTimeoutMilliseconds({
expectedDurationSeconds: 30,
timeoutMilliseconds: 5_000,
})
).toBe(5_000);
expect(
engineJobTimeoutMilliseconds({
timeoutMilliseconds: DEFAULT_JOB_TIMEOUT_MILLISECONDS * 2,
})
).toBe(DEFAULT_JOB_TIMEOUT_MILLISECONDS);
});
});
describe('execution core recycling policy', () => {
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
false
);
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE)).toBe(true);
});
it('refreshes before a fourth contact sheet hits retained tile-filter state', () => {
expect(
shouldRecycleExecutionCore(
3,
'contact-sheet',
MAX_CONTACT_SHEETS_PER_ENGINE - 1
)
).toBe(false);
expect(
shouldRecycleExecutionCore(
3,
'contact-sheet',
MAX_CONTACT_SHEETS_PER_ENGINE
)
).toBe(true);
expect(
shouldRecycleExecutionCore(
3,
'thumbnail-series',
MAX_CONTACT_SHEETS_PER_ENGINE
)
).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
describeEngineMode,
selectEngineMode,
supportsMultithread,
} from '../../src/ffmpeg/engine-mode';
import type { EngineEnvironment } from '../../src/ffmpeg/ffmpeg.types';
const isolated: EngineEnvironment = {
secureContext: true,
crossOriginIsolated: true,
sharedArrayBuffer: true,
hardwareConcurrency: 8,
};
describe('engine mode selection', () => {
it('selects multithread only when every required capability is present', () => {
expect(supportsMultithread(isolated)).toBe(true);
expect(selectEngineMode(isolated, 'automatic')).toBe('multithread');
for (const missing of [
'secureContext',
'crossOriginIsolated',
'sharedArrayBuffer',
] as const) {
expect(
selectEngineMode({ ...isolated, [missing]: false }, 'automatic')
).toBe('single-thread');
}
});
it('honours the safe single-thread preference', () => {
expect(selectEngineMode(isolated, 'force-single-thread')).toBe(
'single-thread'
);
expect(describeEngineMode('single-thread')).toMatch(/compatibility/);
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
getCoreAssetUrls,
resolveFromAppBase,
} from '../../src/ffmpeg/asset-urls';
import { LogBuffer } from '../../src/ffmpeg/log-buffer';
import { normalizeProgress } from '../../src/ffmpeg/progress-parser';
describe('FFmpeg runtime utilities', () => {
it('resolves assets under a nested application base', () => {
expect(
resolveFromAppBase(
'vendor/core.wasm',
'https://tools.example/apps/av/index.html',
'./'
)
).toBe('https://tools.example/apps/av/vendor/core.wasm');
const mt = getCoreAssetUrls(
'multithread',
(path) => `https://tools.example/deep/${path}`
);
expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js');
expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js');
});
it('clamps experimental progress and removes invalid numbers', () => {
expect(normalizeProgress({ progress: 3, time: -10 })).toEqual({
progress: 1,
time: 0,
});
expect(
normalizeProgress({ progress: Number.NaN, time: Number.NaN })
).toEqual({ progress: 0, time: 0 });
});
it('bounds diagnostic log memory', () => {
const logs = new LogBuffer(2, 100);
logs.append({ type: 'stdout', message: 'one' });
logs.append({ type: 'stderr', message: 'two\u0000' });
logs.append({ type: 'stderr', message: 'three' });
expect(logs.snapshot()).toEqual([
{ type: 'stderr', message: 'two' },
{ type: 'stderr', message: 'three' },
]);
});
});

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import type { FFmpegAdapter } from '../../src/ffmpeg/ffmpeg.types';
import {
describeMountedInputAccess,
mountJobFiles,
} from '../../src/ffmpeg/virtual-fs';
function createFilesystemAdapter(
options: { keepWorkDirectory?: boolean; rejectWorkerFs?: boolean } = {}
) {
const directories = new Set(['/input', '/work']);
const files = new Set<string>();
const adapter = {
createDir: vi.fn(async (path: string) => {
directories.add(path);
}),
mount: vi.fn(async () => {
if (options.rejectWorkerFs) {
throw new Error('WORKERFS unavailable');
}
}),
unmount: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string) => {
files.add(path);
}),
deleteFile: vi.fn(async (path: string) => {
files.delete(path);
}),
deleteDir: vi.fn(async (path: string) => {
if (options.keepWorkDirectory && path.startsWith('/work/job-')) return;
directories.delete(path);
}),
listDir: vi.fn(async (path: string) =>
[...directories]
.filter(
(entry) =>
entry !== path && entry.slice(0, entry.lastIndexOf('/')) === path
)
.map((entry) => ({
name: entry.split('/').at(-1) ?? '',
isDir: true,
isLink: false,
}))
),
};
return adapter as unknown as FFmpegAdapter;
}
describe('per-job FFmpeg filesystem', () => {
it('uses generated paths and verifies complete cleanup', async () => {
const adapter = createFilesystemAdapter();
const mounted = await mountJobFiles(
adapter,
'../../job with separators',
[new File(['source'], '../../private name.MP4')],
['../unsafe result.mp4']
);
expect(mounted.inputPaths).toEqual([
'/input/job-job-with-separators/source-0.mp4',
]);
expect(mounted.outputPaths).toEqual([
'/work/job-job-with-separators/output-0.mp4',
]);
expect(mounted).toMatchObject({
inputMode: 'workerfs',
inputBytes: 6,
copiedInputBytes: 0,
});
expect(describeMountedInputAccess(mounted)).toContain(
'0 bytes copied into engine memory'
);
expect(mounted.inputPaths[0]).not.toContain('private');
expect(await mounted.cleanup()).toBe(true);
});
it('accounts for and explains the actual memory-copy fallback', async () => {
const adapter = createFilesystemAdapter({ rejectWorkerFs: true });
const mounted = await mountJobFiles(
adapter,
'copy-fallback',
[new File(['1234'], 'one.mp4'), new File(['567'], 'two.mp4')],
['result.mp4']
);
expect(mounted).toMatchObject({
inputMode: 'memory-copy',
inputBytes: 7,
copiedInputBytes: 7,
});
expect(adapter.writeFile).toHaveBeenCalledTimes(2);
expect(describeMountedInputAccess(mounted)).toBe(
'Input compatibility fallback: WORKERFS mounting was unavailable, so 7 bytes were copied into engine memory.'
);
expect(await mounted.cleanup()).toBe(true);
});
it('reports a directory that survives cleanup', async () => {
const adapter = createFilesystemAdapter({ keepWorkDirectory: true });
const mounted = await mountJobFiles(
adapter,
'job',
[new File(['source'], 'source.wav')],
['result.wav']
);
expect(await mounted.cleanup()).toBe(false);
});
});

269
tests/unit/jobs.test.ts Normal file
View File

@@ -0,0 +1,269 @@
import { describe, expect, it, vi } from 'vitest';
import {
MediaJobQueue,
MemoryJobMetadataStore,
ObjectUrlRegistry,
parseFFmpegProgress,
progressUpdateFromFFmpeg,
weightedJobProgress,
type JobExecutionContext,
type JobExecutionResult,
type MediaJob,
type MediaJobDefinition,
type MediaJobExecutor,
} from '../../src/jobs';
import { freezeCommandPlan } from '../../src/commands';
function definition(id: string, stepIds = ['encode']): MediaJobDefinition {
return {
id,
operation: 'test',
steps: stepIds.map((stepId) => ({
id: stepId,
name: stepId,
weight: stepId === 'read' ? 1 : 9,
plan: freezeCommandPlan({
id: `${id}:${stepId}`,
operation: stepId,
inputs: [],
temporaryFiles: [],
args: ['-version'],
outputs: [],
requiredCapabilities: {},
diagnostics: [],
}),
})),
};
}
class ControlledExecutor implements MediaJobExecutor {
readonly calls: string[] = [];
readonly cancelCurrentJob = vi.fn(async () => undefined);
readonly recoverAfterCancellation = vi.fn(async () => undefined);
readonly cleanup = vi.fn(async () => undefined);
#gates: (() => void)[] = [];
failNext?: Error;
execute(
plan: { readonly id: string },
context: JobExecutionContext
): Promise<JobExecutionResult> {
this.calls.push(plan.id);
context.onProgress({ progress: 0.5 });
if (this.failNext) {
const error = this.failNext;
this.failNext = undefined;
return Promise.reject(error);
}
return new Promise((resolve, reject) => {
const release = () => {
context.signal.removeEventListener('abort', abort);
resolve({ outputs: new Map() });
};
const abort = () => {
const index = this.#gates.indexOf(release);
if (index >= 0) {
this.#gates.splice(index, 1);
}
reject(new DOMException('cancelled', 'AbortError'));
};
context.signal.addEventListener('abort', abort, { once: true });
this.#gates.push(release);
});
}
release(): void {
this.#gates.shift()?.();
}
}
describe('progress parsing', () => {
it('parses FFmpeg machine progress and caps completion until output read', () => {
const parsed = parseFFmpegProgress(
'frame=25\nfps=12.5\nout_time_us=5000000\nspeed=1.5x\nprogress=continue\n'
);
expect(parsed).toEqual({
frame: 25,
fps: 12.5,
outTimeSeconds: 5,
speed: 1.5,
terminal: false,
});
expect(progressUpdateFromFFmpeg(parsed, 10)).toMatchObject({
progress: 0.5,
});
expect(
progressUpdateFromFFmpeg(
parseFFmpegProgress('progress=end\nout_time_us=10000000'),
10
).progress
).toBe(0.99);
});
it('combines weighted multi-step progress below 100 percent', () => {
expect(
weightedJobProgress([
{
id: 'analysis',
name: 'Analysis',
weight: 1,
status: 'completed',
progress: 1,
planId: 'one',
},
{
id: 'encode',
name: 'Encoding',
weight: 9,
status: 'running',
progress: 0.5,
planId: 'two',
},
])
).toBe(0.55);
});
});
describe('sequential durable job queue', () => {
it('runs only one command at a time and reaches 100% after cleanup', async () => {
const executor = new ControlledExecutor();
const store = new MemoryJobMetadataStore();
const queue = new MediaJobQueue({ executor, store });
await queue.initialize();
await queue.enqueue(definition('one'));
await queue.enqueue(definition('two'));
await waitUntil(() => executor.calls.length === 1);
expect(executor.calls).toEqual(['one:encode']);
expect(queue.get('one')?.progress).toBe(0.5);
executor.release();
await waitUntil(() => executor.calls.length === 2);
expect(queue.get('one')).toMatchObject({
status: 'completed',
progress: 1,
});
executor.release();
await queue.waitForIdle();
expect(queue.get('two')).toMatchObject({
status: 'completed',
progress: 1,
});
expect(queue.getResults('two')).toHaveLength(1);
expect(queue.releaseResults('two')).toBe(true);
expect(queue.getResults('two')).toBeUndefined();
expect((await store.list()).map((job) => job.status)).toEqual([
'completed',
'completed',
]);
});
it('terminates processing, recovers the engine, and runs the next job', async () => {
const executor = new ControlledExecutor();
const queue = new MediaJobQueue({
executor,
store: new MemoryJobMetadataStore(),
});
await queue.initialize();
await queue.enqueue(definition('cancel-me'));
await queue.enqueue(definition('after'));
await waitUntil(() => queue.get('cancel-me')?.status === 'running');
await queue.cancel('cancel-me');
expect(executor.cancelCurrentJob).toHaveBeenCalledOnce();
expect(executor.recoverAfterCancellation).toHaveBeenCalledOnce();
expect(queue.get('cancel-me')?.status).toBe('cancelled');
await waitUntil(() => executor.calls.includes('after:encode'));
executor.release();
await queue.waitForIdle();
expect(queue.get('after')?.status).toBe('completed');
});
it('marks failures, cleans up, and retries with the retained definition', async () => {
const executor = new ControlledExecutor();
executor.failNext = new Error('encoder failed');
const queue = new MediaJobQueue({
executor,
store: new MemoryJobMetadataStore(),
});
await queue.initialize();
await queue.enqueue(definition('retry'));
await queue.waitForIdle();
expect(queue.get('retry')).toMatchObject({
status: 'failed',
error: { code: 'execution-failed', recoverable: true },
});
await queue.retry('retry');
await waitUntil(() => executor.calls.length === 2);
executor.release();
await queue.waitForIdle();
expect(queue.get('retry')).toMatchObject({
status: 'completed',
retryCount: 1,
});
});
it('recovers interrupted durable metadata as an explicit failed job', async () => {
const store = new MemoryJobMetadataStore();
const interrupted: MediaJob = {
id: 'old',
operation: 'convert',
status: 'running',
steps: [
{
id: 'encode',
name: 'Encode',
weight: 1,
status: 'running',
progress: 0.4,
planId: 'old:encode',
},
],
progress: 0.4,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:01:00.000Z',
startedAt: '2026-01-01T00:00:01.000Z',
retryCount: 0,
};
await store.put(interrupted);
const queue = new MediaJobQueue({
executor: new ControlledExecutor(),
store,
});
await queue.initialize();
expect(queue.get('old')).toMatchObject({
status: 'failed',
recoveryNote: expect.stringContaining('previous browser session'),
error: { code: 'session-interrupted', recoverable: true },
});
});
});
describe('object URL cleanup', () => {
it('revokes replaced and cleared result URLs', () => {
let index = 0;
const revoke = vi.fn();
const registry = new ObjectUrlRegistry({
createObjectURL: () => `blob:${index++}`,
revokeObjectURL: revoke,
});
expect(registry.replace('output', new Blob(['one']))).toBe('blob:0');
expect(registry.replace('output', new Blob(['two']))).toBe('blob:1');
registry.replace('preview', new Blob(['three']));
expect(revoke).toHaveBeenCalledWith('blob:0');
registry.clear();
expect(revoke).toHaveBeenCalledWith('blob:1');
expect(revoke).toHaveBeenCalledWith('blob:2');
expect(registry.size).toBe(0);
});
});
async function waitUntil(
predicate: () => boolean,
timeoutMs = 2_000
): Promise<void> {
const started = Date.now();
while (!predicate()) {
if (Date.now() - started > timeoutMs) {
throw new Error('Timed out waiting for queue state');
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
}

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

436
tests/unit/presets.test.ts Normal file
View 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([]);
});
});

View File

@@ -0,0 +1,66 @@
import { createEmptyProject } from '../../../src/project/project.schema';
import type {
AvProjectV1,
MediaAssetReference,
} from '../../../src/project/project.types';
export const CREATED_AT = '2026-01-02T03:04:05.000Z';
export const UPDATED_AT = '2026-01-02T04:00:00.000Z';
export function mediaAsset(
overrides: Partial<MediaAssetReference> = {}
): MediaAssetReference {
return {
id: 'asset-1',
originalName: 'source.mp4',
size: 1_024,
lastModified: 1_700_000_000_000,
mimeType: 'video/mp4',
sourceStatus: 'attached',
probe: {
durationSeconds: 10,
formatNames: ['mov', 'mp4'],
tags: {},
streams: [
{
index: 0,
type: 'video',
codecName: 'h264',
disposition: { default: true },
tags: {},
},
],
chapters: [],
warnings: [],
},
...overrides,
};
}
export function populatedProject(): AvProjectV1 {
const project = createEmptyProject({
id: 'project-1',
name: 'Demo project',
timestamp: CREATED_AT,
});
return {
...project,
assets: [mediaAsset()],
timeline: [
{
id: 'clip-1',
assetId: 'asset-1',
sourceInSeconds: 1,
sourceOutSeconds: 4.5,
audio: { enabled: true, fadeInSeconds: 0.2 },
video: { enabled: true },
},
],
ui: {
selectedAssetId: 'asset-1',
selectedClipId: 'clip-1',
inspectorPanel: 'clip',
timelineZoom: 1,
},
};
}

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest';
import {
ProjectReducerError,
projectReducer,
} from '../../../src/project/project.reducer';
import {
selectCanExport,
selectMissingAssets,
selectTimelineDuration,
selectTimelineEntries,
selectUnusedAssets,
} from '../../../src/project/project.selectors';
import { createEmptyProject } from '../../../src/project/project.schema';
import {
CREATED_AT,
UPDATED_AT,
mediaAsset,
populatedProject,
} from './project-fixture';
describe('project reducer', () => {
it('adds assets and sequential clips without mutating prior state', () => {
const empty = createEmptyProject({
id: 'p',
timestamp: CREATED_AT,
});
const withAsset = projectReducer(empty, {
type: 'asset/add',
asset: mediaAsset(),
updatedAt: UPDATED_AT,
});
const withClip = projectReducer(withAsset, {
type: 'clip/add',
clip: {
id: 'clip-1',
assetId: 'asset-1',
sourceInSeconds: 0,
sourceOutSeconds: 2,
},
updatedAt: UPDATED_AT,
});
expect(empty.assets).toEqual([]);
expect(withAsset.timeline).toEqual([]);
expect(withClip.timeline).toHaveLength(1);
expect(withClip.updatedAt).toBe(UPDATED_AT);
});
it('reorders clips deterministically and returns identity for a no-op', () => {
const project = {
...populatedProject(),
timeline: [
populatedProject().timeline[0]!,
{
id: 'clip-2',
assetId: 'asset-1',
sourceInSeconds: 4.5,
sourceOutSeconds: 6,
},
],
};
const moved = projectReducer(project, {
type: 'clip/move',
clipId: 'clip-2',
toIndex: 0,
updatedAt: UPDATED_AT,
});
expect(moved.timeline.map((clip) => clip.id)).toEqual(['clip-2', 'clip-1']);
expect(
projectReducer(moved, {
type: 'clip/move',
clipId: 'clip-2',
toIndex: 0,
updatedAt: UPDATED_AT,
})
).toBe(moved);
});
it('removes dependent clips/subtitles and clears stale UI selection', () => {
const project = {
...populatedProject(),
subtitles: [
{ id: 'subtitle-1', assetId: 'asset-1', mode: 'mux' as const },
],
};
const result = projectReducer(project, {
type: 'asset/remove',
assetId: 'asset-1',
updatedAt: UPDATED_AT,
});
expect(result.assets).toEqual([]);
expect(result.timeline).toEqual([]);
expect(result.subtitles).toEqual([]);
expect(result.ui).toMatchObject({
selectedAssetId: undefined,
selectedClipId: undefined,
});
});
it('guards duplicate IDs, missing assets and invalid ranges', () => {
const project = populatedProject();
expect(() =>
projectReducer(project, {
type: 'asset/add',
asset: mediaAsset(),
updatedAt: UPDATED_AT,
})
).toThrow(ProjectReducerError);
expect(() =>
projectReducer(project, {
type: 'clip/add',
clip: {
id: 'bad',
assetId: 'missing',
sourceInSeconds: 0,
sourceOutSeconds: 1,
},
updatedAt: UPDATED_AT,
})
).toThrow('unknown asset');
expect(() =>
projectReducer(project, {
type: 'clip/update',
clipId: 'clip-1',
changes: { sourceOutSeconds: 0 },
updatedAt: UPDATED_AT,
})
).toThrow();
});
it('does not mark the document updated for transient UI state', () => {
const project = populatedProject();
const result = projectReducer(project, {
type: 'ui/set',
ui: { inspectorPanel: 'output' },
});
expect(result.updatedAt).toBe(project.updatedAt);
});
});
describe('project selectors', () => {
it('derives timeline positions and total duration for a sequential edit', () => {
const project = {
...populatedProject(),
timeline: [
populatedProject().timeline[0]!,
{
id: 'clip-2',
assetId: 'asset-1',
sourceInSeconds: 5,
sourceOutSeconds: 6.25,
},
],
};
expect(selectTimelineDuration(project)).toBe(4.75);
expect(selectTimelineEntries(project)).toMatchObject([
{
timelineStartSeconds: 0,
timelineEndSeconds: 3.5,
durationSeconds: 3.5,
},
{
timelineStartSeconds: 3.5,
timelineEndSeconds: 4.75,
durationSeconds: 1.25,
},
]);
});
it('identifies missing, unused and export-blocking sources', () => {
const project = {
...populatedProject(),
assets: [
mediaAsset({ sourceStatus: 'missing' }),
mediaAsset({ id: 'asset-unused', originalName: 'unused.wav' }),
],
};
expect(selectMissingAssets(project).map((asset) => asset.id)).toEqual([
'asset-1',
]);
expect(selectUnusedAssets(project).map((asset) => asset.id)).toEqual([
'asset-unused',
]);
expect(selectCanExport(project)).toEqual({
canExport: false,
reasons: ['1 referenced source file is missing.'],
});
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
createEmptyProject,
createMediaAssetReference,
} from '../../../src/project/project.schema';
import { CREATED_AT } from './project-fixture';
describe('project factories', () => {
it('creates a complete, deterministic empty document when inputs are supplied', () => {
expect(
createEmptyProject({
id: 'project-1',
name: ' Test ',
timestamp: CREATED_AT,
})
).toMatchObject({
schemaVersion: 1,
id: 'project-1',
name: 'Test',
createdAt: CREATED_AT,
updatedAt: CREATED_AT,
assets: [],
timeline: [],
output: {
presetId: 'mp4-h264-balanced',
fileName: 'output.mp4',
container: 'mp4',
sampleRate: 48_000,
channelLayout: 'stereo',
missingAudioPolicy: 'insert-silence',
},
});
});
it('converts File-compatible identity metadata without retaining the File', () => {
const file = {
name: 'clip.mov',
size: 42,
lastModified: 123,
type: 'video/quicktime',
};
const asset = createMediaAssetReference(file, { id: 'asset-1' });
expect(asset).toEqual({
id: 'asset-1',
originalName: 'clip.mov',
size: 42,
lastModified: 123,
mimeType: 'video/quicktime',
sourceStatus: 'attached',
});
expect(asset).not.toHaveProperty('file');
});
it('rejects malformed file identity metadata', () => {
expect(() =>
createMediaAssetReference({
name: '',
size: -1,
lastModified: 0,
type: '',
})
).toThrow();
});
});

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
ProjectImportError,
importProjectDocument,
projectDocumentFileName,
serializeProject,
} from '../../../src/project/project.serialization';
import { ProjectValidationError } from '../../../src/project/project.validation';
import { populatedProject } from './project-fixture';
describe('project serialization', () => {
it('serializes readable, versioned JSON and can omit transient UI state', () => {
const project = populatedProject();
const json = serializeProject(project, { includeUiState: false });
const parsed = JSON.parse(json) as Record<string, unknown>;
expect(json.endsWith('\n')).toBe(true);
expect(parsed.schemaVersion).toBe(1);
expect(parsed.ui).toBeUndefined();
expect(project.ui).toBeDefined();
});
it('validates before serializing', () => {
const project = {
...populatedProject(),
output: { ...populatedProject().output, fileName: '' },
};
expect(() => serializeProject(project)).toThrow(ProjectValidationError);
});
it('imports a round trip but correctly marks browser sources missing', () => {
const project = populatedProject();
const result = importProjectDocument(serializeProject(project));
expect(result.fromVersion).toBe(1);
expect(result.project.assets[0]?.sourceStatus).toBe('missing');
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'source-reattachment-required' })
);
});
it('can retain runtime source status only when explicitly requested', () => {
const project = populatedProject();
const result = importProjectDocument(serializeProject(project), {
markSourcesMissing: false,
});
expect(result.project).toEqual(project);
expect(result.warnings).toEqual([]);
});
it('round-trips fade curves and editable chapter time bases', () => {
const project = populatedProject();
const persisted = {
...project,
timeline: [
{
...project.timeline[0]!,
audio: {
enabled: true,
fadeInSeconds: 0.2,
fadeCurve: 'qsin' as const,
},
video: {
enabled: true,
fadeOutSeconds: 0.2,
fadeCurve: 'exp' as const,
},
},
],
chapters: [
{
id: 'chapter-1',
title: 'Intro',
startSeconds: 0,
endSeconds: 1,
timeBase: '1/48000',
},
],
};
const result = importProjectDocument(serializeProject(persisted), {
markSourcesMissing: false,
});
expect(result.project.timeline[0]?.audio?.fadeCurve).toBe('qsin');
expect(result.project.timeline[0]?.video?.fadeCurve).toBe('exp');
expect(result.project.chapters[0]?.timeBase).toBe('1/48000');
});
it('still reports reattachment when an exported project was already missing sources', () => {
const project = {
...populatedProject(),
assets: [
{ ...populatedProject().assets[0]!, sourceStatus: 'missing' as const },
],
};
const result = importProjectDocument(serializeProject(project));
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'source-reattachment-required' })
);
});
it('migrates the documented legacy version zero shape', () => {
const legacy = {
schemaVersion: 0,
id: 'legacy-project',
title: 'Legacy project',
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
media: [
{
id: 'legacy-source',
fileName: 'legacy.mp4',
size: 42,
lastModified: 100,
type: 'video/mp4',
sourceStatus: 'attached',
},
],
clips: [
{
id: 'legacy-clip',
assetId: 'legacy-source',
inSeconds: 0,
outSeconds: 2,
},
],
};
const result = importProjectDocument(JSON.stringify(legacy));
expect(result.fromVersion).toBe(0);
expect(result.project).toMatchObject({
schemaVersion: 1,
name: 'Legacy project',
assets: [
{
originalName: 'legacy.mp4',
mimeType: 'video/mp4',
sourceStatus: 'missing',
},
],
timeline: [
{
sourceInSeconds: 0,
sourceOutSeconds: 2,
},
],
});
expect(result.warnings.map((warning) => warning.code)).toEqual(
expect.arrayContaining([
'renamed-field',
'defaulted-field',
'source-reattachment-required',
])
);
});
it('rejects malformed, unversioned, future and oversized documents', () => {
expect(() => importProjectDocument('{')).toThrow(ProjectImportError);
expect(() => importProjectDocument('{}')).toThrow(
'does not declare a schema version'
);
expect(() => importProjectDocument('{"schemaVersion":999}')).toThrow(
'is not supported'
);
expect(() =>
importProjectDocument('{"schemaVersion":1}', { maximumBytes: 5 })
).toThrow('exceeds');
});
it('creates a safe project-document filename', () => {
expect(
projectDocumentFileName({
...populatedProject(),
name: '../../My Project',
})
).toBe('My-Project.avproject.json');
});
});

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';
import {
ProjectValidationError,
assertValidProject,
validateProject,
} from '../../../src/project/project.validation';
import { populatedProject } from './project-fixture';
describe('project validation', () => {
it('returns an isolated, typed copy for a valid version-1 project', () => {
const input = populatedProject();
const result = validateProject(input);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.project).toEqual(input);
expect(result.project).not.toBe(input);
}
});
it('reports duplicate identities, missing references and bad ranges', () => {
const project = populatedProject();
const invalid = {
...project,
assets: [...project.assets, { ...project.assets[0] }],
timeline: [
{
...project.timeline[0],
assetId: 'missing',
sourceInSeconds: 5,
sourceOutSeconds: 3,
crop: { x: -1, y: 0, width: 0, height: 100 },
},
],
};
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining([
'duplicate-id',
'missing-reference',
'invalid-range',
'invalid-value',
])
);
}
});
it('checks clip bounds, fade bounds, chapter bounds and output streams', () => {
const project = populatedProject();
const invalid = {
...project,
timeline: [
{
...project.timeline[0],
sourceOutSeconds: 11,
audio: {
enabled: true,
fadeInSeconds: 8,
fadeOutSeconds: 8,
},
},
],
chapters: [
{
id: 'chapter-1',
startSeconds: 0,
endSeconds: 20,
title: 'Too long',
},
],
output: {
...project.output,
videoEnabled: false,
audioEnabled: false,
},
};
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'invalid-range',
path: 'timeline[0]',
}),
expect.objectContaining({
code: 'out-of-bounds',
path: 'timeline[0].audio',
}),
expect.objectContaining({
code: 'out-of-bounds',
path: 'chapters[0].endSeconds',
}),
expect.objectContaining({
code: 'invalid-value',
path: 'output',
}),
])
);
}
});
it('validates persisted fade curves and chapter time-base resolution', () => {
const project = populatedProject();
const invalid = {
...project,
timeline: [
{
...project.timeline[0],
audio: {
enabled: true,
fadeInSeconds: 0.2,
fadeCurve: 'log',
},
},
],
chapters: [
{
id: 'chapter-1',
startSeconds: 0,
endSeconds: 0.1,
title: 'Too short for this base',
timeBase: '1/1',
},
],
};
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'invalid-value',
path: 'timeline[0].audio.fadeCurve',
}),
expect.objectContaining({
code: 'invalid-value',
path: 'chapters[0].timeBase',
}),
])
);
}
});
it('rejects unsupported schema versions with a useful typed exception', () => {
const project = { ...populatedProject(), schemaVersion: 99 };
expect(() => assertValidProject(project)).toThrow(ProjectValidationError);
const result = validateProject(project);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues[0]).toMatchObject({
code: 'unsupported-version',
path: 'schemaVersion',
});
}
});
it('does not trust malformed normalized probe fields from an import', () => {
const project = populatedProject();
const invalid = structuredClone(project) as unknown as {
assets: Array<{
probe?: {
streams: Array<{
width?: unknown;
rotationDegrees?: unknown;
}>;
};
}>;
};
const stream = invalid.assets[0]?.probe?.streams[0];
if (stream) {
stream.width = '1920';
stream.rotationDegrees = '90';
}
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'invalid-type',
path: 'assets[0].probe.streams[0].width',
})
);
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'invalid-type',
path: 'assets[0].probe.streams[0].rotationDegrees',
})
);
}
});
});

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import {
SourceReattachmentError,
bestReattachmentCandidate,
confirmSourceReattachment,
rankReattachmentCandidates,
} from '../../../src/project/source-reattachment';
import type { SourceHash } from '../../../src/project/project.types';
import { UPDATED_AT, mediaAsset, populatedProject } from './project-fixture';
const HASH_A: SourceHash = {
algorithm: 'sha-256',
value: 'a'.repeat(64),
scope: 'full',
};
const HASH_B: SourceHash = {
algorithm: 'sha-256',
value: 'b'.repeat(64),
scope: 'full',
};
describe('source reattachment matching', () => {
it('ranks size, timestamp, filename, MIME type and optional hash evidence', () => {
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
const candidates = [
{
name: 'renamed.mp4',
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
hash: HASH_A,
},
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
hash: HASH_B,
},
];
const matches = rankReattachmentCandidates(asset, candidates);
expect(matches.map((match) => match.confidence)).toEqual([
'exact',
'likely',
'none',
]);
expect(matches[0]).toMatchObject({
score: 190,
requiresExplicitConfirmation: true,
});
expect(
matches[0]?.evidence.find((evidence) => evidence.field === 'hash')
).toMatchObject({ matches: true });
});
it('does not suggest ambiguous ties', () => {
const asset = mediaAsset({ sourceStatus: 'missing' });
const candidates = [
{
name: 'a.mp4',
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
{
name: 'b.mp4',
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
];
expect(bestReattachmentCandidate(asset, candidates)).toBeUndefined();
});
it('allows filesystem timestamp tolerance but treats size/hash mismatch as unsafe', () => {
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
expect(
rankReattachmentCandidates(asset, [
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified + 1_500,
mimeType: asset.mimeType,
},
])[0]?.confidence
).toBe('likely');
expect(
rankReattachmentCandidates(asset, [
{
name: asset.originalName,
size: asset.size + 1,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
])[0]?.confidence
).toBe('none');
});
it('does not treat incomparable partial and full hashes as a mismatch', () => {
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
const partial: SourceHash = {
algorithm: 'sha-256',
value: 'b'.repeat(64),
scope: 'partial',
bytesHashed: 1_024,
};
expect(
rankReattachmentCandidates(asset, [
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
hash: partial,
},
])[0]?.confidence
).toBe('likely');
});
it('rejects malformed file descriptors before scoring', () => {
expect(() =>
rankReattachmentCandidates(mediaAsset(), [
{
name: '',
size: -1,
lastModified: Number.NaN,
},
])
).toThrow(SourceReattachmentError);
});
it('only marks a source attached after an explicit confirmation call', () => {
const project = {
...populatedProject(),
assets: [mediaAsset({ sourceStatus: 'missing' })],
};
const candidate = {
name: 'renamed.mp4',
size: 1_024,
lastModified: 1_700_000_000_000,
mimeType: 'video/mp4',
};
const result = confirmSourceReattachment(
project,
'asset-1',
candidate,
UPDATED_AT
);
expect(project.assets[0]?.sourceStatus).toBe('missing');
expect(result.assets[0]?.sourceStatus).toBe('attached');
expect(() =>
confirmSourceReattachment(
project,
'asset-1',
{ ...candidate, size: 1 },
UPDATED_AT
)
).toThrow(SourceReattachmentError);
});
});

View File

@@ -0,0 +1,251 @@
import {
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
assertLicenseReady,
collectDirectoryEntries,
createDeterministicZip,
parseReleaseArguments,
safeArchivePath,
} from '../../scripts/package-release.mjs';
import {
checksumLine,
parseChecksumLine,
sha256,
verifyChecksum,
writeChecksum,
} from '../../scripts/checksum-release.mjs';
import {
inspectPortalHeaders,
parsePortalArguments,
} from '../../scripts/portal-assembly-smoke.mjs';
const temporaryDirectories = [];
async function temporaryDirectory() {
const directory = await mkdtemp(
path.join(tmpdir(), 'av-tools-release-test-')
);
temporaryDirectories.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true }))
);
});
describe('release path safety', () => {
it.each([
'',
'/index.html',
'../index.html',
'assets/../index.html',
'assets//main.js',
'C:/index.html',
'assets\\main.js',
'assets/\0main.js',
])('rejects unsafe archive path %j', (unsafePath) => {
expect(() => safeArchivePath(unsafePath)).toThrow();
});
it('accepts a canonical nested path', () => {
expect(safeArchivePath('vendor/ffmpeg/core.wasm')).toBe(
'vendor/ffmpeg/core.wasm'
);
});
it('rejects symlinks while collecting a directory', async () => {
const directory = await temporaryDirectory();
await writeFile(path.join(directory, 'target.txt'), 'target');
await symlink('target.txt', path.join(directory, 'alias.txt'));
await expect(collectDirectoryEntries(directory)).rejects.toThrow(
/symlink/i
);
});
});
describe('deterministic release ZIP', () => {
const entries = [
{ name: 'z.txt', data: Buffer.from('last') },
{ name: 'a.txt', data: Buffer.from('first') },
];
it('is byte-identical regardless of input order', () => {
const first = createDeterministicZip(entries);
const second = createDeterministicZip([...entries].reverse());
expect(first.equals(second)).toBe(true);
expect(sha256(first)).toMatch(/^[a-f0-9]{64}$/);
});
it('stores sorted UTF-8 entries with the normalized epoch and file mode', () => {
const archive = createDeterministicZip(entries);
expect(archive.readUInt32LE(0)).toBe(0x04034b50);
expect(archive.readUInt16LE(10)).toBe(0);
expect(archive.readUInt16LE(12)).toBe(33);
expect(archive.subarray(30, 35).toString('utf8')).toBe('a.txt');
const centralOffset = archive.indexOf(
Buffer.from([0x50, 0x4b, 0x01, 0x02])
);
expect(centralOffset).toBeGreaterThan(0);
expect(archive.readUInt16LE(centralOffset + 4)).toBe(0x0314);
expect(archive.readUInt32LE(centralOffset + 38) >>> 16).toBe(0o100644);
});
it('rejects duplicate paths and local absolute paths in text', () => {
expect(() =>
createDeterministicZip([
{ name: 'same.txt', data: Buffer.from('one') },
{ name: 'same.txt', data: Buffer.from('two') },
])
).toThrow(/duplicate/i);
expect(() =>
createDeterministicZip([
{ name: 'bundle.js', data: Buffer.from('source: "/home/user/file"') },
])
).toThrow(/local absolute path/i);
});
});
describe('licence release gate', () => {
it('blocks a normal release without an application licence', async () => {
const directory = await temporaryDirectory();
await expect(assertLicenseReady(directory)).rejects.toThrow(
/RELEASE BLOCKED/
);
});
it('allows only the explicit development-smoke escape hatch', async () => {
const directory = await temporaryDirectory();
await expect(assertLicenseReady(directory, true)).resolves.toBeNull();
expect(
parseReleaseArguments(['--allow-unlicensed-development-smoke'])
).toEqual({
allowUnlicensedDevelopmentSmoke: true,
force: false,
});
expect(() => parseReleaseArguments(['--publish'])).toThrow(/unknown/i);
});
it('recognizes a complete GPL version 3 root application licence', async () => {
const directory = await temporaryDirectory();
const licence = path.join(directory, 'LICENSE');
await writeFile(
licence,
[
'GNU GENERAL PUBLIC LICENSE',
'Version 3, 29 June 2007',
'END OF TERMS AND CONDITIONS',
].join('\n')
);
await expect(assertLicenseReady(directory)).resolves.toBe(licence);
});
it('rejects an arbitrary file named LICENSE', async () => {
const directory = await temporaryDirectory();
await writeFile(path.join(directory, 'LICENSE'), 'example only');
await expect(assertLicenseReady(directory)).rejects.toThrow(
/complete GNU GPL version 3/i
);
});
});
describe('release checksum', () => {
it('formats and parses the portable sidecar form', () => {
const digest = 'a'.repeat(64);
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
expect(parseChecksumLine(line)).toEqual({
archiveName: 'av-tools-0.1.0.zip',
digest,
});
});
it('writes and verifies a checksum sidecar', async () => {
const directory = await temporaryDirectory();
const archive = path.join(directory, 'av-tools-0.1.0.zip');
await writeFile(archive, 'deterministic bytes');
const result = await writeChecksum(archive);
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
result.digest
);
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
' av-tools-0.1.0.zip'
);
});
it('rejects checksum mismatches', async () => {
const directory = await temporaryDirectory();
const archive = path.join(directory, 'av-tools-0.1.0.zip');
const sidecar = `${archive}.sha256`;
await writeFile(archive, 'changed');
await mkdir(path.dirname(sidecar), { recursive: true });
await writeFile(
sidecar,
checksumLine('0'.repeat(64), path.basename(archive))
);
await expect(verifyChecksum(archive, sidecar)).rejects.toThrow(/mismatch/i);
});
});
describe('portal assembly smoke validation', () => {
const isolatedHeaders = `
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
types { application/wasm wasm; }
# vendor/ffmpeg/0.12.10
`;
it('accepts the required isolation, CSP and MIME profile', () => {
expect(inspectPortalHeaders(isolatedHeaders)).toEqual({
errors: [],
warnings: [],
});
});
it('detects the current media-preview CSP gap', () => {
const inspection = inspectPortalHeaders(
isolatedHeaders.replace("media-src 'self' blob:;", '')
);
expect(inspection.errors).toContain(
"CSP media-src must contain 'self' and blob:"
);
});
it('parses only explicit smoke overrides and paths', () => {
const parsed = parsePortalArguments(
[
'--artifact',
'release/example.zip',
'--allow-unlicensed-development-smoke',
'--allow-known-header-gap',
'--keep-temp',
],
'/work/av-tools'
);
expect(parsed).toEqual({
allowKnownHeaderGap: true,
allowUnlicensedDevelopmentSmoke: true,
artifact: '/work/av-tools/release/example.zip',
keepTemp: true,
portalRoot: '/work/references/toolbox-portal',
});
expect(() => parsePortalArguments(['--publish'], '/work/av-tools')).toThrow(
/unknown/i
);
});
});

410
tests/unit/storage.test.ts Normal file
View File

@@ -0,0 +1,410 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_RESOURCE_POLICY,
MemoryDerivedCache,
ResilientDerivedCache,
createCacheKey,
createResourcePolicy,
estimatePlanResources,
estimateOutputBytes,
policyForDeviceMemory,
preflightResources,
readStorageQuota,
stableStringify,
type DerivedCache,
} from '../../src/storage';
import type { FFmpegCommandPlan } from '../../src/commands/command-plan';
import type { MediaProbe } from '../../src/media';
describe('cache identities', () => {
it('canonicalizes settings and produces deterministic path-safe keys', () => {
const one = createCacheKey({
sourceFingerprint: 'source-123',
operation: 'waveform',
streamIndex: 1,
settings: { zoom: 2, channels: ['left', 'right'] },
});
const two = createCacheKey({
sourceFingerprint: 'source-123',
operation: 'waveform',
streamIndex: 1,
settings: { channels: ['left', 'right'], zoom: 2 },
});
expect(one).toBe(two);
expect(one).toMatch(/^waveform-[0-9a-f]{16}$/u);
expect(stableStringify({ z: 1, a: { y: 2, x: 3 } })).toBe(
'{"a":{"x":3,"y":2},"z":1}'
);
expect(() =>
createCacheKey({
sourceFingerprint: 'source',
operation: '../outside',
})
).toThrow(/safe operation/u);
});
});
describe('resource preflight', () => {
it('allows small jobs and labels estimates as non-guarantees', () => {
const decision = preflightResources({
inputBytes: [10 * 1024 * 1024],
expectedOutputBytes: estimateOutputBytes(10, 2_000, 128),
durationSeconds: 10,
width: 1920,
height: 1080,
frameRate: 25,
passes: 1,
});
expect(decision.status).toBe('allowed');
expect(decision.totals.pixelWorkEstimate).toBe(518_400_000);
expect(decision.estimateDisclaimer).toContain('not a guarantee');
});
it('blocks hard limits and provides actionable suggestions', () => {
const decision = preflightResources({
inputBytes: [DEFAULT_RESOURCE_POLICY.hardSingleInputBytes + 1],
width: 9000,
height: 9000,
thumbnailCount: 501,
contactSheetCells: 501,
expectedOutputBytes: DEFAULT_RESOURCE_POLICY.hardOutputEstimateBytes + 1,
timelineClips: DEFAULT_RESOURCE_POLICY.maxTimelineClips + 1,
streamsPerInput: [DEFAULT_RESOURCE_POLICY.maxStreamsPerInput + 1],
});
expect(decision.status).toBe('blocked');
expect(decision.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining([
'single-input-hard-limit',
'total-input-hard-limit',
'pixel-limit',
'thumbnail-limit',
'contact-sheet-limit',
'output-hard-limit',
'timeline-clip-limit',
'stream-count-limit',
])
);
expect(decision.issues.every((issue) => issue.suggestions.length > 0)).toBe(
true
);
});
it('lowers policy for reliable low-memory signals', () => {
const policy = createResourcePolicy();
expect(policyForDeviceMemory(policy, 2).softInputBytes).toBeLessThan(
policy.softInputBytes
);
});
it('reserves collection and report ZIP capacity before running a batch', () => {
const policy = createResourcePolicy();
const decision = preflightResources(
{
inputBytes: [],
generatedOutputs: 2,
retainedOutputs: policy.maxGeneratedOutputs - 1,
},
policy
);
expect(decision.status).toBe('blocked');
expect(decision.issues).toContainEqual(
expect.objectContaining({
code: 'generated-output-limit',
message: expect.stringMatching(/ZIP with an optional report/iu),
})
);
});
it('rejects malformed generated-image estimates before comparing limits', () => {
expect(() =>
preflightResources({
inputBytes: [],
thumbnailCount: -1,
})
).toThrow(/thumbnailCount/u);
expect(() =>
preflightResources({
inputBytes: [],
contactSheetCells: 1.5,
})
).toThrow(/contactSheetCells/u);
});
it('derives output size, geometry, frame rate, and generated-image semantics from plans', () => {
const contactEstimate = estimatePlanResources(
plan({
operation: 'contact-sheet',
args: [
'-vf',
'fps=0.5,scale=320:-2:force_original_aspect_ratio=decrease,tile=3x2:padding=4:margin=4',
'/work/contact.jpg',
],
outputs: [
{
id: 'contact',
path: '/work/contact.jpg',
fileName: 'contact.jpg',
mediaType: 'image/jpeg',
role: 'contact-sheet',
},
],
expectedDurationSeconds: 12,
}),
[{ size: 2_000_000, probe: videoProbe() }]
);
expect(contactEstimate).toMatchObject({
width: 976,
height: 372,
frameRate: 0.5,
contactSheetCells: 6,
generatedOutputs: 1,
});
const remuxEstimate = estimatePlanResources(
plan({
operation: 'remux',
args: ['-i', '/input/source.mp4', '-c', 'copy', '/work/output.mkv'],
outputs: [
{
id: 'media',
path: '/work/output.mkv',
fileName: 'output.mkv',
mediaType: 'video/x-matroska',
role: 'media',
},
],
expectedDurationSeconds: 10,
}),
[{ size: 2_000_000, probe: videoProbe() }]
);
expect(remuxEstimate.expectedOutputBytes).toBe(2_000_000);
const bitrateEstimate = estimatePlanResources(
plan({
operation: 'convert',
args: [
'-c:v',
'libx264',
'-b:v',
'500k',
'-c:a',
'aac',
'-b:a',
'128k',
'/work/output.mp4',
],
outputs: [
{
id: 'media',
path: '/work/output.mp4',
fileName: 'output.mp4',
mediaType: 'video/mp4',
role: 'media',
},
],
expectedDurationSeconds: 10,
}),
[{ size: 2_000_000, probe: audioVideoProbe() }]
);
expect(bitrateEstimate.expectedOutputBytes).toBe(
estimateOutputBytes(10, 628)
);
const thumbnailEstimate = estimatePlanResources(
plan({
operation: 'thumbnail-series',
args: ['-vf', 'scale=320:-2', '/work/thumb.png'],
outputs: Array.from({ length: 4 }, (_, index) => ({
id: `thumb-${index}`,
path: `/work/thumb-${index}.png`,
fileName: `thumb-${index}.png`,
mediaType: 'image/png',
role: 'thumbnail' as const,
})),
expectedDurationSeconds: 8,
}),
[{ size: 2_000_000, probe: videoProbe() }]
);
expect(thumbnailEstimate).toMatchObject({
width: 320,
height: 180,
frameRate: 0.5,
thumbnailCount: 4,
generatedOutputs: 4,
});
});
});
describe('derived cache lifecycle', () => {
it('lists sizes, expires entries, clears projects and clears all', async () => {
let now = new Date('2026-01-01T00:00:00.000Z');
const cache = new MemoryDerivedCache({
maxBytes: 1_024,
now: () => now,
});
await cache.put(
{
key: 'waveform-a',
kind: 'waveform-peaks',
projectId: 'project-a',
mediaType: 'application/octet-stream',
expiresAt: '2026-01-02T00:00:00.000Z',
},
new Uint8Array([1, 2, 3])
);
await cache.put(
{
key: 'thumb-b',
kind: 'thumbnail',
projectId: 'project-b',
mediaType: 'image/png',
},
new Blob(['image'], { type: 'image/png' })
);
expect(await cache.sizeBytes()).toBe(8);
expect((await cache.list()).map((entry) => entry.key)).toHaveLength(2);
now = new Date('2026-01-03T00:00:00.000Z');
expect(await cache.pruneExpired()).toBe(1);
expect(await cache.get('waveform-a')).toBeUndefined();
expect(await cache.clearProject('project-b')).toBe(1);
expect(await cache.sizeBytes()).toBe(0);
await cache.clear();
expect(await cache.list()).toEqual([]);
});
it('evicts least recently used memory entries under the configured limit', async () => {
let tick = 0;
const cache = new MemoryDerivedCache({
maxBytes: 6,
now: () => new Date(1_000 + tick++),
});
await cache.put({ key: 'one', kind: 'project' }, new Blob(['123']));
await cache.put({ key: 'two', kind: 'project' }, new Blob(['456']));
await cache.get('one');
await cache.put({ key: 'three', kind: 'project' }, new Blob(['789']));
expect(await cache.get('two')).toBeUndefined();
expect(await cache.get('one')).toBeDefined();
expect(await cache.get('three')).toBeDefined();
});
it('switches explicitly to memory fallback when OPFS operations fail', async () => {
const primary = new ThrowingCache();
const fallback = new MemoryDerivedCache();
const cache = new ResilientDerivedCache(primary, fallback);
expect(cache.mode).toBe('opfs');
await cache.put({ key: 'fallback', kind: 'project' }, new Blob(['ok']));
expect(cache.mode).toBe('memory');
expect(cache.fallbackReason).toContain('quota denied');
expect(await cache.get('fallback')).toBeDefined();
await cache.clear();
expect(await cache.list()).toEqual([]);
});
it('reports quota and unavailable estimate failures', async () => {
expect(
await readStorageQuota({
estimate: async () => ({ usage: 25, quota: 100 }),
persisted: async () => true,
})
).toEqual({
available: true,
usageBytes: 25,
quotaBytes: 100,
remainingBytes: 75,
usageFraction: 0.25,
persisted: true,
});
expect(
await readStorageQuota({
estimate: async () => {
throw new Error('denied');
},
})
).toEqual({ available: false, error: 'denied' });
});
});
class ThrowingCache implements DerivedCache {
readonly mode = 'opfs' as const;
async list(): Promise<never> {
throw new Error('quota denied');
}
async get(): Promise<never> {
throw new Error('quota denied');
}
async put(): Promise<never> {
throw new Error('quota denied');
}
async delete(): Promise<never> {
throw new Error('quota denied');
}
async clear(): Promise<never> {
throw new Error('quota denied');
}
async clearProject(): Promise<never> {
throw new Error('quota denied');
}
async pruneExpired(): Promise<never> {
throw new Error('quota denied');
}
async sizeBytes(): Promise<never> {
throw new Error('quota denied');
}
}
function plan(
options: Pick<
FFmpegCommandPlan,
'operation' | 'args' | 'outputs' | 'expectedDurationSeconds'
>
): FFmpegCommandPlan {
return {
id: `job:${options.operation}`,
operation: options.operation,
inputs: [],
temporaryFiles: [],
args: options.args,
outputs: options.outputs,
expectedDurationSeconds: options.expectedDurationSeconds,
requiredCapabilities: {},
diagnostics: [],
};
}
function videoProbe(): MediaProbe {
return {
durationSeconds: 10,
bitRate: 800_000,
formatNames: ['mov', 'mp4'],
tags: {},
chapters: [],
warnings: [],
streams: [
{
index: 0,
type: 'video',
width: 1920,
height: 1080,
frameRate: 25,
disposition: {},
tags: {},
},
],
};
}
function audioVideoProbe(): MediaProbe {
const probe = videoProbe();
return {
...probe,
streams: [
...probe.streams,
{
index: 1,
type: 'audio',
disposition: {},
tags: {},
},
],
};
}

View File

@@ -0,0 +1,329 @@
import { describe, expect, it } from 'vitest';
import {
aggregatePcmPeaks,
decodePcmSamples,
MAX_WAVEFORM_BUCKET_COUNT,
WaveformCancellationError,
WaveformDataError,
} from '../../src/waveform';
describe('waveform PCM aggregation', () => {
it('uses deterministic bucket boundaries for signed 16-bit PCM', () => {
const samples = new Int16Array([
-32_768, -16_384, 0, 16_384, 32_767, 8_192, -8_192, 0,
]);
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: samples,
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 4 }
);
expect(peaks.sampleCount).toBe(8);
expect(peaks.bucketCount).toBe(4);
expect([...peaks.minimum]).toEqual([-1, 0, 0.25, -0.25]);
expect([...peaks.maximum]).toEqual([-0.5, 0.5, 32_767 / 32_768, 0]);
expect([...peaks.rms]).toEqual(
expect.arrayContaining([
expect.closeTo(Math.sqrt(0.625), 6),
expect.closeTo(Math.sqrt(0.125), 6),
expect.closeTo(Math.sqrt(((32_767 / 32_768) ** 2 + 0.25 ** 2) / 2), 6),
expect.closeTo(Math.sqrt(0.03125), 6),
])
);
});
it('never creates empty buckets when fewer samples than requested', () => {
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: float32Bytes([0.1, -0.2, 0.3]),
encoding: 'f32le',
sampleRate: 10,
},
{ bucketCount: 100 }
);
expect(peaks.bucketCount).toBe(3);
expect([...peaks.minimum]).toEqual([
expect.closeTo(0.1, 6),
expect.closeTo(-0.2, 6),
expect.closeTo(0.3, 6),
]);
expect([...peaks.maximum]).toEqual([...peaks.minimum]);
});
it('assigns every sample exactly once when bucket sizes are uneven', () => {
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([
1_000, 2_000, 3_000, -4_000, -5_000, -6_000, 7_000, 8_000, 9_000,
10_000,
]),
encoding: 's16le',
sampleRate: 10,
},
{ bucketCount: 3 }
);
expect([...peaks.minimum]).toEqual([
1_000 / 32_768,
-6_000 / 32_768,
7_000 / 32_768,
]);
expect([...peaks.maximum]).toEqual([
3_000 / 32_768,
-4_000 / 32_768,
10_000 / 32_768,
]);
});
it('handles finite float PCM above full scale without RMS overflow', () => {
const maximumFloat = 3.4028234663852886e38;
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: float32Bytes([maximumFloat, -maximumFloat]),
encoding: 'f32le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
);
expect(peaks.minimum[0]).toBe(-maximumFloat);
expect(peaks.maximum[0]).toBe(maximumFloat);
expect(peaks.rms[0]).toBe(maximumFloat);
expect(Number.isFinite(peaks.rms[0])).toBe(true);
});
it('rejects malformed, non-finite, and resource-exceeding PCM', () => {
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: new Uint8Array([0]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
)
).toThrow(WaveformDataError);
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: float32Bytes([Number.NaN]),
encoding: 'f32le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
)
).toThrow(/not finite/u);
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([1, 2, 3]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 1, maxSamples: 2 }
)
).toThrow(/sample analysis limit/u);
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([1]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: MAX_WAVEFORM_BUCKET_COUNT + 1 }
)
).toThrow(RangeError);
});
it('parses FFmpeg-style mono RIFF/WAVE PCM and rejects stereo', () => {
const wav = createWaveFile({
samples: new Int16Array([-32_768, 0, 16_384, 32_767]),
sampleRate: 8_000,
channels: 1,
addOddJunkChunk: true,
});
const decoded = decodePcmSamples({ container: 'wav', data: wav });
expect(decoded.encoding).toBe('s16le');
expect(decoded.sampleRate).toBe(8_000);
expect([...decoded.samples]).toEqual([-1, 0, 0.5, 32_767 / 32_768]);
const stereo = createWaveFile({
samples: new Int16Array([1, 2]),
sampleRate: 8_000,
channels: 2,
});
expect(() => decodePcmSamples({ container: 'wav', data: stereo })).toThrow(
/requires mono/u
);
});
it('parses little-endian float PCM from a sliced WAVE buffer', () => {
const wav = createFloatWaveFile([1.25, -0.5], 16_000);
const enclosing = new Uint8Array(wav.byteLength + 6);
enclosing.set(wav, 3);
const sliced = enclosing.subarray(3, 3 + wav.byteLength);
const decoded = decodePcmSamples({ container: 'wav', data: sliced });
expect(decoded.encoding).toBe('f32le');
expect(decoded.sampleRate).toBe(16_000);
expect([...decoded.samples]).toEqual([1.25, -0.5]);
});
it('rejects truncated WAVE chunks', () => {
const wav = createWaveFile({
samples: new Int16Array([1, 2]),
sampleRate: 8_000,
channels: 1,
});
expect(() =>
decodePcmSamples({
container: 'wav',
data: wav.subarray(0, wav.byteLength - 1),
})
).toThrow(/truncated|invalid size/u);
});
it('supports cooperative cancellation and bounded progress callbacks', () => {
const pcm = new Int16Array(40_000);
let cancelled = false;
const progress: number[] = [];
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: pcm,
encoding: 's16le',
sampleRate: 8_000,
},
{
bucketCount: 100,
isCancelled: () => cancelled,
onProgress: (value) => {
progress.push(value);
cancelled = true;
},
}
)
).toThrow(WaveformCancellationError);
expect(progress[0]).toBe(0);
expect(progress.every((value) => value >= 0 && value <= 1)).toBe(true);
});
it('reports completion for empty PCM without allocating peak buckets', () => {
const progress: number[] = [];
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Uint8Array(),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 10, onProgress: (value) => progress.push(value) }
);
expect(peaks.bucketCount).toBe(0);
expect(peaks.durationSeconds).toBe(0);
expect(progress).toEqual([1]);
});
});
function float32Bytes(values: readonly number[]): Uint8Array {
const bytes = new Uint8Array(values.length * 4);
const view = new DataView(bytes.buffer);
values.forEach((value, index) => view.setFloat32(index * 4, value, true));
return bytes;
}
function createWaveFile(options: {
readonly samples: Int16Array;
readonly sampleRate: number;
readonly channels: number;
readonly addOddJunkChunk?: boolean;
}): Uint8Array {
const junkBytes = options.addOddJunkChunk ? 10 : 0;
const result = new Uint8Array(44 + junkBytes + options.samples.byteLength);
const view = new DataView(result.buffer);
writeAscii(result, 0, 'RIFF');
view.setUint32(4, result.byteLength - 8, true);
writeAscii(result, 8, 'WAVE');
writeAscii(result, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, options.channels, true);
view.setUint32(24, options.sampleRate, true);
view.setUint32(
28,
options.sampleRate * options.channels * Int16Array.BYTES_PER_ELEMENT,
true
);
view.setUint16(32, options.channels * Int16Array.BYTES_PER_ELEMENT, true);
view.setUint16(34, 16, true);
let dataHeaderOffset = 36;
if (options.addOddJunkChunk) {
writeAscii(result, 36, 'JUNK');
view.setUint32(40, 1, true);
result[44] = 0x2a;
result[45] = 0;
dataHeaderOffset += junkBytes;
}
writeAscii(result, dataHeaderOffset, 'data');
view.setUint32(dataHeaderOffset + 4, options.samples.byteLength, true);
const sampleOffset = dataHeaderOffset + 8;
for (let index = 0; index < options.samples.length; index += 1) {
view.setInt16(
sampleOffset + index * Int16Array.BYTES_PER_ELEMENT,
options.samples[index] ?? 0,
true
);
}
return result;
}
function createFloatWaveFile(
samples: readonly number[],
sampleRate: number
): Uint8Array {
const result = new Uint8Array(44 + samples.length * 4);
const view = new DataView(result.buffer);
writeAscii(result, 0, 'RIFF');
view.setUint32(4, result.byteLength - 8, true);
writeAscii(result, 8, 'WAVE');
writeAscii(result, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 3, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 4, true);
view.setUint16(32, 4, true);
view.setUint16(34, 32, true);
writeAscii(result, 36, 'data');
view.setUint32(40, samples.length * 4, true);
samples.forEach((sample, index) =>
view.setFloat32(44 + index * 4, sample, true)
);
return result;
}
function writeAscii(target: Uint8Array, offset: number, text: string): void {
for (let index = 0; index < text.length; index += 1) {
target[offset + index] = text.charCodeAt(index);
}
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import {
aggregatePcmPeaks,
createWaveformCacheKey,
deserializeWaveformPeaks,
serializeWaveformPeaks,
WaveformDataError,
WAVEFORM_PEAKS_MEDIA_TYPE,
} from '../../src/waveform';
describe('waveform peak cache format', () => {
it('round-trips versioned arrays without sharing input storage', () => {
const peaks = createPeaks();
const serialized = serializeWaveformPeaks(peaks);
const restored = deserializeWaveformPeaks(serialized);
expect(WAVEFORM_PEAKS_MEDIA_TYPE).toBe(
'application/vnd.add-ideas.waveform-peaks'
);
expect(restored).not.toBe(peaks);
expect(restored.minimum.buffer).not.toBe(peaks.minimum.buffer);
expect(restored).toMatchObject({
version: 1,
sourceEncoding: 's16le',
sampleRate: 8_000,
sampleCount: 6,
durationSeconds: 6 / 8_000,
bucketCount: 3,
});
expect([...restored.minimum]).toEqual([...peaks.minimum]);
expect([...restored.maximum]).toEqual([...peaks.maximum]);
expect([...restored.rms]).toEqual([...peaks.rms]);
});
it('detects payload corruption, truncation, and unknown versions', () => {
const serialized = serializeWaveformPeaks(createPeaks());
const corrupt = serialized.slice();
const lastIndex = corrupt.length - 1;
corrupt[lastIndex] = (corrupt[lastIndex] ?? 0) ^ 0xff;
expect(() => deserializeWaveformPeaks(corrupt)).toThrow(/checksum/u);
const corruptHeader = serialized.slice();
new DataView(corruptHeader.buffer).setUint32(12, 44_100, true);
expect(() => deserializeWaveformPeaks(corruptHeader)).toThrow(/checksum/u);
expect(() =>
deserializeWaveformPeaks(serialized.subarray(0, serialized.length - 1))
).toThrow(/payload size/u);
const unknownVersion = serialized.slice();
new DataView(unknownVersion.buffer).setUint16(4, 999, true);
expect(() => deserializeWaveformPeaks(unknownVersion)).toThrow(
WaveformDataError
);
});
it('builds deterministic cache keys from source, stream, and settings', () => {
const base = {
sourceSize: 1_234_567,
sourceLastModified: 1_720_000_000_000,
streamIndex: 1,
sampleRate: 8_000,
bucketCount: 2_048,
encoding: 's16le' as const,
partialHash: 'sha256-deadbeef',
};
const first = createWaveformCacheKey(base);
const second = createWaveformCacheKey({ ...base });
const changed = createWaveformCacheKey({ ...base, streamIndex: 2 });
expect(first).toBe(second);
expect(first).toMatch(/^waveform-peaks-[a-f0-9]{16}$/u);
expect(changed).not.toBe(first);
});
it('rejects unsafe or incomplete cache identities', () => {
expect(() =>
createWaveformCacheKey({
sourceSize: 1,
sourceLastModified: 2,
streamIndex: 0,
sampleRate: 8_000,
bucketCount: 100,
encoding: 's16le',
partialHash: '../../source',
})
).toThrow(TypeError);
});
});
function createPeaks() {
return aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([-20_000, 10_000, -1_000, 2_000, -30_000, 20_000]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 3 }
);
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest';
import {
aggregatePcmPeaks,
describeWaveform,
renderWaveformToCanvas,
} from '../../src/waveform';
describe('waveform canvas rendering', () => {
it('renders bounded columns and supplies a text alternative', () => {
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([-32_768, 0, 16_384, 32_767]),
encoding: 's16le',
sampleRate: 2,
},
{ bucketCount: 4 }
);
const { canvas, context, attributes } = createCanvas(200, 80);
const rendered = renderWaveformToCanvas(canvas, peaks, {
devicePixelRatio: 2,
startBucket: 1,
endBucket: 4,
selection: { startFraction: 0.25, endFraction: 0.75 },
});
expect(canvas.width).toBe(400);
expect(canvas.height).toBe(160);
expect(rendered.startBucket).toBe(1);
expect(rendered.endBucket).toBe(4);
expect(rendered.accessibleLabel).toContain('Audio waveform');
expect(attributes.get('role')).toBe('img');
expect(attributes.get('aria-label')).toBe(rendered.accessibleLabel);
expect(context.moveTo).toHaveBeenCalled();
expect(context.lineTo).toHaveBeenCalled();
expect(context.fillRect).toHaveBeenCalledWith(50, 0, 100, 80);
});
it('describes empty waveforms and respects an explicit accessible label', () => {
const empty = aggregatePcmPeaks(
{
container: 'raw',
data: new Uint8Array(),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
);
const { canvas, attributes } = createCanvas(100, 40);
const result = renderWaveformToCanvas(canvas, empty, {
accessibleLabel: 'No audible content',
});
expect(describeWaveform(empty)).toBe('Empty audio waveform.');
expect(result.accessibleLabel).toBe('No audible content');
expect(attributes.get('aria-label')).toBe('No audible content');
});
it('rejects unsafe dimensions, ranges, and selections', () => {
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([1, 2]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 2 }
);
const { canvas } = createCanvas(100, 40);
expect(() =>
renderWaveformToCanvas(canvas, peaks, { width: 100_000 })
).toThrow(/safe limits/u);
expect(() =>
renderWaveformToCanvas(canvas, peaks, {
startBucket: 2,
endBucket: 1,
})
).toThrow(/bucket range/u);
expect(() =>
renderWaveformToCanvas(canvas, peaks, {
selection: { startFraction: -1, endFraction: 0.5 },
})
).toThrow(/selection/u);
});
});
function createCanvas(width: number, height: number) {
const attributes = new Map<string, string>();
const context = {
setTransform: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
};
const canvas = {
width,
height,
clientWidth: width,
clientHeight: height,
getContext: vi.fn(() => context),
setAttribute: vi.fn((name: string, value: string) => {
attributes.set(name, value);
}),
} as unknown as HTMLCanvasElement;
return { canvas, context, attributes };
}

View File

@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest';
import {
aggregatePcmPeaks,
createWaveformRuntime,
WaveformCancellationError,
WaveformRuntimeBusyError,
WaveformRuntimeTerminatedError,
type WaveformWorkerLike,
} from '../../src/waveform';
import type { WaveformWorkerAggregateRequest } from '../../src/waveform/waveform.types';
describe('waveform worker runtime', () => {
it('offloads aggregation, reports progress, and preserves caller bytes', async () => {
const worker = new FakeWorker();
const input = new Int16Array([-32_768, 0, 16_384, 32_767]);
const originalBytes = [...new Uint8Array(input.buffer)];
const progress = vi.fn();
const runtime = createWaveformRuntime({ workerFactory: () => worker });
const resultPromise = runtime.aggregate(
{
container: 'raw',
data: input,
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 2, onProgress: progress }
);
const request = worker.lastRequest;
expect(request).toBeDefined();
expect(request?.input.data).not.toBe(input.buffer);
expect([...new Uint8Array(input.buffer)]).toEqual(originalBytes);
worker.emitMessage({
type: 'progress',
requestId: request?.requestId,
progress: 0.5,
});
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: request?.input.data ?? new ArrayBuffer(0),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 2 }
);
worker.emitMessage({
type: 'result',
requestId: request?.requestId,
peaks,
});
await expect(resultPromise).resolves.toMatchObject({ bucketCount: 2 });
expect(progress).toHaveBeenCalledWith(0.5);
expect(progress).toHaveBeenLastCalledWith(1);
expect(worker.terminated).toBe(true);
expect(runtime.isActive).toBe(false);
});
it('terminates the worker on AbortSignal cancellation and remains reusable', async () => {
const workers: FakeWorker[] = [];
const runtime = createWaveformRuntime({
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker;
},
});
const controller = new AbortController();
const first = runtime.aggregate(rawInput(), {
bucketCount: 2,
signal: controller.signal,
});
controller.abort();
await expect(first).rejects.toBeInstanceOf(WaveformCancellationError);
expect(workers[0]?.terminated).toBe(true);
expect(runtime.isActive).toBe(false);
const second = runtime.aggregate(rawInput(), { bucketCount: 2 });
const request = workers[1]?.lastRequest;
const peaks = aggregatePcmPeaks(rawInput(), { bucketCount: 2 });
workers[1]?.emitMessage({
type: 'result',
requestId: request?.requestId,
peaks,
});
await expect(second).resolves.toMatchObject({ bucketCount: 2 });
});
it('supports explicit cancellation and rejects overlapping work', async () => {
const worker = new FakeWorker();
const runtime = createWaveformRuntime({ workerFactory: () => worker });
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
await expect(
runtime.aggregate(rawInput(), { bucketCount: 2 })
).rejects.toBeInstanceOf(WaveformRuntimeBusyError);
expect(runtime.cancelActive()).toBe(true);
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
expect(runtime.cancelActive()).toBe(false);
});
it('falls back synchronously when workers are unavailable or blocked', async () => {
const runtime = createWaveformRuntime({ workerFactory: null });
await expect(
runtime.aggregate(rawInput(), { bucketCount: 2 })
).resolves.toMatchObject({ bucketCount: 2 });
const blockedRuntime = createWaveformRuntime({
workerFactory: () => {
throw new Error('CSP blocked worker');
},
});
await expect(
blockedRuntime.aggregate(rawInput(), { bucketCount: 2 })
).resolves.toMatchObject({ bucketCount: 2 });
expect(blockedRuntime.mode).toBe('synchronous');
});
it('tracks and can cancel synchronous fallback before it starts', async () => {
const runtime = createWaveformRuntime({ workerFactory: null });
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
expect(runtime.isActive).toBe(true);
const overlapping = runtime.aggregate(rawInput(), { bucketCount: 2 });
expect(runtime.cancelActive()).toBe(true);
await expect(overlapping).rejects.toBeInstanceOf(WaveformRuntimeBusyError);
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
expect(runtime.isActive).toBe(false);
});
it('makes terminate permanent and cancels active work', async () => {
const worker = new FakeWorker();
const runtime = createWaveformRuntime({ workerFactory: () => worker });
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
runtime.terminate();
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
await expect(
runtime.aggregate(rawInput(), { bucketCount: 2 })
).rejects.toBeInstanceOf(WaveformRuntimeTerminatedError);
});
});
class FakeWorker implements WaveformWorkerLike {
readonly #messageListeners = new Set<
(event: MessageEvent<unknown>) => void
>();
readonly #errorListeners = new Set<(event: Event) => void>();
readonly #messageErrorListeners = new Set<(event: Event) => void>();
lastRequest: WaveformWorkerAggregateRequest | undefined;
terminated = false;
postMessage(message: unknown): void {
this.lastRequest = message as WaveformWorkerAggregateRequest;
}
addEventListener(
type: 'message' | 'error' | 'messageerror',
listener:
((event: MessageEvent<unknown>) => void) | ((event: Event) => void)
): void {
if (type === 'message') {
this.#messageListeners.add(
listener as (event: MessageEvent<unknown>) => void
);
} else if (type === 'error') {
this.#errorListeners.add(listener as (event: Event) => void);
} else {
this.#messageErrorListeners.add(listener as (event: Event) => void);
}
}
removeEventListener(
type: 'message' | 'error' | 'messageerror',
listener:
((event: MessageEvent<unknown>) => void) | ((event: Event) => void)
): void {
if (type === 'message') {
this.#messageListeners.delete(
listener as (event: MessageEvent<unknown>) => void
);
} else if (type === 'error') {
this.#errorListeners.delete(listener as (event: Event) => void);
} else {
this.#messageErrorListeners.delete(listener as (event: Event) => void);
}
}
terminate(): void {
this.terminated = true;
}
emitMessage(data: unknown): void {
const event = { data } as MessageEvent<unknown>;
for (const listener of this.#messageListeners) {
listener(event);
}
}
}
function rawInput() {
return {
container: 'raw' as const,
data: new Int16Array([-100, 100, -200, 200]),
encoding: 's16le' as const,
sampleRate: 8_000,
};
}