358 lines
9.8 KiB
TypeScript
358 lines
9.8 KiB
TypeScript
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('enables Opus after the reviewed core passed both regression modes', () => {
|
|
const engineState: EngineState = {
|
|
status: 'ready',
|
|
mode: 'single-thread',
|
|
capabilities: {
|
|
...capabilities(['mp4', 'opus']),
|
|
encoders: new Set(['libx264', 'aac', 'libopus']),
|
|
},
|
|
};
|
|
|
|
render(
|
|
<QuickConvert
|
|
asset={createAsset('asset-a', 'source-a.mp4')}
|
|
engineState={engineState}
|
|
busy={false}
|
|
onExport={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled();
|
|
});
|
|
|
|
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;
|
|
}
|