feat: publish av-tools 0.1.0
This commit is contained in:
359
tests/browser/ffmpeg-runtime.spec.ts
Normal file
359
tests/browser/ffmpeg-runtime.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/// <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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user