360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
/// <reference types="node" />
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { basename, join } from 'node:path';
|
|
import { expect, test } from '@playwright/test';
|
|
import {
|
|
createCancellationFixture,
|
|
GENERATED_FIXTURES,
|
|
observeRuntime,
|
|
PRIMARY_FIXTURE,
|
|
type TemporaryMediaFixture,
|
|
} from './browser-helpers';
|
|
|
|
function mediaInput(page: import('@playwright/test').Page) {
|
|
return page.locator('input[type="file"][accept*="audio"]');
|
|
}
|
|
|
|
async function selectSingleThread(
|
|
page: import('@playwright/test').Page
|
|
): Promise<void> {
|
|
await page.getByText('Engine & browser').click();
|
|
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
|
}
|
|
|
|
function assertLocalOnly(runtime: ReturnType<typeof observeRuntime>): 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'))
|
|
);
|
|
// Chromium aborts the superseded local preview blob when React switches from
|
|
// the source object URL to the generated result object URL.
|
|
expect(materialFailures).toEqual([]);
|
|
expect(runtime.pageErrors).toEqual([]);
|
|
expect(runtime.consoleErrors).toEqual([]);
|
|
}
|
|
|
|
test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|
let cancellationFixture: TemporaryMediaFixture;
|
|
|
|
test.beforeAll(() => {
|
|
cancellationFixture = createCancellationFixture();
|
|
});
|
|
|
|
test.afterAll(() => {
|
|
cancellationFixture.dispose();
|
|
});
|
|
|
|
test('loads and queries the multithread core under cross-origin isolation', async ({
|
|
page,
|
|
}) => {
|
|
const runtime = observeRuntime(page);
|
|
await page.goto('/');
|
|
const environment = await page.evaluate(() => ({
|
|
isolated: globalThis.crossOriginIsolated,
|
|
sharedArrayBuffer: typeof globalThis.SharedArrayBuffer,
|
|
}));
|
|
test.skip(
|
|
!environment.isolated || environment.sharedArrayBuffer !== 'function',
|
|
'Chromium did not expose SharedArrayBuffer in this server context'
|
|
);
|
|
|
|
await page.getByText('Engine & browser').click();
|
|
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
|
|
|
await expect(
|
|
page.locator('.engine-pill').filter({ hasText: 'Multithreaded' })
|
|
).toBeVisible({ timeout: 120_000 });
|
|
await expect(page.getByText('0.12.15 / 0.12.10')).toBeVisible();
|
|
await expect(page.getByText('Isolation').locator('..')).toContainText(
|
|
'Enabled'
|
|
);
|
|
|
|
const expectedAssets = [
|
|
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
|
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
|
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
|
];
|
|
for (const suffix of expectedAssets) {
|
|
expect(
|
|
runtime.responses.some(
|
|
(response) => response.status === 200 && response.url.endsWith(suffix)
|
|
)
|
|
).toBe(true);
|
|
}
|
|
const wasm = runtime.responses.find((response) =>
|
|
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm')
|
|
);
|
|
expect(wasm?.contentType).toContain('application/wasm');
|
|
expect(
|
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/'))
|
|
).toBe(false);
|
|
|
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
|
'Ready',
|
|
{ timeout: 120_000 }
|
|
);
|
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
|
const result = page.locator('.result-card');
|
|
await expect(result).toContainText('pattern-av-convert.mp4', {
|
|
timeout: 120_000,
|
|
});
|
|
await expect(result).toContainText(/created · verified|completed locally/u);
|
|
const outputBytes = await page
|
|
.locator('.quick-preview video')
|
|
.evaluate(async (element) => {
|
|
const response = await fetch((element as HTMLVideoElement).src);
|
|
return (await response.arrayBuffer()).byteLength;
|
|
});
|
|
expect(outputBytes).toBeGreaterThan(1_000);
|
|
assertLocalOnly(runtime);
|
|
});
|
|
|
|
test('falls back to the single-thread core when the preferred multithread core cannot load', async ({
|
|
page,
|
|
}) => {
|
|
const runtime = observeRuntime(page);
|
|
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
|
|
route.abort('failed')
|
|
);
|
|
await page.goto('/');
|
|
const environment = await page.evaluate(() => ({
|
|
isolated: globalThis.crossOriginIsolated,
|
|
sharedArrayBuffer: typeof globalThis.SharedArrayBuffer,
|
|
}));
|
|
test.skip(
|
|
!environment.isolated || environment.sharedArrayBuffer !== 'function',
|
|
'Chromium did not expose SharedArrayBuffer in this server context'
|
|
);
|
|
|
|
await page.getByText('Engine & browser').click();
|
|
await page.getByLabel('Engine mode').selectOption('prefer-multithread');
|
|
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
|
|
|
await expect(
|
|
page
|
|
.locator('.engine-pill')
|
|
.filter({ hasText: 'Single-threaded compatibility mode' })
|
|
).toBeVisible({ timeout: 120_000 });
|
|
await expect(
|
|
page.getByText(
|
|
'The multithread core failed to load. The app recovered with the single-thread compatibility core.'
|
|
)
|
|
).toBeVisible();
|
|
expect(
|
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/'))
|
|
).toBe(true);
|
|
expect(
|
|
runtime.responses.some(
|
|
(response) =>
|
|
response.status === 200 &&
|
|
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
|
)
|
|
).toBe(true);
|
|
expect(runtime.pageErrors).toEqual([]);
|
|
expect(runtime.consoleErrors).toEqual([]);
|
|
});
|
|
|
|
test('probes H.264/AAC media and produces a verified H.264/AAC result with the single-thread core', async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
|
|
});
|
|
const runtime = observeRuntime(page);
|
|
await page.goto('/');
|
|
await selectSingleThread(page);
|
|
|
|
expect(
|
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
|
).toBe(false);
|
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
|
|
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
|
'Ready',
|
|
{ timeout: 120_000 }
|
|
);
|
|
await expect(page.locator('.source-summary')).toContainText(
|
|
'pattern-av.mp4'
|
|
);
|
|
await expect(page.locator('.source-summary')).toContainText('0:02.00');
|
|
await expect(
|
|
page.getByRole('checkbox', { name: /#0 · h264/u })
|
|
).toBeChecked();
|
|
await expect(
|
|
page.getByRole('checkbox', { name: /#1 · aac/u })
|
|
).toBeChecked();
|
|
await expect(
|
|
page
|
|
.locator('.engine-pill')
|
|
.filter({ hasText: 'Single-threaded compatibility mode' })
|
|
).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
|
const result = page.locator('.result-card');
|
|
await expect(result).toBeVisible({ timeout: 120_000 });
|
|
await expect(result).toContainText('pattern-av-convert.mp4');
|
|
await expect(result).toContainText(/created · verified|completed locally/u);
|
|
|
|
const resultVideo = page.locator('.quick-preview video');
|
|
await expect(resultVideo).toHaveAttribute('src', /^blob:/u);
|
|
const outputBytes = await resultVideo.evaluate(async (element) => {
|
|
const video = element as HTMLVideoElement;
|
|
const response = await fetch(video.src);
|
|
return new Uint8Array(await response.arrayBuffer()).length;
|
|
});
|
|
expect(outputBytes).toBeGreaterThan(1_000);
|
|
|
|
const downloadPromise = page.waitForEvent('download');
|
|
await page.getByRole('button', { name: 'Save result' }).click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBe('pattern-av-convert.mp4');
|
|
const downloadedPath = await download.path();
|
|
expect(downloadedPath).not.toBeNull();
|
|
const probeJson = execFileSync(
|
|
'ffprobe',
|
|
[
|
|
'-v',
|
|
'error',
|
|
'-show_entries',
|
|
'stream=codec_name,codec_type',
|
|
'-of',
|
|
'json',
|
|
downloadedPath as string,
|
|
],
|
|
{ encoding: 'utf8' }
|
|
);
|
|
const nativeProbe = JSON.parse(probeJson) as {
|
|
streams?: Array<{ codec_name?: string; codec_type?: string }>;
|
|
};
|
|
expect(nativeProbe.streams).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
codec_name: 'h264',
|
|
codec_type: 'video',
|
|
}),
|
|
expect.objectContaining({
|
|
codec_name: 'aac',
|
|
codec_type: 'audio',
|
|
}),
|
|
])
|
|
);
|
|
|
|
expect(
|
|
runtime.requests.some((url) =>
|
|
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
|
)
|
|
).toBe(true);
|
|
assertLocalOnly(runtime);
|
|
});
|
|
|
|
test('refreshes retained tile-filter state before a fourth contact sheet', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
const runtime = observeRuntime(page);
|
|
await page.goto('/');
|
|
await selectSingleThread(page);
|
|
await page.getByRole('tab', { name: 'Edit' }).click();
|
|
await mediaInput(page)
|
|
.first()
|
|
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
|
await expect(
|
|
page
|
|
.locator('.media-card')
|
|
.filter({ hasText: 'compatible-a.mp4' })
|
|
.locator('.phase--ready')
|
|
).toBeVisible({ timeout: 120_000 });
|
|
|
|
const advanced = page.locator('.advanced-operations');
|
|
await advanced
|
|
.getByRole('tab', { name: 'Thumbnails and contact sheets' })
|
|
.click();
|
|
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 results = page.locator('.result-card');
|
|
const create = advanced.getByRole('button', {
|
|
name: 'Generate contact sheet',
|
|
});
|
|
for (let index = 1; index <= 4; index += 1) {
|
|
await create.click();
|
|
await expect(results).toHaveCount(index, { timeout: 120_000 });
|
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
|
}
|
|
|
|
await expect(
|
|
results.filter({ hasText: 'compatible-a-contact-sheet' })
|
|
).toHaveCount(4);
|
|
expect(
|
|
runtime.requests.filter((url) =>
|
|
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
|
)
|
|
).toHaveLength(2);
|
|
assertLocalOnly(runtime);
|
|
});
|
|
|
|
test('cancels an active transcode, recreates the engine and completes another job', async ({
|
|
page,
|
|
}) => {
|
|
const runtime = observeRuntime(page);
|
|
await page.goto('/');
|
|
await selectSingleThread(page);
|
|
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
|
'Ready',
|
|
{ timeout: 120_000 }
|
|
);
|
|
|
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
|
const cancelButton = page.getByRole('button', {
|
|
name: 'Cancel & recover',
|
|
});
|
|
await expect(cancelButton).toBeVisible({ timeout: 30_000 });
|
|
await cancelButton.click();
|
|
|
|
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: `Remove ${basename(cancellationFixture.filePath)}`,
|
|
})
|
|
.click();
|
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
|
'Ready',
|
|
{ timeout: 120_000 }
|
|
);
|
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
|
await expect(page.locator('.result-card')).toContainText(
|
|
'pattern-av-convert.mp4',
|
|
{ timeout: 120_000 }
|
|
);
|
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
|
assertLocalOnly(runtime);
|
|
});
|
|
});
|