1103 lines
37 KiB
TypeScript
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);
|
|
});
|
|
});
|