Files
av-tools/tests/browser/operation-matrix.spec.ts

1103 lines
37 KiB
TypeScript

/// <reference types="node" />
import { execFileSync } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
import {
expect,
test,
type Download,
type Locator,
type Page,
} from '@playwright/test';
import {
createCancellationFixture,
GENERATED_FIXTURES,
observeRuntime,
type RuntimeObservation,
type TemporaryMediaFixture,
} from './browser-helpers';
const fixture = (name: string): string => join(GENERATED_FIXTURES, name);
function mediaInput(page: Page): Locator {
return page.locator('input[type="file"][accept*="audio"]').first();
}
async function importMedia(
page: Page,
names: readonly string[]
): Promise<void> {
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
for (const name of names) {
const card = page.locator('.media-card').filter({ hasText: name });
await page
.getByRole('button', { name: `Inspect details for ${name}` })
.click();
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 120_000,
});
}
}
async function initializeSingleThread(page: Page): Promise<void> {
await page.getByText('Engine & browser').click();
await page.getByLabel('Engine mode').selectOption('force-single-thread');
await page.getByRole('button', { name: 'Initialize engine' }).click();
await expect(
page
.locator('.engine-pill')
.filter({ hasText: 'Single-threaded compatibility mode' })
).toBeVisible({ timeout: 120_000 });
}
const RESULT_SNAPSHOT_ATTRIBUTE = 'data-playwright-existing-result';
async function markExistingResults(page: Page): Promise<void> {
await page.locator('.result-card').evaluateAll((cards) => {
for (const card of cards) {
card.setAttribute('data-playwright-existing-result', '');
}
});
}
function newResultCards(page: Page): Locator {
return page.locator(`.result-card:not([${RESULT_SNAPSHOT_ATTRIBUTE}])`);
}
function accumulatedOutputName(fileName: string): RegExp {
const dot = fileName.lastIndexOf('.');
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
const extension = dot > 0 ? fileName.slice(dot) : '';
const escape = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
return new RegExp(`^${escape(stem)}(?:-\\d+)?${escape(extension)}$`, 'u');
}
async function runForResults(
page: Page,
action: () => Promise<void>,
expectedFileNames: readonly string[],
options: { verifyMedia?: boolean } = {}
): Promise<readonly Locator[]> {
await markExistingResults(page);
await action();
const cards = expectedFileNames.map((_, index) =>
newResultCards(page).nth(index)
);
await expect
.poll(
async () => {
if ((await cards[0]?.locator('strong').count()) === 1) {
return 'result';
}
if ((await page.getByRole('alert').count()) > 0) {
const alert = (await page.getByRole('alert').allTextContents()).join(
' | '
);
const diagnostics =
(await page.locator('.diagnostics pre').textContent()) ?? '';
throw new Error(
`${alert}\n\nFFmpeg diagnostics:\n${diagnostics
.split('\n')
.slice(-160)
.join('\n')}`
);
}
return 'waiting';
},
{ timeout: 120_000 }
)
.toBe('result');
for (const [index, card] of cards.entries()) {
await expect(card.locator('strong')).toHaveText(
accumulatedOutputName(expectedFileNames[index] as string),
{ timeout: 120_000 }
);
await expect(card).toContainText(
/created · (?:verified|verification warning)|completed locally/u,
{ timeout: 120_000 }
);
if (options.verifyMedia) {
await expect(card).toContainText(/verified stream/u, {
timeout: 120_000,
});
}
}
await expect(page.getByRole('alert')).toHaveCount(0);
return cards;
}
async function downloadResult(
page: Page,
card: Locator
): Promise<{ download: Download; filePath: string }> {
const downloadPromise = page.waitForEvent('download');
await card.locator('button.button--primary').click();
const download = await downloadPromise;
const filePath = await download.path();
expect(filePath).not.toBeNull();
expect(statSync(filePath as string).size).toBeGreaterThan(0);
return { download, filePath: filePath as string };
}
function probe(filePath: string): {
streams?: Array<{
codec_name?: string;
codec_type?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
tags?: Record<string, string>;
disposition?: Record<string, number>;
}>;
format?: { duration?: string; tags?: Record<string, string> };
chapters?: Array<{ tags?: Record<string, string> }>;
} {
return JSON.parse(
execFileSync(
'ffprobe',
[
'-v',
'error',
'-show_streams',
'-show_format',
'-show_chapters',
'-of',
'json',
filePath,
],
{ encoding: 'utf8' }
)
) as ReturnType<typeof probe>;
}
function assertLocalOnly(runtime: RuntimeObservation): void {
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
expect(
networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173')
).toBe(true);
const materialFailures = runtime.failedRequests.filter(
(failure) =>
!(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED'))
);
expect(materialFailures).toEqual([]);
expect(runtime.pageErrors).toEqual([]);
expect(runtime.consoleErrors).toEqual([]);
}
test.describe.serial('real ffmpeg.wasm operation matrix', () => {
let cancellationFixture: TemporaryMediaFixture;
test.beforeAll(() => {
cancellationFixture = createCancellationFixture();
});
test.afterAll(() => {
cancellationFixture.dispose();
});
test('executes conversion, structural, analysis, image, metadata, chapter and subtitle workflows in one local engine session', async ({
page,
}) => {
test.setTimeout(300_000);
page.setDefaultTimeout(15_000);
await page.addInitScript(() => {
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
});
const runtime = observeRuntime(page);
await page.goto('/');
await initializeSingleThread(page);
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
const sourceName = basename(cancellationFixture.filePath);
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
await page
.getByRole('button', {
name: `Inspect details for ${sourceName}`,
})
.click();
await expect(
page
.locator('.media-card')
.filter({ hasText: sourceName })
.locator('.phase--ready')
).toBeVisible({ timeout: 120_000 });
await page.getByRole('button', { name: 'Convert', exact: true }).click();
const enqueueButton = page.getByRole('button', {
name: 'Add to queue',
});
await expect(enqueueButton).toBeVisible();
await enqueueButton.click();
await expect(page.getByText('1 waiting', { exact: true })).toBeVisible();
const firstCancelButton = page.getByRole('button', {
name: 'Cancel & recover',
});
const firstCancelElement = await firstCancelButton.elementHandle();
await firstCancelButton.click();
await expect
.poll(
() =>
firstCancelElement?.evaluate((element) => element.isConnected) ??
false,
{ timeout: 120_000 }
)
.toBe(false);
await expect(page.getByText('1 waiting', { exact: true })).toBeHidden({
timeout: 120_000,
});
const secondCancelButton = page.getByRole('button', {
name: 'Cancel & recover',
});
await expect(secondCancelButton).toBeVisible({
timeout: 120_000,
});
await secondCancelButton.click();
await expect(secondCancelButton).toBeHidden({ timeout: 120_000 });
await expect(page.getByRole('alert')).toContainText(
'The operation was cancelled and the engine was recreated.',
{ timeout: 120_000 }
);
await expect(
page
.locator('.engine-pill')
.filter({ hasText: 'Single-threaded compatibility mode' })
).toBeVisible({ timeout: 120_000 });
await page.getByRole('button', { name: 'New', exact: true }).click();
});
await test.step('audio conversion and WebM remux/re-encode', async () => {
await importMedia(page, ['tone.flac']);
await page.getByLabel('Export preset').selectOption('audio-mp3');
const [mp3Card] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['tone-convert.mp3'],
{ verifyMedia: true }
);
const mp3 = await downloadResult(page, mp3Card as Locator);
expect(probe(mp3.filePath).streams).toEqual(
expect.arrayContaining([
expect.objectContaining({
codec_name: 'mp3',
codec_type: 'audio',
}),
])
);
await page.getByLabel('Export preset').selectOption('audio-ogg-vorbis');
const [oggCard] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['tone-convert.ogg'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, oggCard as Locator)).filePath).streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({
codec_name: 'vorbis',
codec_type: 'audio',
}),
])
);
await page.getByLabel('Export preset').selectOption('audio-flac');
const [flacCard] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['tone-convert.flac'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, flacCard as Locator)).filePath)
.streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({
codec_name: 'flac',
codec_type: 'audio',
}),
])
);
await page.getByLabel('Export preset').selectOption('audio-wav-pcm');
const [wavCard] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['tone-convert.wav'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, wavCard as Locator)).filePath).streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({
codec_name: 'pcm_s16le',
codec_type: 'audio',
}),
])
);
await importMedia(page, ['pattern-av.mp4']);
await page
.locator('.media-card')
.filter({ hasText: 'pattern-av.mp4' })
.click();
await page.getByLabel('Export preset').selectOption('audio-mp3');
await page.getByRole('checkbox', { name: /#0 · h264/u }).uncheck();
const [extractedAudioCard] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['pattern-av-convert.mp3'],
{ verifyMedia: true }
);
const extractedAudio = probe(
(await downloadResult(page, extractedAudioCard as Locator)).filePath
);
expect(
extractedAudio.streams?.map((stream) => stream.codec_type)
).toEqual(['audio']);
expect(extractedAudio.streams?.[0]?.codec_name).toBe('mp3');
await page
.getByLabel('Export preset')
.selectOption('mp4-h264-compatibility');
await page.getByRole('checkbox', { name: /#0 · h264/u }).check();
await page.getByRole('checkbox', { name: /#1 · aac/u }).uncheck();
const [videoOnlyCard] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['pattern-av-convert.mp4'],
{ verifyMedia: true }
);
const videoOnly = probe(
(await downloadResult(page, videoOnlyCard as Locator)).filePath
);
expect(videoOnly.streams?.map((stream) => stream.codec_type)).toEqual([
'video',
]);
expect(videoOnly.streams?.[0]?.codec_name).toBe('h264');
await importMedia(page, ['pattern.webm']);
await page
.locator('.media-card')
.filter({ hasText: 'pattern.webm' })
.click();
await page.getByRole('button', { name: 'Fast remux' }).click();
await page.getByLabel('Target container').selectOption('matroska');
const [remuxCard] = await runForResults(
page,
() => page.getByRole('button', { name: 'Remux', exact: true }).click(),
['pattern-remux.mkv'],
{ verifyMedia: true }
);
const remux = await downloadResult(page, remuxCard as Locator);
expect(probe(remux.filePath).streams).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'vp8', codec_type: 'video' }),
expect.objectContaining({
codec_name: 'vorbis',
codec_type: 'audio',
}),
])
);
await page.getByRole('button', { name: 'Re-encode' }).click();
await page.getByLabel('Export preset').selectOption('webm-vp8-vorbis');
const [webmCard] = await runForResults(
page,
() =>
page.getByRole('button', { name: 'Convert', exact: true }).click(),
['pattern-convert.webm'],
{ verifyMedia: true }
);
const webm = await downloadResult(page, webmCard as Locator);
expect(probe(webm.filePath).streams).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'vp8', codec_type: 'video' }),
expect.objectContaining({
codec_name: 'vorbis',
codec_type: 'audio',
}),
])
);
await importMedia(page, ['metadata.mp4']);
await page
.locator('.media-card')
.filter({ hasText: 'metadata.mp4' })
.click();
await page.getByRole('button', { name: 'Fast remux' }).click();
await page.getByLabel('Target container').selectOption('mp4');
await page.getByRole('checkbox', { name: 'Remove metadata' }).check();
const [metadataRemovalCard] = await runForResults(
page,
() => page.getByRole('button', { name: 'Remux', exact: true }).click(),
['metadata-remux.mp4'],
{ verifyMedia: true }
);
const metadataRemovalProbe = probe(
(await downloadResult(page, metadataRemovalCard as Locator)).filePath
);
expect(metadataRemovalProbe.format?.tags?.title).toBeUndefined();
expect(metadataRemovalProbe.format?.tags?.artist).toBeUndefined();
expect(metadataRemovalProbe.format?.tags?.comment).toBeUndefined();
});
await test.step('fast and accurate trim plus fast and normalized concat', async () => {
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('tab', { name: 'Edit' }).click();
await importMedia(page, ['compatible-a.mp4', 'compatible-b.mp4']);
const selectedTimelineClip = page
.locator('.timeline-clip')
.filter({ hasText: 'compatible-a.mp4' });
await selectedTimelineClip.locator('.timeline-clip__main').click();
await selectedTimelineClip.getByLabel('In').fill('0.1');
await selectedTimelineClip.getByLabel('Out').fill('0.8');
const structural = page.locator('.structural-operations');
const [fastTrimCard] = await runForResults(
page,
() =>
structural.getByRole('button', { name: 'Export fast trim' }).click(),
['compatible-a-trim.mp4'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, fastTrimCard as Locator)).filePath)
.streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
])
);
await structural.getByLabel('Accurate re-encode').check();
await structural
.getByLabel('Accurate trim preset')
.selectOption('mp4-h264-compatibility');
const [accurateTrimCard] = await runForResults(
page,
() =>
structural.getByRole('button', { name: 'Export exact trim' }).click(),
['compatible-a-trim.mp4'],
{ verifyMedia: true }
);
expect(
probe(
(await downloadResult(page, accurateTrimCard as Locator)).filePath
).streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
])
);
await expect(
structural.getByRole('heading', {
name: 'Fast compatibility passed',
})
).toBeVisible();
const [fastConcatCard] = await runForResults(
page,
() =>
structural
.getByRole('button', { name: 'Export fast concat' })
.click(),
['compatible-a-concat.mp4'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, fastConcatCard as Locator)).filePath)
.streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
])
);
await structural.getByLabel('Normalized re-encode').check();
await structural
.getByLabel('Normalized concat preset')
.selectOption('mp4-h264-compatibility');
await structural.getByLabel('Insert silence').check();
await structural.getByLabel('Reject subtitle input').check();
const [normalizedConcatCard] = await runForResults(
page,
() =>
structural
.getByRole('button', { name: 'Export normalized concat' })
.click(),
['compatible-a-concat.mp4'],
{ verifyMedia: true }
);
expect(
probe(
(await downloadResult(page, normalizedConcatCard as Locator)).filePath
).streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
])
);
await structural.getByLabel('Crossfade duration').fill('0.2');
const crossfadeAction = structural.getByRole('button', {
name: 'Export normalized concat',
});
if (await crossfadeAction.isEnabled()) {
const [crossfadeCard] = await runForResults(
page,
() => crossfadeAction.click(),
['compatible-a-concat.mp4'],
{ verifyMedia: true }
);
const crossfadeDuration = Number(
probe((await downloadResult(page, crossfadeCard as Locator)).filePath)
.format?.duration
);
expect(crossfadeDuration).toBeGreaterThan(1.35);
expect(crossfadeDuration).toBeLessThan(1.75);
} else {
await expect(
structural.getByText(
/required browser and FFmpeg capabilities have not been verified|filter (?:x|a)fade/iu
)
).toBeVisible();
}
});
const advanced = page.locator('.advanced-operations');
await test.step('fast/accurate split and waveform/loudness analysis', async () => {
await advanced.getByLabel('Marker time (seconds)').fill('0.5');
await advanced.getByRole('button', { name: 'Add marker' }).click();
await runForResults(
page,
() =>
advanced.getByRole('button', { name: 'Queue 2 segments' }).click(),
['compatible-a-split-001.mp4', 'compatible-a-split-002.mp4'],
{ verifyMedia: true }
);
await advanced.getByLabel('Accurate re-encode').check();
await advanced
.getByLabel('Accurate split preset')
.selectOption('mp4-h264-compatibility');
await runForResults(
page,
() =>
advanced.getByRole('button', { name: 'Queue 2 segments' }).click(),
['compatible-a-split-001.mp4', 'compatible-a-split-002.mp4'],
{ verifyMedia: true }
);
await advanced
.getByRole('tab', { name: 'Waveform and loudness' })
.click();
const [waveformCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate static waveform' })
.click(),
['compatible-a-waveform-static.png']
);
expect(
statSync((await downloadResult(page, waveformCard as Locator)).filePath)
.size
).toBeGreaterThan(1_000);
await advanced
.getByRole('button', { name: 'Analyze peak waveform' })
.click();
await expect(
page.getByRole('region', {
name: 'Waveform editor for compatible-a.mp4',
})
).toBeVisible({ timeout: 120_000 });
await advanced.getByRole('button', { name: 'Measure EBU R128' }).click();
await expect(page.locator('.project-notice')).toContainText('Measured', {
timeout: 120_000,
});
await expect(advanced.getByText('Measured loudness')).toBeVisible();
await advanced
.getByLabel('Normalization preset')
.selectOption('audio-mp3');
const [normalizedAudioCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Apply measured normalization' })
.click(),
['compatible-a-normalize.mp3'],
{ verifyMedia: true }
);
expect(
probe(
(await downloadResult(page, normalizedAudioCard as Locator)).filePath
).streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'mp3', codec_type: 'audio' }),
])
);
const [peakAudioCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Apply peak normalization' })
.click(),
['compatible-a-peak-normalize.mp3'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, peakAudioCard as Locator)).filePath)
.streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'mp3', codec_type: 'audio' }),
])
);
});
await test.step('thumbnail series and contact sheet', async () => {
await advanced
.getByRole('tab', { name: 'Thumbnails and contact sheets' })
.click();
await advanced.getByLabel('Number of frames').fill('3');
await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate thumbnail series' })
.click(),
[
'compatible-a-thumbnail-001-250ms.jpg',
'compatible-a-thumbnail-002-500ms.jpg',
'compatible-a-thumbnail-003-750ms.jpg',
]
);
await advanced
.getByRole('spinbutton', { name: 'Frames', exact: true })
.fill('4');
await advanced
.getByRole('spinbutton', { name: 'Rows', exact: true })
.fill('2');
await advanced
.getByRole('spinbutton', { name: 'Columns', exact: true })
.fill('2');
await advanced
.getByRole('spinbutton', { name: /^Cell width/u })
.fill('128');
const [sheetCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate contact sheet' })
.click(),
['compatible-a-contact-sheet.jpg']
);
expect(
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
.size
).toBeGreaterThan(500);
await importMedia(page, ['pattern-av.mp4']);
const patternClip = page
.locator('.timeline-clip')
.filter({ hasText: 'pattern-av.mp4' });
await patternClip.locator('.timeline-clip__main').click();
const imageSelection = advanced.getByLabel('Frame selection').first();
const sceneOption = imageSelection.locator(
'option[value="scene-changes"]'
);
if (!(await sceneOption.isDisabled())) {
await imageSelection.selectOption('scene-changes');
await advanced.getByLabel('Number of frames').fill('2');
await advanced.getByLabel('Scene sensitivity threshold').fill('0.01');
await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate thumbnail series' })
.click(),
[
'pattern-av-thumbnail-001-83ms.jpg',
'pattern-av-thumbnail-002-1000ms.jpg',
]
);
const contactSelection = advanced.getByLabel('Frame selection').nth(1);
await contactSelection.selectOption('scene-changes');
await advanced.getByLabel('Contact-sheet scene threshold').fill('0.01');
await advanced
.getByRole('spinbutton', { name: 'Frames', exact: true })
.fill('2');
await advanced
.getByRole('spinbutton', { name: 'Rows', exact: true })
.fill('1');
await advanced
.getByRole('spinbutton', { name: 'Columns', exact: true })
.fill('2');
const timestampLabels = advanced.getByLabel('Timestamp labels');
if (await timestampLabels.isEnabled()) {
await timestampLabels.check();
await advanced.getByLabel('Source filename label').check();
}
const [sceneSheetCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate contact sheet' })
.click(),
['pattern-av-contact-sheet.jpg']
);
expect(
statSync(
(await downloadResult(page, sceneSheetCard as Locator)).filePath
).size
).toBeGreaterThan(500);
}
await patternClip.getByRole('button', { name: 'Remove clip' }).click();
await expect(patternClip).toHaveCount(0);
await page
.locator('.timeline-clip')
.filter({ hasText: 'compatible-a.mp4' })
.first()
.locator('.timeline-clip__main')
.click();
});
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
const inspector = page.locator('.inspector');
await inspector.getByRole('tab', { name: 'Frames' }).click();
const inspectorBody = inspector.locator('.inspector__body');
await inspectorBody
.getByRole('spinbutton', { name: 'Time (seconds)', exact: true })
.fill('0.2');
await inspectorBody
.getByRole('spinbutton', { name: 'Width', exact: true })
.fill('96');
await inspectorBody
.getByRole('combobox', { name: 'Format', exact: true })
.selectOption('png');
const [singleFrameCard] = await runForResults(
page,
() =>
inspectorBody.getByRole('button', { name: 'Extract frame' }).click(),
['compatible-a-thumbnail-200ms.png']
);
expect(
statSync(
(await downloadResult(page, singleFrameCard as Locator)).filePath
).size
).toBeGreaterThan(100);
await inspector.getByRole('tab', { name: 'Transform' }).click();
const cropFields = inspector.locator('.crop-editor__numeric');
await cropFields
.getByRole('spinbutton', { name: 'X', exact: true })
.fill('4');
await cropFields
.getByRole('spinbutton', { name: 'Y', exact: true })
.fill('2');
await cropFields
.getByRole('spinbutton', { name: 'Width', exact: true })
.fill('120');
await cropFields
.getByRole('spinbutton', { name: 'Height', exact: true })
.fill('68');
const resizeFields = inspector.locator('.inspector-section > .mini-grid');
await resizeFields
.getByRole('spinbutton', { name: 'Width', exact: true })
.fill('96');
await resizeFields
.getByRole('spinbutton', { name: 'Height', exact: true })
.fill('54');
await resizeFields
.getByRole('combobox', { name: 'Rotate', exact: true })
.selectOption('90');
await resizeFields
.getByRole('combobox', { name: 'Scale algorithm', exact: true })
.selectOption('bicubic');
await resizeFields
.getByRole('textbox', { name: 'Padding colour', exact: true })
.fill('#112233');
await page
.getByRole('spinbutton', { name: 'Output frame rate', exact: true })
.fill('12');
const videoFades = inspector
.locator('fieldset.subpanel')
.filter({ hasText: 'Video fades' });
await videoFades
.getByRole('spinbutton', { name: 'Fade in from black (s)' })
.fill('0.1');
await videoFades
.getByRole('spinbutton', { name: 'Fade out to black (s)' })
.fill('0.1');
await inspector.getByRole('tab', { name: 'Audio' }).click();
await expect(
inspectorBody.getByRole('checkbox', { name: 'Include audio' })
).toBeChecked();
await inspectorBody
.getByRole('spinbutton', { name: 'Fade in (s)', exact: true })
.fill('0.1');
await inspectorBody
.getByRole('spinbutton', { name: 'Fade out (s)', exact: true })
.fill('0.1');
const [timelineCard] = await runForResults(
page,
() => page.getByRole('button', { name: 'Render timeline' }).click(),
['output.mp4'],
{ verifyMedia: true }
);
const timelineProbe = probe(
(await downloadResult(page, timelineCard as Locator)).filePath
);
const timelineVideo = timelineProbe.streams?.find(
(stream) => stream.codec_type === 'video'
);
expect(timelineVideo).toMatchObject({
codec_name: 'h264',
width: 96,
height: 54,
avg_frame_rate: '12/1',
});
expect(
timelineProbe.streams?.some(
(stream) =>
stream.codec_type === 'audio' && stream.codec_name === 'aac'
)
).toBe(true);
});
await test.step('metadata and chapters survive round-trip probing', async () => {
await advanced.getByRole('tab', { name: 'Metadata policies' }).click();
const metadataPanel = advanced.getByRole('tabpanel');
await metadataPanel
.getByRole('textbox', { name: 'Title', exact: true })
.fill('Browser Matrix');
await metadataPanel
.getByRole('textbox', { name: 'Artist', exact: true })
.fill('av-tools Playwright');
const [metadataCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Apply metadata policies' })
.click(),
['compatible-a-metadata.mp4'],
{ verifyMedia: true }
);
const metadataProbe = probe(
(await downloadResult(page, metadataCard as Locator)).filePath
);
expect(metadataProbe.format?.tags).toMatchObject({
title: 'Browser Matrix',
artist: 'av-tools Playwright',
});
await advanced.getByRole('tab', { name: 'Chapter execution' }).click();
await advanced
.getByLabel('Create output chapters from')
.selectOption('fixed');
await advanced.getByLabel('Chapter interval').fill('0.5');
const [chapterCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Replace output chapters' })
.click(),
['compatible-a-chapters.mp4'],
{ verifyMedia: true }
);
const chapterProbe = probe(
(await downloadResult(page, chapterCard as Locator)).filePath
);
expect(chapterProbe.chapters).toHaveLength(2);
expect(
chapterProbe.chapters?.map((chapter) => chapter.tags?.title)
).toEqual(['Chapter 1', 'Chapter 2']);
});
await test.step('subtitle convert, mux, burn-in and extraction', async () => {
await advanced.getByRole('tab', { name: 'Subtitle operations' }).click();
const subtitlePanel = advanced.getByRole('tabpanel');
const subtitleOperation = subtitlePanel
.locator('label.advanced-field')
.filter({ hasText: /^Operation/u })
.locator('select');
const outputTextFormat = subtitlePanel
.locator('label.advanced-field')
.filter({ hasText: /^Output text format/u })
.locator('select');
await subtitleOperation.selectOption('convert');
const localSubtitleInput = subtitlePanel.locator(
'label.advanced-file-field input[type="file"]'
);
await markExistingResults(page);
await localSubtitleInput.setInputFiles({
name: 'invalid-subtitle.srt',
mimeType: 'application/x-subrip',
buffer: Buffer.from('this is not a timed subtitle\n'),
});
await subtitlePanel
.getByRole('button', { name: 'Convert subtitle' })
.click();
await expect(
advanced.locator('.advanced-operations__status')
).toContainText(/FFmpeg (?:exited|produced)/u, {
timeout: 120_000,
});
await expect(newResultCards(page)).toHaveCount(0);
await expect(
page
.locator('.engine-pill')
.filter({ hasText: 'Single-threaded compatibility mode' })
).toBeVisible({ timeout: 120_000 });
await localSubtitleInput.setInputFiles(fixture('subtitle.srt'));
await outputTextFormat.selectOption('vtt');
await subtitlePanel.getByLabel('Time offset').fill('0.1');
const [convertedCard] = await runForResults(
page,
() =>
subtitlePanel
.getByRole('button', { name: 'Convert subtitle' })
.click(),
['subtitle-subtitle.vtt']
);
const converted = await downloadResult(page, convertedCard as Locator);
expect(readFileSync(converted.filePath, 'utf8')).toContain('WEBVTT');
await subtitleOperation.selectOption('soft-mux');
await subtitlePanel
.locator('label.advanced-field')
.filter({ hasText: /^Target container/u })
.locator('select')
.selectOption('matroska');
await subtitlePanel
.getByRole('textbox', { name: 'Language', exact: true })
.fill('eng');
await subtitlePanel
.getByRole('textbox', { name: 'Track title', exact: true })
.fill('Browser subtitle');
await subtitlePanel
.getByRole('checkbox', { name: 'Default track' })
.check();
const [muxedCard] = await runForResults(
page,
() =>
subtitlePanel
.getByRole('button', { name: 'Mux soft subtitle' })
.click(),
['compatible-a-subtitled.mkv'],
{ verifyMedia: true }
);
const muxed = await downloadResult(page, muxedCard as Locator);
expect(probe(muxed.filePath).streams).toEqual(
expect.arrayContaining([
expect.objectContaining({
codec_name: 'subrip',
codec_type: 'subtitle',
tags: expect.objectContaining({
language: 'eng',
title: 'Browser subtitle',
}),
disposition: expect.objectContaining({ default: 1 }),
}),
])
);
await subtitleOperation.selectOption('burn-in');
await subtitlePanel
.locator('label.advanced-field')
.filter({ hasText: /^Video export preset/u })
.locator('select')
.selectOption('mp4-h264-compatibility');
const [burnedCard] = await runForResults(
page,
() =>
subtitlePanel
.getByRole('button', { name: 'Burn subtitle into video' })
.click(),
['compatible-a-subtitle-burn.mp4'],
{ verifyMedia: true }
);
expect(
probe((await downloadResult(page, burnedCard as Locator)).filePath)
.streams
).toEqual(
expect.arrayContaining([
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
])
);
await page.getByRole('button', { name: 'New', exact: true }).click();
await mediaInput(page).setInputFiles({
name: 'subtitled-source.mkv',
mimeType: 'video/x-matroska',
buffer: readFileSync(muxed.filePath),
});
await page
.getByRole('button', {
name: 'Inspect details for subtitled-source.mkv',
})
.click();
await expect(
page
.locator('.media-card')
.filter({ hasText: 'subtitled-source.mkv' })
.locator('.phase--ready')
).toBeVisible({ timeout: 120_000 });
await subtitleOperation.selectOption('extract');
await outputTextFormat.selectOption('srt');
const [extractedCard] = await runForResults(
page,
() =>
subtitlePanel
.getByRole('button', { name: 'Extract subtitle' })
.click(),
['subtitled-source-subtitle-eng.srt']
);
const extracted = await downloadResult(page, extractedCard as Locator);
expect(readFileSync(extracted.filePath, 'utf8')).toContain(
'Generated subtitle'
);
});
expect(
runtime.requests.some((url) =>
url.endsWith(
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
)
)
).toBe(true);
assertLocalOnly(runtime);
});
});