feat: publish av-tools 0.1.0
This commit is contained in:
256
tests/browser/application.spec.ts
Normal file
256
tests/browser/application.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
observeRuntime,
|
||||
startNestedStaticServer,
|
||||
type NestedStaticServer,
|
||||
} from './browser-helpers';
|
||||
|
||||
test.describe('static application and Toolbox shell', () => {
|
||||
test('runs standalone, stays lazy and supports keyboard-operated shell controls', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Audio & Video Tools');
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Audio & Video Tools',
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-toolbox-context]')).toHaveAttribute(
|
||||
'data-toolbox-context',
|
||||
'standalone'
|
||||
);
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Quick Convert' })
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled();
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
expect(
|
||||
runtime.requests.some((url) => /\/assets\/worker-[^/]+\.js$/u.test(url))
|
||||
).toBe(false);
|
||||
|
||||
const helpButton = page.getByRole('button', { name: 'Help' });
|
||||
await helpButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const helpDialog = page.getByRole('dialog', {
|
||||
name: 'Audio & Video Tools',
|
||||
});
|
||||
await expect(helpDialog).toBeVisible();
|
||||
await expect(
|
||||
helpDialog.getByRole('heading', {
|
||||
level: 2,
|
||||
name: 'Audio & Video Tools',
|
||||
})
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(helpDialog).toBeHidden();
|
||||
|
||||
const personalizeButton = page.getByRole('button', {
|
||||
name: 'Personalize',
|
||||
});
|
||||
await personalizeButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: 'Personalize your toolbox',
|
||||
})
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dark' }).click();
|
||||
await expect(page.locator('.toolbox-shell').first()).toHaveAttribute(
|
||||
'data-toolbox-theme',
|
||||
'dark'
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(personalizeButton).toBeFocused();
|
||||
|
||||
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);
|
||||
expect(runtime.failedRequests).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps preview primary and exposes accessible Edit drawers on narrow screens', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Preview' })).toBeVisible();
|
||||
const mediaDrawer = page.locator('#edit-media-drawer');
|
||||
const inspectorDrawer = page.locator('#edit-inspector-drawer');
|
||||
await expect(mediaDrawer).toBeHidden();
|
||||
await expect(inspectorDrawer).toBeHidden();
|
||||
|
||||
const sources = page.getByRole('button', { name: 'Sources' });
|
||||
await sources.click();
|
||||
await expect(mediaDrawer).toBeVisible();
|
||||
await expect(
|
||||
mediaDrawer.getByRole('button', { name: 'Close media bin' })
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(mediaDrawer).toBeHidden();
|
||||
await expect(sources).toBeFocused();
|
||||
|
||||
const inspector = page.getByRole('button', { name: 'Inspector' });
|
||||
await inspector.click();
|
||||
await expect(inspectorDrawer).toBeVisible();
|
||||
const closeInspector = inspectorDrawer.getByRole('button', {
|
||||
name: 'Close inspector',
|
||||
});
|
||||
await expect(closeInspector).toBeFocused();
|
||||
await closeInspector.click();
|
||||
await expect(inspectorDrawer).toBeHidden();
|
||||
await expect(inspector).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('nested static deployment and Toolbox context', () => {
|
||||
let nestedServer: NestedStaticServer;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
nestedServer = await startNestedStaticServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await nestedServer.close();
|
||||
});
|
||||
|
||||
test('loads a valid same-origin catalog and all runtime assets below the nested path', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto(`${nestedServer.appUrl}?toolbox=../toolbox.catalog.json`);
|
||||
|
||||
await expect(page.locator('[data-toolbox-context]')).toHaveAttribute(
|
||||
'data-toolbox-context',
|
||||
'connected'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'add·ideas Toolbox' })
|
||||
).toHaveAttribute('href', `${nestedServer.origin}/deep/nested/`);
|
||||
|
||||
await page.getByRole('button', { name: 'Apps' }).click();
|
||||
const appSwitcher = page.getByRole('navigation', {
|
||||
name: 'Toolbox applications',
|
||||
});
|
||||
await expect(
|
||||
appSwitcher.getByRole('link', { name: 'Browser Test Tool' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
appSwitcher.getByRole('link', { name: 'Audio & Video Tools' })
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
/\/deep\/nested\/av\/assets\/index-[^/]+\.js$/u.test(url)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
/\/deep\/nested\/av\/assets\/index-[^/]+\.css$/u.test(url)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
|
||||
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 });
|
||||
await expect(page.getByText('0.12.15 / 0.12.10')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Encoders').locator('..').locator('dd')
|
||||
).not.toHaveText('0');
|
||||
await expect(
|
||||
page.getByText('Filters').locator('..').locator('dd')
|
||||
).not.toHaveText('0');
|
||||
|
||||
const coreScript = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js'
|
||||
)
|
||||
);
|
||||
const coreWasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(coreScript).toMatchObject({
|
||||
status: 200,
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
});
|
||||
expect(coreWasm).toMatchObject({
|
||||
status: 200,
|
||||
contentType: 'application/wasm',
|
||||
});
|
||||
expect(
|
||||
runtime.responses.some((response) =>
|
||||
/\/deep\/nested\/av\/assets\/worker-[^/]+\.js$/u.test(response.url)
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
|
||||
expect(
|
||||
networkUrls.every((url) => new URL(url).origin === nestedServer.origin)
|
||||
).toBe(true);
|
||||
expect(runtime.failedRequests).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects an invalid same-origin catalog and remains usable standalone', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto(`${nestedServer.appUrl}?toolbox=../invalid-catalog.json`);
|
||||
|
||||
await expect
|
||||
.poll(() => runtime.consoleWarnings.length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.locator('[data-toolbox-context]')).toHaveAttribute(
|
||||
'data-toolbox-context',
|
||||
'standalone'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
runtime.consoleWarnings.some((warning) =>
|
||||
warning.includes('Toolbox context unavailable; continuing standalone.')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.requests.some(
|
||||
(url) =>
|
||||
url === `${nestedServer.origin}/deep/nested/invalid-catalog.json`
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.failedRequests).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
298
tests/browser/browser-helpers.ts
Normal file
298
tests/browser/browser-helpers.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
|
||||
|
||||
export const REPOSITORY_ROOT = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../..'
|
||||
);
|
||||
export const DIST_ROOT = join(REPOSITORY_ROOT, 'dist');
|
||||
export const GENERATED_FIXTURES = join(
|
||||
REPOSITORY_ROOT,
|
||||
'tests/fixtures/generated'
|
||||
);
|
||||
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
||||
|
||||
const NESTED_PREFIX = '/deep/nested/av/';
|
||||
const SECURITY_HEADERS = {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Resource-Policy': 'same-origin',
|
||||
};
|
||||
|
||||
function contentType(filePath: string): string {
|
||||
const types: Readonly<Record<string, string>> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wasm': 'application/wasm',
|
||||
};
|
||||
return types[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function readApplicationManifest(): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(DIST_ROOT, 'toolbox-app.json'), 'utf8')
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function writeJson(
|
||||
response: import('node:http').ServerResponse,
|
||||
status: number,
|
||||
value: unknown
|
||||
): void {
|
||||
response.writeHead(status, {
|
||||
...SECURITY_HEADERS,
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function serveFile(
|
||||
response: import('node:http').ServerResponse,
|
||||
filePath: string
|
||||
): void {
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
response.writeHead(404, SECURITY_HEADERS);
|
||||
response.end('Not found');
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
...SECURITY_HEADERS,
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': String(stats.size),
|
||||
'Content-Type': contentType(filePath),
|
||||
});
|
||||
response.end(readFileSync(filePath));
|
||||
} catch {
|
||||
response.writeHead(404, SECURITY_HEADERS);
|
||||
response.end('Not found');
|
||||
}
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolveClose, rejectClose) => {
|
||||
server.close((error) => {
|
||||
if (error) rejectClose(error);
|
||||
else resolveClose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface NestedStaticServer {
|
||||
origin: string;
|
||||
appUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the built artifact exactly as it will be assembled below a portal
|
||||
* path. This catches root-absolute URLs that a root-only preview cannot.
|
||||
*/
|
||||
export async function startNestedStaticServer(): Promise<NestedStaticServer> {
|
||||
const applicationManifest = readApplicationManifest();
|
||||
const otherManifest = {
|
||||
...applicationManifest,
|
||||
id: 'de.add-ideas.browser-test-tool',
|
||||
name: 'Browser Test Tool',
|
||||
description: 'A same-origin catalog peer used by browser validation.',
|
||||
entry: './',
|
||||
actions: [],
|
||||
};
|
||||
const catalog = {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.browser-test-toolbox',
|
||||
name: 'Browser Test Toolbox',
|
||||
home: './',
|
||||
theme: { mode: 'system', brand: 'Browser test' },
|
||||
apps: [
|
||||
{ manifest: './av/toolbox-app.json', enabled: true },
|
||||
{ manifest: './other/toolbox-app.json', enabled: true },
|
||||
],
|
||||
};
|
||||
const distWithSeparator = `${resolve(DIST_ROOT)}${sep}`;
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||
const { pathname } = requestUrl;
|
||||
|
||||
if (pathname === '/deep/nested/toolbox.catalog.json') {
|
||||
writeJson(response, 200, catalog);
|
||||
return;
|
||||
}
|
||||
if (pathname === '/deep/nested/invalid-catalog.json') {
|
||||
writeJson(response, 200, {
|
||||
schemaVersion: 999,
|
||||
id: 'invalid',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/deep/nested/other/toolbox-app.json') {
|
||||
writeJson(response, 200, otherManifest);
|
||||
return;
|
||||
}
|
||||
if (pathname === '/deep/nested/favicon.svg') {
|
||||
serveFile(response, join(DIST_ROOT, 'favicon.svg'));
|
||||
return;
|
||||
}
|
||||
if (!pathname.startsWith(NESTED_PREFIX)) {
|
||||
response.writeHead(404, SECURITY_HEADERS);
|
||||
response.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = decodeURIComponent(
|
||||
pathname.slice(NESTED_PREFIX.length)
|
||||
);
|
||||
const requestedPath = resolve(
|
||||
DIST_ROOT,
|
||||
relativePath === '' ? 'index.html' : relativePath
|
||||
);
|
||||
if (
|
||||
requestedPath !== resolve(DIST_ROOT) &&
|
||||
!requestedPath.startsWith(distWithSeparator)
|
||||
) {
|
||||
response.writeHead(400, SECURITY_HEADERS);
|
||||
response.end('Invalid path');
|
||||
return;
|
||||
}
|
||||
serveFile(response, requestedPath);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once('error', rejectListen);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
await closeServer(server);
|
||||
throw new Error('Nested test server did not expose a TCP port');
|
||||
}
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
return {
|
||||
origin,
|
||||
appUrl: `${origin}${NESTED_PREFIX}`,
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeObservation {
|
||||
requests: string[];
|
||||
responses: Array<{
|
||||
url: string;
|
||||
status: number;
|
||||
contentType: string | undefined;
|
||||
}>;
|
||||
failedRequests: string[];
|
||||
pageErrors: string[];
|
||||
consoleErrors: string[];
|
||||
consoleWarnings: string[];
|
||||
}
|
||||
|
||||
export function observeRuntime(page: Page): RuntimeObservation {
|
||||
const observation: RuntimeObservation = {
|
||||
requests: [],
|
||||
responses: [],
|
||||
failedRequests: [],
|
||||
pageErrors: [],
|
||||
consoleErrors: [],
|
||||
consoleWarnings: [],
|
||||
};
|
||||
|
||||
page.on('request', (request: Request) => {
|
||||
observation.requests.push(request.url());
|
||||
});
|
||||
page.on('response', (response: Response) => {
|
||||
observation.responses.push({
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
contentType: response.headers()['content-type'],
|
||||
});
|
||||
});
|
||||
page.on('requestfailed', (request: Request) => {
|
||||
observation.failedRequests.push(
|
||||
`${request.url()}: ${request.failure()?.errorText ?? 'unknown failure'}`
|
||||
);
|
||||
});
|
||||
page.on('pageerror', (error: Error) => {
|
||||
observation.pageErrors.push(error.stack ?? error.message);
|
||||
});
|
||||
page.on('console', (message: ConsoleMessage) => {
|
||||
if (message.type() === 'error') {
|
||||
observation.consoleErrors.push(message.text());
|
||||
}
|
||||
if (message.type() === 'warning') {
|
||||
observation.consoleWarnings.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
return observation;
|
||||
}
|
||||
|
||||
export interface TemporaryMediaFixture {
|
||||
filePath: string;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createCancellationFixture(): TemporaryMediaFixture {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'av-tools-browser-'));
|
||||
const filePath = join(directory, 'long-cancellation-source.mp4');
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-y',
|
||||
'-stream_loop',
|
||||
'14',
|
||||
'-i',
|
||||
PRIMARY_FIXTURE,
|
||||
'-t',
|
||||
'30',
|
||||
'-vf',
|
||||
'scale=1280:720',
|
||||
'-r',
|
||||
'30',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-crf',
|
||||
'32',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'64k',
|
||||
filePath,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
} catch (error) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
dispose: () => rmSync(directory, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
143
tests/browser/fixture-recovery.spec.ts
Normal file
143
tests/browser/fixture-recovery.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
GENERATED_FIXTURES,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type RuntimeObservation,
|
||||
} from './browser-helpers';
|
||||
|
||||
function mediaInput(page: Page) {
|
||||
return page.locator('input[type="file"][accept*="audio"]').first();
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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('generated edge-case fixtures in pinned ffmpeg.wasm', () => {
|
||||
test('probes attached cover art without treating it as the playback video stream', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await initializeSingleThread(page);
|
||||
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
||||
);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 60_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
'attached-cover.mp3'
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText('0:01.08');
|
||||
await expect(page.locator('.quick-preview audio')).toHaveAttribute(
|
||||
'src',
|
||||
/^blob:/u
|
||||
);
|
||||
await expect(page.locator('.quick-preview video')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#0 · mp3/u })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#1 · png/u })
|
||||
).toBeChecked();
|
||||
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
const inspectorStreams = page.locator('.inspector-streams');
|
||||
await expect(
|
||||
inspectorStreams.locator('li').filter({ hasText: '#0 · mp3' })
|
||||
).toContainText('audio');
|
||||
await expect(
|
||||
inspectorStreams.locator('li').filter({ hasText: '#1 · png' })
|
||||
).toContainText('video');
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
await advanced.getByRole('tab', { name: 'Metadata policies' }).click();
|
||||
await expect(advanced.getByLabel('Cover art')).toHaveValue('keep');
|
||||
await advanced
|
||||
.getByText('Other stream dispositions', { exact: true })
|
||||
.nth(1)
|
||||
.click();
|
||||
await expect(
|
||||
advanced.getByRole('checkbox', {
|
||||
name: 'Attached pic stream #1',
|
||||
})
|
||||
).toBeChecked();
|
||||
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('bounds a truncated-file probe failure, cleans up, and probes a valid file next', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await initializeSingleThread(page);
|
||||
|
||||
const probeStartedAt = Date.now();
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'truncated.mp4')
|
||||
);
|
||||
const malformedCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'truncated.mp4' });
|
||||
await expect(malformedCard.locator('.phase--error')).toContainText(
|
||||
'ffprobe did not find any media streams.',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible();
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
const validCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await expect(validCard.locator('.phase--ready')).toContainText('1 audio', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await validCard.click();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#0 · h264/u })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#1 · aac/u })
|
||||
).toBeChecked();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
996
tests/browser/operation-matrix.spec.ts
Normal file
996
tests/browser/operation-matrix.spec.ts
Normal file
@@ -0,0 +1,996 @@
|
||||
/// <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 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?: { 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 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' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
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 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 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/st/ffmpeg-core.wasm')
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
242
tests/browser/performance.spec.ts
Normal file
242
tests/browser/performance.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PRIMARY_FIXTURE } from './browser-helpers';
|
||||
|
||||
interface BenchmarkResult {
|
||||
readonly mode: 'single-thread' | 'multithread';
|
||||
readonly source: 'small' | 'medium';
|
||||
readonly sourceDescription: string;
|
||||
readonly cacheCondition: string;
|
||||
readonly initMs: number;
|
||||
readonly probeMs: number;
|
||||
readonly conversionMs: number;
|
||||
readonly outputReadMs: number;
|
||||
readonly cleanupMs: number;
|
||||
readonly exportEndToEndMs: number;
|
||||
readonly outputBytes: number;
|
||||
readonly hardwareConcurrency: number;
|
||||
readonly crossOriginIsolated: boolean;
|
||||
readonly inputBytes: number;
|
||||
readonly usedJsHeapBytes?: number;
|
||||
}
|
||||
|
||||
test.describe('opt-in pinned-core performance record', () => {
|
||||
test.skip(
|
||||
process.env.AV_BENCHMARK !== '1',
|
||||
'Run with AV_BENCHMARK=1 through npm run benchmark:browser.'
|
||||
);
|
||||
|
||||
test('records comparable fresh-context ST and MT end-to-end timings', async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(600_000);
|
||||
const medium = createMediumFixture();
|
||||
const results: BenchmarkResult[] = [];
|
||||
try {
|
||||
results.push(
|
||||
await runMode(browser, 'single-thread', PRIMARY_FIXTURE, 'small')
|
||||
);
|
||||
results.push(
|
||||
await runMode(browser, 'multithread', PRIMARY_FIXTURE, 'small')
|
||||
);
|
||||
results.push(
|
||||
await runMode(browser, 'single-thread', medium.filePath, 'medium')
|
||||
);
|
||||
results.push(
|
||||
await runMode(browser, 'multithread', medium.filePath, 'medium')
|
||||
);
|
||||
await testInfo.attach('av-tools-browser-benchmark.json', {
|
||||
body: Buffer.from(`${JSON.stringify(results, null, 2)}\n`),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
process.stdout.write(`AV_BENCHMARK_RESULT ${JSON.stringify(results)}\n`);
|
||||
} finally {
|
||||
medium.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function runMode(
|
||||
browser: Browser,
|
||||
mode: BenchmarkResult['mode'],
|
||||
fixturePath: string,
|
||||
source: BenchmarkResult['source']
|
||||
): Promise<BenchmarkResult> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
await page.addInitScript(() => {
|
||||
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
|
||||
});
|
||||
await page.goto('http://127.0.0.1:4173/');
|
||||
const environment = await page.evaluate(() => ({
|
||||
hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
crossOriginIsolated: globalThis.crossOriginIsolated,
|
||||
}));
|
||||
if (mode === 'multithread') {
|
||||
expect(environment.crossOriginIsolated).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page
|
||||
.getByLabel('Engine mode')
|
||||
.selectOption(
|
||||
mode === 'single-thread' ? 'force-single-thread' : 'prefer-multithread'
|
||||
);
|
||||
const initStarted = await monotonicNow(page);
|
||||
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
||||
await expect(
|
||||
page.locator('.engine-pill').filter({
|
||||
hasText:
|
||||
mode === 'single-thread'
|
||||
? 'Single-threaded compatibility mode'
|
||||
: 'Multithreaded',
|
||||
})
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
const initMs = (await monotonicNow(page)) - initStarted;
|
||||
|
||||
const probeStarted = await monotonicNow(page);
|
||||
await mediaInput(page).setInputFiles(fixturePath);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
const probeMs = (await monotonicNow(page)) - probeStarted;
|
||||
|
||||
const exportStarted = await monotonicNow(page);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
const result = page.locator('.result-card').last();
|
||||
await expect(result).toContainText(
|
||||
/created · verified|completed locally/u,
|
||||
{
|
||||
timeout: 180_000,
|
||||
}
|
||||
);
|
||||
const exportEndToEndMs = (await monotonicNow(page)) - exportStarted;
|
||||
const reportDownload = page.waitForEvent('download');
|
||||
await page
|
||||
.getByRole('button', { name: 'Export validation report' })
|
||||
.click();
|
||||
const reportPath = await (await reportDownload).path();
|
||||
expect(reportPath).not.toBeNull();
|
||||
const report = JSON.parse(
|
||||
readFileSync(reportPath as string, 'utf8')
|
||||
) as PerformanceReport;
|
||||
const outputBytes = await page
|
||||
.locator('.quick-preview video')
|
||||
.evaluate(async (element) => {
|
||||
const response = await fetch((element as HTMLVideoElement).src);
|
||||
return (await response.arrayBuffer()).byteLength;
|
||||
});
|
||||
const usedJsHeapBytes = await page.evaluate(() => {
|
||||
const memory = (
|
||||
performance as Performance & {
|
||||
memory?: { usedJSHeapSize?: number };
|
||||
}
|
||||
).memory;
|
||||
return memory?.usedJSHeapSize;
|
||||
});
|
||||
|
||||
const benchmarkSample: BenchmarkResult = {
|
||||
mode,
|
||||
source,
|
||||
sourceDescription:
|
||||
source === 'small'
|
||||
? '2 s, 160×90 H.264/AAC synthetic pattern'
|
||||
: '6 s, 640×360 H.264/AAC synthetic loop',
|
||||
cacheCondition:
|
||||
'fresh browser context; browser HTTP cache may be warm after the first run',
|
||||
initMs: rounded(initMs),
|
||||
probeMs: rounded(probeMs),
|
||||
conversionMs: rounded(report.execution.conversionMilliseconds),
|
||||
outputReadMs: rounded(report.execution.outputReadMilliseconds),
|
||||
cleanupMs: rounded(report.execution.cleanupMilliseconds),
|
||||
exportEndToEndMs: rounded(exportEndToEndMs),
|
||||
outputBytes,
|
||||
hardwareConcurrency: environment.hardwareConcurrency,
|
||||
crossOriginIsolated: environment.crossOriginIsolated,
|
||||
inputBytes: statSync(fixturePath).size,
|
||||
...(usedJsHeapBytes === undefined ? {} : { usedJsHeapBytes }),
|
||||
};
|
||||
process.stdout.write(
|
||||
`AV_BENCHMARK_SAMPLE ${JSON.stringify(benchmarkSample)}\n`
|
||||
);
|
||||
return benchmarkSample;
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
interface PerformanceReport {
|
||||
readonly execution: {
|
||||
readonly conversionMilliseconds: number;
|
||||
readonly outputReadMilliseconds: number;
|
||||
readonly cleanupMilliseconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
function createMediumFixture(): {
|
||||
readonly filePath: string;
|
||||
readonly dispose: () => void;
|
||||
} {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'av-tools-benchmark-'));
|
||||
const filePath = join(directory, 'medium-pattern.mp4');
|
||||
try {
|
||||
execFileSync(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-y',
|
||||
'-stream_loop',
|
||||
'2',
|
||||
'-i',
|
||||
PRIMARY_FIXTURE,
|
||||
'-t',
|
||||
'6',
|
||||
'-vf',
|
||||
'scale=640:360',
|
||||
'-r',
|
||||
'24',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-crf',
|
||||
'30',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'64k',
|
||||
filePath,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
} catch (error) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
dispose: () => rmSync(directory, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaInput(page: Page) {
|
||||
return page.locator('input[type="file"][accept*="audio"]').first();
|
||||
}
|
||||
|
||||
async function monotonicNow(page: Page): Promise<number> {
|
||||
return page.evaluate(() => performance.now());
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
185
tests/browser/ui-workflows.spec.ts
Normal file
185
tests/browser/ui-workflows.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { GENERATED_FIXTURES, PRIMARY_FIXTURE } from './browser-helpers';
|
||||
|
||||
test('covers drop, editor keyboard/drag controls, cache, and project reattachment', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'canPlayType', {
|
||||
configurable: true,
|
||||
value: () => '',
|
||||
});
|
||||
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
|
||||
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
||||
'src',
|
||||
/^blob:/u
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Create proxy' }).click();
|
||||
await expect(
|
||||
page.locator('.result-card').filter({ hasText: 'pattern-av-preview.mp4' })
|
||||
).toContainText(/created · verified|completed locally/u, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.getByText('Derived cache').click();
|
||||
const clearCache = page.getByRole('button', { name: 'Clear cache' });
|
||||
await expect(clearCache).toBeEnabled({ timeout: 10_000 });
|
||||
await clearCache.click();
|
||||
await expect(page.getByRole('status')).toContainText(
|
||||
'Derived cache cleared.'
|
||||
);
|
||||
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
||||
await mediaInput(page).setInputFiles(compatibleB);
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
const clips = page.locator('.timeline-clip');
|
||||
await expect(clips).toHaveCount(2);
|
||||
const secondName = await clips.nth(1).locator('strong').textContent();
|
||||
await clips.nth(1).dragTo(clips.nth(0));
|
||||
await expect(clips.nth(0).locator('strong')).toHaveText(
|
||||
secondName ?? 'compatible-b.mp4'
|
||||
);
|
||||
|
||||
await clips
|
||||
.filter({ hasText: 'compatible-b.mp4' })
|
||||
.locator('.timeline-clip__main')
|
||||
.click();
|
||||
const inspector = page.locator('.inspector');
|
||||
await inspector.getByRole('tab', { name: 'Transform' }).click();
|
||||
const crop = inspector.locator('.crop-editor__numeric');
|
||||
await crop.getByRole('spinbutton', { name: 'Width' }).fill('100');
|
||||
await crop.getByRole('spinbutton', { name: 'Height' }).fill('60');
|
||||
const cropMove = inspector.getByRole('button', {
|
||||
name: 'Move crop rectangle',
|
||||
});
|
||||
await cropMove.focus();
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await expect(crop.getByRole('spinbutton', { name: 'X' })).toHaveValue('10');
|
||||
|
||||
const resizeRight = inspector.getByRole('button', {
|
||||
name: 'Resize crop from right',
|
||||
});
|
||||
const handleBox = await resizeRight.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
const widthBefore = Number(
|
||||
await crop.getByRole('spinbutton', { name: 'Width' }).inputValue()
|
||||
);
|
||||
const handleCenterX = (handleBox?.x ?? 0) + (handleBox?.width ?? 0) / 2;
|
||||
const handleCenterY = (handleBox?.y ?? 0) + (handleBox?.height ?? 0) / 2;
|
||||
await page.mouse.move(handleCenterX, handleCenterY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleCenterX + 16, handleCenterY);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Number(await crop.getByRole('spinbutton', { name: 'Width' }).inputValue())
|
||||
)
|
||||
.not.toBe(widthBefore);
|
||||
|
||||
await crop.getByRole('spinbutton', { name: 'X' }).fill('999');
|
||||
await expect(inspector.locator('.crop-editor__errors')).toHaveAttribute(
|
||||
'role',
|
||||
'alert'
|
||||
);
|
||||
await inspector.getByRole('button', { name: 'Reset crop' }).click();
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
await advanced.getByRole('tab', { name: 'Waveform and loudness' }).click();
|
||||
await advanced.getByRole('button', { name: 'Analyze peak waveform' }).click();
|
||||
const inHandle = page.getByRole('slider', { name: 'In trim handle' });
|
||||
await expect(inHandle).toBeVisible({ timeout: 120_000 });
|
||||
const inBefore = Number(await inHandle.getAttribute('aria-valuenow'));
|
||||
await inHandle.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect
|
||||
.poll(async () => Number(await inHandle.getAttribute('aria-valuenow')))
|
||||
.toBeGreaterThan(inBefore);
|
||||
|
||||
const projectDownload = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Save project' }).click();
|
||||
const projectPath = await (await projectDownload).path();
|
||||
expect(projectPath).not.toBeNull();
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page
|
||||
.locator('.project-actions input[type="file"]')
|
||||
.setInputFiles(projectPath as string);
|
||||
await expect(page.locator('.timeline-clip')).toHaveCount(2);
|
||||
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.locator('.timeline-clip')).toHaveCount(2);
|
||||
});
|
||||
|
||||
function mediaInput(page: import('@playwright/test').Page) {
|
||||
return page.locator('input[type="file"][accept*="audio"]').first();
|
||||
}
|
||||
|
||||
async function dropFixture(
|
||||
page: import('@playwright/test').Page,
|
||||
filePath: string,
|
||||
mimeType: string
|
||||
): Promise<void> {
|
||||
const bytes = readFileSync(filePath).toString('base64');
|
||||
const stats = statSync(filePath);
|
||||
await page
|
||||
.locator('.drop-zone')
|
||||
.first()
|
||||
.evaluate(
|
||||
(element, fixture) => {
|
||||
const binary = atob(fixture.base64);
|
||||
const data = Uint8Array.from(binary, (character) =>
|
||||
character.charCodeAt(0)
|
||||
);
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(
|
||||
new File([data], fixture.name, {
|
||||
type: fixture.mimeType,
|
||||
lastModified: fixture.lastModified,
|
||||
})
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new DragEvent('dragenter', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: transfer,
|
||||
})
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: transfer,
|
||||
})
|
||||
);
|
||||
},
|
||||
{
|
||||
base64: bytes,
|
||||
name: filePath.split('/').at(-1) ?? 'fixture.mp4',
|
||||
mimeType,
|
||||
lastModified: Math.trunc(stats.mtimeMs),
|
||||
}
|
||||
);
|
||||
}
|
||||
60
tests/fixtures/README.md
vendored
Normal file
60
tests/fixtures/README.md
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated test fixtures
|
||||
|
||||
The binary fixtures in `generated/` contain synthetic test patterns, solid
|
||||
colours, sine waves, short subtitle strings and deliberately malformed bytes.
|
||||
They contain no downloaded or recorded media. Native FFmpeg is a
|
||||
development/test-generation tool only; the browser application never invokes
|
||||
it.
|
||||
|
||||
Regenerate the exact fixture set with:
|
||||
|
||||
```sh
|
||||
npm run fixtures:generate
|
||||
# or, to replace the known generated set:
|
||||
scripts/generate-fixtures.sh --force
|
||||
```
|
||||
|
||||
The generator records the native FFmpeg first-version line when it runs.
|
||||
The checked-in bytes were generated on 2026-07-24 with FFmpeg 7.1.3 on
|
||||
Freedesktop SDK 25.08. Output may differ with another FFmpeg build; review and
|
||||
update this inventory and `generated/SHA256SUMS` when intentionally
|
||||
regenerating. Regeneration requires native H.264/x264, VP8/libvpx,
|
||||
Vorbis/libvorbis, MP3/LAME, AAC, FLAC and PNG encoders. The fixture data is
|
||||
dedicated to the public domain under
|
||||
CC0-1.0; this statement applies only to `tests/fixtures/generated/`, not to the
|
||||
application, which is licensed separately under GPL-3.0-or-later.
|
||||
|
||||
## Inventory
|
||||
|
||||
The exact native argument vectors are in
|
||||
[`scripts/generate-fixtures.sh`](../../scripts/generate-fixtures.sh). “Generated
|
||||
by” below identifies the relevant source and encoding operation. Every fixture
|
||||
is intentionally tiny and exists to exercise a bounded behavior, not to
|
||||
establish broad codec compatibility.
|
||||
|
||||
All rows have licence **CC0-1.0 (fixture data only)**.
|
||||
|
||||
| Fixture | SHA-256 | Expected duration and streams | Expected metadata | Generated by / purpose |
|
||||
| -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `pattern-av.mp4` | `3bd192d1b6cc9f1adad0cdbadbe82c61a5ae1789ce99102fcfb2011f1e1e9927` | 2.000 s; H.264 160×90 at 12 fps; mono AAC 16 kHz | title `Generated AV fixture` | `testsrc2` + 440 Hz `sine`, x264/AAC; primary import, probe, preview and conversion smoke |
|
||||
| `compatible-a.mp4` | `93f5c81e90e592217b7d98204fbc5631602055f782659a44390db64c6f125fd2` | 1.000 s; H.264 128×72 at 10 fps; mono AAC 16 kHz | no semantic tags | red `color` + 330 Hz `sine`, x264/AAC; first stream-copy concat clip |
|
||||
| `compatible-b.mp4` | `34a49f3f0dbfa3c90562524c1762e360fddce55de8e1fa2ead3a405927c2eff2` | 1.000 s; H.264 128×72 at 10 fps; mono AAC 16 kHz | no semantic tags | blue `color` + 550 Hz `sine`, same settings as A; second stream-copy concat clip |
|
||||
| `different-dimensions.mp4` | `018be12c1c13ca2b196b3e30996d712cc3174ddb5a2225e65c0acf25b94ca20d` | 1.000 s; H.264 96×96 at 10 fps; mono AAC 16 kHz | no semantic tags | `testsrc2` + 660 Hz `sine`; incompatible concat, crop and resize |
|
||||
| `tone.wav` | `0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c` | 1.000 s; mono PCM signed 16-bit LE, 16 kHz | title `Generated tone` | 440 Hz `sine`, PCM; waveform and lossless audio output |
|
||||
| `tone.mp3` | `68e45aefbe931a1ec3a9566713c80da64e58e379efe8ef86e8a7b846391334ce` | 1.080 s; mono MP3, 16 kHz | title `Generated tone` | 440 Hz `sine`, LAME 32 kbit/s; MP3 input/output |
|
||||
| `tone.flac` | `3f27e37b4d4c17c7ac1c3131c21fbe561c0d4637610e615fc96ff12b2870a5f8` | 1.000 s; mono FLAC, 16 kHz | title `Generated tone` | 440 Hz `sine`, FLAC level 5; FLAC input/output |
|
||||
| `pattern.webm` | `0613c85e11dffd72f166a7cdba03b57e9b293acd9edb8b308d4ec0feaf19ff8e` | 1.016 s; VP8 128×72 at 10 fps; mono Vorbis 16 kHz | title `Generated WebM fixture` | `testsrc2` + 440 Hz `sine`, libvpx/Vorbis; WebM and alternate-codec path |
|
||||
| `subtitle.srt` | `f36e5e9dd09da79774e7d4288bc177a273e63bc678132a23ee0b756184dd0f91` | cue 0.100–0.700 s; UTF-8 text | cue text `Generated subtitle` | literal generated text; SRT import and shift |
|
||||
| `subtitle.vtt` | `6e19fc5920479512739e126433c7d80fbd168fc8a34786a30d2e270f48da8bff` | cue 0.100–0.700 s; UTF-8 text | cue text `Generated subtitle` | literal generated text; WebVTT import and shift |
|
||||
| `subtitle.ass` | `cb1c44b5808a5aaa173d756d0b819b282138f73ce50ca9c883ee377eb646ddd8` | dialogue 0.100–0.700 s; UTF-8 text | 128×72 script, local generic `sans-serif` style | literal generated text; ASS parsing and local-font burn-in path |
|
||||
| `metadata.mp4` | `372d81c7bcaae67c09042fda95860902eb728374301ae97848e536a27b065dcd` | 1.000 s; same H.264/AAC streams as `compatible-a.mp4` | title `Metadata fixture`; artist `add ideas test generator`; comment `Local generated media` | stream-copy remux of A with tags; metadata round trip/removal |
|
||||
| `chapters.mp4` | `4f1b2bd90d1a041c90d325805e42a76518b6569f93715dbd711773cdedfb1222` | 1.000 s; H.264, AAC and MP4 chapter-data stream; chapters 0–0.5 s and 0.5–1.0 s | title `Chapter fixture`; chapter titles `First`, `Second` | stream-copy remux of B with generated FFMETADATA; chapter inspection/export |
|
||||
| `attached-cover.mp3` | `62fe9916406bbd9b25213f1aede0791ad382b235e425a42ea463f58c403588c3` | 1.080 s; mono MP3 plus 32×32 PNG with `attached_pic=1` | title `Attached cover fixture`; cover title/comment | 440 Hz `sine` + generated yellow `color` PNG; attachment/cover-art stream handling |
|
||||
| `truncated.mp4` | `c7e91f4a3d1e67bc1bcba2e7511299de5bda2867ba886d1bec786d1f60cfe93a` | invalid: first 128 bytes of `compatible-a.mp4`; no usable streams | none expected | bounded `head -c 128`; safe malformed-input diagnostics and cleanup |
|
||||
|
||||
`generated/SHA256SUMS` is the canonical machine-checkable copy of these
|
||||
digests. Verify it from its directory:
|
||||
|
||||
```sh
|
||||
(cd tests/fixtures/generated && sha256sum -c SHA256SUMS)
|
||||
```
|
||||
15
tests/fixtures/generated/SHA256SUMS
vendored
Normal file
15
tests/fixtures/generated/SHA256SUMS
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
62fe9916406bbd9b25213f1aede0791ad382b235e425a42ea463f58c403588c3 attached-cover.mp3
|
||||
4f1b2bd90d1a041c90d325805e42a76518b6569f93715dbd711773cdedfb1222 chapters.mp4
|
||||
93f5c81e90e592217b7d98204fbc5631602055f782659a44390db64c6f125fd2 compatible-a.mp4
|
||||
34a49f3f0dbfa3c90562524c1762e360fddce55de8e1fa2ead3a405927c2eff2 compatible-b.mp4
|
||||
018be12c1c13ca2b196b3e30996d712cc3174ddb5a2225e65c0acf25b94ca20d different-dimensions.mp4
|
||||
372d81c7bcaae67c09042fda95860902eb728374301ae97848e536a27b065dcd metadata.mp4
|
||||
3bd192d1b6cc9f1adad0cdbadbe82c61a5ae1789ce99102fcfb2011f1e1e9927 pattern-av.mp4
|
||||
0613c85e11dffd72f166a7cdba03b57e9b293acd9edb8b308d4ec0feaf19ff8e pattern.webm
|
||||
cb1c44b5808a5aaa173d756d0b819b282138f73ce50ca9c883ee377eb646ddd8 subtitle.ass
|
||||
f36e5e9dd09da79774e7d4288bc177a273e63bc678132a23ee0b756184dd0f91 subtitle.srt
|
||||
6e19fc5920479512739e126433c7d80fbd168fc8a34786a30d2e270f48da8bff subtitle.vtt
|
||||
3f27e37b4d4c17c7ac1c3131c21fbe561c0d4637610e615fc96ff12b2870a5f8 tone.flac
|
||||
68e45aefbe931a1ec3a9566713c80da64e58e379efe8ef86e8a7b846391334ce tone.mp3
|
||||
0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c tone.wav
|
||||
c7e91f4a3d1e67bc1bcba2e7511299de5bda2867ba886d1bec786d1f60cfe93a truncated.mp4
|
||||
BIN
tests/fixtures/generated/attached-cover.mp3
vendored
Normal file
BIN
tests/fixtures/generated/attached-cover.mp3
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/chapters.mp4
vendored
Normal file
BIN
tests/fixtures/generated/chapters.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/compatible-a.mp4
vendored
Normal file
BIN
tests/fixtures/generated/compatible-a.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/compatible-b.mp4
vendored
Normal file
BIN
tests/fixtures/generated/compatible-b.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/different-dimensions.mp4
vendored
Normal file
BIN
tests/fixtures/generated/different-dimensions.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/metadata.mp4
vendored
Normal file
BIN
tests/fixtures/generated/metadata.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/pattern-av.mp4
vendored
Normal file
BIN
tests/fixtures/generated/pattern-av.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/pattern.webm
vendored
Normal file
BIN
tests/fixtures/generated/pattern.webm
vendored
Normal file
Binary file not shown.
12
tests/fixtures/generated/subtitle.ass
vendored
Normal file
12
tests/fixtures/generated/subtitle.ass
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 128
|
||||
PlayResY: 72
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,sans-serif,12,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,8,8,8,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:00.10,0:00:00.70,Default,,0,0,0,,Generated subtitle
|
||||
3
tests/fixtures/generated/subtitle.srt
vendored
Normal file
3
tests/fixtures/generated/subtitle.srt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,100 --> 00:00:00,700
|
||||
Generated subtitle
|
||||
4
tests/fixtures/generated/subtitle.vtt
vendored
Normal file
4
tests/fixtures/generated/subtitle.vtt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00:00.100 --> 00:00:00.700
|
||||
Generated subtitle
|
||||
BIN
tests/fixtures/generated/tone.flac
vendored
Normal file
BIN
tests/fixtures/generated/tone.flac
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/tone.mp3
vendored
Normal file
BIN
tests/fixtures/generated/tone.mp3
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/tone.wav
vendored
Normal file
BIN
tests/fixtures/generated/tone.wav
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/generated/truncated.mp4
vendored
Normal file
BIN
tests/fixtures/generated/truncated.mp4
vendored
Normal file
Binary file not shown.
1273
tests/integration/command-runtime-native.test.ts
Normal file
1273
tests/integration/command-runtime-native.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
555
tests/unit/advanced-operations.test.tsx
Normal file
555
tests/unit/advanced-operations.test.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AdvancedOperations,
|
||||
type AdvancedCapabilityKey,
|
||||
type AdvancedCapabilityStatus,
|
||||
type AdvancedOperationsProps,
|
||||
} from '../../src/components/AdvancedOperations';
|
||||
|
||||
const AVAILABLE: AdvancedCapabilityStatus = { available: true };
|
||||
|
||||
function capabilities(
|
||||
...keys: readonly AdvancedCapabilityKey[]
|
||||
): AdvancedOperationsProps['availability'] {
|
||||
return Object.fromEntries(keys.map((key) => [key, AVAILABLE]));
|
||||
}
|
||||
|
||||
const SOURCE: NonNullable<AdvancedOperationsProps['source']> = {
|
||||
durationSeconds: 90,
|
||||
sourceExtension: 'mp4',
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
selectedAudioStreamIndex: 1,
|
||||
};
|
||||
|
||||
const EXPORT_PRESETS: NonNullable<AdvancedOperationsProps['exportPresets']> = [
|
||||
{
|
||||
id: 'mp4-h264-balanced',
|
||||
name: 'MP4 · H.264 + AAC',
|
||||
fileExtension: 'mp4',
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
},
|
||||
{
|
||||
id: 'audio-mp3',
|
||||
name: 'MP3',
|
||||
fileExtension: 'mp3',
|
||||
supportsAudio: true,
|
||||
supportsVideo: false,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AdvancedOperations', () => {
|
||||
it('fails closed when a runtime capability was not explicitly verified', () => {
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
splitMarkers={[30]}
|
||||
onSplit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Queue 2 segments' });
|
||||
expect(action).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The required browser and FFmpeg capabilities have not been verified.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds a validated marker request and explains fast versus accurate mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSplit = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
splitMarkers={[60, 30]}
|
||||
exportPresets={EXPORT_PRESETS}
|
||||
availability={capabilities('split-fast', 'split-accurate')}
|
||||
onSplit={onSplit}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/boundaries may move to nearby keyframes/iu)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/practical frame accuracy/iu)).toBeInTheDocument();
|
||||
const ranges = screen.getByLabelText('Exact planned segment ranges');
|
||||
expect(within(ranges).getByText('0 s–30 s')).toBeVisible();
|
||||
expect(within(ranges).getByText('30 s–60 s')).toBeVisible();
|
||||
expect(within(ranges).getByText('60 s–90 s')).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Queue 3 segments' }));
|
||||
await waitFor(() =>
|
||||
expect(onSplit).toHaveBeenCalledWith({
|
||||
mode: 'fast',
|
||||
definition: { type: 'markers', markers: [30, 60] },
|
||||
targetExtension: 'mp4',
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Accurate re-encode/iu })
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Queue 3 segments' }));
|
||||
await waitFor(() =>
|
||||
expect(onSplit).toHaveBeenLastCalledWith({
|
||||
mode: 'accurate',
|
||||
definition: { type: 'markers', markers: [30, 60] },
|
||||
targetExtension: 'mp4',
|
||||
presetId: 'mp4-h264-balanced',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows exact latest-batch status and individual segment failure details', () => {
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
splitMarkers={[30]}
|
||||
splitBatch={{
|
||||
planId: 'split-plan',
|
||||
segments: [
|
||||
{
|
||||
outputId: 'segment-1',
|
||||
fileName: 'source-split-001.mp4',
|
||||
startSeconds: 0,
|
||||
endSeconds: 30,
|
||||
state: 'verified',
|
||||
},
|
||||
{
|
||||
outputId: 'segment-2',
|
||||
fileName: 'source-split-002.mp4',
|
||||
startSeconds: 30,
|
||||
endSeconds: 90,
|
||||
state: 'failed',
|
||||
message: 'FFmpeg did not return this expected output.',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const status = screen.getByLabelText('Split segment status');
|
||||
expect(within(status).getByText('source-split-001.mp4')).toBeVisible();
|
||||
expect(within(status).getByText('0 s–30 s')).toBeVisible();
|
||||
expect(within(status).getByText('verified')).toBeVisible();
|
||||
expect(
|
||||
within(status).getByText('FFmpeg did not return this expected output.')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('supports keyboard tab navigation and emits typed loudness targets', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAudioAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
exportPresets={EXPORT_PRESETS}
|
||||
availability={capabilities(
|
||||
'loudness-measure',
|
||||
'loudness-normalize',
|
||||
'peak-normalize',
|
||||
'waveform-static',
|
||||
'waveform-peaks'
|
||||
)}
|
||||
onAudioAction={onAudioAction}
|
||||
/>
|
||||
);
|
||||
|
||||
const splitTab = screen.getByRole('tab', { name: 'Split media' });
|
||||
splitTab.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Waveform and loudness' })
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText(/^Target profile/iu),
|
||||
'podcast'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Measure EBU R128' }));
|
||||
await waitFor(() =>
|
||||
expect(onAudioAction).toHaveBeenCalledWith({
|
||||
type: 'loudness-measurement',
|
||||
audioStreamIndex: 1,
|
||||
targets: {
|
||||
integratedLufs: -16,
|
||||
truePeakDbtp: -1,
|
||||
loudnessRangeLu: 11,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const normalize = screen.getByRole('button', {
|
||||
name: 'Apply measured normalization',
|
||||
});
|
||||
expect(normalize).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Run the first-pass loudness measurement before normalization.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses an audio-only preset for measured normalization', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAudioAction = vi.fn();
|
||||
const measurement = {
|
||||
inputIntegratedLufs: -19.7,
|
||||
inputTruePeakDbtp: -2.1,
|
||||
inputLoudnessRangeLu: 4.2,
|
||||
inputThresholdLufs: -30,
|
||||
targetOffsetLu: -0.3,
|
||||
};
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
exportPresets={EXPORT_PRESETS}
|
||||
loudnessMeasurement={measurement}
|
||||
availability={capabilities('loudness-normalize')}
|
||||
onAudioAction={onAudioAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', { name: 'Waveform and loudness' })
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply measured normalization' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onAudioAction).toHaveBeenCalledWith({
|
||||
type: 'loudness-normalization',
|
||||
audioStreamIndex: 1,
|
||||
targets: {
|
||||
integratedLufs: -14,
|
||||
truePeakDbtp: -1,
|
||||
loudnessRangeLu: 11,
|
||||
},
|
||||
measurement,
|
||||
presetId: 'audio-mp3',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps unverified contact-sheet labels off and emits a bounded series request', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onThumbnailSeries = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
availability={capabilities('thumbnail-series', 'contact-sheet')}
|
||||
imageFormatAvailability={{ jpeg: AVAILABLE }}
|
||||
onThumbnailSeries={onThumbnailSeries}
|
||||
onContactSheet={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: 'Thumbnails and contact sheets',
|
||||
})
|
||||
);
|
||||
expect(screen.getByLabelText('Timestamp labels')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Source filename label')).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Labels stay off until drawtext and a safe local font are verified.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onThumbnailSeries).toHaveBeenCalledWith({
|
||||
distribution: { type: 'evenly-spaced', count: 12 },
|
||||
width: 480,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubtitleAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
subtitleStreams={[
|
||||
{ index: 2, codec: 'dvd_subtitle', language: 'deu' },
|
||||
{ index: 3, codec: 'subrip', language: 'eng' },
|
||||
]}
|
||||
availability={capabilities('subtitle-extract')}
|
||||
onSubtitleAction={onSubtitleAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Subtitle operations' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Extract subtitle' })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/bitmap subtitle extraction is not text conversion/iu)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Subtitle stream'), '3');
|
||||
await user.click(screen.getByRole('button', { name: 'Extract subtitle' }));
|
||||
await waitFor(() =>
|
||||
expect(onSubtitleAction).toHaveBeenCalledWith({
|
||||
type: 'extract',
|
||||
stream: { index: 3, codec: 'subrip', language: 'eng' },
|
||||
outputFormat: 'srt',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('retains validated stream metadata JSON for apply and export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
availability={capabilities('metadata-write')}
|
||||
onMetadataAction={onMetadataAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
|
||||
await user.upload(
|
||||
screen.getByLabelText('Import metadata JSON'),
|
||||
new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
format: { title: 'Imported title', genre: 'Synthetic' },
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
'metadata.json',
|
||||
{ type: 'application/json' }
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenCalledWith({
|
||||
type: 'import',
|
||||
edits: {
|
||||
format: { title: 'Imported title', genre: 'Synthetic' },
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText(/1 stream metadata edit loaded/iu)).toBeVisible();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply metadata policies' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenLastCalledWith({
|
||||
type: 'apply',
|
||||
edits: {
|
||||
format: { genre: 'Synthetic', title: 'Imported title' },
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
},
|
||||
sourceMetadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
coverArtPolicy: 'keep',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('edits probed stream language, title, and typed dispositions directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={{ ...SOURCE, sourceId: 'source-a' }}
|
||||
metadataStreams={[
|
||||
{
|
||||
index: 1,
|
||||
type: 'audio',
|
||||
codec: 'aac',
|
||||
language: 'eng',
|
||||
title: 'Original',
|
||||
disposition: { default: false, forced: false },
|
||||
},
|
||||
]}
|
||||
availability={capabilities('metadata-write')}
|
||||
onMetadataAction={onMetadataAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
|
||||
const language = screen.getByLabelText('Stream #1 language');
|
||||
await user.clear(language);
|
||||
await user.type(language, 'deu');
|
||||
const title = screen.getByLabelText('Stream #1 title');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'German dialogue');
|
||||
await user.click(screen.getByLabelText('Default stream #1'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply metadata policies' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenCalledWith({
|
||||
type: 'apply',
|
||||
edits: {
|
||||
format: {},
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
dispositions: { 1: ['default'] },
|
||||
},
|
||||
sourceMetadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
coverArtPolicy: 'keep',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not clear untouched source container tags', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={{ ...SOURCE, sourceId: 'source-tags' }}
|
||||
metadataFormatTags={{
|
||||
title: 'Original title',
|
||||
artist: 'Original artist',
|
||||
album: 'Original album',
|
||||
}}
|
||||
availability={capabilities('metadata-write')}
|
||||
onMetadataAction={onMetadataAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
|
||||
expect(screen.getByLabelText('Artist')).toHaveValue('Original artist');
|
||||
const title = screen.getByLabelText('Title');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'Replacement title');
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply metadata policies' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenCalledWith({
|
||||
type: 'apply',
|
||||
edits: { format: { title: 'Replacement title' } },
|
||||
sourceMetadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
coverArtPolicy: 'keep',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates only an allowlisted typed user preset', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUserPresetAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
availability={capabilities('preset-storage')}
|
||||
onUserPresetAction={onUserPresetAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'User export presets' }));
|
||||
const editor = screen
|
||||
.getByRole('heading', { name: 'Create typed preset' })
|
||||
.closest('section');
|
||||
expect(editor).not.toBeNull();
|
||||
const editorQueries = within(editor as HTMLElement);
|
||||
await user.type(editorQueries.getByLabelText('Safe ID'), 'my-web-export');
|
||||
await user.type(editorQueries.getByLabelText('Name'), 'My web export');
|
||||
await user.click(
|
||||
editorQueries.getByRole('button', { name: 'Create preset' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUserPresetAction).toHaveBeenCalledWith({
|
||||
type: 'create',
|
||||
preset: {
|
||||
schemaVersion: 1,
|
||||
id: 'my-web-export',
|
||||
name: 'My web export',
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: {
|
||||
codec: 'libx264',
|
||||
qualityMode: 'balanced',
|
||||
crf: 23,
|
||||
},
|
||||
audio: {
|
||||
codec: 'aac',
|
||||
bitrateKbps: 160,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
metadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'none',
|
||||
fastStart: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(onUserPresetAction.mock.calls)).not.toMatch(
|
||||
/(?:args|filter|protocol|inputPath)/u
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer preset policies that require explicit advanced inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
availability={capabilities('preset-storage')}
|
||||
onUserPresetAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'User export presets' }));
|
||||
const editor = screen
|
||||
.getByRole('heading', { name: 'Create typed preset' })
|
||||
.closest('section');
|
||||
expect(editor).not.toBeNull();
|
||||
const editorQueries = within(editor as HTMLElement);
|
||||
|
||||
expect(
|
||||
within(editorQueries.getByLabelText('Metadata')).queryByRole('option', {
|
||||
name: 'Apply edits',
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(editorQueries.getByLabelText('Chapters')).queryByRole('option', {
|
||||
name: 'Replace',
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(editorQueries.getByLabelText('Subtitles')).queryByRole('option', {
|
||||
name: 'Burn in',
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
editorQueries.getByText(/remain explicit operations/iu)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
458
tests/unit/commands-advanced.test.ts
Normal file
458
tests/unit/commands-advanced.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildContactSheetPlan,
|
||||
buildConvertPlan,
|
||||
buildFadePlan,
|
||||
buildLoudnessAnalysisPlan,
|
||||
buildLoudnessApplyPlan,
|
||||
buildMetadataEditPlan,
|
||||
buildPeakAnalysisPlan,
|
||||
buildPeakNormalizationFilter,
|
||||
buildPeakNormalizationPlan,
|
||||
buildSubtitleBurnPlan,
|
||||
buildSubtitleExtractionPlan,
|
||||
buildSubtitleMuxPlan,
|
||||
buildStaticWaveformPlan,
|
||||
buildSingleThumbnailPlan,
|
||||
buildThumbnailSeriesPlan,
|
||||
buildTransformPlan,
|
||||
classifySubtitleCodec,
|
||||
createChaptersAtIntervals,
|
||||
createChaptersFromMarkers,
|
||||
escapeFFMetadata,
|
||||
escapeFilterValue,
|
||||
metadataArguments,
|
||||
parseChapterJson,
|
||||
parseLoudnessMeasurement,
|
||||
parsePeakMeasurement,
|
||||
parseMetadataJson,
|
||||
resolveThumbnailTimes,
|
||||
serializeChapterJson,
|
||||
serializeFFMetadata,
|
||||
validateChapters,
|
||||
} from '../../src/commands';
|
||||
import { findBuiltInPreset } from '../../src/presets';
|
||||
|
||||
const SOURCE = {
|
||||
id: 'media',
|
||||
sourceIndex: 0,
|
||||
fileName: 'source.mp4',
|
||||
extension: 'mp4',
|
||||
} as const;
|
||||
|
||||
describe('metadata and chapter commands', () => {
|
||||
it('serializes deterministic metadata args without a command string', () => {
|
||||
expect(
|
||||
metadataArguments({
|
||||
format: { title: 'A title', artist: 'An artist' },
|
||||
streams: { 1: { language: 'deu', title: null } },
|
||||
})
|
||||
).toEqual([
|
||||
'-metadata',
|
||||
'artist=An artist',
|
||||
'-metadata',
|
||||
'title=A title',
|
||||
'-metadata:s:1',
|
||||
'language=deu',
|
||||
'-metadata:s:1',
|
||||
'title=',
|
||||
]);
|
||||
expect(
|
||||
parseMetadataJson(
|
||||
'{"format":{"title":"Demo"},"streams":{"0":{"language":"eng"}},"dispositions":{"0":["forced","default"]}}'
|
||||
)
|
||||
).toEqual({
|
||||
format: { title: 'Demo' },
|
||||
streams: { 0: { language: 'eng' } },
|
||||
dispositions: { 0: ['default', 'forced'] },
|
||||
});
|
||||
expect(
|
||||
metadataArguments({
|
||||
dispositions: { 0: [], 1: ['forced', 'default'] },
|
||||
})
|
||||
).toEqual(['-disposition:0', '0', '-disposition:1', 'default+forced']);
|
||||
expect(() =>
|
||||
parseMetadataJson('{"dispositions":{"0":["default","default"]}}')
|
||||
).toThrow(/duplicates/iu);
|
||||
expect(() => parseMetadataJson('{"args":["-i","remote"]}')).toThrow(
|
||||
/unsupported/iu
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes every FFMETADATA delimiter and round-trips chapter JSON', () => {
|
||||
expect(escapeFFMetadata('a=b;c#d\\e\nf')).toBe('a\\=b\\;c\\#d\\\\e\\\nf');
|
||||
const chapters = [
|
||||
{ id: 'intro', title: 'Intro #1', startSeconds: 0, endSeconds: 2 },
|
||||
{ id: 'main', title: 'Main', startSeconds: 2, endSeconds: 5 },
|
||||
];
|
||||
expect(serializeFFMetadata(chapters)).toContain('title=Intro \\#1');
|
||||
const json = serializeChapterJson(chapters);
|
||||
expect(parseChapterJson(json).chapters).toEqual(chapters);
|
||||
expect(
|
||||
validateChapters([chapters[0]!, { ...chapters[1]!, startSeconds: 1 }])[0]
|
||||
).toMatchObject({ severity: 'error' });
|
||||
expect(createChaptersFromMarkers([2, 5], 8)).toHaveLength(3);
|
||||
expect(
|
||||
createChaptersAtIntervals(10, 4).map((chapter) => chapter.startSeconds)
|
||||
).toEqual([0, 4, 8]);
|
||||
});
|
||||
|
||||
it('preserves validated per-chapter time bases and converts seconds to ticks', () => {
|
||||
const metadata = serializeFFMetadata([
|
||||
{
|
||||
id: 'milliseconds',
|
||||
title: 'Milliseconds',
|
||||
startSeconds: 0,
|
||||
endSeconds: 0.5,
|
||||
timeBase: '1/1000',
|
||||
},
|
||||
{
|
||||
id: 'samples',
|
||||
title: 'Samples',
|
||||
startSeconds: 0.5,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/48000',
|
||||
},
|
||||
]);
|
||||
expect(metadata).toContain('TIMEBASE=1/1000\nSTART=0\nEND=500');
|
||||
expect(metadata).toContain('TIMEBASE=1/48000\nSTART=24000\nEND=48000');
|
||||
expect(() =>
|
||||
serializeFFMetadata([
|
||||
{
|
||||
id: 'invalid',
|
||||
title: 'Invalid',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '0/1000',
|
||||
},
|
||||
])
|
||||
).toThrow(/time base/iu);
|
||||
expect(() =>
|
||||
serializeFFMetadata([
|
||||
{
|
||||
id: 'coarse',
|
||||
title: 'Coarse',
|
||||
startSeconds: 0,
|
||||
endSeconds: 0.1,
|
||||
timeBase: '1/1',
|
||||
},
|
||||
])
|
||||
).toThrow(/too coarse/iu);
|
||||
});
|
||||
|
||||
it('builds a stream-copy metadata edit with explicit removal diagnostics', () => {
|
||||
const plan = buildMetadataEditPlan({
|
||||
jobId: 'meta',
|
||||
source: SOURCE,
|
||||
edits: { format: { title: 'Edited' } },
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
sourceMetadataPolicy: 'remove',
|
||||
chapterPolicy: 'remove',
|
||||
});
|
||||
expect(plan.args).toContain('title=Edited');
|
||||
expect(plan.args).toContain('copy');
|
||||
expect(plan.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: 'metadata-removal-scope' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loudness and subtitle commands', () => {
|
||||
const LOG = `
|
||||
[Parsed_loudnorm_0] {
|
||||
"input_i" : "-19.70",
|
||||
"input_tp" : "-2.10",
|
||||
"input_lra" : "4.20",
|
||||
"input_thresh" : "-30.00",
|
||||
"target_offset" : "-0.30"
|
||||
}`;
|
||||
|
||||
it('parses complete loudnorm JSON and rejects invalid measurements', () => {
|
||||
expect(parseLoudnessMeasurement(LOG)).toEqual({
|
||||
inputIntegratedLufs: -19.7,
|
||||
inputTruePeakDbtp: -2.1,
|
||||
inputLoudnessRangeLu: 4.2,
|
||||
inputThresholdLufs: -30,
|
||||
targetOffsetLu: -0.3,
|
||||
});
|
||||
expect(() =>
|
||||
parseLoudnessMeasurement(
|
||||
'{"input_i":"-inf","input_tp":"0","input_lra":"0","input_thresh":"0"}'
|
||||
)
|
||||
).toThrow(/finite/u);
|
||||
expect(buildPeakNormalizationFilter(-1, { inputPeakDbfs: -6 })).toMatch(
|
||||
/^volume=1\.778/u
|
||||
);
|
||||
});
|
||||
|
||||
it('builds distinct first and second loudness passes', () => {
|
||||
const targets = {
|
||||
integratedLufs: -16,
|
||||
truePeakDbtp: -1,
|
||||
loudnessRangeLu: 11,
|
||||
};
|
||||
const measurement = parseLoudnessMeasurement(LOG);
|
||||
const analysis = buildLoudnessAnalysisPlan({
|
||||
jobId: 'loud',
|
||||
source: SOURCE,
|
||||
targets,
|
||||
audioStreamIndex: 1,
|
||||
});
|
||||
expect(analysis.outputs).toEqual([]);
|
||||
expect(analysis.args).toContain('-f');
|
||||
expect(analysis.args).toContain('null');
|
||||
expect(analysis.args).toContain('0:1');
|
||||
expect(
|
||||
analysis.args.find((value) => value.startsWith('loudnorm='))
|
||||
).toContain('print_format=json');
|
||||
const apply = buildLoudnessApplyPlan({
|
||||
jobId: 'loud',
|
||||
source: SOURCE,
|
||||
targets,
|
||||
measurement,
|
||||
audioStreamIndex: 1,
|
||||
preset: findBuiltInPreset('audio-m4a-aac')!,
|
||||
});
|
||||
expect(apply.args.find((value) => value.startsWith('loudnorm='))).toContain(
|
||||
'measured_I=-19.7'
|
||||
);
|
||||
const peakAnalysis = buildPeakAnalysisPlan({
|
||||
jobId: 'peak',
|
||||
source: SOURCE,
|
||||
audioStreamIndex: 1,
|
||||
preset: findBuiltInPreset('audio-m4a-aac')!,
|
||||
});
|
||||
expect(
|
||||
peakAnalysis.args.find((value) => value.includes('volumedetect'))
|
||||
).toContain('aformat=channel_layouts=stereo');
|
||||
const peakMeasurement = parsePeakMeasurement('max_volume: -6.0 dB');
|
||||
const peak = buildPeakNormalizationPlan({
|
||||
jobId: 'peak',
|
||||
source: SOURCE,
|
||||
audioStreamIndex: 1,
|
||||
targetPeakDb: -1,
|
||||
measurement: peakMeasurement,
|
||||
preset: findBuiltInPreset('audio-m4a-aac')!,
|
||||
});
|
||||
expect(peak.args.find((value) => value.includes('volume='))).toContain(
|
||||
'volume=1.778'
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies text and bitmap subtitles and blocks bitmap-to-text extraction', () => {
|
||||
expect(classifySubtitleCodec('subrip')).toBe('text');
|
||||
expect(classifySubtitleCodec('hdmv_pgs_subtitle')).toBe('bitmap');
|
||||
expect(() =>
|
||||
buildSubtitleExtractionPlan({
|
||||
jobId: 'sub',
|
||||
source: SOURCE,
|
||||
stream: { index: 2, codec: 'hdmv_pgs_subtitle' },
|
||||
outputFormat: 'srt',
|
||||
})
|
||||
).toThrow(/bitmap/iu);
|
||||
});
|
||||
|
||||
it('converts text subtitles for target containers and safely builds burn-in', () => {
|
||||
const subtitle = {
|
||||
id: 'subtitle',
|
||||
sourceIndex: 1,
|
||||
fileName: "my:sub's.srt",
|
||||
extension: 'srt',
|
||||
} as const;
|
||||
const mux = buildSubtitleMuxPlan({
|
||||
jobId: 'mux',
|
||||
source: SOURCE,
|
||||
subtitle,
|
||||
subtitleCodec: 'subrip',
|
||||
sourceStreams: [
|
||||
{ index: 0, kind: 'video', codec: 'h264' },
|
||||
{ index: 1, kind: 'audio', codec: 'aac' },
|
||||
],
|
||||
targetContainer: 'mp4',
|
||||
targetExtension: 'mp4',
|
||||
language: 'eng',
|
||||
default: true,
|
||||
});
|
||||
expect(mux.args).toContain('mov_text');
|
||||
const burn = buildSubtitleBurnPlan({
|
||||
jobId: 'burn',
|
||||
source: SOURCE,
|
||||
subtitle,
|
||||
preset: findBuiltInPreset('mp4-h264-balanced')!,
|
||||
});
|
||||
expect(burn.inputs).toHaveLength(2);
|
||||
expect(burn.args.find((value) => value.startsWith('subtitles='))).toContain(
|
||||
'/input/job-burn/source-1.srt'
|
||||
);
|
||||
expect(escapeFilterValue("/path/a:b,c's.ass")).toBe(
|
||||
"/path/a\\:b\\,c\\'s.ass"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveform plans', () => {
|
||||
it('builds a bounded static derivative for an absolute audio stream', () => {
|
||||
const plan = buildStaticWaveformPlan({
|
||||
jobId: 'wave',
|
||||
source: SOURCE,
|
||||
audioStreamIndex: 1,
|
||||
width: 640,
|
||||
height: 160,
|
||||
});
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
'[0:1]aformat=channel_layouts=mono,showwavespic=s=640x160'
|
||||
);
|
||||
expect(plan.outputs[0]).toMatchObject({
|
||||
role: 'analysis',
|
||||
mediaType: 'image/png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnail and contact sheet plans', () => {
|
||||
it('supports explicit stretched single-frame dimensions and quality', () => {
|
||||
const plan = buildSingleThumbnailPlan({
|
||||
jobId: 'single-thumb',
|
||||
source: SOURCE,
|
||||
timeSeconds: 1.25,
|
||||
size: { width: 640, height: 360, maintainAspect: false },
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
});
|
||||
expect(plan.args).toContain('scale=640:360');
|
||||
expect(plan.args).not.toContain(
|
||||
'scale=640:360:force_original_aspect_ratio=decrease'
|
||||
);
|
||||
expect(plan.args).toEqual(
|
||||
expect.arrayContaining(['-q:v', '6', '-frames:v', '1'])
|
||||
);
|
||||
});
|
||||
|
||||
it('generates ordered unique times from interval, count, and explicit selections', () => {
|
||||
expect(
|
||||
resolveThumbnailTimes({ durationSeconds: 10, intervalSeconds: 4 })
|
||||
).toEqual([0, 4, 8]);
|
||||
expect(resolveThumbnailTimes({ durationSeconds: 10, count: 3 })).toEqual([
|
||||
2.5, 5, 7.5,
|
||||
]);
|
||||
expect(
|
||||
resolveThumbnailTimes({ durationSeconds: 10, timesSeconds: [5, 1, 5] })
|
||||
).toEqual([1, 5]);
|
||||
});
|
||||
|
||||
it('builds one invocation for a series and declares every output', () => {
|
||||
const plan = buildThumbnailSeriesPlan({
|
||||
jobId: 'thumbs',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
count: 3,
|
||||
format: 'png',
|
||||
size: { width: 320 },
|
||||
});
|
||||
expect(plan.outputs).toHaveLength(3);
|
||||
expect(plan.args.filter((value) => value === '-i')).toHaveLength(1);
|
||||
expect(plan.args).toContain('-filter_complex');
|
||||
expect(plan.args.filter((value) => value === '-frames:v')).toHaveLength(3);
|
||||
for (const output of plan.outputs) {
|
||||
expect(plan.args).toContain(output.path);
|
||||
}
|
||||
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades contact-sheet labels when drawtext is unavailable', () => {
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: 'sheet',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
frameCount: 6,
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
cellWidth: 240,
|
||||
timestampLabels: true,
|
||||
format: 'jpeg',
|
||||
drawtextAvailable: false,
|
||||
});
|
||||
expect(plan.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'labels-unavailable',
|
||||
severity: 'warning',
|
||||
})
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composed visual operations', () => {
|
||||
it('creates palette-based GIF conversion instead of a raw encoder pass', () => {
|
||||
const plan = buildConvertPlan({
|
||||
jobId: 'gif',
|
||||
source: SOURCE,
|
||||
preset: findBuiltInPreset('animated-gif')!,
|
||||
});
|
||||
expect(plan.args).toContain('-filter_complex');
|
||||
expect(plan.args.find((value) => value.includes('palettegen'))).toContain(
|
||||
'paletteuse'
|
||||
);
|
||||
expect(plan.args).not.toContain('-vf');
|
||||
});
|
||||
|
||||
it('wraps transforms and A/V fades into executable conversion plans', () => {
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced')!;
|
||||
const transformed = buildTransformPlan({
|
||||
jobId: 'transform',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
settings: {
|
||||
crop: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 640,
|
||||
height: 360,
|
||||
sourceWidth: 1280,
|
||||
sourceHeight: 720,
|
||||
requireEvenDimensions: true,
|
||||
},
|
||||
frameRate: 25,
|
||||
},
|
||||
});
|
||||
expect(transformed.args).toContain('crop=640:360:0:0,fps=25');
|
||||
const faded = buildFadePlan({
|
||||
jobId: 'fade',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
durationSeconds: 10,
|
||||
audioFadeInSeconds: 1,
|
||||
videoFadeOutSeconds: 2,
|
||||
audioCurve: 'qsin',
|
||||
videoCurve: 'exp',
|
||||
});
|
||||
expect(faded.args.find((value) => value.startsWith('afade='))).toContain(
|
||||
'curve=qsin'
|
||||
);
|
||||
expect(faded.args.find((value) => value.startsWith('geq='))).toContain(
|
||||
'exp(5*'
|
||||
);
|
||||
expect(faded.requiredCapabilities.filters).toContain('geq');
|
||||
expect(() =>
|
||||
buildFadePlan({
|
||||
jobId: 'overlap',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
durationSeconds: 2,
|
||||
audioFadeInSeconds: 1.5,
|
||||
audioFadeOutSeconds: 1,
|
||||
})
|
||||
).toThrow(/overlap/u);
|
||||
expect(() =>
|
||||
buildFadePlan({
|
||||
jobId: 'unsupported-curve',
|
||||
source: SOURCE,
|
||||
preset,
|
||||
durationSeconds: 2,
|
||||
audioFadeInSeconds: 1,
|
||||
audioCurve: 'log' as 'tri',
|
||||
})
|
||||
).toThrow(/tri, qsin, or exp/u);
|
||||
});
|
||||
});
|
||||
392
tests/unit/commands.test.ts
Normal file
392
tests/unit/commands.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildConvertPlan,
|
||||
buildFastConcatPlan,
|
||||
buildPreviewProxyPlan,
|
||||
buildRemuxPlan,
|
||||
buildSplitPlan,
|
||||
buildStreamRemovalPlan,
|
||||
buildTimelineExportPlan,
|
||||
buildTrimPlan,
|
||||
calculateResize,
|
||||
createConcatList,
|
||||
createPlannedInput,
|
||||
createVideoFiltergraph,
|
||||
displayArguments,
|
||||
sourceVirtualPath,
|
||||
validateConcatCompatibility,
|
||||
validateCrop,
|
||||
validateSplitMarkers,
|
||||
} from '../../src/commands';
|
||||
import { findBuiltInPreset } from '../../src/presets';
|
||||
|
||||
const SOURCE = {
|
||||
id: 'source',
|
||||
sourceIndex: 0,
|
||||
fileName: "../../My holiday's clip.mp4",
|
||||
extension: 'mp4',
|
||||
} as const;
|
||||
|
||||
describe('structured command plans', () => {
|
||||
it('generates safe deterministic virtual paths without source path disclosure', () => {
|
||||
expect(sourceVirtualPath('../job id', SOURCE)).toBe(
|
||||
'/input/job-job-id/source-0.mp4'
|
||||
);
|
||||
expect(createPlannedInput('abc', SOURCE)).toEqual({
|
||||
id: 'source',
|
||||
sourceIndex: 0,
|
||||
kind: 'media',
|
||||
path: '/input/job-abc/source-0.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders shell-like diagnostics without changing structured arguments', () => {
|
||||
const args = [
|
||||
'-i',
|
||||
"/input/a file's.mp4",
|
||||
'-metadata',
|
||||
'title=hello world',
|
||||
];
|
||||
expect(displayArguments(args)).toBe(
|
||||
"-i '/input/a file'\\''s.mp4' -metadata 'title=hello world'"
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'-i',
|
||||
"/input/a file's.mp4",
|
||||
'-metadata',
|
||||
'title=hello world',
|
||||
]);
|
||||
});
|
||||
|
||||
it('escapes generated concat paths and rejects line injection', () => {
|
||||
expect(createConcatList(['/input/source-0.mp4', "/input/it's.mp4"])).toBe(
|
||||
"file '/input/source-0.mp4'\nfile '/input/it'\\''s.mp4'\n"
|
||||
);
|
||||
expect(() =>
|
||||
createConcatList(["/input/a.mp4\nfile '/etc/passwd'"])
|
||||
).toThrow(/line breaks/u);
|
||||
});
|
||||
|
||||
it('composes conversion filters into one -vf argument', () => {
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced');
|
||||
expect(preset).toBeDefined();
|
||||
const plan = buildConvertPlan({
|
||||
jobId: 'convert-1',
|
||||
source: SOURCE,
|
||||
preset: preset!,
|
||||
videoFiltergraph: 'crop=640:360:0:0,fade=t=in:st=0:d=1',
|
||||
audioFiltergraph: 'afade=t=in:st=0:d=1',
|
||||
expectedDurationSeconds: 10,
|
||||
});
|
||||
expect(plan.args.filter((argument) => argument === '-vf')).toHaveLength(1);
|
||||
expect(plan.args).toContain('crop=640:360:0:0,fade=t=in:st=0:d=1');
|
||||
expect(plan.args).toContain('afade=t=in:st=0:d=1');
|
||||
expect(plan.args.at(-1)).toMatch(/\.mp4$/u);
|
||||
expect(Object.isFrozen(plan.args)).toBe(true);
|
||||
});
|
||||
|
||||
it('distinguishes fast and accurate trim argument placement', () => {
|
||||
const fast = buildTrimPlan({
|
||||
jobId: 'trim-fast',
|
||||
source: SOURCE,
|
||||
mode: 'fast',
|
||||
startSeconds: 1.25,
|
||||
endSeconds: 3.5,
|
||||
sourceDurationSeconds: 10,
|
||||
targetExtension: 'mp4',
|
||||
});
|
||||
expect(fast.args.slice(0, 7)).toEqual([
|
||||
'-ss',
|
||||
'1.25',
|
||||
'-to',
|
||||
'3.5',
|
||||
'-i',
|
||||
'/input/job-trim-fast/source-0.mp4',
|
||||
'-map',
|
||||
]);
|
||||
expect(fast.args).toContain('copy');
|
||||
expect(fast.diagnostics[0]?.severity).toBe('warning');
|
||||
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced');
|
||||
const accurate = buildTrimPlan({
|
||||
jobId: 'trim-accurate',
|
||||
source: SOURCE,
|
||||
mode: 'accurate',
|
||||
startSeconds: 1.25,
|
||||
endSeconds: 3.5,
|
||||
preset: preset!,
|
||||
});
|
||||
expect(accurate.args.slice(0, 6)).toEqual([
|
||||
'-ss',
|
||||
'1.25',
|
||||
'-to',
|
||||
'3.5',
|
||||
'-i',
|
||||
'/input/job-trim-accurate/source-0.mp4',
|
||||
]);
|
||||
expect(accurate.args).toContain('libx264');
|
||||
});
|
||||
|
||||
it('validates and expands every supported split definition', () => {
|
||||
expect(validateSplitMarkers([8, 2, 5], 10)).toEqual([2, 5, 8]);
|
||||
expect(() => validateSplitMarkers([2, 2.01], 10)).toThrow(/apart/u);
|
||||
const plan = buildSplitPlan({
|
||||
jobId: 'split',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
definition: { type: 'part-count', parts: 3 },
|
||||
mode: 'fast',
|
||||
targetExtension: 'mp4',
|
||||
});
|
||||
expect(plan.outputs.map((output) => output.fileName)).toEqual([
|
||||
'My-holiday-s-clip-split-001.mp4',
|
||||
'My-holiday-s-clip-split-002.mp4',
|
||||
'My-holiday-s-clip-split-003.mp4',
|
||||
]);
|
||||
expect(plan.outputs.map((output) => output.timeRange)).toEqual([
|
||||
{ startSeconds: 0, endSeconds: 10 / 3 },
|
||||
{ startSeconds: 10 / 3, endSeconds: 20 / 3 },
|
||||
{ startSeconds: 20 / 3, endSeconds: 10 },
|
||||
]);
|
||||
expect(
|
||||
plan.args.filter((argument) => argument === '-progress')
|
||||
).toHaveLength(1);
|
||||
expect(plan.args.filter((argument) => argument === 'copy')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('blocks incompatible remuxes before returning a plan', () => {
|
||||
expect(() =>
|
||||
buildRemuxPlan({
|
||||
jobId: 'remux',
|
||||
source: SOURCE,
|
||||
targetContainer: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
description: {
|
||||
streams: [{ index: 0, kind: 'video', codec: 'vp9' }],
|
||||
},
|
||||
})
|
||||
).toThrow(/cannot be remuxed/u);
|
||||
});
|
||||
|
||||
it('maps selected attachment and data streams during compatible remux', () => {
|
||||
const plan = buildRemuxPlan({
|
||||
jobId: 'remux-auxiliary',
|
||||
source: SOURCE,
|
||||
targetContainer: 'matroska',
|
||||
fileExtension: 'mkv',
|
||||
description: {
|
||||
streams: [
|
||||
{ index: 2, kind: 'attachment', codec: 'ttf' },
|
||||
{ index: 3, kind: 'data', codec: 'bin_data' },
|
||||
],
|
||||
},
|
||||
streamSelection: {
|
||||
attachments: [2],
|
||||
data: [3],
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan.args).toEqual(
|
||||
expect.arrayContaining(['-map', '0:2', '-map', '0:3', '-c', 'copy'])
|
||||
);
|
||||
});
|
||||
|
||||
it('maps only explicitly retained streams for stream removal', () => {
|
||||
const plan = buildStreamRemovalPlan({
|
||||
jobId: 'streams',
|
||||
source: SOURCE,
|
||||
selection: { video: [0], audio: [1], subtitles: [] },
|
||||
targetExtension: 'mkv',
|
||||
targetMuxer: 'matroska',
|
||||
});
|
||||
expect(plan.args).toEqual([
|
||||
'-i',
|
||||
'/input/job-streams/source-0.mp4',
|
||||
'-map',
|
||||
'0:0',
|
||||
'-map',
|
||||
'0:1',
|
||||
'-c',
|
||||
'copy',
|
||||
'-map_metadata',
|
||||
'0',
|
||||
'-map_chapters',
|
||||
'0',
|
||||
'/work/job-streams/My-holiday-s-clip-streams.mkv',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concat and transform validation', () => {
|
||||
const STREAMS = [
|
||||
{
|
||||
kind: 'video' as const,
|
||||
codec: 'h264',
|
||||
width: 640,
|
||||
height: 360,
|
||||
pixelFormat: 'yuv420p',
|
||||
timeBase: '1/12800',
|
||||
},
|
||||
{
|
||||
kind: 'audio' as const,
|
||||
codec: 'aac',
|
||||
sampleRate: 48_000,
|
||||
channelLayout: 'stereo',
|
||||
timeBase: '1/48000',
|
||||
},
|
||||
];
|
||||
const SOURCES = [
|
||||
{
|
||||
...SOURCE,
|
||||
id: 'one',
|
||||
sourceIndex: 0,
|
||||
durationSeconds: 2,
|
||||
streams: STREAMS,
|
||||
},
|
||||
{
|
||||
...SOURCE,
|
||||
id: 'two',
|
||||
sourceIndex: 1,
|
||||
fileName: 'two.mp4',
|
||||
durationSeconds: 3,
|
||||
streams: STREAMS,
|
||||
},
|
||||
];
|
||||
|
||||
it('checks exact fast-concat stream arrangement and properties', () => {
|
||||
expect(validateConcatCompatibility(SOURCES)).toEqual([]);
|
||||
expect(
|
||||
validateConcatCompatibility([
|
||||
SOURCES[0]!,
|
||||
{
|
||||
...SOURCES[1]!,
|
||||
streams: [{ ...STREAMS[0]!, width: 1280 }, STREAMS[1]!],
|
||||
},
|
||||
])[0]
|
||||
).toMatchObject({ code: 'stream-property', severity: 'error' });
|
||||
const plan = buildFastConcatPlan({
|
||||
jobId: 'concat',
|
||||
sources: SOURCES,
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
});
|
||||
expect(plan.temporaryFiles[0]?.content).toBe(
|
||||
"file '/input/job-concat/source-0.mp4'\nfile '/input/job-concat/source-1.mp4'\n"
|
||||
);
|
||||
expect(plan.expectedDurationSeconds).toBe(5);
|
||||
});
|
||||
|
||||
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
|
||||
expect(
|
||||
validateCrop({
|
||||
x: 3,
|
||||
y: 5,
|
||||
width: 101,
|
||||
height: 51,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 100,
|
||||
requireEvenDimensions: true,
|
||||
})
|
||||
).toMatchObject({ x: 2, y: 4, width: 100, height: 50 });
|
||||
expect(
|
||||
calculateResize({
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
width: 640,
|
||||
height: 640,
|
||||
mode: 'contain',
|
||||
})
|
||||
).toEqual({ width: 640, height: 360 });
|
||||
});
|
||||
|
||||
it('orders typed filter stages independently of insertion order', () => {
|
||||
const builder = createVideoFiltergraph()
|
||||
.add({ id: 'fade', stage: 'fade', expression: 'fade=t=in:d=1' })
|
||||
.add({ id: 'crop', stage: 'crop', expression: 'crop=100:100:0:0' })
|
||||
.add({ id: 'fps', stage: 'frame-rate', expression: 'fps=25' });
|
||||
expect(builder.build().graph).toBe('crop=100:100:0:0,fps=25,fade=t=in:d=1');
|
||||
});
|
||||
|
||||
it('builds a bounded preview proxy with optional audio mapping', () => {
|
||||
const plan = buildPreviewProxyPlan({
|
||||
jobId: 'preview',
|
||||
source: SOURCE,
|
||||
sourceDurationSeconds: 120,
|
||||
startSeconds: 10,
|
||||
});
|
||||
expect(plan.expectedDurationSeconds).toBe(30);
|
||||
expect(plan.args).toContain('0:a:0?');
|
||||
expect(plan.args).toContain("scale=w='max(2,trunc(min(960,iw)/2)*2)':h=-2");
|
||||
});
|
||||
|
||||
it('builds a bounded AAC/M4A proxy for an audio-only source', () => {
|
||||
const plan = buildPreviewProxyPlan({
|
||||
jobId: 'audio-preview',
|
||||
source: SOURCE,
|
||||
sourceDurationSeconds: 90,
|
||||
maxDurationSeconds: 20,
|
||||
hasVideo: false,
|
||||
});
|
||||
|
||||
expect(plan.expectedDurationSeconds).toBe(20);
|
||||
expect(plan.outputs[0]?.fileName).toMatch(/-preview\.m4a$/u);
|
||||
expect(plan.outputs[0]?.mediaType).toBe('audio/mp4');
|
||||
expect(plan.args).toContain('0:a:0');
|
||||
expect(plan.args).toContain('aac');
|
||||
expect(plan.args).not.toContain('0:v:0');
|
||||
expect(plan.args).not.toContain('libx264');
|
||||
expect(plan.args).not.toContain('-vf');
|
||||
expect(plan.requiredCapabilities).toMatchObject({
|
||||
muxers: ['ipod'],
|
||||
encoders: ['aac'],
|
||||
filters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds one normalized timeline filter graph', () => {
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced');
|
||||
const plan = buildTimelineExportPlan({
|
||||
jobId: 'timeline',
|
||||
preset: preset!,
|
||||
outputWidth: 640,
|
||||
outputHeight: 360,
|
||||
frameRate: 25,
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
outputFileName: '../../My final cut.mov',
|
||||
clips: [
|
||||
{
|
||||
id: 'one',
|
||||
source: SOURCES[0]!,
|
||||
sourceInSeconds: 0.25,
|
||||
sourceOutSeconds: 1.75,
|
||||
hasVideo: true,
|
||||
hasAudio: true,
|
||||
audioFadeInSeconds: 0.25,
|
||||
audioFadeCurve: 'qsin',
|
||||
videoFadeOutSeconds: 0.25,
|
||||
videoFadeCurve: 'exp',
|
||||
},
|
||||
{
|
||||
id: 'two',
|
||||
source: SOURCES[1]!,
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 2,
|
||||
hasVideo: true,
|
||||
hasAudio: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
plan.args.filter((argument) => argument === '-filter_complex')
|
||||
).toHaveLength(1);
|
||||
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1];
|
||||
expect(graph).toContain('anullsrc=');
|
||||
expect(graph).toContain('curve=qsin');
|
||||
expect(graph).toContain("geq=lum_expr='lum(X,Y)*((exp(5*");
|
||||
expect(graph).toContain('concat=n=2:v=1:a=1[vout][aout]');
|
||||
expect(plan.requiredCapabilities.filters).toContain('geq');
|
||||
expect(plan.expectedDurationSeconds).toBe(3.5);
|
||||
expect(plan.outputs[0]?.fileName).toBe('My-final-cut.mp4');
|
||||
expect(plan.outputs[0]?.path).not.toContain('..');
|
||||
});
|
||||
});
|
||||
139
tests/unit/components/capability-panel.test.tsx
Normal file
139
tests/unit/components/capability-panel.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CapabilityPanel } from '../../../src/components/CapabilityPanel';
|
||||
import type {
|
||||
EngineState,
|
||||
FFmpegCapabilities,
|
||||
} from '../../../src/ffmpeg/ffmpeg.types';
|
||||
import { createResourcePolicy } from '../../../src/storage';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('CapabilityPanel', () => {
|
||||
it('shows the active device-adjusted resource policy', async () => {
|
||||
const user = userEvent.setup();
|
||||
const policy = createResourcePolicy({
|
||||
softInputBytes: 128 * 1024 * 1024,
|
||||
hardSingleInputBytes: 512 * 1024 * 1024,
|
||||
hardTotalInputBytes: 768 * 1024 * 1024,
|
||||
softOutputEstimateBytes: 256 * 1024 * 1024,
|
||||
hardOutputEstimateBytes: 512 * 1024 * 1024,
|
||||
});
|
||||
|
||||
render(
|
||||
<CapabilityPanel
|
||||
state={{ status: 'idle' }}
|
||||
preference="automatic"
|
||||
resourcePolicy={policy}
|
||||
deviceMemoryGiB={2}
|
||||
onPreferenceChange={vi.fn()}
|
||||
onInitialize={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Engine & browser'));
|
||||
expect(screen.getByText('Active resource limits')).toBeVisible();
|
||||
expect(screen.getByText('Adjusted for 2 GiB device memory')).toBeVisible();
|
||||
expect(screen.getByText('128 MiB / 512 MiB')).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(/conservative estimates, not media format limits/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('reports the exact FFmpeg version, build flags, and capability counts', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CapabilityPanel
|
||||
state={readyEngine()}
|
||||
preference="automatic"
|
||||
resourcePolicy={createResourcePolicy()}
|
||||
onPreferenceChange={vi.fn()}
|
||||
onInitialize={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Engine & browser'));
|
||||
expect(screen.getByTitle(/ffmpeg version n5\.1\.4/iu)).toHaveTextContent(
|
||||
'ffmpeg version n5.1.4'
|
||||
);
|
||||
expect(valueFor('Demuxers')).toHaveTextContent('3');
|
||||
expect(valueFor('Muxers')).toHaveTextContent('2');
|
||||
expect(valueFor('Decoders')).toHaveTextContent('4');
|
||||
expect(valueFor('Encoders')).toHaveTextContent('5');
|
||||
expect(valueFor('Filters')).toHaveTextContent('6');
|
||||
expect(
|
||||
screen.getByText('Detected capability inventory (20 entries)')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Demuxers \(3\)\s+matroska, mov, wav[\s\S]+Encoders \(5\)\s+aac, libvorbis, libvpx, libx264, png/u
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('FFmpeg build configuration (2 flags)')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/--enable-gpl\s+--enable-libx264/u)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes controlled initialization failure details and retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInitialize = vi.fn();
|
||||
const state: EngineState = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'load-failed',
|
||||
message: 'The compatibility core could not be loaded.',
|
||||
details: 'worker bootstrap failed',
|
||||
exitCode: 1,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<CapabilityPanel
|
||||
state={state}
|
||||
preference="automatic"
|
||||
resourcePolicy={createResourcePolicy()}
|
||||
onPreferenceChange={vi.fn()}
|
||||
onInitialize={onInitialize}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Engine & browser'));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'The compatibility core could not be loaded.'
|
||||
);
|
||||
expect(screen.getByText('load-failed')).toBeVisible();
|
||||
expect(screen.getByText('worker bootstrap failed')).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Retry initialization' })
|
||||
);
|
||||
expect(onInitialize).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
function valueFor(label: string): HTMLElement {
|
||||
const container = screen.getByText(label).closest('div');
|
||||
if (!container) throw new Error(`Missing capability cell for ${label}`);
|
||||
return within(container).getByRole('definition');
|
||||
}
|
||||
|
||||
function readyEngine(): EngineState {
|
||||
const capabilities: FFmpegCapabilities = {
|
||||
versionText:
|
||||
'ffmpeg version n5.1.4 Copyright the FFmpeg developers\nconfiguration: test',
|
||||
buildConfiguration: ['--enable-gpl', '--enable-libx264'],
|
||||
demuxers: new Set(['mov', 'matroska', 'wav']),
|
||||
muxers: new Set(['mp4', 'webm']),
|
||||
decoders: new Set(['h264', 'aac', 'vp8', 'vorbis']),
|
||||
encoders: new Set(['libx264', 'aac', 'libvpx', 'libvorbis', 'png']),
|
||||
filters: new Set(['scale', 'crop', 'fps', 'format', 'aresample', 'volume']),
|
||||
};
|
||||
return {
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
149
tests/unit/components/crop-overlay.test.tsx
Normal file
149
tests/unit/components/crop-overlay.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CropOverlay } from '../../../src/components/CropOverlay';
|
||||
|
||||
describe('CropOverlay', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('maps a clockwise-oriented preview back to encoded source pixels', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<CropOverlay
|
||||
sourceWidth={100}
|
||||
sourceHeight={80}
|
||||
orientation={90}
|
||||
value={{ x: 10, y: 20, width: 30, height: 40 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<img src="blob:local-preview" alt="Local preview" />
|
||||
</CropOverlay>
|
||||
);
|
||||
|
||||
const rectangle = container.querySelector('.crop-editor__rectangle');
|
||||
expect(rectangle).toHaveStyle({
|
||||
left: '25%',
|
||||
top: '10%',
|
||||
width: '50%',
|
||||
height: '30%',
|
||||
});
|
||||
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole('button', { name: 'Move crop rectangle' }),
|
||||
{ key: 'ArrowRight' }
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
x: 10,
|
||||
y: 19,
|
||||
width: 30,
|
||||
height: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports pointer dragging with bounded percentage-to-pixel math', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<CropOverlay
|
||||
sourceWidth={200}
|
||||
sourceHeight={100}
|
||||
value={{ x: 20, y: 10, width: 100, height: 60 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div>Preview</div>
|
||||
</CropOverlay>
|
||||
);
|
||||
const stage = container.querySelector('.crop-editor__stage');
|
||||
if (!(stage instanceof HTMLElement)) throw new Error('Missing crop stage');
|
||||
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 200,
|
||||
bottom: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const move = screen.getByRole('button', { name: 'Move crop rectangle' });
|
||||
|
||||
fireEvent.pointerDown(move, {
|
||||
pointerId: 7,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerMove(move, {
|
||||
pointerId: 7,
|
||||
clientX: 60,
|
||||
clientY: 30,
|
||||
});
|
||||
fireEvent.pointerUp(move, { pointerId: 7 });
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
x: 70,
|
||||
y: 30,
|
||||
width: 100,
|
||||
height: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks common display ratios without confusing rotated dimensions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CropOverlay
|
||||
sourceWidth={100}
|
||||
sourceHeight={80}
|
||||
orientation={90}
|
||||
value={{ x: 0, y: 0, width: 100, height: 80 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div>Preview</div>
|
||||
</CropOverlay>
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Common ratio' }),
|
||||
'16:9'
|
||||
);
|
||||
|
||||
const crop = onChange.mock.lastCall?.[0] as
|
||||
{ width: number; height: number } | undefined;
|
||||
expect(crop).toBeDefined();
|
||||
expect((crop?.width ?? 0) / (crop?.height ?? 1)).toBeCloseTo(9 / 16, 2);
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Lock aspect ratio' })
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it('rejects invalid numeric edits and emits even reset bounds when required', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CropOverlay
|
||||
sourceWidth={101}
|
||||
sourceHeight={99}
|
||||
requireEvenDimensions
|
||||
value={{ x: 0, y: 0, width: 100, height: 98 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div>Preview</div>
|
||||
</CropOverlay>
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole('spinbutton', { name: 'Width' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'All crop fields are required.'
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset crop' }));
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 98,
|
||||
});
|
||||
});
|
||||
});
|
||||
363
tests/unit/components/editor-integration.test.tsx
Normal file
363
tests/unit/components/editor-integration.test.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ImportedMediaAsset } from '../../../src/app/application-state';
|
||||
import { Inspector } from '../../../src/components/Inspector';
|
||||
import { Timeline } from '../../../src/components/Timeline';
|
||||
import type { AvProjectV1, TimelineClip } from '../../../src/project';
|
||||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||||
|
||||
describe('visual editor integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
createCanvasContext()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows encoded dimensions separately from normalized display metadata', () => {
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
asset={createImportedAsset()}
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={vi.fn()}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Encoded pixels 101×99 · coded 102×100 · 25.000 fps')
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('SAR 4:3 · DAR 16:9 · rotation -90°')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('uses the local video object URL in the rotation-aware crop editor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const project = createProject();
|
||||
const clip = {
|
||||
...project.timeline[0]!,
|
||||
crop: { x: 0, y: 0, width: 100, height: 98 },
|
||||
rotation: 90 as const,
|
||||
};
|
||||
const asset = createImportedAsset();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
asset={asset}
|
||||
project={project}
|
||||
clip={clip}
|
||||
disabled
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
|
||||
const cropEditor = screen.getByRole('region', {
|
||||
name: 'Crop local-video.mp4',
|
||||
});
|
||||
expect(cropEditor).toHaveAttribute('data-orientation', '90');
|
||||
expect(
|
||||
screen.getByLabelText('Local preview of local-video.mp4')
|
||||
).toHaveAttribute('src', 'blob:local-video');
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Move crop rectangle' })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('group', { name: 'Encoded source pixels' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps numeric crop inputs when a visual video source is unavailable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
expect(
|
||||
screen.getByText(/visual crop preview is unavailable/i)
|
||||
).toBeVisible();
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'WIDTH' }), {
|
||||
target: { value: '101' },
|
||||
});
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
crop: { x: 0, y: 0, width: 100, height: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('edits allowlisted fade curves and persists chapter time bases', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const onChaptersChange = vi.fn();
|
||||
const baseProject = createProject();
|
||||
const project: AvProjectV1 = {
|
||||
...baseProject,
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Intro',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/1000',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={onChaptersChange}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Video fade curve' }),
|
||||
'exp'
|
||||
);
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
video: { enabled: true, fadeCurve: 'exp' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Audio' }));
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Audio fade curve' }),
|
||||
'qsin'
|
||||
);
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
audio: { enabled: true, fadeCurve: 'qsin' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Chapters' }));
|
||||
fireEvent.change(screen.getByLabelText('Chapter 1 time base'), {
|
||||
target: { value: '1/48000' },
|
||||
});
|
||||
expect(onChaptersChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ id: 'chapter-1', timeBase: '1/48000' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates the selected clip and forwards validated waveform selections', () => {
|
||||
const project = createProject();
|
||||
const onUpdate = vi.fn();
|
||||
const onSelectionChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={vi.fn()}
|
||||
waveformPeaks={createPeaks()}
|
||||
currentTimeSeconds={4}
|
||||
splitMarkers={[3]}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('local-video.mp4')).toBeVisible();
|
||||
expect(screen.getByText('Single sequential audio track')).toBeVisible();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith('clip-1', {
|
||||
sourceInSeconds: 1.1,
|
||||
sourceOutSeconds: 9,
|
||||
});
|
||||
expect(onSelectionChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1.1,
|
||||
outSeconds: 9,
|
||||
markers: [3],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original clip-card timeline when waveform data is omitted', () => {
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('local-video.mp4')).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText('Single sequential audio track')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('spinbutton', { name: 'In' })).toHaveValue(1);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Out' })).toHaveValue(9);
|
||||
});
|
||||
|
||||
it('resets the selected clip trim to its full probed source duration', async () => {
|
||||
const user = userEvent.setup();
|
||||
const project = createProject();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Reset trim to full source' })
|
||||
);
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('clip-1', {
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createProject(): AvProjectV1 {
|
||||
const clip: TimelineClip = {
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 1,
|
||||
sourceOutSeconds: 9,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: 'project-1',
|
||||
name: 'Test project',
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
originalName: 'local-video.mp4',
|
||||
size: 4,
|
||||
lastModified: 0,
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
probe: createProbe(),
|
||||
},
|
||||
],
|
||||
timeline: [clip],
|
||||
output: {
|
||||
presetId: 'video-h264',
|
||||
fileName: 'output.mp4',
|
||||
container: 'mp4',
|
||||
videoEnabled: true,
|
||||
audioEnabled: true,
|
||||
},
|
||||
metadata: { custom: {} },
|
||||
chapters: [],
|
||||
subtitles: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createImportedAsset(): ImportedMediaAsset {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
file: new File(['test'], 'local-video.mp4', { type: 'video/mp4' }),
|
||||
objectUrl: 'blob:local-video',
|
||||
phase: 'ready',
|
||||
probe: createProbe(),
|
||||
};
|
||||
}
|
||||
|
||||
function createProbe() {
|
||||
return {
|
||||
durationSeconds: 10,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video' as const,
|
||||
codecName: 'h264',
|
||||
width: 101,
|
||||
height: 99,
|
||||
codedWidth: 102,
|
||||
codedHeight: 100,
|
||||
sampleAspectRatio: '4:3',
|
||||
displayAspectRatio: '16:9',
|
||||
rotationDegrees: -90,
|
||||
frameRate: 25,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPeaks() {
|
||||
return aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
Math.round(Math.sin(index / 5) * 20_000)
|
||||
)
|
||||
),
|
||||
encoding: 's16le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
function createCanvasContext(): CanvasRenderingContext2D {
|
||||
return {
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
48
tests/unit/components/help-dialog.test.tsx
Normal file
48
tests/unit/components/help-dialog.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HelpDialog } from '../../../src/components/HelpDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('HelpDialog', () => {
|
||||
it('exposes bundled engine, licence, source and privacy context', () => {
|
||||
render(<HelpDialog open={false} onClose={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: 'About & licences',
|
||||
hidden: true,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('n5.1.4')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.12.15')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/copyright © 2026 Albrecht Degering/i)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Application GPL', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('LICENSE'));
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'MIT text', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('ffmpeg.wasm-MIT'));
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'GPL text', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Complete inventory',
|
||||
hidden: true,
|
||||
})
|
||||
).toHaveAttribute('href', expect.stringContaining('THIRD_PARTY_NOTICES'));
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Source & reproducibility record',
|
||||
hidden: true,
|
||||
})
|
||||
).toHaveAttribute('href', expect.stringContaining('SOURCE'));
|
||||
expect(
|
||||
screen.getByText(/source files stay inside the browser session/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
196
tests/unit/components/job-panel.test.tsx
Normal file
196
tests/unit/components/job-panel.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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 { JobPanel } from '../../../src/components/JobPanel';
|
||||
import type { ExportResult } from '../../../src/export';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('JobPanel result selection', () => {
|
||||
it('downloads only the selected generated results as ZIP', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveZip = vi.fn();
|
||||
const first = result('one', 'one.png');
|
||||
const second = result('two', 'two.png');
|
||||
|
||||
render(
|
||||
<JobPanel
|
||||
engineState={{
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
versionText: 'test',
|
||||
buildConfiguration: [],
|
||||
demuxers: new Set(),
|
||||
muxers: new Set(),
|
||||
decoders: new Set(),
|
||||
encoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
}}
|
||||
jobs={[]}
|
||||
results={[first, second]}
|
||||
verifications={{}}
|
||||
logs={[]}
|
||||
onCancel={vi.fn()}
|
||||
onCancelJob={vi.fn()}
|
||||
onRetryJob={vi.fn()}
|
||||
onClearJobHistory={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onSaveZip={onSaveZip}
|
||||
onSaveReport={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onClearResults={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Download selected as ZIP (2)',
|
||||
})
|
||||
).toBeEnabled()
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select two.png for ZIP' })
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Download selected as ZIP (1)' })
|
||||
);
|
||||
expect(onSaveZip).toHaveBeenCalledWith([first]);
|
||||
});
|
||||
|
||||
it('marks browser-rejected media as download-only and does not offer Preview', () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('');
|
||||
const output = mediaResult('audio', 'archive.flac', 'audio/flac');
|
||||
|
||||
render(
|
||||
<JobPanel
|
||||
engineState={{
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
versionText: 'test',
|
||||
buildConfiguration: [],
|
||||
demuxers: new Set(),
|
||||
muxers: new Set(),
|
||||
decoders: new Set(),
|
||||
encoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
}}
|
||||
jobs={[]}
|
||||
results={[output]}
|
||||
verifications={{}}
|
||||
logs={[]}
|
||||
onCancel={vi.fn()}
|
||||
onCancelJob={vi.fn()}
|
||||
onRetryJob={vi.fn()}
|
||||
onClearJobHistory={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onSaveZip={vi.fn()}
|
||||
onSaveReport={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onClearResults={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/Download only: The browser reports no native/iu)
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Save result' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows the exact split interval and per-result verification state', () => {
|
||||
const output = {
|
||||
...mediaResult('segment', 'clip-001.mp4', 'video/mp4'),
|
||||
timeRange: { startSeconds: 1.25, endSeconds: 3.5 },
|
||||
};
|
||||
|
||||
render(
|
||||
<JobPanel
|
||||
engineState={{
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
versionText: 'test',
|
||||
buildConfiguration: [],
|
||||
demuxers: new Set(),
|
||||
muxers: new Set(),
|
||||
decoders: new Set(),
|
||||
encoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
}}
|
||||
jobs={[]}
|
||||
results={[output]}
|
||||
verifications={{
|
||||
segment: {
|
||||
warning: 'Output was created, but its verification probe failed.',
|
||||
},
|
||||
}}
|
||||
logs={[]}
|
||||
onCancel={vi.fn()}
|
||||
onCancelJob={vi.fn()}
|
||||
onRetryJob={vi.fn()}
|
||||
onClearJobHistory={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onSaveZip={vi.fn()}
|
||||
onSaveReport={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onClearResults={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Segment range 00:00:01.250–00:00:03.500 · 00:00:02.250 duration'
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(/created · verification warning/iu)).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('Output was created, but its verification probe failed.')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
function result(id: string, fileName: string): ExportResult {
|
||||
const blob = new Blob([id], { type: 'image/png' });
|
||||
return {
|
||||
id,
|
||||
planId: `plan-${id}`,
|
||||
outputId: `output-${id}`,
|
||||
fileName,
|
||||
blob,
|
||||
mimeType: blob.type,
|
||||
role: 'thumbnail',
|
||||
size: blob.size,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function mediaResult(
|
||||
id: string,
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): ExportResult {
|
||||
const blob = new Blob([id], { type: mimeType });
|
||||
return {
|
||||
id,
|
||||
planId: `plan-${id}`,
|
||||
outputId: `output-${id}`,
|
||||
fileName,
|
||||
blob,
|
||||
mimeType,
|
||||
role: 'media',
|
||||
size: blob.size,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
259
tests/unit/components/media-preview.test.tsx
Normal file
259
tests/unit/components/media-preview.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ImportedMediaAsset } from '../../../src/app/application-state';
|
||||
import {
|
||||
MediaPreview,
|
||||
type PreviewExportResult,
|
||||
} from '../../../src/components/MediaPreview';
|
||||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(
|
||||
'probably'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MediaPreview', () => {
|
||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateProxy = vi.fn();
|
||||
const onProxySettingsChange = vi.fn();
|
||||
const { container } = render(
|
||||
<MediaPreview
|
||||
asset={videoAsset()}
|
||||
onCreateProxy={onCreateProxy}
|
||||
proxySettings={{ maxDurationSeconds: 30, maxWidth: 960 }}
|
||||
onProxySettingsChange={onProxySettingsChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.error(requiredElement(container.querySelector('video')));
|
||||
|
||||
expect(screen.getByText('Create a disposable preview proxy')).toBeVisible();
|
||||
fireEvent.change(
|
||||
screen.getByRole('spinbutton', { name: 'Maximum seconds' }),
|
||||
{ target: { value: '60' } }
|
||||
);
|
||||
expect(onProxySettingsChange).toHaveBeenLastCalledWith({
|
||||
maxDurationSeconds: 60,
|
||||
maxWidth: 960,
|
||||
});
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Maximum width' }),
|
||||
'1280'
|
||||
);
|
||||
expect(onProxySettingsChange).toHaveBeenLastCalledWith({
|
||||
maxDurationSeconds: 30,
|
||||
maxWidth: 1280,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Create proxy' }));
|
||||
expect(onCreateProxy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('seeks a source preview externally and reports its actual playback time', () => {
|
||||
const onCurrentTimeChange = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<MediaPreview
|
||||
asset={videoAsset()}
|
||||
currentTimeSeconds={3.25}
|
||||
onCurrentTimeChange={onCurrentTimeChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const video = requiredElement(container.querySelector('video'));
|
||||
expect(video.currentTime).toBeCloseTo(3.25);
|
||||
|
||||
video.currentTime = 7.75;
|
||||
fireEvent.timeUpdate(video);
|
||||
expect(onCurrentTimeChange).toHaveBeenLastCalledWith(7.75);
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={videoAsset()}
|
||||
currentTimeSeconds={1.5}
|
||||
onCurrentTimeChange={onCurrentTimeChange}
|
||||
/>
|
||||
);
|
||||
expect(video.currentTime).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it('uses the result media kind, honors result seeks, and reports result time', () => {
|
||||
const onCurrentTimeChange = vi.fn();
|
||||
const result: PreviewExportResult = {
|
||||
name: 'audio-result.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
objectUrl: 'blob:audio-result',
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<MediaPreview
|
||||
asset={videoAsset()}
|
||||
result={result}
|
||||
currentTimeSeconds={2}
|
||||
onCurrentTimeChange={onCurrentTimeChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('video')).toBeNull();
|
||||
const audio = requiredElement(container.querySelector('audio'));
|
||||
expect(audio).toHaveAttribute('src', result.objectUrl);
|
||||
expect(audio.currentTime).toBeCloseTo(2);
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={videoAsset()}
|
||||
result={result}
|
||||
currentTimeSeconds={5.5}
|
||||
onCurrentTimeChange={onCurrentTimeChange}
|
||||
/>
|
||||
);
|
||||
expect(audio.currentTime).toBeCloseTo(5.5);
|
||||
|
||||
audio.currentTime = 8.125;
|
||||
fireEvent.timeUpdate(audio);
|
||||
expect(onCurrentTimeChange).toHaveBeenLastCalledWith(8.125);
|
||||
});
|
||||
|
||||
it('uses bounded peaks for an audio-only source and offers an AAC/M4A proxy', async () => {
|
||||
vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('');
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
canvasContext()
|
||||
);
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([-32_768, 0, 16_384, 32_767]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 2,
|
||||
},
|
||||
{ bucketCount: 4 }
|
||||
);
|
||||
const { container } = render(
|
||||
<MediaPreview
|
||||
asset={audioAsset()}
|
||||
waveformPeaks={peaks}
|
||||
currentTimeSeconds={1}
|
||||
onCreateProxy={vi.fn()}
|
||||
proxySettings={{ maxDurationSeconds: 30, maxWidth: 960 }}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('canvas')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Bounded waveform preview for source.flac.'
|
||||
)
|
||||
);
|
||||
expect(container.querySelector('.audio-preview__disc')).toBeNull();
|
||||
expect(container.querySelector('audio')).toBeInTheDocument();
|
||||
expect(screen.getByText(/AAC\/M4A/iu)).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole('combobox', { name: 'Maximum width' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an explicit download-only result instead of mounting unsupported playback', () => {
|
||||
vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('');
|
||||
const result: PreviewExportResult = {
|
||||
name: 'archive.flac',
|
||||
mimeType: 'audio/flac',
|
||||
objectUrl: 'blob:unsupported-result',
|
||||
};
|
||||
const { container } = render(<MediaPreview result={result} />);
|
||||
|
||||
expect(container.querySelector('audio')).toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Download only: this browser cannot preview this result.'
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Download only · archive.flac')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
function videoAsset(): ImportedMediaAsset {
|
||||
return {
|
||||
id: 'asset-video',
|
||||
file: new File(['video'], 'source.mp4', { type: 'video/mp4' }),
|
||||
objectUrl: 'blob:source-video',
|
||||
phase: 'ready',
|
||||
probe: {
|
||||
durationSeconds: 10,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video',
|
||||
codecName: 'h264',
|
||||
width: 640,
|
||||
height: 360,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function audioAsset(): ImportedMediaAsset {
|
||||
return {
|
||||
id: 'asset-audio',
|
||||
file: new File(['audio'], 'source.flac', { type: 'audio/flac' }),
|
||||
objectUrl: 'blob:source-audio',
|
||||
phase: 'ready',
|
||||
probe: {
|
||||
durationSeconds: 2,
|
||||
formatNames: ['flac'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'audio',
|
||||
codecName: 'flac',
|
||||
sampleRate: 2,
|
||||
channels: 1,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function canvasContext(): CanvasRenderingContext2D {
|
||||
return {
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function requiredElement<T extends Element>(element: T | null): T {
|
||||
if (!element) {
|
||||
throw new Error('Expected media element to be rendered');
|
||||
}
|
||||
return element;
|
||||
}
|
||||
335
tests/unit/components/quick-convert.test.tsx
Normal file
335
tests/unit/components/quick-convert.test.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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('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;
|
||||
}
|
||||
376
tests/unit/components/structural-operations.test.tsx
Normal file
376
tests/unit/components/structural-operations.test.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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 {
|
||||
StructuralOperations,
|
||||
type StructuralCapabilityKey,
|
||||
type StructuralCapabilityStatus,
|
||||
type StructuralOperationsProps,
|
||||
} from '../../../src/components/StructuralOperations';
|
||||
|
||||
const AVAILABLE: StructuralCapabilityStatus = { available: true };
|
||||
|
||||
const SELECTED_CLIP: NonNullable<StructuralOperationsProps['selectedClip']> = {
|
||||
id: 'clip-a',
|
||||
label: 'Opening shot',
|
||||
sourceInSeconds: 5.25,
|
||||
sourceOutSeconds: 18.75,
|
||||
sourceDurationSeconds: 60,
|
||||
sourceExtension: '.MP4',
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
};
|
||||
|
||||
const TIMELINE: NonNullable<StructuralOperationsProps['timeline']> = {
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-a',
|
||||
label: 'Opening shot',
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
hasSubtitles: false,
|
||||
},
|
||||
{
|
||||
id: 'clip-b',
|
||||
label: 'Silent cutaway',
|
||||
hasAudio: false,
|
||||
hasVideo: true,
|
||||
hasSubtitles: false,
|
||||
},
|
||||
],
|
||||
fastTargetExtension: 'mp4',
|
||||
fastTargetMuxer: 'mp4',
|
||||
fastCompatibilityDiagnostics: [],
|
||||
};
|
||||
|
||||
const PRESETS: NonNullable<StructuralOperationsProps['exportPresets']> = [
|
||||
{
|
||||
id: 'video-ready',
|
||||
name: 'MP4 · H.264 + AAC',
|
||||
fileExtension: 'mp4',
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
},
|
||||
{
|
||||
id: 'video-disabled',
|
||||
name: 'WebM · VP9 + Opus',
|
||||
fileExtension: 'webm',
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
disabledReason: 'The Opus encoder is not available.',
|
||||
},
|
||||
{
|
||||
id: 'audio-only',
|
||||
name: 'MP3 audio',
|
||||
fileExtension: 'mp3',
|
||||
supportsAudio: true,
|
||||
supportsVideo: false,
|
||||
},
|
||||
];
|
||||
|
||||
function capabilities(
|
||||
...keys: readonly StructuralCapabilityKey[]
|
||||
): StructuralOperationsProps['availability'] {
|
||||
return Object.fromEntries(keys.map((key) => [key, AVAILABLE]));
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('StructuralOperations', () => {
|
||||
it('uses the selected clip range for a fast stream-copy trim', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTrim = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
selectedClip={SELECTED_CLIP}
|
||||
availability={capabilities('trim-fast')}
|
||||
onTrim={onTrim}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('00:00:05.250–00:00:18.750')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/first frame may move to a nearby keyframe/iu)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export fast trim' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onTrim).toHaveBeenCalledWith({
|
||||
operation: 'trim',
|
||||
mode: 'fast',
|
||||
clipId: 'clip-a',
|
||||
startSeconds: 5.25,
|
||||
endSeconds: 18.75,
|
||||
targetExtension: 'mp4',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requires and emits an available typed preset for an accurate trim', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTrim = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
selectedClip={SELECTED_CLIP}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('trim-accurate')}
|
||||
onTrim={onTrim}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Accurate re-encode/iu })
|
||||
);
|
||||
const action = screen.getByRole('button', { name: 'Export exact trim' });
|
||||
expect(action).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Choose an available typed preset for the accurate trim.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
const preset = screen.getByLabelText('Accurate trim preset');
|
||||
expect(
|
||||
screen.getByRole('option', { name: /WebM · VP9 \+ Opus/iu })
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole('option', { name: /MP3 audio/iu })).toBeDisabled();
|
||||
await user.selectOptions(preset, 'video-ready');
|
||||
await user.click(action);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onTrim).toHaveBeenCalledWith({
|
||||
operation: 'trim',
|
||||
mode: 'accurate',
|
||||
clipId: 'clip-a',
|
||||
startSeconds: 5.25,
|
||||
endSeconds: 18.75,
|
||||
presetId: 'video-ready',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a capability was not explicitly verified', () => {
|
||||
render(
|
||||
<StructuralOperations selectedClip={SELECTED_CLIP} onTrim={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export fast trim' })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The required browser and FFmpeg capabilities have not been verified.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows concrete incompatibilities and blocks fast concat', () => {
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{
|
||||
...TIMELINE,
|
||||
fastCompatibilityDiagnostics: [
|
||||
{
|
||||
code: 'stream-property',
|
||||
severity: 'error',
|
||||
message: 'Clip 2 has incompatible width: 1280 instead of 1920.',
|
||||
path: 'sources.1.streams.0.width',
|
||||
},
|
||||
],
|
||||
}}
|
||||
availability={capabilities('concat-fast')}
|
||||
onConcat={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Fast compatibility failed' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Clip 2 has incompatible width: 1280 instead of 1920.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Checked field: sources.1.streams.0.width')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export fast concat' })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('emits the verified sequential clip order for fast concat', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConcat = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={TIMELINE}
|
||||
availability={capabilities('concat-fast')}
|
||||
onConcat={onConcat}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Fast compatibility passed' })
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Export fast concat' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConcat).toHaveBeenCalledWith({
|
||||
operation: 'concat',
|
||||
mode: 'fast',
|
||||
clipIds: ['clip-a', 'clip-b'],
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an explicit missing-audio policy for normalized concat', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConcat = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={TIMELINE}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('concat-normalized')}
|
||||
onConcat={onConcat}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No audio: Silent cutaway')).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Normalized re-encode/iu })
|
||||
);
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText('Normalized concat preset'),
|
||||
'video-ready'
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', {
|
||||
name: 'Export normalized concat',
|
||||
});
|
||||
expect(action).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText('Choose an explicit missing-audio policy.')
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
|
||||
expect(action).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText('Choose an explicit subtitle policy.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Reject subtitle input/iu })
|
||||
);
|
||||
await user.click(action);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConcat).toHaveBeenCalledWith({
|
||||
operation: 'concat',
|
||||
mode: 'normalized',
|
||||
clipIds: ['clip-a', 'clip-b'],
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'reject',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requires destructive subtitle loss to be explicit for normalized concat', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConcat = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{
|
||||
...TIMELINE,
|
||||
clips: TIMELINE.clips.map((clip, index) =>
|
||||
index === 0 ? { ...clip, hasSubtitles: true } : clip
|
||||
),
|
||||
}}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('concat-normalized')}
|
||||
onConcat={onConcat}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Normalized re-encode/iu })
|
||||
);
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText('Normalized concat preset'),
|
||||
'video-ready'
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Reject subtitle input/iu })
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', {
|
||||
name: 'Export normalized concat',
|
||||
});
|
||||
expect(action).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/sequence contains subtitles.*explicitly drop all/iu)
|
||||
).toBeVisible();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Drop all subtitles/iu })
|
||||
);
|
||||
await user.click(action);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConcat).toHaveBeenCalledWith({
|
||||
operation: 'concat',
|
||||
mode: 'normalized',
|
||||
clipIds: ['clip-a', 'clip-b'],
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'drop-all',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an absent fast compatibility result as unchecked', () => {
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{ ...TIMELINE, fastCompatibilityDiagnostics: undefined }}
|
||||
availability={capabilities('concat-fast')}
|
||||
onConcat={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Fast compatibility not checked' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Fast concat remains disabled until every input stream is checked.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a synchronous runner rejection and releases pending state', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<StructuralOperations
|
||||
selectedClip={SELECTED_CLIP}
|
||||
availability={capabilities('trim-fast')}
|
||||
onTrim={() => {
|
||||
throw new Error('Queue capacity was reached.');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Export fast trim' });
|
||||
await user.click(action);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'The operation could not be queued: Queue capacity was reached.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(action).toBeEnabled());
|
||||
});
|
||||
});
|
||||
216
tests/unit/components/waveform-editor.test.tsx
Normal file
216
tests/unit/components/waveform-editor.test.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WaveformEditor } from '../../../src/components/WaveformEditor';
|
||||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||||
|
||||
describe('WaveformEditor', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
createCanvasContext()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a bounded canvas with accessible trim and marker controls', async () => {
|
||||
render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={1}
|
||||
outSeconds={9}
|
||||
markers={[3]}
|
||||
currentTimeSeconds={5}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||
throw new Error('Missing waveform canvas');
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(canvas).toHaveAttribute(
|
||||
'aria-label',
|
||||
expect.stringContaining('Selected 00:00:01.000–00:00:09.000')
|
||||
)
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'In trim handle' })
|
||||
).toHaveAttribute('aria-valuenow', '1');
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Out trim handle' })
|
||||
).toHaveAttribute('aria-valuenow', '9');
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Split marker 1' })
|
||||
).toHaveAttribute('aria-valuenow', '3');
|
||||
expect(screen.getByText('Single sequential audio track')).toBeVisible();
|
||||
});
|
||||
|
||||
it('emits validated keyboard edits and current-time actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={1}
|
||||
outSeconds={9}
|
||||
markers={[3]}
|
||||
currentTimeSeconds={4}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1.1,
|
||||
outSeconds: 9,
|
||||
markers: [3],
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Add split at current time' })
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1,
|
||||
outSeconds: 9,
|
||||
markers: [3, 4],
|
||||
});
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'Split marker 1' }), {
|
||||
key: 'Delete',
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1,
|
||||
outSeconds: 9,
|
||||
markers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('drags split markers and seeks using the visible zoomed range', async () => {
|
||||
const onChange = vi.fn();
|
||||
const onSeek = vi.fn();
|
||||
const { container } = render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={0}
|
||||
outSeconds={10}
|
||||
markers={[2]}
|
||||
onChange={onChange}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
);
|
||||
const stage = container.querySelector('.waveform-editor__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing waveform stage');
|
||||
}
|
||||
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 168,
|
||||
width: 100,
|
||||
height: 168,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const marker = screen.getByRole('slider', { name: 'Split marker 1' });
|
||||
|
||||
fireEvent.pointerDown(marker, {
|
||||
pointerId: 11,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
});
|
||||
fireEvent.pointerMove(marker, {
|
||||
pointerId: 11,
|
||||
clientX: 50,
|
||||
clientY: 20,
|
||||
});
|
||||
fireEvent.pointerUp(marker, { pointerId: 11 });
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 0,
|
||||
outSeconds: 10,
|
||||
markers: [5],
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(stage, {
|
||||
button: 0,
|
||||
clientX: 75,
|
||||
clientY: 30,
|
||||
});
|
||||
expect(onSeek).toHaveBeenLastCalledWith(7.5);
|
||||
|
||||
fireEvent.change(screen.getByRole('slider', { name: 'Zoom waveform' }), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Scroll waveform' })
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it('normalizes untrusted selection values and caps marker count', () => {
|
||||
render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={Number.NaN}
|
||||
outSeconds={999}
|
||||
markers={[
|
||||
Number.NaN,
|
||||
-1,
|
||||
2,
|
||||
2.0004,
|
||||
...Array.from({ length: 510 }, (_, index) => 2.01 + index * 0.01),
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Selected 00:00:00.000–00:00:10.000/u)
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('254/254')).toBeVisible();
|
||||
expect(
|
||||
screen.getAllByRole('slider', { name: /Split marker/u })
|
||||
).toHaveLength(254);
|
||||
});
|
||||
});
|
||||
|
||||
function createPeaks() {
|
||||
return aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
Math.round(Math.sin(index / 5) * 20_000)
|
||||
)
|
||||
),
|
||||
encoding: 's16le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
function createCanvasContext(): CanvasRenderingContext2D {
|
||||
return {
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
49
tests/unit/derived-cache.test.ts
Normal file
49
tests/unit/derived-cache.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
cacheDerivative,
|
||||
readCachedDerivative,
|
||||
} from '../../src/app/derived-cache';
|
||||
|
||||
describe('application derivative cache', () => {
|
||||
it('reuses only an exact source and settings identity', async () => {
|
||||
const file = new File(['source'], 'preview-source.mp4', {
|
||||
type: 'video/mp4',
|
||||
lastModified: 123,
|
||||
});
|
||||
const settings = {
|
||||
maxDurationSeconds: 30,
|
||||
maxWidth: 960,
|
||||
includeAudio: true,
|
||||
};
|
||||
const derivative = new Blob(['proxy'], { type: 'video/mp4' });
|
||||
|
||||
await cacheDerivative(
|
||||
file,
|
||||
'preview-proxy-test',
|
||||
'preview-proxy',
|
||||
'project-preview-cache-test',
|
||||
derivative,
|
||||
settings
|
||||
);
|
||||
|
||||
expect(
|
||||
await readCachedDerivative(file, 'preview-proxy-test', settings)
|
||||
).toEqual(derivative);
|
||||
expect(
|
||||
await readCachedDerivative(file, 'preview-proxy-test', {
|
||||
...settings,
|
||||
maxWidth: 1280,
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await readCachedDerivative(
|
||||
new File(['other'], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
}),
|
||||
'preview-proxy-test',
|
||||
settings
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
45
tests/unit/engine-lifecycle.test.ts
Normal file
45
tests/unit/engine-lifecycle.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DeferredResourceLifecycle } from '../../src/ffmpeg/engine-lifecycle';
|
||||
|
||||
describe('singleton engine lifecycle', () => {
|
||||
it('cancels deferred disposal when StrictMode immediately reacquires', () => {
|
||||
const dispose = vi.fn();
|
||||
const deferred: Array<() => void> = [];
|
||||
const lifecycle = new DeferredResourceLifecycle({ dispose }, (callback) =>
|
||||
deferred.push(callback)
|
||||
);
|
||||
|
||||
const releaseFirstMount = lifecycle.acquire();
|
||||
releaseFirstMount();
|
||||
const releaseStrictModeRemount = lifecycle.acquire();
|
||||
deferred.shift()?.();
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(lifecycle.leaseCount).toBe(1);
|
||||
|
||||
releaseStrictModeRemount();
|
||||
deferred.shift()?.();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(lifecycle.leaseCount).toBe(0);
|
||||
});
|
||||
|
||||
it('disposes only after the last lease and ignores duplicate releases', () => {
|
||||
const dispose = vi.fn();
|
||||
const deferred: Array<() => void> = [];
|
||||
const lifecycle = new DeferredResourceLifecycle({ dispose }, (callback) =>
|
||||
deferred.push(callback)
|
||||
);
|
||||
const releaseOne = lifecycle.acquire();
|
||||
const releaseTwo = lifecycle.acquire();
|
||||
|
||||
releaseOne();
|
||||
releaseOne();
|
||||
expect(deferred).toHaveLength(0);
|
||||
expect(lifecycle.leaseCount).toBe(1);
|
||||
|
||||
releaseTwo();
|
||||
expect(deferred).toHaveLength(1);
|
||||
deferred[0]?.();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
176
tests/unit/export-output.test.ts
Normal file
176
tests/unit/export-output.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createExportReport,
|
||||
deriveOutputFileName,
|
||||
exportReportBlob,
|
||||
isBrowserPreviewMimeType,
|
||||
isSafeArchiveLeafName,
|
||||
mimeTypeFromOutputName,
|
||||
normalizeOutputMimeType,
|
||||
preferredExtensionForMimeType,
|
||||
redactPrivatePaths,
|
||||
safeExportFileName,
|
||||
serializeExportReport,
|
||||
uniqueExportFileNames,
|
||||
type ExportResult,
|
||||
} from '../../src/export';
|
||||
|
||||
describe('safe export output names', () => {
|
||||
it('removes directory traversal, device names, and unsafe punctuation', () => {
|
||||
expect(safeExportFileName('../../CON.mp4')).toBe('_CON.mp4');
|
||||
expect(safeExportFileName('..\\..\\holiday 🎬.MP4')).toBe('holiday.mp4');
|
||||
expect(
|
||||
safeExportFileName('/private/video.mov', { extension: '.webm' })
|
||||
).toBe('video.webm');
|
||||
expect(isSafeArchiveLeafName('../unsafe.mp4')).toBe(false);
|
||||
expect(isSafeArchiveLeafName('safe-output.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('derives numbered names and rejects invalid part positions', () => {
|
||||
expect(
|
||||
deriveOutputFileName({
|
||||
sourceName: 'My recording.mov',
|
||||
operation: 'split',
|
||||
extension: 'mp4',
|
||||
partIndex: 1,
|
||||
partCount: 12,
|
||||
})
|
||||
).toBe('My-recording-split-002.mp4');
|
||||
expect(() =>
|
||||
deriveOutputFileName({
|
||||
sourceName: 'source.mp4',
|
||||
operation: 'split',
|
||||
partIndex: 2,
|
||||
partCount: 2,
|
||||
})
|
||||
).toThrow(/part index/iu);
|
||||
});
|
||||
|
||||
it('deduplicates names for case-insensitive filesystems', () => {
|
||||
expect(
|
||||
uniqueExportFileNames([
|
||||
'Clip.mp4',
|
||||
'clip.mp4',
|
||||
'../../CLIP.mp4',
|
||||
'other.mp4',
|
||||
])
|
||||
).toEqual(['Clip.mp4', 'clip-2.mp4', 'CLIP-3.mp4', 'other.mp4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('output MIME helpers', () => {
|
||||
it('normalizes base types and falls back to known extensions', () => {
|
||||
expect(normalizeOutputMimeType('Video/MP4; codecs=avc1')).toBe('video/mp4');
|
||||
expect(normalizeOutputMimeType('not a mime', 'frame.png')).toBe(
|
||||
'image/png'
|
||||
);
|
||||
expect(normalizeOutputMimeType(undefined, 'unknown.xyz')).toBe(
|
||||
'application/octet-stream'
|
||||
);
|
||||
expect(mimeTypeFromOutputName('captions.vtt')).toBe('text/vtt');
|
||||
expect(preferredExtensionForMimeType('image/jpeg')).toBe('.jpg');
|
||||
expect(isBrowserPreviewMimeType('audio/mpeg')).toBe(true);
|
||||
expect(isBrowserPreviewMimeType('application/json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy-safe JSON export reports', () => {
|
||||
const result = {
|
||||
id: 'result',
|
||||
planId: 'plan',
|
||||
outputId: 'media',
|
||||
fileName: '../../converted.mp4',
|
||||
blob: new Blob(['private bytes']),
|
||||
mimeType: 'video/mp4',
|
||||
role: 'media',
|
||||
size: 13,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
} satisfies ExportResult;
|
||||
|
||||
it('includes plan diagnostics and output metadata without source data', async () => {
|
||||
const privateRuntimeResult = {
|
||||
...result,
|
||||
// Extra runtime fields must never be copied into the report.
|
||||
sourceBlob: new Blob(['source secret']),
|
||||
hostPath: '/home/zemion/private/source.mp4',
|
||||
};
|
||||
const report = createExportReport({
|
||||
plan: {
|
||||
id: 'plan-/home/zemion/private/source.mp4',
|
||||
operation: 'convert',
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'codec-warning',
|
||||
severity: 'warning',
|
||||
message:
|
||||
'Read /mnt/DATA/Videos/secret.mov via blob:https://local/id',
|
||||
field: 'C:\\Users\\Person\\Videos\\secret.mov',
|
||||
},
|
||||
],
|
||||
},
|
||||
results: [privateRuntimeResult],
|
||||
execution: {
|
||||
status: 'completed',
|
||||
engineMode: 'single-thread',
|
||||
ffmpegVersion: '6.0',
|
||||
elapsedSeconds: 1.25,
|
||||
conversionMilliseconds: 1_000,
|
||||
outputReadMilliseconds: 25,
|
||||
cleanupMilliseconds: 5,
|
||||
exitCode: 0,
|
||||
},
|
||||
generatedAt: '2026-07-24T12:00:00+02:00',
|
||||
});
|
||||
|
||||
expect(report.generatedAt).toBe('2026-07-24T10:00:00.000Z');
|
||||
expect(report.plan.id).toContain('[local path]');
|
||||
expect(report.outputs[0]).toEqual({
|
||||
id: 'result',
|
||||
outputId: 'media',
|
||||
fileName: 'converted.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
role: 'media',
|
||||
sizeBytes: 13,
|
||||
});
|
||||
expect(report.diagnostics[0]?.message).toBe(
|
||||
'Read [local path] via [object URL]'
|
||||
);
|
||||
expect(report.diagnostics[0]?.field).toBe('[local path]');
|
||||
expect(report.privacy).toEqual({
|
||||
includesSourceMedia: false,
|
||||
includesCommandArguments: false,
|
||||
privatePathsRedacted: true,
|
||||
});
|
||||
expect(report.execution).toMatchObject({
|
||||
conversionMilliseconds: 1_000,
|
||||
outputReadMilliseconds: 25,
|
||||
cleanupMilliseconds: 5,
|
||||
});
|
||||
|
||||
const serialized = serializeExportReport(report);
|
||||
expect(serialized.endsWith('\n')).toBe(true);
|
||||
expect(serialized).not.toContain('sourceBlob');
|
||||
expect(serialized).not.toContain('hostPath');
|
||||
expect(serialized).not.toContain('/home/zemion');
|
||||
const blob = exportReportBlob(report);
|
||||
expect(blob.type).toBe('application/json');
|
||||
expect(await blob.text()).toBe(serialized);
|
||||
});
|
||||
|
||||
it('redacts common local path and URL forms', () => {
|
||||
expect(
|
||||
redactPrivatePaths(
|
||||
'file:///home/me/a.mp4 C:\\fakepath\\b.mp4 /Users/me/c.mp4'
|
||||
)
|
||||
).toBe('[local path] [local path] [local path]');
|
||||
});
|
||||
|
||||
it('rejects invalid numeric report metadata', () => {
|
||||
expect(() =>
|
||||
createExportReport({
|
||||
plan: { id: 'plan', operation: 'convert', diagnostics: [] },
|
||||
results: [{ ...result, size: Number.NaN }],
|
||||
})
|
||||
).toThrow(/output size/iu);
|
||||
});
|
||||
});
|
||||
169
tests/unit/export-results.test.ts
Normal file
169
tests/unit/export-results.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
collectCommandOutputs,
|
||||
DEFAULT_RESULT_COLLECTION_LIMITS,
|
||||
DEFAULT_RESULT_ZIP_LIMITS,
|
||||
ExportResultCollection,
|
||||
ExportResultLimitError,
|
||||
MissingCommandOutputError,
|
||||
saveCollectionResult,
|
||||
type BlobSaver,
|
||||
type ExportResultInput,
|
||||
} from '../../src/export';
|
||||
import type { FFmpegCommandPlan } from '../../src/commands';
|
||||
|
||||
function input(
|
||||
id: string,
|
||||
fileName = 'result.mp4',
|
||||
contents = 'result'
|
||||
): ExportResultInput {
|
||||
return {
|
||||
id,
|
||||
planId: 'plan',
|
||||
outputId: id,
|
||||
fileName,
|
||||
blob: new Blob([contents]),
|
||||
mimeType: 'video/mp4',
|
||||
role: 'media',
|
||||
};
|
||||
}
|
||||
|
||||
describe('export result collection', () => {
|
||||
it('reserves one ZIP entry for the optional report', () => {
|
||||
expect(DEFAULT_RESULT_COLLECTION_LIMITS.maxResults).toBe(255);
|
||||
expect(DEFAULT_RESULT_ZIP_LIMITS.maxEntries).toBe(
|
||||
DEFAULT_RESULT_COLLECTION_LIMITS.maxResults + 1
|
||||
);
|
||||
});
|
||||
|
||||
it('owns result Blobs and revokes every replaced or released object URL', () => {
|
||||
let urlIndex = 0;
|
||||
const revokeObjectURL = vi.fn();
|
||||
const collection = new ExportResultCollection({
|
||||
objectUrlApi: {
|
||||
createObjectURL: () => `blob:result-${urlIndex++}`,
|
||||
revokeObjectURL,
|
||||
},
|
||||
now: () => new Date('2026-07-24T10:00:00.000Z'),
|
||||
});
|
||||
const first = collection.add(input('one'));
|
||||
expect(first).toMatchObject({
|
||||
fileName: 'result.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
size: 6,
|
||||
createdAt: '2026-07-24T10:00:00.000Z',
|
||||
});
|
||||
expect(collection.objectUrl('one')).toBe('blob:result-0');
|
||||
expect(collection.objectUrl('one')).toBe('blob:result-0');
|
||||
|
||||
collection.add(input('two', 'RESULT.mp4'));
|
||||
expect(collection.get('two')?.fileName).toBe('RESULT-2.mp4');
|
||||
collection.replace(input('one', 'replacement.mp4', 'new'));
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-0');
|
||||
expect(collection.totalBytes).toBe(9);
|
||||
|
||||
collection.objectUrl('two');
|
||||
expect(collection.remove('two')).toBe(true);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-1');
|
||||
collection.dispose();
|
||||
expect(collection.totalBytes).toBe(0);
|
||||
expect(() => collection.list()).toThrow(/disposed/iu);
|
||||
expect(() => collection.dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
it('adds a batch atomically and enforces count and byte limits', () => {
|
||||
const collection = new ExportResultCollection({
|
||||
limits: {
|
||||
maxResults: 2,
|
||||
maxSingleResultBytes: 5,
|
||||
maxTotalBytes: 8,
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
collection.addMany([
|
||||
input('one', 'one.bin', '1234'),
|
||||
input('bad', 'bad.bin', '123456'),
|
||||
])
|
||||
).toThrowError(ExportResultLimitError);
|
||||
expect(collection.size).toBe(0);
|
||||
|
||||
collection.add(input('one', 'one.bin', '1234'));
|
||||
expect(() => collection.add(input('two', 'two.bin', '12345'))).toThrowError(
|
||||
expect.objectContaining({ limit: 'total-result-bytes' })
|
||||
);
|
||||
expect(collection.size).toBe(1);
|
||||
});
|
||||
|
||||
it('collects all planned outputs atomically from IDs or virtual paths', () => {
|
||||
const collection = new ExportResultCollection();
|
||||
const plan: Pick<FFmpegCommandPlan, 'id' | 'outputs'> = {
|
||||
id: 'split-plan',
|
||||
outputs: [
|
||||
{
|
||||
id: 'one',
|
||||
path: '/work/job/one.mp4',
|
||||
fileName: 'clip-001.mp4',
|
||||
mediaType: 'video/mp4',
|
||||
role: 'media',
|
||||
timeRange: { startSeconds: 0, endSeconds: 5 },
|
||||
},
|
||||
{
|
||||
id: 'two',
|
||||
path: '/work/job/two.mp4',
|
||||
fileName: 'clip-002.mp4',
|
||||
mediaType: 'video/mp4',
|
||||
role: 'media',
|
||||
},
|
||||
],
|
||||
};
|
||||
const results = collectCommandOutputs(
|
||||
plan,
|
||||
new Map<string, Blob | Uint8Array>([
|
||||
['one', Uint8Array.of(1, 2)],
|
||||
['/work/job/two.mp4', new Blob(['two'])],
|
||||
]),
|
||||
collection
|
||||
);
|
||||
expect(results.map((result) => result.outputId)).toEqual(['one', 'two']);
|
||||
expect(results[0]?.blob.type).toBe('video/mp4');
|
||||
expect(results[0]?.timeRange).toEqual({
|
||||
startSeconds: 0,
|
||||
endSeconds: 5,
|
||||
});
|
||||
|
||||
const empty = new ExportResultCollection();
|
||||
expect(() =>
|
||||
collectCommandOutputs(plan, new Map([['one', new Blob(['one'])]]), empty)
|
||||
).toThrowError(MissingCommandOutputError);
|
||||
expect(empty.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser result saving', () => {
|
||||
it('sanitizes the download name and releases a successfully saved result', async () => {
|
||||
const collection = new ExportResultCollection();
|
||||
collection.add(input('one', 'result.mp4'));
|
||||
const saver = vi.fn<BlobSaver>(async () => 'downloaded');
|
||||
|
||||
await expect(
|
||||
saveCollectionResult(collection, 'one', {
|
||||
fileName: '../../My result.mp4',
|
||||
saver,
|
||||
})
|
||||
).resolves.toBe('downloaded');
|
||||
expect(saver).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'My-result.mp4',
|
||||
'video/mp4'
|
||||
);
|
||||
expect(collection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('retains the result when the save picker is cancelled', async () => {
|
||||
const collection = new ExportResultCollection();
|
||||
collection.add(input('one'));
|
||||
const saver: BlobSaver = async () => 'cancelled';
|
||||
await saveCollectionResult(collection, 'one', { saver });
|
||||
expect(collection.size).toBe(1);
|
||||
});
|
||||
});
|
||||
109
tests/unit/export-zip.test.ts
Normal file
109
tests/unit/export-zip.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { unzipSync } from 'fflate';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createResultZip,
|
||||
saveResultsZip,
|
||||
type BlobSaver,
|
||||
type ExportResult,
|
||||
} from '../../src/export';
|
||||
|
||||
function result(id: string, fileName: string, contents: string): ExportResult {
|
||||
const blob = new Blob([contents], { type: 'text/plain' });
|
||||
return {
|
||||
id,
|
||||
planId: 'plan',
|
||||
outputId: id,
|
||||
fileName,
|
||||
blob,
|
||||
mimeType: 'text/plain',
|
||||
role: 'analysis',
|
||||
size: blob.size,
|
||||
createdAt: '2026-07-24T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('deterministic result ZIP creation', () => {
|
||||
it('sorts and deduplicates safe paths with fixed archive metadata', async () => {
|
||||
const entries = [
|
||||
{ fileName: '../../Z.txt', blob: new Blob(['z']) },
|
||||
{ fileName: 'a.txt', blob: new Blob(['first']) },
|
||||
{ fileName: 'A.TXT', blob: new Blob(['second']) },
|
||||
];
|
||||
const first = await createResultZip(entries, {
|
||||
fileName: '../My outputs.any',
|
||||
});
|
||||
const second = await createResultZip(entries, {
|
||||
fileName: '../My outputs.any',
|
||||
});
|
||||
|
||||
expect(first.fileName).toBe('My-outputs.zip');
|
||||
expect(first.entryNames).toEqual(['A-2.txt', 'Z.txt', 'a.txt']);
|
||||
expect(first.totalInputBytes).toBe(12);
|
||||
expect(new Uint8Array(await first.blob.arrayBuffer())).toEqual(
|
||||
new Uint8Array(await second.blob.arrayBuffer())
|
||||
);
|
||||
|
||||
const extracted = unzipSync(new Uint8Array(await first.blob.arrayBuffer()));
|
||||
expect(Object.keys(extracted)).toEqual(['A-2.txt', 'Z.txt', 'a.txt']);
|
||||
expect(new TextDecoder().decode(extracted['a.txt'])).toBe('first');
|
||||
expect(new TextDecoder().decode(extracted['A-2.txt'])).toBe('second');
|
||||
});
|
||||
|
||||
it('rejects empty, oversized, and pre-cancelled batches', async () => {
|
||||
await expect(createResultZip([])).rejects.toThrow(/at least one/iu);
|
||||
await expect(
|
||||
createResultZip([{ fileName: 'large.bin', blob: new Blob(['12345']) }], {
|
||||
limits: {
|
||||
maxEntries: 2,
|
||||
maxEntryBytes: 4,
|
||||
maxTotalInputBytes: 4,
|
||||
maxArchiveBytes: 100,
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
expect.objectContaining({
|
||||
limit: 'entry-bytes',
|
||||
})
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
createResultZip([{ fileName: 'one.txt', blob: new Blob(['one']) }], {
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
it('packages an optional report and passes the ZIP to the save adapter', async () => {
|
||||
const saverCalls: { fileName: string; type: string }[] = [];
|
||||
const saver: BlobSaver = async (blob, fileName) => {
|
||||
saverCalls.push({ fileName, type: blob.type });
|
||||
return 'saved';
|
||||
};
|
||||
const saved = await saveResultsZip([result('one', 'one.txt', 'contents')], {
|
||||
fileName: 'batch.zip',
|
||||
saver,
|
||||
report: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: '2026-07-24T10:00:00.000Z',
|
||||
plan: { id: 'plan', operation: 'convert' },
|
||||
outputs: [],
|
||||
diagnostics: [],
|
||||
privacy: {
|
||||
includesSourceMedia: false,
|
||||
includesCommandArguments: false,
|
||||
privatePathsRedacted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saved.outcome).toBe('saved');
|
||||
expect(saved.archive.entryNames).toEqual([
|
||||
'convert-export-report.json',
|
||||
'one.txt',
|
||||
]);
|
||||
expect(saverCalls).toEqual([
|
||||
{ fileName: 'batch.zip', type: 'application/zip' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
78
tests/unit/ffmpeg-capabilities.test.ts
Normal file
78
tests/unit/ffmpeg-capabilities.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deserializeCapabilities,
|
||||
parseBuildConfiguration,
|
||||
parseCodecs,
|
||||
parseFilters,
|
||||
parseFormats,
|
||||
parseNamedComponents,
|
||||
serializeCapabilities,
|
||||
} from '../../src/ffmpeg/capability-parser';
|
||||
|
||||
describe('FFmpeg capability parsing', () => {
|
||||
it('parses demuxer and muxer flags including aliases', () => {
|
||||
const parsed = parseFormats(`
|
||||
File formats:
|
||||
D. = Demuxing supported
|
||||
.E = Muxing supported
|
||||
DE matroska,webm Matroska / WebM
|
||||
D mov,mp4,m4a QuickTime / MOV
|
||||
E mp3 MP3
|
||||
`);
|
||||
expect(parsed.demuxers).toEqual(
|
||||
new Set(['matroska', 'webm', 'mov', 'mp4', 'm4a'])
|
||||
);
|
||||
expect(parsed.muxers).toEqual(new Set(['matroska', 'webm', 'mp3']));
|
||||
});
|
||||
|
||||
it('parses codecs, encoders, filters and configuration flags', () => {
|
||||
const codecs = parseCodecs(`
|
||||
Codecs:
|
||||
D..... = Decoding supported
|
||||
.E.... = Encoding supported
|
||||
DEV.LS h264 H.264
|
||||
D.A.L. aac AAC
|
||||
.EV.L. vp9 VP9
|
||||
`);
|
||||
expect(codecs.decoders).toEqual(new Set(['h264', 'aac']));
|
||||
expect(codecs.encoders).toEqual(new Set(['h264', 'vp9']));
|
||||
|
||||
expect(
|
||||
parseNamedComponents(`
|
||||
Encoders:
|
||||
V..... libx264 H.264
|
||||
A..... aac AAC
|
||||
`)
|
||||
).toEqual(new Set(['libx264', 'aac']));
|
||||
|
||||
expect(
|
||||
parseFilters(`
|
||||
Filters:
|
||||
..C scale V->V Scale the input video size.
|
||||
T.. loudnorm A->A EBU R128 loudness normalization
|
||||
... concat N->N Concatenate audio and video streams.
|
||||
`)
|
||||
).toEqual(new Set(['scale', 'loudnorm', 'concat']));
|
||||
|
||||
expect(
|
||||
parseBuildConfiguration(
|
||||
'ffmpeg version x\nconfiguration: --enable-gpl --disable-doc'
|
||||
)
|
||||
).toEqual(['--enable-gpl', '--disable-doc']);
|
||||
});
|
||||
|
||||
it('serializes Set values for durable caches without losing data', () => {
|
||||
const original = {
|
||||
versionText: 'ffmpeg version n6.1',
|
||||
buildConfiguration: ['--enable-gpl'],
|
||||
demuxers: new Set(['mov']),
|
||||
muxers: new Set(['mp4']),
|
||||
decoders: new Set(['h264']),
|
||||
encoders: new Set(['libx264']),
|
||||
filters: new Set(['scale']),
|
||||
};
|
||||
expect(deserializeCapabilities(serializeCapabilities(original))).toEqual(
|
||||
original
|
||||
);
|
||||
});
|
||||
});
|
||||
124
tests/unit/ffmpeg-engine-arguments.test.ts
Normal file
124
tests/unit/ffmpeg-engine-arguments.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE,
|
||||
MAX_EXECUTIONS_PER_ENGINE,
|
||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||
engineJobTimeoutMilliseconds,
|
||||
prepareEngineArguments,
|
||||
shouldRecycleExecutionCore,
|
||||
} from '../../src/ffmpeg/EngineManager';
|
||||
|
||||
describe('prepareEngineArguments', () => {
|
||||
it('leaves single-thread commands unchanged', () => {
|
||||
const args = ['-i', '/input/source.mp4', '/work/output.mp4'];
|
||||
expect(
|
||||
prepareEngineArguments(
|
||||
args,
|
||||
'single-thread',
|
||||
['/input/source.mp4'],
|
||||
['/work/output.mp4']
|
||||
)
|
||||
).toEqual(args);
|
||||
});
|
||||
|
||||
it('bounds multithread input and output codec threads', () => {
|
||||
expect(
|
||||
prepareEngineArguments(
|
||||
['-i', '/input/source.mp4', '-c:v', 'libx264', '/work/output.mp4'],
|
||||
'multithread',
|
||||
['/input/source.mp4'],
|
||||
['/work/output.mp4']
|
||||
)
|
||||
).toEqual([
|
||||
'-threads',
|
||||
String(MULTITHREAD_CODEC_THREAD_LIMIT),
|
||||
'-i',
|
||||
'/input/source.mp4',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-threads',
|
||||
String(MULTITHREAD_CODEC_THREAD_LIMIT),
|
||||
'/work/output.mp4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds a generated output pattern in the final output position', () => {
|
||||
expect(
|
||||
prepareEngineArguments(
|
||||
['-i', '/input/source.mp4', '-f', 'segment', '/work/part-%03d.mp4'],
|
||||
'multithread',
|
||||
['/input/source.mp4'],
|
||||
['/work/part-001.mp4', '/work/part-002.mp4']
|
||||
)
|
||||
).toEqual([
|
||||
'-threads',
|
||||
'2',
|
||||
'-i',
|
||||
'/input/source.mp4',
|
||||
'-f',
|
||||
'segment',
|
||||
'-threads',
|
||||
'2',
|
||||
'/work/part-%03d.mp4',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engineJobTimeoutMilliseconds', () => {
|
||||
it('uses a bounded duration-aware watchdog', () => {
|
||||
expect(engineJobTimeoutMilliseconds({ expectedDurationSeconds: 2 })).toBe(
|
||||
120_000
|
||||
);
|
||||
expect(
|
||||
engineJobTimeoutMilliseconds({ expectedDurationSeconds: 10_000 })
|
||||
).toBe(DEFAULT_JOB_TIMEOUT_MILLISECONDS);
|
||||
});
|
||||
|
||||
it('honors a shorter explicit timeout and caps a longer one', () => {
|
||||
expect(
|
||||
engineJobTimeoutMilliseconds({
|
||||
expectedDurationSeconds: 30,
|
||||
timeoutMilliseconds: 5_000,
|
||||
})
|
||||
).toBe(5_000);
|
||||
expect(
|
||||
engineJobTimeoutMilliseconds({
|
||||
timeoutMilliseconds: DEFAULT_JOB_TIMEOUT_MILLISECONDS * 2,
|
||||
})
|
||||
).toBe(DEFAULT_JOB_TIMEOUT_MILLISECONDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution core recycling policy', () => {
|
||||
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
||||
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
||||
false
|
||||
);
|
||||
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE)).toBe(true);
|
||||
});
|
||||
|
||||
it('refreshes before a fourth contact sheet hits retained tile-filter state', () => {
|
||||
expect(
|
||||
shouldRecycleExecutionCore(
|
||||
3,
|
||||
'contact-sheet',
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE - 1
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRecycleExecutionCore(
|
||||
3,
|
||||
'contact-sheet',
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRecycleExecutionCore(
|
||||
3,
|
||||
'thumbnail-series',
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
38
tests/unit/ffmpeg-mode.test.ts
Normal file
38
tests/unit/ffmpeg-mode.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describeEngineMode,
|
||||
selectEngineMode,
|
||||
supportsMultithread,
|
||||
} from '../../src/ffmpeg/engine-mode';
|
||||
import type { EngineEnvironment } from '../../src/ffmpeg/ffmpeg.types';
|
||||
|
||||
const isolated: EngineEnvironment = {
|
||||
secureContext: true,
|
||||
crossOriginIsolated: true,
|
||||
sharedArrayBuffer: true,
|
||||
hardwareConcurrency: 8,
|
||||
};
|
||||
|
||||
describe('engine mode selection', () => {
|
||||
it('selects multithread only when every required capability is present', () => {
|
||||
expect(supportsMultithread(isolated)).toBe(true);
|
||||
expect(selectEngineMode(isolated, 'automatic')).toBe('multithread');
|
||||
|
||||
for (const missing of [
|
||||
'secureContext',
|
||||
'crossOriginIsolated',
|
||||
'sharedArrayBuffer',
|
||||
] as const) {
|
||||
expect(
|
||||
selectEngineMode({ ...isolated, [missing]: false }, 'automatic')
|
||||
).toBe('single-thread');
|
||||
}
|
||||
});
|
||||
|
||||
it('honours the safe single-thread preference', () => {
|
||||
expect(selectEngineMode(isolated, 'force-single-thread')).toBe(
|
||||
'single-thread'
|
||||
);
|
||||
expect(describeEngineMode('single-thread')).toMatch(/compatibility/);
|
||||
});
|
||||
});
|
||||
47
tests/unit/ffmpeg-utils.test.ts
Normal file
47
tests/unit/ffmpeg-utils.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCoreAssetUrls,
|
||||
resolveFromAppBase,
|
||||
} from '../../src/ffmpeg/asset-urls';
|
||||
import { LogBuffer } from '../../src/ffmpeg/log-buffer';
|
||||
import { normalizeProgress } from '../../src/ffmpeg/progress-parser';
|
||||
|
||||
describe('FFmpeg runtime utilities', () => {
|
||||
it('resolves assets under a nested application base', () => {
|
||||
expect(
|
||||
resolveFromAppBase(
|
||||
'vendor/core.wasm',
|
||||
'https://tools.example/apps/av/index.html',
|
||||
'./'
|
||||
)
|
||||
).toBe('https://tools.example/apps/av/vendor/core.wasm');
|
||||
|
||||
const mt = getCoreAssetUrls(
|
||||
'multithread',
|
||||
(path) => `https://tools.example/deep/${path}`
|
||||
);
|
||||
expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js');
|
||||
expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js');
|
||||
});
|
||||
|
||||
it('clamps experimental progress and removes invalid numbers', () => {
|
||||
expect(normalizeProgress({ progress: 3, time: -10 })).toEqual({
|
||||
progress: 1,
|
||||
time: 0,
|
||||
});
|
||||
expect(
|
||||
normalizeProgress({ progress: Number.NaN, time: Number.NaN })
|
||||
).toEqual({ progress: 0, time: 0 });
|
||||
});
|
||||
|
||||
it('bounds diagnostic log memory', () => {
|
||||
const logs = new LogBuffer(2, 100);
|
||||
logs.append({ type: 'stdout', message: 'one' });
|
||||
logs.append({ type: 'stderr', message: 'two\u0000' });
|
||||
logs.append({ type: 'stderr', message: 'three' });
|
||||
expect(logs.snapshot()).toEqual([
|
||||
{ type: 'stderr', message: 'two' },
|
||||
{ type: 'stderr', message: 'three' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
109
tests/unit/ffmpeg-virtual-fs.test.ts
Normal file
109
tests/unit/ffmpeg-virtual-fs.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FFmpegAdapter } from '../../src/ffmpeg/ffmpeg.types';
|
||||
import {
|
||||
describeMountedInputAccess,
|
||||
mountJobFiles,
|
||||
} from '../../src/ffmpeg/virtual-fs';
|
||||
|
||||
function createFilesystemAdapter(
|
||||
options: { keepWorkDirectory?: boolean; rejectWorkerFs?: boolean } = {}
|
||||
) {
|
||||
const directories = new Set(['/input', '/work']);
|
||||
const files = new Set<string>();
|
||||
const adapter = {
|
||||
createDir: vi.fn(async (path: string) => {
|
||||
directories.add(path);
|
||||
}),
|
||||
mount: vi.fn(async () => {
|
||||
if (options.rejectWorkerFs) {
|
||||
throw new Error('WORKERFS unavailable');
|
||||
}
|
||||
}),
|
||||
unmount: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async (path: string) => {
|
||||
files.add(path);
|
||||
}),
|
||||
deleteFile: vi.fn(async (path: string) => {
|
||||
files.delete(path);
|
||||
}),
|
||||
deleteDir: vi.fn(async (path: string) => {
|
||||
if (options.keepWorkDirectory && path.startsWith('/work/job-')) return;
|
||||
directories.delete(path);
|
||||
}),
|
||||
listDir: vi.fn(async (path: string) =>
|
||||
[...directories]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry !== path && entry.slice(0, entry.lastIndexOf('/')) === path
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.split('/').at(-1) ?? '',
|
||||
isDir: true,
|
||||
isLink: false,
|
||||
}))
|
||||
),
|
||||
};
|
||||
return adapter as unknown as FFmpegAdapter;
|
||||
}
|
||||
|
||||
describe('per-job FFmpeg filesystem', () => {
|
||||
it('uses generated paths and verifies complete cleanup', async () => {
|
||||
const adapter = createFilesystemAdapter();
|
||||
const mounted = await mountJobFiles(
|
||||
adapter,
|
||||
'../../job with separators',
|
||||
[new File(['source'], '../../private name.MP4')],
|
||||
['../unsafe result.mp4']
|
||||
);
|
||||
|
||||
expect(mounted.inputPaths).toEqual([
|
||||
'/input/job-job-with-separators/source-0.mp4',
|
||||
]);
|
||||
expect(mounted.outputPaths).toEqual([
|
||||
'/work/job-job-with-separators/output-0.mp4',
|
||||
]);
|
||||
expect(mounted).toMatchObject({
|
||||
inputMode: 'workerfs',
|
||||
inputBytes: 6,
|
||||
copiedInputBytes: 0,
|
||||
});
|
||||
expect(describeMountedInputAccess(mounted)).toContain(
|
||||
'0 bytes copied into engine memory'
|
||||
);
|
||||
expect(mounted.inputPaths[0]).not.toContain('private');
|
||||
expect(await mounted.cleanup()).toBe(true);
|
||||
});
|
||||
|
||||
it('accounts for and explains the actual memory-copy fallback', async () => {
|
||||
const adapter = createFilesystemAdapter({ rejectWorkerFs: true });
|
||||
const mounted = await mountJobFiles(
|
||||
adapter,
|
||||
'copy-fallback',
|
||||
[new File(['1234'], 'one.mp4'), new File(['567'], 'two.mp4')],
|
||||
['result.mp4']
|
||||
);
|
||||
|
||||
expect(mounted).toMatchObject({
|
||||
inputMode: 'memory-copy',
|
||||
inputBytes: 7,
|
||||
copiedInputBytes: 7,
|
||||
});
|
||||
expect(adapter.writeFile).toHaveBeenCalledTimes(2);
|
||||
expect(describeMountedInputAccess(mounted)).toBe(
|
||||
'Input compatibility fallback: WORKERFS mounting was unavailable, so 7 bytes were copied into engine memory.'
|
||||
);
|
||||
expect(await mounted.cleanup()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a directory that survives cleanup', async () => {
|
||||
const adapter = createFilesystemAdapter({ keepWorkDirectory: true });
|
||||
const mounted = await mountJobFiles(
|
||||
adapter,
|
||||
'job',
|
||||
[new File(['source'], 'source.wav')],
|
||||
['result.wav']
|
||||
);
|
||||
|
||||
expect(await mounted.cleanup()).toBe(false);
|
||||
});
|
||||
});
|
||||
269
tests/unit/jobs.test.ts
Normal file
269
tests/unit/jobs.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MediaJobQueue,
|
||||
MemoryJobMetadataStore,
|
||||
ObjectUrlRegistry,
|
||||
parseFFmpegProgress,
|
||||
progressUpdateFromFFmpeg,
|
||||
weightedJobProgress,
|
||||
type JobExecutionContext,
|
||||
type JobExecutionResult,
|
||||
type MediaJob,
|
||||
type MediaJobDefinition,
|
||||
type MediaJobExecutor,
|
||||
} from '../../src/jobs';
|
||||
import { freezeCommandPlan } from '../../src/commands';
|
||||
|
||||
function definition(id: string, stepIds = ['encode']): MediaJobDefinition {
|
||||
return {
|
||||
id,
|
||||
operation: 'test',
|
||||
steps: stepIds.map((stepId) => ({
|
||||
id: stepId,
|
||||
name: stepId,
|
||||
weight: stepId === 'read' ? 1 : 9,
|
||||
plan: freezeCommandPlan({
|
||||
id: `${id}:${stepId}`,
|
||||
operation: stepId,
|
||||
inputs: [],
|
||||
temporaryFiles: [],
|
||||
args: ['-version'],
|
||||
outputs: [],
|
||||
requiredCapabilities: {},
|
||||
diagnostics: [],
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
class ControlledExecutor implements MediaJobExecutor {
|
||||
readonly calls: string[] = [];
|
||||
readonly cancelCurrentJob = vi.fn(async () => undefined);
|
||||
readonly recoverAfterCancellation = vi.fn(async () => undefined);
|
||||
readonly cleanup = vi.fn(async () => undefined);
|
||||
#gates: (() => void)[] = [];
|
||||
failNext?: Error;
|
||||
|
||||
execute(
|
||||
plan: { readonly id: string },
|
||||
context: JobExecutionContext
|
||||
): Promise<JobExecutionResult> {
|
||||
this.calls.push(plan.id);
|
||||
context.onProgress({ progress: 0.5 });
|
||||
if (this.failNext) {
|
||||
const error = this.failNext;
|
||||
this.failNext = undefined;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const release = () => {
|
||||
context.signal.removeEventListener('abort', abort);
|
||||
resolve({ outputs: new Map() });
|
||||
};
|
||||
const abort = () => {
|
||||
const index = this.#gates.indexOf(release);
|
||||
if (index >= 0) {
|
||||
this.#gates.splice(index, 1);
|
||||
}
|
||||
reject(new DOMException('cancelled', 'AbortError'));
|
||||
};
|
||||
context.signal.addEventListener('abort', abort, { once: true });
|
||||
this.#gates.push(release);
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.#gates.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
describe('progress parsing', () => {
|
||||
it('parses FFmpeg machine progress and caps completion until output read', () => {
|
||||
const parsed = parseFFmpegProgress(
|
||||
'frame=25\nfps=12.5\nout_time_us=5000000\nspeed=1.5x\nprogress=continue\n'
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
frame: 25,
|
||||
fps: 12.5,
|
||||
outTimeSeconds: 5,
|
||||
speed: 1.5,
|
||||
terminal: false,
|
||||
});
|
||||
expect(progressUpdateFromFFmpeg(parsed, 10)).toMatchObject({
|
||||
progress: 0.5,
|
||||
});
|
||||
expect(
|
||||
progressUpdateFromFFmpeg(
|
||||
parseFFmpegProgress('progress=end\nout_time_us=10000000'),
|
||||
10
|
||||
).progress
|
||||
).toBe(0.99);
|
||||
});
|
||||
|
||||
it('combines weighted multi-step progress below 100 percent', () => {
|
||||
expect(
|
||||
weightedJobProgress([
|
||||
{
|
||||
id: 'analysis',
|
||||
name: 'Analysis',
|
||||
weight: 1,
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
planId: 'one',
|
||||
},
|
||||
{
|
||||
id: 'encode',
|
||||
name: 'Encoding',
|
||||
weight: 9,
|
||||
status: 'running',
|
||||
progress: 0.5,
|
||||
planId: 'two',
|
||||
},
|
||||
])
|
||||
).toBe(0.55);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequential durable job queue', () => {
|
||||
it('runs only one command at a time and reaches 100% after cleanup', async () => {
|
||||
const executor = new ControlledExecutor();
|
||||
const store = new MemoryJobMetadataStore();
|
||||
const queue = new MediaJobQueue({ executor, store });
|
||||
await queue.initialize();
|
||||
await queue.enqueue(definition('one'));
|
||||
await queue.enqueue(definition('two'));
|
||||
await waitUntil(() => executor.calls.length === 1);
|
||||
expect(executor.calls).toEqual(['one:encode']);
|
||||
expect(queue.get('one')?.progress).toBe(0.5);
|
||||
executor.release();
|
||||
await waitUntil(() => executor.calls.length === 2);
|
||||
expect(queue.get('one')).toMatchObject({
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
});
|
||||
executor.release();
|
||||
await queue.waitForIdle();
|
||||
expect(queue.get('two')).toMatchObject({
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
});
|
||||
expect(queue.getResults('two')).toHaveLength(1);
|
||||
expect(queue.releaseResults('two')).toBe(true);
|
||||
expect(queue.getResults('two')).toBeUndefined();
|
||||
expect((await store.list()).map((job) => job.status)).toEqual([
|
||||
'completed',
|
||||
'completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('terminates processing, recovers the engine, and runs the next job', async () => {
|
||||
const executor = new ControlledExecutor();
|
||||
const queue = new MediaJobQueue({
|
||||
executor,
|
||||
store: new MemoryJobMetadataStore(),
|
||||
});
|
||||
await queue.initialize();
|
||||
await queue.enqueue(definition('cancel-me'));
|
||||
await queue.enqueue(definition('after'));
|
||||
await waitUntil(() => queue.get('cancel-me')?.status === 'running');
|
||||
await queue.cancel('cancel-me');
|
||||
expect(executor.cancelCurrentJob).toHaveBeenCalledOnce();
|
||||
expect(executor.recoverAfterCancellation).toHaveBeenCalledOnce();
|
||||
expect(queue.get('cancel-me')?.status).toBe('cancelled');
|
||||
await waitUntil(() => executor.calls.includes('after:encode'));
|
||||
executor.release();
|
||||
await queue.waitForIdle();
|
||||
expect(queue.get('after')?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('marks failures, cleans up, and retries with the retained definition', async () => {
|
||||
const executor = new ControlledExecutor();
|
||||
executor.failNext = new Error('encoder failed');
|
||||
const queue = new MediaJobQueue({
|
||||
executor,
|
||||
store: new MemoryJobMetadataStore(),
|
||||
});
|
||||
await queue.initialize();
|
||||
await queue.enqueue(definition('retry'));
|
||||
await queue.waitForIdle();
|
||||
expect(queue.get('retry')).toMatchObject({
|
||||
status: 'failed',
|
||||
error: { code: 'execution-failed', recoverable: true },
|
||||
});
|
||||
await queue.retry('retry');
|
||||
await waitUntil(() => executor.calls.length === 2);
|
||||
executor.release();
|
||||
await queue.waitForIdle();
|
||||
expect(queue.get('retry')).toMatchObject({
|
||||
status: 'completed',
|
||||
retryCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers interrupted durable metadata as an explicit failed job', async () => {
|
||||
const store = new MemoryJobMetadataStore();
|
||||
const interrupted: MediaJob = {
|
||||
id: 'old',
|
||||
operation: 'convert',
|
||||
status: 'running',
|
||||
steps: [
|
||||
{
|
||||
id: 'encode',
|
||||
name: 'Encode',
|
||||
weight: 1,
|
||||
status: 'running',
|
||||
progress: 0.4,
|
||||
planId: 'old:encode',
|
||||
},
|
||||
],
|
||||
progress: 0.4,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:01:00.000Z',
|
||||
startedAt: '2026-01-01T00:00:01.000Z',
|
||||
retryCount: 0,
|
||||
};
|
||||
await store.put(interrupted);
|
||||
const queue = new MediaJobQueue({
|
||||
executor: new ControlledExecutor(),
|
||||
store,
|
||||
});
|
||||
await queue.initialize();
|
||||
expect(queue.get('old')).toMatchObject({
|
||||
status: 'failed',
|
||||
recoveryNote: expect.stringContaining('previous browser session'),
|
||||
error: { code: 'session-interrupted', recoverable: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('object URL cleanup', () => {
|
||||
it('revokes replaced and cleared result URLs', () => {
|
||||
let index = 0;
|
||||
const revoke = vi.fn();
|
||||
const registry = new ObjectUrlRegistry({
|
||||
createObjectURL: () => `blob:${index++}`,
|
||||
revokeObjectURL: revoke,
|
||||
});
|
||||
expect(registry.replace('output', new Blob(['one']))).toBe('blob:0');
|
||||
expect(registry.replace('output', new Blob(['two']))).toBe('blob:1');
|
||||
registry.replace('preview', new Blob(['three']));
|
||||
expect(revoke).toHaveBeenCalledWith('blob:0');
|
||||
registry.clear();
|
||||
expect(revoke).toHaveBeenCalledWith('blob:1');
|
||||
expect(revoke).toHaveBeenCalledWith('blob:2');
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 2_000
|
||||
): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
throw new Error('Timed out waiting for queue state');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
86
tests/unit/media/duration-timecode.test.ts
Normal file
86
tests/unit/media/duration-timecode.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
addDurations,
|
||||
clampTime,
|
||||
roundSeconds,
|
||||
validateSourceRange,
|
||||
} from '../../../src/media/duration';
|
||||
import {
|
||||
formatFrameTimecode,
|
||||
formatTimecode,
|
||||
parseTimecode,
|
||||
parseTimecodeDetailed,
|
||||
} from '../../../src/media/timecode';
|
||||
|
||||
describe('duration arithmetic', () => {
|
||||
it('adds durations without exposing common binary drift', () => {
|
||||
expect(addDurations([0.1, 0.2, 1.0000004])).toBe(1.3);
|
||||
expect(roundSeconds(-0.0000001, 3)).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects invalid durations and clamps preview times', () => {
|
||||
expect(() => addDurations([1, Number.NaN])).toThrow();
|
||||
expect(() => addDurations([-1])).toThrow();
|
||||
expect(clampTime(-2, 10)).toBe(0);
|
||||
expect(clampTime(12, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('validates source in/out ranges and source bounds', () => {
|
||||
expect(validateSourceRange(1, 3, 5)).toEqual({
|
||||
valid: true,
|
||||
durationSeconds: 2,
|
||||
issues: [],
|
||||
});
|
||||
expect(validateSourceRange(3, 1, 5).issues[0]?.code).toBe(
|
||||
'source-out-before-in'
|
||||
);
|
||||
expect(validateSourceRange(1, 1, 5).issues[0]?.code).toBe('clip-too-short');
|
||||
expect(
|
||||
validateSourceRange(1, 6, 5).issues.some(
|
||||
(issue) => issue.code === 'source-out-after-duration'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timecode parsing and formatting', () => {
|
||||
it.each([
|
||||
['12.5', 12.5, undefined],
|
||||
['01:02.500', 62.5, undefined],
|
||||
['02:03:04.125', 7_384.125, undefined],
|
||||
['00:00:10:12', 10.5, { frameRate: 24 }],
|
||||
])('parses %s', (text, expected, options) => {
|
||||
expect(parseTimecode(text, options)).toBe(expected);
|
||||
});
|
||||
|
||||
it('reports malformed components', () => {
|
||||
expect(parseTimecodeDetailed('01:60')).toEqual({
|
||||
ok: false,
|
||||
reason: 'seconds-out-of-range',
|
||||
});
|
||||
expect(parseTimecodeDetailed('00:00:01:10')).toEqual({
|
||||
ok: false,
|
||||
reason: 'frame-rate-required',
|
||||
});
|
||||
expect(parseTimecodeDetailed('-00:01', { allowNegative: false })).toEqual({
|
||||
ok: false,
|
||||
reason: 'negative-not-allowed',
|
||||
});
|
||||
expect(parseTimecode('anything')).toBeUndefined();
|
||||
expect(parseTimecode('1.5:02:03')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats carry, precision, negative values and nominal frames', () => {
|
||||
expect(formatTimecode(59.9996)).toBe('00:01:00.000');
|
||||
expect(
|
||||
formatTimecode(62.5, {
|
||||
decimalPlaces: 2,
|
||||
alwaysShowHours: false,
|
||||
})
|
||||
).toBe('01:02.50');
|
||||
expect(formatTimecode(-1.25, { decimalPlaces: 2 })).toBe('-00:00:01.25');
|
||||
expect(formatFrameTimecode(10.5, 24)).toBe('00:00:10:12');
|
||||
expect(() => formatFrameTimecode(5, 0.5)).toThrow();
|
||||
});
|
||||
});
|
||||
249
tests/unit/media/probe-parser.test.ts
Normal file
249
tests/unit/media/probe-parser.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProbeParseError,
|
||||
normalizeFfprobeReport,
|
||||
parseFfprobeJson,
|
||||
parseFraction,
|
||||
parseFractionDetailed,
|
||||
parseFrameRate,
|
||||
} from '../../../src/media/probe-parser';
|
||||
|
||||
describe('fraction parsing', () => {
|
||||
it.each([
|
||||
['30000/1001', 30000 / 1001],
|
||||
['25/1', 25],
|
||||
['-1/2', -0.5],
|
||||
[' 1 / 4 ', 0.25],
|
||||
['23.976', 23.976],
|
||||
[5, 5],
|
||||
])('parses %j safely', (input, expected) => {
|
||||
expect(parseFraction(input)).toBeCloseTo(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0/0', 'division-by-zero'],
|
||||
['1/0', 'division-by-zero'],
|
||||
['NaN', 'invalid-syntax'],
|
||||
['Infinity', 'invalid-syntax'],
|
||||
['1e3', 'invalid-syntax'],
|
||||
['1/2/3', 'invalid-syntax'],
|
||||
['999999999999999999/1', 'component-too-large'],
|
||||
])('rejects %j with a useful reason', (input, reason) => {
|
||||
expect(parseFractionDetailed(input)).toEqual({ ok: false, reason });
|
||||
});
|
||||
|
||||
it('rejects implausible, zero and negative frame rates', () => {
|
||||
expect(parseFrameRate('0/0')).toBeUndefined();
|
||||
expect(parseFrameRate('0/1')).toBeUndefined();
|
||||
expect(parseFrameRate('-25/1')).toBeUndefined();
|
||||
expect(parseFrameRate('1001/1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ffprobe normalization', () => {
|
||||
it('normalizes format, streams, dispositions, tags and chapters', () => {
|
||||
const report = {
|
||||
format: {
|
||||
duration: '12.500000',
|
||||
start_time: '-0.021',
|
||||
bit_rate: '192000',
|
||||
format_name: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
format_long_name: 'QuickTime / MOV',
|
||||
tags: { title: 'Demo' },
|
||||
},
|
||||
streams: [
|
||||
{
|
||||
index: 2,
|
||||
codec_type: 'audio',
|
||||
codec_name: 'aac',
|
||||
sample_rate: '48000',
|
||||
channels: 2,
|
||||
channel_layout: 'stereo',
|
||||
disposition: { default: 1, forced: 0 },
|
||||
tags: { language: 'deu', title: 'German' },
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
codec_type: 'video',
|
||||
codec_name: 'h264',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
coded_width: 1920,
|
||||
coded_height: 1088,
|
||||
pix_fmt: 'yuv420p',
|
||||
sample_aspect_ratio: '8:6',
|
||||
display_aspect_ratio: '16/9',
|
||||
time_base: '1/30000',
|
||||
avg_frame_rate: '30000/1001',
|
||||
r_frame_rate: '30/1',
|
||||
side_data_list: [
|
||||
{
|
||||
side_data_type: 'Display Matrix',
|
||||
rotation: -90,
|
||||
},
|
||||
],
|
||||
disposition: { default: true },
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 4,
|
||||
time_base: '1/1000',
|
||||
start: 1000,
|
||||
end: 2500,
|
||||
tags: { title: 'Opening' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const probe = normalizeFfprobeReport(report);
|
||||
|
||||
expect(probe).toMatchObject({
|
||||
durationSeconds: 12.5,
|
||||
startTimeSeconds: -0.021,
|
||||
bitRate: 192000,
|
||||
formatNames: ['mov', 'mp4', 'm4a', '3gp', '3g2', 'mj2'],
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
tags: { title: 'Demo' },
|
||||
warnings: [],
|
||||
});
|
||||
expect(probe.streams.map((stream) => stream.index)).toEqual([0, 2]);
|
||||
expect(probe.streams[0]).toMatchObject({
|
||||
type: 'video',
|
||||
codecName: 'h264',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
sampleAspectRatio: '4:3',
|
||||
displayAspectRatio: '16:9',
|
||||
rotationDegrees: -90,
|
||||
frameRate: 30000 / 1001,
|
||||
timeBase: '1/30000',
|
||||
disposition: { default: true },
|
||||
});
|
||||
expect(probe.streams[1]).toMatchObject({
|
||||
type: 'audio',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
language: 'deu',
|
||||
title: 'German',
|
||||
disposition: { default: true, forced: false },
|
||||
});
|
||||
expect(probe.chapters).toEqual([
|
||||
{
|
||||
id: 4,
|
||||
startSeconds: 1,
|
||||
endSeconds: 2.5,
|
||||
timeBase: '1/1000',
|
||||
title: 'Opening',
|
||||
tags: { title: 'Opening' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to r_frame_rate and records malformed data as warnings', () => {
|
||||
const probe = normalizeFfprobeReport({
|
||||
format: { duration: 'not-a-number', tags: { title: 42 } },
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
codec_type: 'video',
|
||||
avg_frame_rate: '0/0',
|
||||
r_frame_rate: '25/1',
|
||||
disposition: { default: 'sometimes' },
|
||||
tags: [],
|
||||
},
|
||||
{ index: 1, codec_type: 'telepathy', tags: {}, disposition: {} },
|
||||
{ index: 1, codec_type: 'audio', tags: {}, disposition: {} },
|
||||
{ codec_type: 'audio' },
|
||||
'not a stream',
|
||||
],
|
||||
chapters: [{ id: 0, start_time: '2', end_time: '1', tags: {} }],
|
||||
});
|
||||
|
||||
expect(probe.durationSeconds).toBeUndefined();
|
||||
expect(probe.streams).toHaveLength(2);
|
||||
expect(probe.streams[0]?.frameRate).toBe(25);
|
||||
expect(probe.streams[1]?.type).toBe('unknown');
|
||||
expect(probe.chapters).toEqual([]);
|
||||
expect(probe.warnings.map((warning) => warning.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'invalid-field',
|
||||
'invalid-fraction',
|
||||
'unknown-stream-type',
|
||||
'duplicate-stream-index',
|
||||
'missing-stream-index',
|
||||
'invalid-stream',
|
||||
'invalid-chapter',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('parses legacy rotation and rejects misleading aspect ratios', () => {
|
||||
const probe = normalizeFfprobeReport({
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
codec_type: 'video',
|
||||
sample_aspect_ratio: '0:0',
|
||||
display_aspect_ratio: 'not-available',
|
||||
side_data_list: [{ rotation: 'not-a-number' }],
|
||||
disposition: {},
|
||||
tags: { rotate: '270' },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
codec_type: 'video',
|
||||
sample_aspect_ratio: ' 32 / 18 ',
|
||||
display_aspect_ratio: '64:36',
|
||||
disposition: {},
|
||||
tags: { rotate: '540' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(probe.streams[0]).toMatchObject({
|
||||
rotationDegrees: -90,
|
||||
});
|
||||
expect(probe.streams[0]).not.toHaveProperty('sampleAspectRatio');
|
||||
expect(probe.streams[0]).not.toHaveProperty('displayAspectRatio');
|
||||
expect(probe.streams[1]).toMatchObject({
|
||||
sampleAspectRatio: '16:9',
|
||||
displayAspectRatio: '16:9',
|
||||
rotationDegrees: 180,
|
||||
});
|
||||
expect(probe.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid-fraction',
|
||||
path: 'streams[0].sample_aspect_ratio',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'invalid-fraction',
|
||||
path: 'streams[0].display_aspect_ratio',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'invalid-field',
|
||||
path: 'streams[0].side_data_list[0].rotation',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps raw diagnostics separate from the normalized model', () => {
|
||||
const rawReport = '{"format":{"format_name":"wav"},"streams":[]}';
|
||||
const result = parseFfprobeJson(rawReport);
|
||||
|
||||
expect(result.rawReport).toBe(rawReport);
|
||||
expect(result.probe.formatNames).toEqual(['wav']);
|
||||
expect(result.probe).not.toHaveProperty('rawReport');
|
||||
});
|
||||
|
||||
it('throws a typed error for malformed JSON or a non-object root', () => {
|
||||
expect(() => parseFfprobeJson('{')).toThrow(ProbeParseError);
|
||||
expect(() => normalizeFfprobeReport([])).toThrow(
|
||||
'The ffprobe report must be a JSON object.'
|
||||
);
|
||||
});
|
||||
});
|
||||
53
tests/unit/media/safe-file-name.test.ts
Normal file
53
tests/unit/media/safe-file-name.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createSafeVirtualFileName,
|
||||
safeFileName,
|
||||
safeOutputFileName,
|
||||
splitFileName,
|
||||
} from '../../../src/media/safe-file-name';
|
||||
|
||||
describe('safe filenames', () => {
|
||||
it('removes path traversal, control characters and shell-like punctuation', () => {
|
||||
expect(safeFileName('../../My $ video\u0000 (final).MP4')).toBe(
|
||||
'My-video-final.mp4'
|
||||
);
|
||||
expect(safeFileName('..\\..\\evil.webm')).toBe('evil.webm');
|
||||
expect(safeFileName('../')).toBe('file');
|
||||
});
|
||||
|
||||
it('handles Unicode, hidden names and reserved device names', () => {
|
||||
expect(safeFileName('Crème brûlée.mov')).toBe('Creme-brulee.mov');
|
||||
expect(safeFileName('.env')).toBe('env');
|
||||
expect(safeFileName('CON.mp4')).toBe('_CON.mp4');
|
||||
});
|
||||
|
||||
it('preserves a short extension while enforcing the requested maximum', () => {
|
||||
const result = safeFileName(`${'a'.repeat(200)}.mp4`, {
|
||||
maxLength: 32,
|
||||
});
|
||||
expect(result).toHaveLength(32);
|
||||
expect(result.endsWith('.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates generated virtual names without leaking the original stem', () => {
|
||||
expect(createSafeVirtualFileName('private-holiday.mov', 3)).toBe(
|
||||
'source-3.mov'
|
||||
);
|
||||
expect(() => createSafeVirtualFileName('x.mp4', -1)).toThrow();
|
||||
});
|
||||
|
||||
it('creates deterministic download names and splits compound extensions', () => {
|
||||
expect(safeOutputFileName('My export (1)', '.WEBM')).toBe(
|
||||
'My-export-1.webm'
|
||||
);
|
||||
expect(safeOutputFileName('Project', 'avproject.json')).toBe(
|
||||
'Project.avproject.json'
|
||||
);
|
||||
expect(safeOutputFileName('Project.v2', 'mp4')).toBe('Project.v2.mp4');
|
||||
expect(splitFileName('archive.tar.gz')).toEqual({
|
||||
stem: 'archive.tar',
|
||||
extension: '.gz',
|
||||
});
|
||||
});
|
||||
});
|
||||
174
tests/unit/media/stream-playback.test.ts
Normal file
174
tests/unit/media/stream-playback.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assessBrowserPlayback,
|
||||
assessGeneratedResultPreview,
|
||||
buildPlaybackMimeCandidates,
|
||||
} from '../../../src/media/browser-playback';
|
||||
import type {
|
||||
MediaProbe,
|
||||
MediaStreamProbe,
|
||||
} from '../../../src/media/media.types';
|
||||
import {
|
||||
selectDefaultStreams,
|
||||
streamSelectionMapArgs,
|
||||
validateStreamSelection,
|
||||
} from '../../../src/media/stream-selection';
|
||||
|
||||
function stream(
|
||||
index: number,
|
||||
type: MediaStreamProbe['type'],
|
||||
overrides: Partial<MediaStreamProbe> = {}
|
||||
): MediaStreamProbe {
|
||||
return {
|
||||
index,
|
||||
type,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function probe(streams: MediaStreamProbe[]): MediaProbe {
|
||||
return {
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
streams,
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('stream selection', () => {
|
||||
const media = probe([
|
||||
stream(0, 'video', { codecName: 'h264' }),
|
||||
stream(1, 'audio', {
|
||||
language: 'eng',
|
||||
disposition: { default: true },
|
||||
}),
|
||||
stream(2, 'audio', {
|
||||
language: 'de-DE',
|
||||
}),
|
||||
stream(3, 'subtitle', { language: 'deu' }),
|
||||
stream(4, 'video', { disposition: { attached_pic: true } }),
|
||||
]);
|
||||
|
||||
it('chooses default streams, avoids cover art and can include subtitles', () => {
|
||||
expect(
|
||||
selectDefaultStreams(media, {
|
||||
preferredAudioLanguages: ['de'],
|
||||
preferredSubtitleLanguages: ['deu'],
|
||||
includeSubtitles: true,
|
||||
})
|
||||
).toEqual({
|
||||
videoStreamIndex: 0,
|
||||
audioStreamIndexes: [2],
|
||||
subtitleStreamIndexes: [3],
|
||||
});
|
||||
});
|
||||
|
||||
it('validates stream existence, type and duplicate mappings', () => {
|
||||
const result = validateStreamSelection(media, {
|
||||
videoStreamIndex: 1,
|
||||
audioStreamIndexes: [2, 2, 99],
|
||||
subtitleStreamIndexes: [],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.map((issue) => issue.code)).toEqual([
|
||||
'wrong-stream-type',
|
||||
'duplicate-stream',
|
||||
'stream-not-found',
|
||||
]);
|
||||
});
|
||||
|
||||
it('generates structured map arguments in deterministic order', () => {
|
||||
expect(
|
||||
streamSelectionMapArgs({
|
||||
videoStreamIndex: 0,
|
||||
audioStreamIndexes: [2],
|
||||
subtitleStreamIndexes: [3],
|
||||
})
|
||||
).toEqual(['-map', '0:0', '-map', '0:2', '-map', '0:3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser playback assessment', () => {
|
||||
const mp4 = probe([
|
||||
stream(0, 'video', { codecName: 'h264' }),
|
||||
stream(1, 'audio', { codecName: 'aac' }),
|
||||
]);
|
||||
|
||||
it('builds codec-qualified and container-only MIME candidates', () => {
|
||||
expect(buildPlaybackMimeCandidates(mp4)).toEqual([
|
||||
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
|
||||
'video/mp4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps browser support separate from FFmpeg readability', () => {
|
||||
expect(
|
||||
assessBrowserPlayback(mp4, (mime) =>
|
||||
mime.includes('codecs') ? 'probably' : ''
|
||||
)
|
||||
).toMatchObject({
|
||||
support: 'probably',
|
||||
needsPreviewProxy: false,
|
||||
});
|
||||
expect(assessBrowserPlayback(mp4, () => '')).toMatchObject({
|
||||
support: 'unsupported',
|
||||
needsPreviewProxy: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat an audio/video MIME prefix as playable when the browser rejects it', () => {
|
||||
expect(
|
||||
assessGeneratedResultPreview(
|
||||
{
|
||||
fileName: 'generated.flac',
|
||||
mimeType: 'audio/flac',
|
||||
},
|
||||
() => ''
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'audio',
|
||||
previewable: false,
|
||||
support: 'unsupported',
|
||||
});
|
||||
expect(
|
||||
assessGeneratedResultPreview(
|
||||
{
|
||||
fileName: 'generated.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
probe: mp4,
|
||||
},
|
||||
(mime) => (mime.includes('codecs') ? 'probably' : '')
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'video',
|
||||
previewable: true,
|
||||
support: 'probably',
|
||||
});
|
||||
expect(
|
||||
assessGeneratedResultPreview(
|
||||
{
|
||||
fileName: 'frame.png',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
() => ''
|
||||
)
|
||||
).toMatchObject({ kind: 'image', previewable: true });
|
||||
expect(
|
||||
assessGeneratedResultPreview(
|
||||
{
|
||||
fileName: 'frame.tiff',
|
||||
mimeType: 'image/tiff',
|
||||
},
|
||||
() => ''
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'image',
|
||||
previewable: false,
|
||||
support: 'unsupported',
|
||||
});
|
||||
});
|
||||
});
|
||||
436
tests/unit/presets.test.ts
Normal file
436
tests/unit/presets.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AUDIO_PRESETS,
|
||||
BUILT_IN_PRESETS,
|
||||
MemoryUserPresetPersistence,
|
||||
UserPresetRegistry,
|
||||
VIDEO_PRESETS,
|
||||
migrateUserPreset,
|
||||
parseUserPresetJson,
|
||||
presetAvailability,
|
||||
presetEncodingArguments,
|
||||
serializeUserPreset,
|
||||
validateUserPreset,
|
||||
type UserExportPreset,
|
||||
} from '../../src/presets';
|
||||
|
||||
const USER_PRESET: UserExportPreset = {
|
||||
schemaVersion: 1,
|
||||
id: 'my-podcast',
|
||||
name: 'My podcast',
|
||||
description: 'Speech export',
|
||||
kind: 'audio',
|
||||
container: 'mp3',
|
||||
fileExtension: 'mp3',
|
||||
audio: {
|
||||
codec: 'libmp3lame',
|
||||
bitrateKbps: 128,
|
||||
sampleRate: 44_100,
|
||||
channels: 2,
|
||||
},
|
||||
metadataPolicy: 'copy',
|
||||
chapterPolicy: 'remove',
|
||||
subtitlePolicy: 'none',
|
||||
};
|
||||
|
||||
describe('built-in preset registry', () => {
|
||||
it('contains immutable, uniquely identified video and audio definitions', () => {
|
||||
expect(VIDEO_PRESETS.length).toBeGreaterThanOrEqual(6);
|
||||
expect(AUDIO_PRESETS.length).toBeGreaterThanOrEqual(6);
|
||||
expect(new Set(BUILT_IN_PRESETS.map((preset) => preset.id)).size).toBe(
|
||||
BUILT_IN_PRESETS.length
|
||||
);
|
||||
expect(Object.isFrozen(BUILT_IN_PRESETS[0])).toBe(true);
|
||||
expect(Object.isFrozen(BUILT_IN_PRESETS[0]?.settingsSummary)).toBe(true);
|
||||
});
|
||||
|
||||
it('disables a preset with exact missing capability reasons', () => {
|
||||
const preset = BUILT_IN_PRESETS.find(
|
||||
(candidate) => candidate.id === 'webm-vp9-opus'
|
||||
)!;
|
||||
const unavailable = presetAvailability(preset, {
|
||||
muxers: new Set(['webm']),
|
||||
encoders: new Set(['libvpx-vp9']),
|
||||
decoders: new Set(),
|
||||
filters: new Set(),
|
||||
});
|
||||
expect(unavailable).toEqual({
|
||||
status: 'unavailable',
|
||||
reasons: ['Missing encoders: libopus'],
|
||||
});
|
||||
expect(
|
||||
presetAvailability(preset, {
|
||||
muxers: new Set(['webm']),
|
||||
encoders: new Set(['libvpx-vp9', 'libopus']),
|
||||
decoders: new Set(),
|
||||
filters: new Set(),
|
||||
})
|
||||
).toEqual({ status: 'available', reasons: [] });
|
||||
expect(
|
||||
presetAvailability(
|
||||
preset,
|
||||
{
|
||||
muxers: new Set(['webm']),
|
||||
encoders: new Set(['libvpx-vp9', 'libopus']),
|
||||
decoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
{ filters: ['drawtext'] }
|
||||
)
|
||||
).toEqual({
|
||||
status: 'degraded',
|
||||
reasons: ['Optional filters unavailable: drawtext'],
|
||||
});
|
||||
});
|
||||
|
||||
it('turns allowlisted settings into structured encoder arguments', () => {
|
||||
expect(presetEncodingArguments(USER_PRESET)).toEqual([
|
||||
'-vn',
|
||||
'-c:a',
|
||||
'libmp3lame',
|
||||
'-b:a',
|
||||
'128k',
|
||||
'-ar',
|
||||
'44100',
|
||||
'-ac',
|
||||
'2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user preset validation and persistence', () => {
|
||||
it('round-trips a versioned preset as JSON', () => {
|
||||
const json = serializeUserPreset(USER_PRESET);
|
||||
expect(parseUserPresetJson(json)).toEqual(USER_PRESET);
|
||||
expect(validateUserPreset(USER_PRESET)).toMatchObject({ valid: true });
|
||||
});
|
||||
|
||||
it('migrates an allowlisted legacy schema without accepting raw fields', () => {
|
||||
expect(
|
||||
migrateUserPreset({
|
||||
schemaVersion: 0,
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
kind: 'audio',
|
||||
format: 'ipod',
|
||||
audio: { codec: 'aac', bitrateKbps: 128 },
|
||||
includeChapters: true,
|
||||
})
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
kind: 'audio',
|
||||
container: 'ipod',
|
||||
fileExtension: 'm4a',
|
||||
audio: { codec: 'aac', bitrateKbps: 128 },
|
||||
metadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'none',
|
||||
});
|
||||
expect(() =>
|
||||
migrateUserPreset({
|
||||
schemaVersion: 0,
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
kind: 'audio',
|
||||
format: 'mp3',
|
||||
args: ['-i', 'remote'],
|
||||
})
|
||||
).toThrow(/unsupported field/iu);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['args', ['-i', 'https://example.invalid/a']],
|
||||
['filter', 'movie=https://example.invalid'],
|
||||
['inputPath', '/etc/passwd'],
|
||||
['protocol', 'https'],
|
||||
])('rejects imported arbitrary %s fields', (field, value) => {
|
||||
expect(
|
||||
validateUserPreset({ ...USER_PRESET, [field]: value })
|
||||
).toMatchObject({
|
||||
valid: false,
|
||||
errors: [expect.stringContaining(`"${field}"`)],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown codecs and unsafe identifiers', () => {
|
||||
expect(
|
||||
validateUserPreset({
|
||||
...USER_PRESET,
|
||||
id: '../../unsafe',
|
||||
audio: { codec: 'shell', bitrateKbps: 128 },
|
||||
})
|
||||
).toMatchObject({ valid: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'MP4 H.264/AAC video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: { codec: 'libx264', qualityMode: 'balanced', crf: 23 },
|
||||
audio: { codec: 'aac', bitrateKbps: 160 },
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'convert',
|
||||
fastStart: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'WebM VP9/Opus video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'webm',
|
||||
fileExtension: 'webm',
|
||||
video: { codec: 'libvpx-vp9', qualityMode: 'smaller-file', crf: 32 },
|
||||
audio: { codec: 'libopus', bitrateKbps: 128 },
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'convert',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Matroska VP8/FLAC video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'matroska',
|
||||
fileExtension: 'mkv',
|
||||
video: { codec: 'libvpx', qualityMode: 'compatibility', crf: 18 },
|
||||
audio: { codec: 'flac' },
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'copy-compatible',
|
||||
},
|
||||
],
|
||||
[
|
||||
'animated WebP derivative',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'derivative',
|
||||
container: 'webp',
|
||||
fileExtension: 'webp',
|
||||
video: {
|
||||
codec: 'libwebp_anim',
|
||||
qualityMode: 'balanced',
|
||||
crf: 60,
|
||||
},
|
||||
audio: undefined,
|
||||
metadataPolicy: 'remove',
|
||||
chapterPolicy: 'remove',
|
||||
subtitlePolicy: 'none',
|
||||
},
|
||||
],
|
||||
[
|
||||
'GIF derivative',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'derivative',
|
||||
container: 'gif',
|
||||
fileExtension: 'gif',
|
||||
video: { codec: 'gif', qualityMode: 'compatibility' },
|
||||
audio: undefined,
|
||||
metadataPolicy: 'remove',
|
||||
chapterPolicy: 'remove',
|
||||
subtitlePolicy: 'none',
|
||||
},
|
||||
],
|
||||
['MP3 audio', USER_PRESET],
|
||||
[
|
||||
'M4A/AAC audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'ipod',
|
||||
fileExtension: 'm4a',
|
||||
audio: { codec: 'aac', bitrateKbps: 160 },
|
||||
fastStart: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Ogg/Vorbis audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'ogg',
|
||||
fileExtension: 'ogg',
|
||||
audio: { codec: 'libvorbis', bitrateKbps: 160 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'Opus audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'opus',
|
||||
fileExtension: 'opus',
|
||||
audio: { codec: 'libopus', bitrateKbps: 128 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'WAV/PCM audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'wav',
|
||||
fileExtension: 'wav',
|
||||
audio: { codec: 'pcm_s16le' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'FLAC audio',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'flac',
|
||||
fileExtension: 'flac',
|
||||
audio: { codec: 'flac' },
|
||||
},
|
||||
],
|
||||
])('accepts the reviewed %s combination', (_label, preset) => {
|
||||
expect(validateUserPreset(preset)).toMatchObject({
|
||||
valid: true,
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unknown container',
|
||||
{ ...USER_PRESET, container: 'avi', fileExtension: 'avi' },
|
||||
/container "avi".*allowlist/iu,
|
||||
],
|
||||
[
|
||||
'a mismatched extension',
|
||||
{
|
||||
...USER_PRESET,
|
||||
container: 'ipod',
|
||||
fileExtension: 'mp4',
|
||||
audio: { codec: 'aac' },
|
||||
},
|
||||
/fileExtension must be "m4a"/u,
|
||||
],
|
||||
[
|
||||
'the wrong media kind',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'audio',
|
||||
container: 'webm',
|
||||
fileExtension: 'webm',
|
||||
video: undefined,
|
||||
audio: { codec: 'libopus' },
|
||||
},
|
||||
/container "webm".*kind "audio"/u,
|
||||
],
|
||||
[
|
||||
'an incompatible video codec',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: { codec: 'libvpx-vp9', qualityMode: 'balanced' },
|
||||
audio: { codec: 'aac' },
|
||||
},
|
||||
/video codec "libvpx-vp9".*container "mp4"/u,
|
||||
],
|
||||
[
|
||||
'an incompatible audio codec',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'webm',
|
||||
fileExtension: 'webm',
|
||||
video: { codec: 'libvpx', qualityMode: 'balanced' },
|
||||
audio: { codec: 'aac' },
|
||||
},
|
||||
/audio codec "aac".*container "webm"/u,
|
||||
],
|
||||
[
|
||||
'audio in an image derivative',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'derivative',
|
||||
container: 'gif',
|
||||
fileExtension: 'gif',
|
||||
video: { codec: 'gif', qualityMode: 'compatibility' },
|
||||
},
|
||||
/container "gif" does not support audio settings/u,
|
||||
],
|
||||
[
|
||||
'a video container without video',
|
||||
{
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
audio: { codec: 'aac' },
|
||||
video: undefined,
|
||||
},
|
||||
/container "mp4" requires video settings/u,
|
||||
],
|
||||
[
|
||||
'fast-start outside MP4/M4A',
|
||||
{ ...USER_PRESET, fastStart: true },
|
||||
/fastStart is only supported.*not "mp3"/u,
|
||||
],
|
||||
])('rejects %s with a clear compatibility error', (_label, preset, error) => {
|
||||
const result = validateUserPreset(preset);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join('; ')).toMatch(error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['metadataPolicy', 'edit', /metadataPolicy "edit".*Quick presets/u],
|
||||
['chapterPolicy', 'replace', /chapterPolicy "replace".*Quick presets/u],
|
||||
[
|
||||
'subtitlePolicy',
|
||||
'burn-in',
|
||||
/subtitlePolicy "burn-in".*explicit subtitle source/u,
|
||||
],
|
||||
])(
|
||||
'rejects the unsupported Quick preset policy %s=%s',
|
||||
(field, value, error) => {
|
||||
const result = validateUserPreset({
|
||||
...USER_PRESET,
|
||||
[field]: value,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join('; ')).toMatch(error);
|
||||
}
|
||||
);
|
||||
|
||||
it('applies relational compatibility checks to imported JSON', () => {
|
||||
expect(() =>
|
||||
parseUserPresetJson(
|
||||
JSON.stringify({
|
||||
...USER_PRESET,
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: { codec: 'libvpx-vp9', qualityMode: 'balanced' },
|
||||
audio: { codec: 'aac' },
|
||||
})
|
||||
)
|
||||
).toThrow(/video codec "libvpx-vp9".*container "mp4"/iu);
|
||||
});
|
||||
|
||||
it('supports create, clone, rename, export, import, delete and reset', async () => {
|
||||
const persistence = new MemoryUserPresetPersistence();
|
||||
const registry = new UserPresetRegistry(persistence);
|
||||
await registry.initialize();
|
||||
await registry.save(USER_PRESET);
|
||||
const clone = await registry.clone('my-podcast', 'my-podcast-copy', 'Copy');
|
||||
expect(clone.name).toBe('Copy');
|
||||
await registry.rename(clone.id, 'Renamed');
|
||||
expect(registry.get(clone.id)?.name).toBe('Renamed');
|
||||
const exported = registry.export('my-podcast');
|
||||
await registry.delete('my-podcast');
|
||||
expect(registry.get('my-podcast')).toBeUndefined();
|
||||
await registry.import(exported);
|
||||
expect(registry.get('my-podcast')).toEqual(USER_PRESET);
|
||||
await registry.reset();
|
||||
expect(registry.list()).toEqual([]);
|
||||
|
||||
const reloaded = new UserPresetRegistry(persistence);
|
||||
await reloaded.initialize();
|
||||
expect(reloaded.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
66
tests/unit/project/project-fixture.ts
Normal file
66
tests/unit/project/project-fixture.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createEmptyProject } from '../../../src/project/project.schema';
|
||||
import type {
|
||||
AvProjectV1,
|
||||
MediaAssetReference,
|
||||
} from '../../../src/project/project.types';
|
||||
|
||||
export const CREATED_AT = '2026-01-02T03:04:05.000Z';
|
||||
export const UPDATED_AT = '2026-01-02T04:00:00.000Z';
|
||||
|
||||
export function mediaAsset(
|
||||
overrides: Partial<MediaAssetReference> = {}
|
||||
): MediaAssetReference {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
originalName: 'source.mp4',
|
||||
size: 1_024,
|
||||
lastModified: 1_700_000_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
probe: {
|
||||
durationSeconds: 10,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video',
|
||||
codecName: 'h264',
|
||||
disposition: { default: true },
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function populatedProject(): AvProjectV1 {
|
||||
const project = createEmptyProject({
|
||||
id: 'project-1',
|
||||
name: 'Demo project',
|
||||
timestamp: CREATED_AT,
|
||||
});
|
||||
return {
|
||||
...project,
|
||||
assets: [mediaAsset()],
|
||||
timeline: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 1,
|
||||
sourceOutSeconds: 4.5,
|
||||
audio: { enabled: true, fadeInSeconds: 0.2 },
|
||||
video: { enabled: true },
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
selectedAssetId: 'asset-1',
|
||||
selectedClipId: 'clip-1',
|
||||
inspectorPanel: 'clip',
|
||||
timelineZoom: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
191
tests/unit/project/project-reducer-selectors.test.ts
Normal file
191
tests/unit/project/project-reducer-selectors.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProjectReducerError,
|
||||
projectReducer,
|
||||
} from '../../../src/project/project.reducer';
|
||||
import {
|
||||
selectCanExport,
|
||||
selectMissingAssets,
|
||||
selectTimelineDuration,
|
||||
selectTimelineEntries,
|
||||
selectUnusedAssets,
|
||||
} from '../../../src/project/project.selectors';
|
||||
import { createEmptyProject } from '../../../src/project/project.schema';
|
||||
import {
|
||||
CREATED_AT,
|
||||
UPDATED_AT,
|
||||
mediaAsset,
|
||||
populatedProject,
|
||||
} from './project-fixture';
|
||||
|
||||
describe('project reducer', () => {
|
||||
it('adds assets and sequential clips without mutating prior state', () => {
|
||||
const empty = createEmptyProject({
|
||||
id: 'p',
|
||||
timestamp: CREATED_AT,
|
||||
});
|
||||
const withAsset = projectReducer(empty, {
|
||||
type: 'asset/add',
|
||||
asset: mediaAsset(),
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
const withClip = projectReducer(withAsset, {
|
||||
type: 'clip/add',
|
||||
clip: {
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 2,
|
||||
},
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
|
||||
expect(empty.assets).toEqual([]);
|
||||
expect(withAsset.timeline).toEqual([]);
|
||||
expect(withClip.timeline).toHaveLength(1);
|
||||
expect(withClip.updatedAt).toBe(UPDATED_AT);
|
||||
});
|
||||
|
||||
it('reorders clips deterministically and returns identity for a no-op', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
timeline: [
|
||||
populatedProject().timeline[0]!,
|
||||
{
|
||||
id: 'clip-2',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 4.5,
|
||||
sourceOutSeconds: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
const moved = projectReducer(project, {
|
||||
type: 'clip/move',
|
||||
clipId: 'clip-2',
|
||||
toIndex: 0,
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
expect(moved.timeline.map((clip) => clip.id)).toEqual(['clip-2', 'clip-1']);
|
||||
expect(
|
||||
projectReducer(moved, {
|
||||
type: 'clip/move',
|
||||
clipId: 'clip-2',
|
||||
toIndex: 0,
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toBe(moved);
|
||||
});
|
||||
|
||||
it('removes dependent clips/subtitles and clears stale UI selection', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
subtitles: [
|
||||
{ id: 'subtitle-1', assetId: 'asset-1', mode: 'mux' as const },
|
||||
],
|
||||
};
|
||||
const result = projectReducer(project, {
|
||||
type: 'asset/remove',
|
||||
assetId: 'asset-1',
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.timeline).toEqual([]);
|
||||
expect(result.subtitles).toEqual([]);
|
||||
expect(result.ui).toMatchObject({
|
||||
selectedAssetId: undefined,
|
||||
selectedClipId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('guards duplicate IDs, missing assets and invalid ranges', () => {
|
||||
const project = populatedProject();
|
||||
expect(() =>
|
||||
projectReducer(project, {
|
||||
type: 'asset/add',
|
||||
asset: mediaAsset(),
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toThrow(ProjectReducerError);
|
||||
expect(() =>
|
||||
projectReducer(project, {
|
||||
type: 'clip/add',
|
||||
clip: {
|
||||
id: 'bad',
|
||||
assetId: 'missing',
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 1,
|
||||
},
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toThrow('unknown asset');
|
||||
expect(() =>
|
||||
projectReducer(project, {
|
||||
type: 'clip/update',
|
||||
clipId: 'clip-1',
|
||||
changes: { sourceOutSeconds: 0 },
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('does not mark the document updated for transient UI state', () => {
|
||||
const project = populatedProject();
|
||||
const result = projectReducer(project, {
|
||||
type: 'ui/set',
|
||||
ui: { inspectorPanel: 'output' },
|
||||
});
|
||||
expect(result.updatedAt).toBe(project.updatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project selectors', () => {
|
||||
it('derives timeline positions and total duration for a sequential edit', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
timeline: [
|
||||
populatedProject().timeline[0]!,
|
||||
{
|
||||
id: 'clip-2',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 5,
|
||||
sourceOutSeconds: 6.25,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(selectTimelineDuration(project)).toBe(4.75);
|
||||
expect(selectTimelineEntries(project)).toMatchObject([
|
||||
{
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 3.5,
|
||||
durationSeconds: 3.5,
|
||||
},
|
||||
{
|
||||
timelineStartSeconds: 3.5,
|
||||
timelineEndSeconds: 4.75,
|
||||
durationSeconds: 1.25,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies missing, unused and export-blocking sources', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
assets: [
|
||||
mediaAsset({ sourceStatus: 'missing' }),
|
||||
mediaAsset({ id: 'asset-unused', originalName: 'unused.wav' }),
|
||||
],
|
||||
};
|
||||
expect(selectMissingAssets(project).map((asset) => asset.id)).toEqual([
|
||||
'asset-1',
|
||||
]);
|
||||
expect(selectUnusedAssets(project).map((asset) => asset.id)).toEqual([
|
||||
'asset-unused',
|
||||
]);
|
||||
expect(selectCanExport(project)).toEqual({
|
||||
canExport: false,
|
||||
reasons: ['1 referenced source file is missing.'],
|
||||
});
|
||||
});
|
||||
});
|
||||
65
tests/unit/project/project-schema.test.ts
Normal file
65
tests/unit/project/project-schema.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createEmptyProject,
|
||||
createMediaAssetReference,
|
||||
} from '../../../src/project/project.schema';
|
||||
import { CREATED_AT } from './project-fixture';
|
||||
|
||||
describe('project factories', () => {
|
||||
it('creates a complete, deterministic empty document when inputs are supplied', () => {
|
||||
expect(
|
||||
createEmptyProject({
|
||||
id: 'project-1',
|
||||
name: ' Test ',
|
||||
timestamp: CREATED_AT,
|
||||
})
|
||||
).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
id: 'project-1',
|
||||
name: 'Test',
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: CREATED_AT,
|
||||
assets: [],
|
||||
timeline: [],
|
||||
output: {
|
||||
presetId: 'mp4-h264-balanced',
|
||||
fileName: 'output.mp4',
|
||||
container: 'mp4',
|
||||
sampleRate: 48_000,
|
||||
channelLayout: 'stereo',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('converts File-compatible identity metadata without retaining the File', () => {
|
||||
const file = {
|
||||
name: 'clip.mov',
|
||||
size: 42,
|
||||
lastModified: 123,
|
||||
type: 'video/quicktime',
|
||||
};
|
||||
const asset = createMediaAssetReference(file, { id: 'asset-1' });
|
||||
expect(asset).toEqual({
|
||||
id: 'asset-1',
|
||||
originalName: 'clip.mov',
|
||||
size: 42,
|
||||
lastModified: 123,
|
||||
mimeType: 'video/quicktime',
|
||||
sourceStatus: 'attached',
|
||||
});
|
||||
expect(asset).not.toHaveProperty('file');
|
||||
});
|
||||
|
||||
it('rejects malformed file identity metadata', () => {
|
||||
expect(() =>
|
||||
createMediaAssetReference({
|
||||
name: '',
|
||||
size: -1,
|
||||
lastModified: 0,
|
||||
type: '',
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
179
tests/unit/project/project-serialization.test.ts
Normal file
179
tests/unit/project/project-serialization.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProjectImportError,
|
||||
importProjectDocument,
|
||||
projectDocumentFileName,
|
||||
serializeProject,
|
||||
} from '../../../src/project/project.serialization';
|
||||
import { ProjectValidationError } from '../../../src/project/project.validation';
|
||||
import { populatedProject } from './project-fixture';
|
||||
|
||||
describe('project serialization', () => {
|
||||
it('serializes readable, versioned JSON and can omit transient UI state', () => {
|
||||
const project = populatedProject();
|
||||
const json = serializeProject(project, { includeUiState: false });
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
|
||||
expect(json.endsWith('\n')).toBe(true);
|
||||
expect(parsed.schemaVersion).toBe(1);
|
||||
expect(parsed.ui).toBeUndefined();
|
||||
expect(project.ui).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates before serializing', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
output: { ...populatedProject().output, fileName: '' },
|
||||
};
|
||||
expect(() => serializeProject(project)).toThrow(ProjectValidationError);
|
||||
});
|
||||
|
||||
it('imports a round trip but correctly marks browser sources missing', () => {
|
||||
const project = populatedProject();
|
||||
const result = importProjectDocument(serializeProject(project));
|
||||
|
||||
expect(result.fromVersion).toBe(1);
|
||||
expect(result.project.assets[0]?.sourceStatus).toBe('missing');
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({ code: 'source-reattachment-required' })
|
||||
);
|
||||
});
|
||||
|
||||
it('can retain runtime source status only when explicitly requested', () => {
|
||||
const project = populatedProject();
|
||||
const result = importProjectDocument(serializeProject(project), {
|
||||
markSourcesMissing: false,
|
||||
});
|
||||
expect(result.project).toEqual(project);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trips fade curves and editable chapter time bases', () => {
|
||||
const project = populatedProject();
|
||||
const persisted = {
|
||||
...project,
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0]!,
|
||||
audio: {
|
||||
enabled: true,
|
||||
fadeInSeconds: 0.2,
|
||||
fadeCurve: 'qsin' as const,
|
||||
},
|
||||
video: {
|
||||
enabled: true,
|
||||
fadeOutSeconds: 0.2,
|
||||
fadeCurve: 'exp' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Intro',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/48000',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = importProjectDocument(serializeProject(persisted), {
|
||||
markSourcesMissing: false,
|
||||
});
|
||||
|
||||
expect(result.project.timeline[0]?.audio?.fadeCurve).toBe('qsin');
|
||||
expect(result.project.timeline[0]?.video?.fadeCurve).toBe('exp');
|
||||
expect(result.project.chapters[0]?.timeBase).toBe('1/48000');
|
||||
});
|
||||
|
||||
it('still reports reattachment when an exported project was already missing sources', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
assets: [
|
||||
{ ...populatedProject().assets[0]!, sourceStatus: 'missing' as const },
|
||||
],
|
||||
};
|
||||
const result = importProjectDocument(serializeProject(project));
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({ code: 'source-reattachment-required' })
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates the documented legacy version zero shape', () => {
|
||||
const legacy = {
|
||||
schemaVersion: 0,
|
||||
id: 'legacy-project',
|
||||
title: 'Legacy project',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
media: [
|
||||
{
|
||||
id: 'legacy-source',
|
||||
fileName: 'legacy.mp4',
|
||||
size: 42,
|
||||
lastModified: 100,
|
||||
type: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
},
|
||||
],
|
||||
clips: [
|
||||
{
|
||||
id: 'legacy-clip',
|
||||
assetId: 'legacy-source',
|
||||
inSeconds: 0,
|
||||
outSeconds: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = importProjectDocument(JSON.stringify(legacy));
|
||||
|
||||
expect(result.fromVersion).toBe(0);
|
||||
expect(result.project).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
name: 'Legacy project',
|
||||
assets: [
|
||||
{
|
||||
originalName: 'legacy.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'missing',
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'renamed-field',
|
||||
'defaulted-field',
|
||||
'source-reattachment-required',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed, unversioned, future and oversized documents', () => {
|
||||
expect(() => importProjectDocument('{')).toThrow(ProjectImportError);
|
||||
expect(() => importProjectDocument('{}')).toThrow(
|
||||
'does not declare a schema version'
|
||||
);
|
||||
expect(() => importProjectDocument('{"schemaVersion":999}')).toThrow(
|
||||
'is not supported'
|
||||
);
|
||||
expect(() =>
|
||||
importProjectDocument('{"schemaVersion":1}', { maximumBytes: 5 })
|
||||
).toThrow('exceeds');
|
||||
});
|
||||
|
||||
it('creates a safe project-document filename', () => {
|
||||
expect(
|
||||
projectDocumentFileName({
|
||||
...populatedProject(),
|
||||
name: '../../My Project',
|
||||
})
|
||||
).toBe('My-Project.avproject.json');
|
||||
});
|
||||
});
|
||||
199
tests/unit/project/project-validation.test.ts
Normal file
199
tests/unit/project/project-validation.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProjectValidationError,
|
||||
assertValidProject,
|
||||
validateProject,
|
||||
} from '../../../src/project/project.validation';
|
||||
import { populatedProject } from './project-fixture';
|
||||
|
||||
describe('project validation', () => {
|
||||
it('returns an isolated, typed copy for a valid version-1 project', () => {
|
||||
const input = populatedProject();
|
||||
const result = validateProject(input);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.project).toEqual(input);
|
||||
expect(result.project).not.toBe(input);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports duplicate identities, missing references and bad ranges', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = {
|
||||
...project,
|
||||
assets: [...project.assets, { ...project.assets[0] }],
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0],
|
||||
assetId: 'missing',
|
||||
sourceInSeconds: 5,
|
||||
sourceOutSeconds: 3,
|
||||
crop: { x: -1, y: 0, width: 0, height: 100 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateProject(invalid);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'duplicate-id',
|
||||
'missing-reference',
|
||||
'invalid-range',
|
||||
'invalid-value',
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('checks clip bounds, fade bounds, chapter bounds and output streams', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = {
|
||||
...project,
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0],
|
||||
sourceOutSeconds: 11,
|
||||
audio: {
|
||||
enabled: true,
|
||||
fadeInSeconds: 8,
|
||||
fadeOutSeconds: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
startSeconds: 0,
|
||||
endSeconds: 20,
|
||||
title: 'Too long',
|
||||
},
|
||||
],
|
||||
output: {
|
||||
...project.output,
|
||||
videoEnabled: false,
|
||||
audioEnabled: false,
|
||||
},
|
||||
};
|
||||
const result = validateProject(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid-range',
|
||||
path: 'timeline[0]',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'out-of-bounds',
|
||||
path: 'timeline[0].audio',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'out-of-bounds',
|
||||
path: 'chapters[0].endSeconds',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'invalid-value',
|
||||
path: 'output',
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates persisted fade curves and chapter time-base resolution', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = {
|
||||
...project,
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0],
|
||||
audio: {
|
||||
enabled: true,
|
||||
fadeInSeconds: 0.2,
|
||||
fadeCurve: 'log',
|
||||
},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
startSeconds: 0,
|
||||
endSeconds: 0.1,
|
||||
title: 'Too short for this base',
|
||||
timeBase: '1/1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateProject(invalid);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid-value',
|
||||
path: 'timeline[0].audio.fadeCurve',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'invalid-value',
|
||||
path: 'chapters[0].timeBase',
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported schema versions with a useful typed exception', () => {
|
||||
const project = { ...populatedProject(), schemaVersion: 99 };
|
||||
expect(() => assertValidProject(project)).toThrow(ProjectValidationError);
|
||||
const result = validateProject(project);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues[0]).toMatchObject({
|
||||
code: 'unsupported-version',
|
||||
path: 'schemaVersion',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trust malformed normalized probe fields from an import', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = structuredClone(project) as unknown as {
|
||||
assets: Array<{
|
||||
probe?: {
|
||||
streams: Array<{
|
||||
width?: unknown;
|
||||
rotationDegrees?: unknown;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const stream = invalid.assets[0]?.probe?.streams[0];
|
||||
if (stream) {
|
||||
stream.width = '1920';
|
||||
stream.rotationDegrees = '90';
|
||||
}
|
||||
|
||||
const result = validateProject(invalid);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'invalid-type',
|
||||
path: 'assets[0].probe.streams[0].width',
|
||||
})
|
||||
);
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'invalid-type',
|
||||
path: 'assets[0].probe.streams[0].rotationDegrees',
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
169
tests/unit/project/source-reattachment.test.ts
Normal file
169
tests/unit/project/source-reattachment.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
SourceReattachmentError,
|
||||
bestReattachmentCandidate,
|
||||
confirmSourceReattachment,
|
||||
rankReattachmentCandidates,
|
||||
} from '../../../src/project/source-reattachment';
|
||||
import type { SourceHash } from '../../../src/project/project.types';
|
||||
import { UPDATED_AT, mediaAsset, populatedProject } from './project-fixture';
|
||||
|
||||
const HASH_A: SourceHash = {
|
||||
algorithm: 'sha-256',
|
||||
value: 'a'.repeat(64),
|
||||
scope: 'full',
|
||||
};
|
||||
const HASH_B: SourceHash = {
|
||||
algorithm: 'sha-256',
|
||||
value: 'b'.repeat(64),
|
||||
scope: 'full',
|
||||
};
|
||||
|
||||
describe('source reattachment matching', () => {
|
||||
it('ranks size, timestamp, filename, MIME type and optional hash evidence', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
|
||||
const candidates = [
|
||||
{
|
||||
name: 'renamed.mp4',
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
hash: HASH_A,
|
||||
},
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
hash: HASH_B,
|
||||
},
|
||||
];
|
||||
const matches = rankReattachmentCandidates(asset, candidates);
|
||||
|
||||
expect(matches.map((match) => match.confidence)).toEqual([
|
||||
'exact',
|
||||
'likely',
|
||||
'none',
|
||||
]);
|
||||
expect(matches[0]).toMatchObject({
|
||||
score: 190,
|
||||
requiresExplicitConfirmation: true,
|
||||
});
|
||||
expect(
|
||||
matches[0]?.evidence.find((evidence) => evidence.field === 'hash')
|
||||
).toMatchObject({ matches: true });
|
||||
});
|
||||
|
||||
it('does not suggest ambiguous ties', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing' });
|
||||
const candidates = [
|
||||
{
|
||||
name: 'a.mp4',
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
{
|
||||
name: 'b.mp4',
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
];
|
||||
expect(bestReattachmentCandidate(asset, candidates)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows filesystem timestamp tolerance but treats size/hash mismatch as unsafe', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
|
||||
expect(
|
||||
rankReattachmentCandidates(asset, [
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified + 1_500,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
])[0]?.confidence
|
||||
).toBe('likely');
|
||||
expect(
|
||||
rankReattachmentCandidates(asset, [
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size + 1,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
])[0]?.confidence
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('does not treat incomparable partial and full hashes as a mismatch', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
|
||||
const partial: SourceHash = {
|
||||
algorithm: 'sha-256',
|
||||
value: 'b'.repeat(64),
|
||||
scope: 'partial',
|
||||
bytesHashed: 1_024,
|
||||
};
|
||||
expect(
|
||||
rankReattachmentCandidates(asset, [
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
hash: partial,
|
||||
},
|
||||
])[0]?.confidence
|
||||
).toBe('likely');
|
||||
});
|
||||
|
||||
it('rejects malformed file descriptors before scoring', () => {
|
||||
expect(() =>
|
||||
rankReattachmentCandidates(mediaAsset(), [
|
||||
{
|
||||
name: '',
|
||||
size: -1,
|
||||
lastModified: Number.NaN,
|
||||
},
|
||||
])
|
||||
).toThrow(SourceReattachmentError);
|
||||
});
|
||||
|
||||
it('only marks a source attached after an explicit confirmation call', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
assets: [mediaAsset({ sourceStatus: 'missing' })],
|
||||
};
|
||||
const candidate = {
|
||||
name: 'renamed.mp4',
|
||||
size: 1_024,
|
||||
lastModified: 1_700_000_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
};
|
||||
const result = confirmSourceReattachment(
|
||||
project,
|
||||
'asset-1',
|
||||
candidate,
|
||||
UPDATED_AT
|
||||
);
|
||||
|
||||
expect(project.assets[0]?.sourceStatus).toBe('missing');
|
||||
expect(result.assets[0]?.sourceStatus).toBe('attached');
|
||||
expect(() =>
|
||||
confirmSourceReattachment(
|
||||
project,
|
||||
'asset-1',
|
||||
{ ...candidate, size: 1 },
|
||||
UPDATED_AT
|
||||
)
|
||||
).toThrow(SourceReattachmentError);
|
||||
});
|
||||
});
|
||||
251
tests/unit/release-package.test.mjs
Normal file
251
tests/unit/release-package.test.mjs
Normal file
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
assertLicenseReady,
|
||||
collectDirectoryEntries,
|
||||
createDeterministicZip,
|
||||
parseReleaseArguments,
|
||||
safeArchivePath,
|
||||
} from '../../scripts/package-release.mjs';
|
||||
import {
|
||||
checksumLine,
|
||||
parseChecksumLine,
|
||||
sha256,
|
||||
verifyChecksum,
|
||||
writeChecksum,
|
||||
} from '../../scripts/checksum-release.mjs';
|
||||
import {
|
||||
inspectPortalHeaders,
|
||||
parsePortalArguments,
|
||||
} from '../../scripts/portal-assembly-smoke.mjs';
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
async function temporaryDirectory() {
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), 'av-tools-release-test-')
|
||||
);
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('release path safety', () => {
|
||||
it.each([
|
||||
'',
|
||||
'/index.html',
|
||||
'../index.html',
|
||||
'assets/../index.html',
|
||||
'assets//main.js',
|
||||
'C:/index.html',
|
||||
'assets\\main.js',
|
||||
'assets/\0main.js',
|
||||
])('rejects unsafe archive path %j', (unsafePath) => {
|
||||
expect(() => safeArchivePath(unsafePath)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts a canonical nested path', () => {
|
||||
expect(safeArchivePath('vendor/ffmpeg/core.wasm')).toBe(
|
||||
'vendor/ffmpeg/core.wasm'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects symlinks while collecting a directory', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await writeFile(path.join(directory, 'target.txt'), 'target');
|
||||
await symlink('target.txt', path.join(directory, 'alias.txt'));
|
||||
await expect(collectDirectoryEntries(directory)).rejects.toThrow(
|
||||
/symlink/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deterministic release ZIP', () => {
|
||||
const entries = [
|
||||
{ name: 'z.txt', data: Buffer.from('last') },
|
||||
{ name: 'a.txt', data: Buffer.from('first') },
|
||||
];
|
||||
|
||||
it('is byte-identical regardless of input order', () => {
|
||||
const first = createDeterministicZip(entries);
|
||||
const second = createDeterministicZip([...entries].reverse());
|
||||
expect(first.equals(second)).toBe(true);
|
||||
expect(sha256(first)).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('stores sorted UTF-8 entries with the normalized epoch and file mode', () => {
|
||||
const archive = createDeterministicZip(entries);
|
||||
expect(archive.readUInt32LE(0)).toBe(0x04034b50);
|
||||
expect(archive.readUInt16LE(10)).toBe(0);
|
||||
expect(archive.readUInt16LE(12)).toBe(33);
|
||||
expect(archive.subarray(30, 35).toString('utf8')).toBe('a.txt');
|
||||
|
||||
const centralOffset = archive.indexOf(
|
||||
Buffer.from([0x50, 0x4b, 0x01, 0x02])
|
||||
);
|
||||
expect(centralOffset).toBeGreaterThan(0);
|
||||
expect(archive.readUInt16LE(centralOffset + 4)).toBe(0x0314);
|
||||
expect(archive.readUInt32LE(centralOffset + 38) >>> 16).toBe(0o100644);
|
||||
});
|
||||
|
||||
it('rejects duplicate paths and local absolute paths in text', () => {
|
||||
expect(() =>
|
||||
createDeterministicZip([
|
||||
{ name: 'same.txt', data: Buffer.from('one') },
|
||||
{ name: 'same.txt', data: Buffer.from('two') },
|
||||
])
|
||||
).toThrow(/duplicate/i);
|
||||
expect(() =>
|
||||
createDeterministicZip([
|
||||
{ name: 'bundle.js', data: Buffer.from('source: "/home/user/file"') },
|
||||
])
|
||||
).toThrow(/local absolute path/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('licence release gate', () => {
|
||||
it('blocks a normal release without an application licence', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await expect(assertLicenseReady(directory)).rejects.toThrow(
|
||||
/RELEASE BLOCKED/
|
||||
);
|
||||
});
|
||||
|
||||
it('allows only the explicit development-smoke escape hatch', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await expect(assertLicenseReady(directory, true)).resolves.toBeNull();
|
||||
expect(
|
||||
parseReleaseArguments(['--allow-unlicensed-development-smoke'])
|
||||
).toEqual({
|
||||
allowUnlicensedDevelopmentSmoke: true,
|
||||
force: false,
|
||||
});
|
||||
expect(() => parseReleaseArguments(['--publish'])).toThrow(/unknown/i);
|
||||
});
|
||||
|
||||
it('recognizes a complete GPL version 3 root application licence', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const licence = path.join(directory, 'LICENSE');
|
||||
await writeFile(
|
||||
licence,
|
||||
[
|
||||
'GNU GENERAL PUBLIC LICENSE',
|
||||
'Version 3, 29 June 2007',
|
||||
'END OF TERMS AND CONDITIONS',
|
||||
].join('\n')
|
||||
);
|
||||
await expect(assertLicenseReady(directory)).resolves.toBe(licence);
|
||||
});
|
||||
|
||||
it('rejects an arbitrary file named LICENSE', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await writeFile(path.join(directory, 'LICENSE'), 'example only');
|
||||
await expect(assertLicenseReady(directory)).rejects.toThrow(
|
||||
/complete GNU GPL version 3/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('release checksum', () => {
|
||||
it('formats and parses the portable sidecar form', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
|
||||
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
|
||||
expect(parseChecksumLine(line)).toEqual({
|
||||
archiveName: 'av-tools-0.1.0.zip',
|
||||
digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes and verifies a checksum sidecar', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
||||
await writeFile(archive, 'deterministic bytes');
|
||||
const result = await writeChecksum(archive);
|
||||
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
||||
result.digest
|
||||
);
|
||||
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
||||
' av-tools-0.1.0.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects checksum mismatches', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
||||
const sidecar = `${archive}.sha256`;
|
||||
await writeFile(archive, 'changed');
|
||||
await mkdir(path.dirname(sidecar), { recursive: true });
|
||||
await writeFile(
|
||||
sidecar,
|
||||
checksumLine('0'.repeat(64), path.basename(archive))
|
||||
);
|
||||
await expect(verifyChecksum(archive, sidecar)).rejects.toThrow(/mismatch/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('portal assembly smoke validation', () => {
|
||||
const isolatedHeaders = `
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
|
||||
types { application/wasm wasm; }
|
||||
# vendor/ffmpeg/0.12.10
|
||||
`;
|
||||
|
||||
it('accepts the required isolation, CSP and MIME profile', () => {
|
||||
expect(inspectPortalHeaders(isolatedHeaders)).toEqual({
|
||||
errors: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('detects the current media-preview CSP gap', () => {
|
||||
const inspection = inspectPortalHeaders(
|
||||
isolatedHeaders.replace("media-src 'self' blob:;", '')
|
||||
);
|
||||
expect(inspection.errors).toContain(
|
||||
"CSP media-src must contain 'self' and blob:"
|
||||
);
|
||||
});
|
||||
|
||||
it('parses only explicit smoke overrides and paths', () => {
|
||||
const parsed = parsePortalArguments(
|
||||
[
|
||||
'--artifact',
|
||||
'release/example.zip',
|
||||
'--allow-unlicensed-development-smoke',
|
||||
'--allow-known-header-gap',
|
||||
'--keep-temp',
|
||||
],
|
||||
'/work/av-tools'
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
allowKnownHeaderGap: true,
|
||||
allowUnlicensedDevelopmentSmoke: true,
|
||||
artifact: '/work/av-tools/release/example.zip',
|
||||
keepTemp: true,
|
||||
portalRoot: '/work/references/toolbox-portal',
|
||||
});
|
||||
expect(() => parsePortalArguments(['--publish'], '/work/av-tools')).toThrow(
|
||||
/unknown/i
|
||||
);
|
||||
});
|
||||
});
|
||||
410
tests/unit/storage.test.ts
Normal file
410
tests/unit/storage.test.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_RESOURCE_POLICY,
|
||||
MemoryDerivedCache,
|
||||
ResilientDerivedCache,
|
||||
createCacheKey,
|
||||
createResourcePolicy,
|
||||
estimatePlanResources,
|
||||
estimateOutputBytes,
|
||||
policyForDeviceMemory,
|
||||
preflightResources,
|
||||
readStorageQuota,
|
||||
stableStringify,
|
||||
type DerivedCache,
|
||||
} from '../../src/storage';
|
||||
import type { FFmpegCommandPlan } from '../../src/commands/command-plan';
|
||||
import type { MediaProbe } from '../../src/media';
|
||||
|
||||
describe('cache identities', () => {
|
||||
it('canonicalizes settings and produces deterministic path-safe keys', () => {
|
||||
const one = createCacheKey({
|
||||
sourceFingerprint: 'source-123',
|
||||
operation: 'waveform',
|
||||
streamIndex: 1,
|
||||
settings: { zoom: 2, channels: ['left', 'right'] },
|
||||
});
|
||||
const two = createCacheKey({
|
||||
sourceFingerprint: 'source-123',
|
||||
operation: 'waveform',
|
||||
streamIndex: 1,
|
||||
settings: { channels: ['left', 'right'], zoom: 2 },
|
||||
});
|
||||
expect(one).toBe(two);
|
||||
expect(one).toMatch(/^waveform-[0-9a-f]{16}$/u);
|
||||
expect(stableStringify({ z: 1, a: { y: 2, x: 3 } })).toBe(
|
||||
'{"a":{"x":3,"y":2},"z":1}'
|
||||
);
|
||||
expect(() =>
|
||||
createCacheKey({
|
||||
sourceFingerprint: 'source',
|
||||
operation: '../outside',
|
||||
})
|
||||
).toThrow(/safe operation/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource preflight', () => {
|
||||
it('allows small jobs and labels estimates as non-guarantees', () => {
|
||||
const decision = preflightResources({
|
||||
inputBytes: [10 * 1024 * 1024],
|
||||
expectedOutputBytes: estimateOutputBytes(10, 2_000, 128),
|
||||
durationSeconds: 10,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 25,
|
||||
passes: 1,
|
||||
});
|
||||
expect(decision.status).toBe('allowed');
|
||||
expect(decision.totals.pixelWorkEstimate).toBe(518_400_000);
|
||||
expect(decision.estimateDisclaimer).toContain('not a guarantee');
|
||||
});
|
||||
|
||||
it('blocks hard limits and provides actionable suggestions', () => {
|
||||
const decision = preflightResources({
|
||||
inputBytes: [DEFAULT_RESOURCE_POLICY.hardSingleInputBytes + 1],
|
||||
width: 9000,
|
||||
height: 9000,
|
||||
thumbnailCount: 501,
|
||||
contactSheetCells: 501,
|
||||
expectedOutputBytes: DEFAULT_RESOURCE_POLICY.hardOutputEstimateBytes + 1,
|
||||
timelineClips: DEFAULT_RESOURCE_POLICY.maxTimelineClips + 1,
|
||||
streamsPerInput: [DEFAULT_RESOURCE_POLICY.maxStreamsPerInput + 1],
|
||||
});
|
||||
expect(decision.status).toBe('blocked');
|
||||
expect(decision.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'single-input-hard-limit',
|
||||
'total-input-hard-limit',
|
||||
'pixel-limit',
|
||||
'thumbnail-limit',
|
||||
'contact-sheet-limit',
|
||||
'output-hard-limit',
|
||||
'timeline-clip-limit',
|
||||
'stream-count-limit',
|
||||
])
|
||||
);
|
||||
expect(decision.issues.every((issue) => issue.suggestions.length > 0)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('lowers policy for reliable low-memory signals', () => {
|
||||
const policy = createResourcePolicy();
|
||||
expect(policyForDeviceMemory(policy, 2).softInputBytes).toBeLessThan(
|
||||
policy.softInputBytes
|
||||
);
|
||||
});
|
||||
|
||||
it('reserves collection and report ZIP capacity before running a batch', () => {
|
||||
const policy = createResourcePolicy();
|
||||
const decision = preflightResources(
|
||||
{
|
||||
inputBytes: [],
|
||||
generatedOutputs: 2,
|
||||
retainedOutputs: policy.maxGeneratedOutputs - 1,
|
||||
},
|
||||
policy
|
||||
);
|
||||
expect(decision.status).toBe('blocked');
|
||||
expect(decision.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'generated-output-limit',
|
||||
message: expect.stringMatching(/ZIP with an optional report/iu),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed generated-image estimates before comparing limits', () => {
|
||||
expect(() =>
|
||||
preflightResources({
|
||||
inputBytes: [],
|
||||
thumbnailCount: -1,
|
||||
})
|
||||
).toThrow(/thumbnailCount/u);
|
||||
expect(() =>
|
||||
preflightResources({
|
||||
inputBytes: [],
|
||||
contactSheetCells: 1.5,
|
||||
})
|
||||
).toThrow(/contactSheetCells/u);
|
||||
});
|
||||
|
||||
it('derives output size, geometry, frame rate, and generated-image semantics from plans', () => {
|
||||
const contactEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'contact-sheet',
|
||||
args: [
|
||||
'-vf',
|
||||
'fps=0.5,scale=320:-2:force_original_aspect_ratio=decrease,tile=3x2:padding=4:margin=4',
|
||||
'/work/contact.jpg',
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'contact',
|
||||
path: '/work/contact.jpg',
|
||||
fileName: 'contact.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
role: 'contact-sheet',
|
||||
},
|
||||
],
|
||||
expectedDurationSeconds: 12,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: videoProbe() }]
|
||||
);
|
||||
expect(contactEstimate).toMatchObject({
|
||||
width: 976,
|
||||
height: 372,
|
||||
frameRate: 0.5,
|
||||
contactSheetCells: 6,
|
||||
generatedOutputs: 1,
|
||||
});
|
||||
|
||||
const remuxEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'remux',
|
||||
args: ['-i', '/input/source.mp4', '-c', 'copy', '/work/output.mkv'],
|
||||
outputs: [
|
||||
{
|
||||
id: 'media',
|
||||
path: '/work/output.mkv',
|
||||
fileName: 'output.mkv',
|
||||
mediaType: 'video/x-matroska',
|
||||
role: 'media',
|
||||
},
|
||||
],
|
||||
expectedDurationSeconds: 10,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: videoProbe() }]
|
||||
);
|
||||
expect(remuxEstimate.expectedOutputBytes).toBe(2_000_000);
|
||||
|
||||
const bitrateEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'convert',
|
||||
args: [
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-b:v',
|
||||
'500k',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'128k',
|
||||
'/work/output.mp4',
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'media',
|
||||
path: '/work/output.mp4',
|
||||
fileName: 'output.mp4',
|
||||
mediaType: 'video/mp4',
|
||||
role: 'media',
|
||||
},
|
||||
],
|
||||
expectedDurationSeconds: 10,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: audioVideoProbe() }]
|
||||
);
|
||||
expect(bitrateEstimate.expectedOutputBytes).toBe(
|
||||
estimateOutputBytes(10, 628)
|
||||
);
|
||||
|
||||
const thumbnailEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'thumbnail-series',
|
||||
args: ['-vf', 'scale=320:-2', '/work/thumb.png'],
|
||||
outputs: Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `thumb-${index}`,
|
||||
path: `/work/thumb-${index}.png`,
|
||||
fileName: `thumb-${index}.png`,
|
||||
mediaType: 'image/png',
|
||||
role: 'thumbnail' as const,
|
||||
})),
|
||||
expectedDurationSeconds: 8,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: videoProbe() }]
|
||||
);
|
||||
expect(thumbnailEstimate).toMatchObject({
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 0.5,
|
||||
thumbnailCount: 4,
|
||||
generatedOutputs: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived cache lifecycle', () => {
|
||||
it('lists sizes, expires entries, clears projects and clears all', async () => {
|
||||
let now = new Date('2026-01-01T00:00:00.000Z');
|
||||
const cache = new MemoryDerivedCache({
|
||||
maxBytes: 1_024,
|
||||
now: () => now,
|
||||
});
|
||||
await cache.put(
|
||||
{
|
||||
key: 'waveform-a',
|
||||
kind: 'waveform-peaks',
|
||||
projectId: 'project-a',
|
||||
mediaType: 'application/octet-stream',
|
||||
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
new Uint8Array([1, 2, 3])
|
||||
);
|
||||
await cache.put(
|
||||
{
|
||||
key: 'thumb-b',
|
||||
kind: 'thumbnail',
|
||||
projectId: 'project-b',
|
||||
mediaType: 'image/png',
|
||||
},
|
||||
new Blob(['image'], { type: 'image/png' })
|
||||
);
|
||||
expect(await cache.sizeBytes()).toBe(8);
|
||||
expect((await cache.list()).map((entry) => entry.key)).toHaveLength(2);
|
||||
now = new Date('2026-01-03T00:00:00.000Z');
|
||||
expect(await cache.pruneExpired()).toBe(1);
|
||||
expect(await cache.get('waveform-a')).toBeUndefined();
|
||||
expect(await cache.clearProject('project-b')).toBe(1);
|
||||
expect(await cache.sizeBytes()).toBe(0);
|
||||
await cache.clear();
|
||||
expect(await cache.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('evicts least recently used memory entries under the configured limit', async () => {
|
||||
let tick = 0;
|
||||
const cache = new MemoryDerivedCache({
|
||||
maxBytes: 6,
|
||||
now: () => new Date(1_000 + tick++),
|
||||
});
|
||||
await cache.put({ key: 'one', kind: 'project' }, new Blob(['123']));
|
||||
await cache.put({ key: 'two', kind: 'project' }, new Blob(['456']));
|
||||
await cache.get('one');
|
||||
await cache.put({ key: 'three', kind: 'project' }, new Blob(['789']));
|
||||
expect(await cache.get('two')).toBeUndefined();
|
||||
expect(await cache.get('one')).toBeDefined();
|
||||
expect(await cache.get('three')).toBeDefined();
|
||||
});
|
||||
|
||||
it('switches explicitly to memory fallback when OPFS operations fail', async () => {
|
||||
const primary = new ThrowingCache();
|
||||
const fallback = new MemoryDerivedCache();
|
||||
const cache = new ResilientDerivedCache(primary, fallback);
|
||||
expect(cache.mode).toBe('opfs');
|
||||
await cache.put({ key: 'fallback', kind: 'project' }, new Blob(['ok']));
|
||||
expect(cache.mode).toBe('memory');
|
||||
expect(cache.fallbackReason).toContain('quota denied');
|
||||
expect(await cache.get('fallback')).toBeDefined();
|
||||
await cache.clear();
|
||||
expect(await cache.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports quota and unavailable estimate failures', async () => {
|
||||
expect(
|
||||
await readStorageQuota({
|
||||
estimate: async () => ({ usage: 25, quota: 100 }),
|
||||
persisted: async () => true,
|
||||
})
|
||||
).toEqual({
|
||||
available: true,
|
||||
usageBytes: 25,
|
||||
quotaBytes: 100,
|
||||
remainingBytes: 75,
|
||||
usageFraction: 0.25,
|
||||
persisted: true,
|
||||
});
|
||||
expect(
|
||||
await readStorageQuota({
|
||||
estimate: async () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
})
|
||||
).toEqual({ available: false, error: 'denied' });
|
||||
});
|
||||
});
|
||||
|
||||
class ThrowingCache implements DerivedCache {
|
||||
readonly mode = 'opfs' as const;
|
||||
async list(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async get(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async put(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async delete(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async clear(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async clearProject(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async pruneExpired(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async sizeBytes(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
}
|
||||
|
||||
function plan(
|
||||
options: Pick<
|
||||
FFmpegCommandPlan,
|
||||
'operation' | 'args' | 'outputs' | 'expectedDurationSeconds'
|
||||
>
|
||||
): FFmpegCommandPlan {
|
||||
return {
|
||||
id: `job:${options.operation}`,
|
||||
operation: options.operation,
|
||||
inputs: [],
|
||||
temporaryFiles: [],
|
||||
args: options.args,
|
||||
outputs: options.outputs,
|
||||
expectedDurationSeconds: options.expectedDurationSeconds,
|
||||
requiredCapabilities: {},
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function videoProbe(): MediaProbe {
|
||||
return {
|
||||
durationSeconds: 10,
|
||||
bitRate: 800_000,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 25,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function audioVideoProbe(): MediaProbe {
|
||||
const probe = videoProbe();
|
||||
return {
|
||||
...probe,
|
||||
streams: [
|
||||
...probe.streams,
|
||||
{
|
||||
index: 1,
|
||||
type: 'audio',
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
329
tests/unit/waveform-runtime-aggregation.test.ts
Normal file
329
tests/unit/waveform-runtime-aggregation.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
aggregatePcmPeaks,
|
||||
decodePcmSamples,
|
||||
MAX_WAVEFORM_BUCKET_COUNT,
|
||||
WaveformCancellationError,
|
||||
WaveformDataError,
|
||||
} from '../../src/waveform';
|
||||
|
||||
describe('waveform PCM aggregation', () => {
|
||||
it('uses deterministic bucket boundaries for signed 16-bit PCM', () => {
|
||||
const samples = new Int16Array([
|
||||
-32_768, -16_384, 0, 16_384, 32_767, 8_192, -8_192, 0,
|
||||
]);
|
||||
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: samples,
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 4 }
|
||||
);
|
||||
|
||||
expect(peaks.sampleCount).toBe(8);
|
||||
expect(peaks.bucketCount).toBe(4);
|
||||
expect([...peaks.minimum]).toEqual([-1, 0, 0.25, -0.25]);
|
||||
expect([...peaks.maximum]).toEqual([-0.5, 0.5, 32_767 / 32_768, 0]);
|
||||
expect([...peaks.rms]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.closeTo(Math.sqrt(0.625), 6),
|
||||
expect.closeTo(Math.sqrt(0.125), 6),
|
||||
expect.closeTo(Math.sqrt(((32_767 / 32_768) ** 2 + 0.25 ** 2) / 2), 6),
|
||||
expect.closeTo(Math.sqrt(0.03125), 6),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('never creates empty buckets when fewer samples than requested', () => {
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: float32Bytes([0.1, -0.2, 0.3]),
|
||||
encoding: 'f32le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 100 }
|
||||
);
|
||||
|
||||
expect(peaks.bucketCount).toBe(3);
|
||||
expect([...peaks.minimum]).toEqual([
|
||||
expect.closeTo(0.1, 6),
|
||||
expect.closeTo(-0.2, 6),
|
||||
expect.closeTo(0.3, 6),
|
||||
]);
|
||||
expect([...peaks.maximum]).toEqual([...peaks.minimum]);
|
||||
});
|
||||
|
||||
it('assigns every sample exactly once when bucket sizes are uneven', () => {
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([
|
||||
1_000, 2_000, 3_000, -4_000, -5_000, -6_000, 7_000, 8_000, 9_000,
|
||||
10_000,
|
||||
]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 3 }
|
||||
);
|
||||
|
||||
expect([...peaks.minimum]).toEqual([
|
||||
1_000 / 32_768,
|
||||
-6_000 / 32_768,
|
||||
7_000 / 32_768,
|
||||
]);
|
||||
expect([...peaks.maximum]).toEqual([
|
||||
3_000 / 32_768,
|
||||
-4_000 / 32_768,
|
||||
10_000 / 32_768,
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles finite float PCM above full scale without RMS overflow', () => {
|
||||
const maximumFloat = 3.4028234663852886e38;
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: float32Bytes([maximumFloat, -maximumFloat]),
|
||||
encoding: 'f32le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 1 }
|
||||
);
|
||||
|
||||
expect(peaks.minimum[0]).toBe(-maximumFloat);
|
||||
expect(peaks.maximum[0]).toBe(maximumFloat);
|
||||
expect(peaks.rms[0]).toBe(maximumFloat);
|
||||
expect(Number.isFinite(peaks.rms[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed, non-finite, and resource-exceeding PCM', () => {
|
||||
expect(() =>
|
||||
aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Uint8Array([0]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 1 }
|
||||
)
|
||||
).toThrow(WaveformDataError);
|
||||
|
||||
expect(() =>
|
||||
aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: float32Bytes([Number.NaN]),
|
||||
encoding: 'f32le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 1 }
|
||||
)
|
||||
).toThrow(/not finite/u);
|
||||
|
||||
expect(() =>
|
||||
aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([1, 2, 3]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 1, maxSamples: 2 }
|
||||
)
|
||||
).toThrow(/sample analysis limit/u);
|
||||
|
||||
expect(() =>
|
||||
aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([1]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: MAX_WAVEFORM_BUCKET_COUNT + 1 }
|
||||
)
|
||||
).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('parses FFmpeg-style mono RIFF/WAVE PCM and rejects stereo', () => {
|
||||
const wav = createWaveFile({
|
||||
samples: new Int16Array([-32_768, 0, 16_384, 32_767]),
|
||||
sampleRate: 8_000,
|
||||
channels: 1,
|
||||
addOddJunkChunk: true,
|
||||
});
|
||||
|
||||
const decoded = decodePcmSamples({ container: 'wav', data: wav });
|
||||
expect(decoded.encoding).toBe('s16le');
|
||||
expect(decoded.sampleRate).toBe(8_000);
|
||||
expect([...decoded.samples]).toEqual([-1, 0, 0.5, 32_767 / 32_768]);
|
||||
|
||||
const stereo = createWaveFile({
|
||||
samples: new Int16Array([1, 2]),
|
||||
sampleRate: 8_000,
|
||||
channels: 2,
|
||||
});
|
||||
expect(() => decodePcmSamples({ container: 'wav', data: stereo })).toThrow(
|
||||
/requires mono/u
|
||||
);
|
||||
});
|
||||
|
||||
it('parses little-endian float PCM from a sliced WAVE buffer', () => {
|
||||
const wav = createFloatWaveFile([1.25, -0.5], 16_000);
|
||||
const enclosing = new Uint8Array(wav.byteLength + 6);
|
||||
enclosing.set(wav, 3);
|
||||
const sliced = enclosing.subarray(3, 3 + wav.byteLength);
|
||||
|
||||
const decoded = decodePcmSamples({ container: 'wav', data: sliced });
|
||||
expect(decoded.encoding).toBe('f32le');
|
||||
expect(decoded.sampleRate).toBe(16_000);
|
||||
expect([...decoded.samples]).toEqual([1.25, -0.5]);
|
||||
});
|
||||
|
||||
it('rejects truncated WAVE chunks', () => {
|
||||
const wav = createWaveFile({
|
||||
samples: new Int16Array([1, 2]),
|
||||
sampleRate: 8_000,
|
||||
channels: 1,
|
||||
});
|
||||
expect(() =>
|
||||
decodePcmSamples({
|
||||
container: 'wav',
|
||||
data: wav.subarray(0, wav.byteLength - 1),
|
||||
})
|
||||
).toThrow(/truncated|invalid size/u);
|
||||
});
|
||||
|
||||
it('supports cooperative cancellation and bounded progress callbacks', () => {
|
||||
const pcm = new Int16Array(40_000);
|
||||
let cancelled = false;
|
||||
const progress: number[] = [];
|
||||
|
||||
expect(() =>
|
||||
aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: pcm,
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{
|
||||
bucketCount: 100,
|
||||
isCancelled: () => cancelled,
|
||||
onProgress: (value) => {
|
||||
progress.push(value);
|
||||
cancelled = true;
|
||||
},
|
||||
}
|
||||
)
|
||||
).toThrow(WaveformCancellationError);
|
||||
expect(progress[0]).toBe(0);
|
||||
expect(progress.every((value) => value >= 0 && value <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports completion for empty PCM without allocating peak buckets', () => {
|
||||
const progress: number[] = [];
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Uint8Array(),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 10, onProgress: (value) => progress.push(value) }
|
||||
);
|
||||
|
||||
expect(peaks.bucketCount).toBe(0);
|
||||
expect(peaks.durationSeconds).toBe(0);
|
||||
expect(progress).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
function float32Bytes(values: readonly number[]): Uint8Array {
|
||||
const bytes = new Uint8Array(values.length * 4);
|
||||
const view = new DataView(bytes.buffer);
|
||||
values.forEach((value, index) => view.setFloat32(index * 4, value, true));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function createWaveFile(options: {
|
||||
readonly samples: Int16Array;
|
||||
readonly sampleRate: number;
|
||||
readonly channels: number;
|
||||
readonly addOddJunkChunk?: boolean;
|
||||
}): Uint8Array {
|
||||
const junkBytes = options.addOddJunkChunk ? 10 : 0;
|
||||
const result = new Uint8Array(44 + junkBytes + options.samples.byteLength);
|
||||
const view = new DataView(result.buffer);
|
||||
writeAscii(result, 0, 'RIFF');
|
||||
view.setUint32(4, result.byteLength - 8, true);
|
||||
writeAscii(result, 8, 'WAVE');
|
||||
writeAscii(result, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, options.channels, true);
|
||||
view.setUint32(24, options.sampleRate, true);
|
||||
view.setUint32(
|
||||
28,
|
||||
options.sampleRate * options.channels * Int16Array.BYTES_PER_ELEMENT,
|
||||
true
|
||||
);
|
||||
view.setUint16(32, options.channels * Int16Array.BYTES_PER_ELEMENT, true);
|
||||
view.setUint16(34, 16, true);
|
||||
let dataHeaderOffset = 36;
|
||||
if (options.addOddJunkChunk) {
|
||||
writeAscii(result, 36, 'JUNK');
|
||||
view.setUint32(40, 1, true);
|
||||
result[44] = 0x2a;
|
||||
result[45] = 0;
|
||||
dataHeaderOffset += junkBytes;
|
||||
}
|
||||
writeAscii(result, dataHeaderOffset, 'data');
|
||||
view.setUint32(dataHeaderOffset + 4, options.samples.byteLength, true);
|
||||
const sampleOffset = dataHeaderOffset + 8;
|
||||
for (let index = 0; index < options.samples.length; index += 1) {
|
||||
view.setInt16(
|
||||
sampleOffset + index * Int16Array.BYTES_PER_ELEMENT,
|
||||
options.samples[index] ?? 0,
|
||||
true
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createFloatWaveFile(
|
||||
samples: readonly number[],
|
||||
sampleRate: number
|
||||
): Uint8Array {
|
||||
const result = new Uint8Array(44 + samples.length * 4);
|
||||
const view = new DataView(result.buffer);
|
||||
writeAscii(result, 0, 'RIFF');
|
||||
view.setUint32(4, result.byteLength - 8, true);
|
||||
writeAscii(result, 8, 'WAVE');
|
||||
writeAscii(result, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 3, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 4, true);
|
||||
view.setUint16(32, 4, true);
|
||||
view.setUint16(34, 32, true);
|
||||
writeAscii(result, 36, 'data');
|
||||
view.setUint32(40, samples.length * 4, true);
|
||||
samples.forEach((sample, index) =>
|
||||
view.setFloat32(44 + index * 4, sample, true)
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeAscii(target: Uint8Array, offset: number, text: string): void {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
target[offset + index] = text.charCodeAt(index);
|
||||
}
|
||||
}
|
||||
102
tests/unit/waveform-runtime-cache.test.ts
Normal file
102
tests/unit/waveform-runtime-cache.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
aggregatePcmPeaks,
|
||||
createWaveformCacheKey,
|
||||
deserializeWaveformPeaks,
|
||||
serializeWaveformPeaks,
|
||||
WaveformDataError,
|
||||
WAVEFORM_PEAKS_MEDIA_TYPE,
|
||||
} from '../../src/waveform';
|
||||
|
||||
describe('waveform peak cache format', () => {
|
||||
it('round-trips versioned arrays without sharing input storage', () => {
|
||||
const peaks = createPeaks();
|
||||
const serialized = serializeWaveformPeaks(peaks);
|
||||
const restored = deserializeWaveformPeaks(serialized);
|
||||
|
||||
expect(WAVEFORM_PEAKS_MEDIA_TYPE).toBe(
|
||||
'application/vnd.add-ideas.waveform-peaks'
|
||||
);
|
||||
expect(restored).not.toBe(peaks);
|
||||
expect(restored.minimum.buffer).not.toBe(peaks.minimum.buffer);
|
||||
expect(restored).toMatchObject({
|
||||
version: 1,
|
||||
sourceEncoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
sampleCount: 6,
|
||||
durationSeconds: 6 / 8_000,
|
||||
bucketCount: 3,
|
||||
});
|
||||
expect([...restored.minimum]).toEqual([...peaks.minimum]);
|
||||
expect([...restored.maximum]).toEqual([...peaks.maximum]);
|
||||
expect([...restored.rms]).toEqual([...peaks.rms]);
|
||||
});
|
||||
|
||||
it('detects payload corruption, truncation, and unknown versions', () => {
|
||||
const serialized = serializeWaveformPeaks(createPeaks());
|
||||
|
||||
const corrupt = serialized.slice();
|
||||
const lastIndex = corrupt.length - 1;
|
||||
corrupt[lastIndex] = (corrupt[lastIndex] ?? 0) ^ 0xff;
|
||||
expect(() => deserializeWaveformPeaks(corrupt)).toThrow(/checksum/u);
|
||||
|
||||
const corruptHeader = serialized.slice();
|
||||
new DataView(corruptHeader.buffer).setUint32(12, 44_100, true);
|
||||
expect(() => deserializeWaveformPeaks(corruptHeader)).toThrow(/checksum/u);
|
||||
|
||||
expect(() =>
|
||||
deserializeWaveformPeaks(serialized.subarray(0, serialized.length - 1))
|
||||
).toThrow(/payload size/u);
|
||||
|
||||
const unknownVersion = serialized.slice();
|
||||
new DataView(unknownVersion.buffer).setUint16(4, 999, true);
|
||||
expect(() => deserializeWaveformPeaks(unknownVersion)).toThrow(
|
||||
WaveformDataError
|
||||
);
|
||||
});
|
||||
|
||||
it('builds deterministic cache keys from source, stream, and settings', () => {
|
||||
const base = {
|
||||
sourceSize: 1_234_567,
|
||||
sourceLastModified: 1_720_000_000_000,
|
||||
streamIndex: 1,
|
||||
sampleRate: 8_000,
|
||||
bucketCount: 2_048,
|
||||
encoding: 's16le' as const,
|
||||
partialHash: 'sha256-deadbeef',
|
||||
};
|
||||
const first = createWaveformCacheKey(base);
|
||||
const second = createWaveformCacheKey({ ...base });
|
||||
const changed = createWaveformCacheKey({ ...base, streamIndex: 2 });
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^waveform-peaks-[a-f0-9]{16}$/u);
|
||||
expect(changed).not.toBe(first);
|
||||
});
|
||||
|
||||
it('rejects unsafe or incomplete cache identities', () => {
|
||||
expect(() =>
|
||||
createWaveformCacheKey({
|
||||
sourceSize: 1,
|
||||
sourceLastModified: 2,
|
||||
streamIndex: 0,
|
||||
sampleRate: 8_000,
|
||||
bucketCount: 100,
|
||||
encoding: 's16le',
|
||||
partialHash: '../../source',
|
||||
})
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
function createPeaks() {
|
||||
return aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([-20_000, 10_000, -1_000, 2_000, -30_000, 20_000]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 3 }
|
||||
);
|
||||
}
|
||||
114
tests/unit/waveform-runtime-canvas.test.ts
Normal file
114
tests/unit/waveform-runtime-canvas.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
aggregatePcmPeaks,
|
||||
describeWaveform,
|
||||
renderWaveformToCanvas,
|
||||
} from '../../src/waveform';
|
||||
|
||||
describe('waveform canvas rendering', () => {
|
||||
it('renders bounded columns and supplies a text alternative', () => {
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([-32_768, 0, 16_384, 32_767]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 2,
|
||||
},
|
||||
{ bucketCount: 4 }
|
||||
);
|
||||
const { canvas, context, attributes } = createCanvas(200, 80);
|
||||
|
||||
const rendered = renderWaveformToCanvas(canvas, peaks, {
|
||||
devicePixelRatio: 2,
|
||||
startBucket: 1,
|
||||
endBucket: 4,
|
||||
selection: { startFraction: 0.25, endFraction: 0.75 },
|
||||
});
|
||||
|
||||
expect(canvas.width).toBe(400);
|
||||
expect(canvas.height).toBe(160);
|
||||
expect(rendered.startBucket).toBe(1);
|
||||
expect(rendered.endBucket).toBe(4);
|
||||
expect(rendered.accessibleLabel).toContain('Audio waveform');
|
||||
expect(attributes.get('role')).toBe('img');
|
||||
expect(attributes.get('aria-label')).toBe(rendered.accessibleLabel);
|
||||
expect(context.moveTo).toHaveBeenCalled();
|
||||
expect(context.lineTo).toHaveBeenCalled();
|
||||
expect(context.fillRect).toHaveBeenCalledWith(50, 0, 100, 80);
|
||||
});
|
||||
|
||||
it('describes empty waveforms and respects an explicit accessible label', () => {
|
||||
const empty = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Uint8Array(),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 1 }
|
||||
);
|
||||
const { canvas, attributes } = createCanvas(100, 40);
|
||||
const result = renderWaveformToCanvas(canvas, empty, {
|
||||
accessibleLabel: 'No audible content',
|
||||
});
|
||||
|
||||
expect(describeWaveform(empty)).toBe('Empty audio waveform.');
|
||||
expect(result.accessibleLabel).toBe('No audible content');
|
||||
expect(attributes.get('aria-label')).toBe('No audible content');
|
||||
});
|
||||
|
||||
it('rejects unsafe dimensions, ranges, and selections', () => {
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array([1, 2]),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 2 }
|
||||
);
|
||||
const { canvas } = createCanvas(100, 40);
|
||||
|
||||
expect(() =>
|
||||
renderWaveformToCanvas(canvas, peaks, { width: 100_000 })
|
||||
).toThrow(/safe limits/u);
|
||||
expect(() =>
|
||||
renderWaveformToCanvas(canvas, peaks, {
|
||||
startBucket: 2,
|
||||
endBucket: 1,
|
||||
})
|
||||
).toThrow(/bucket range/u);
|
||||
expect(() =>
|
||||
renderWaveformToCanvas(canvas, peaks, {
|
||||
selection: { startFraction: -1, endFraction: 0.5 },
|
||||
})
|
||||
).toThrow(/selection/u);
|
||||
});
|
||||
});
|
||||
|
||||
function createCanvas(width: number, height: number) {
|
||||
const attributes = new Map<string, string>();
|
||||
const context = {
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
};
|
||||
const canvas = {
|
||||
width,
|
||||
height,
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
getContext: vi.fn(() => context),
|
||||
setAttribute: vi.fn((name: string, value: string) => {
|
||||
attributes.set(name, value);
|
||||
}),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
return { canvas, context, attributes };
|
||||
}
|
||||
211
tests/unit/waveform-runtime-worker.test.ts
Normal file
211
tests/unit/waveform-runtime-worker.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
aggregatePcmPeaks,
|
||||
createWaveformRuntime,
|
||||
WaveformCancellationError,
|
||||
WaveformRuntimeBusyError,
|
||||
WaveformRuntimeTerminatedError,
|
||||
type WaveformWorkerLike,
|
||||
} from '../../src/waveform';
|
||||
import type { WaveformWorkerAggregateRequest } from '../../src/waveform/waveform.types';
|
||||
|
||||
describe('waveform worker runtime', () => {
|
||||
it('offloads aggregation, reports progress, and preserves caller bytes', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const input = new Int16Array([-32_768, 0, 16_384, 32_767]);
|
||||
const originalBytes = [...new Uint8Array(input.buffer)];
|
||||
const progress = vi.fn();
|
||||
const runtime = createWaveformRuntime({ workerFactory: () => worker });
|
||||
|
||||
const resultPromise = runtime.aggregate(
|
||||
{
|
||||
container: 'raw',
|
||||
data: input,
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 2, onProgress: progress }
|
||||
);
|
||||
const request = worker.lastRequest;
|
||||
expect(request).toBeDefined();
|
||||
expect(request?.input.data).not.toBe(input.buffer);
|
||||
expect([...new Uint8Array(input.buffer)]).toEqual(originalBytes);
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'progress',
|
||||
requestId: request?.requestId,
|
||||
progress: 0.5,
|
||||
});
|
||||
const peaks = aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: request?.input.data ?? new ArrayBuffer(0),
|
||||
encoding: 's16le',
|
||||
sampleRate: 8_000,
|
||||
},
|
||||
{ bucketCount: 2 }
|
||||
);
|
||||
worker.emitMessage({
|
||||
type: 'result',
|
||||
requestId: request?.requestId,
|
||||
peaks,
|
||||
});
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ bucketCount: 2 });
|
||||
expect(progress).toHaveBeenCalledWith(0.5);
|
||||
expect(progress).toHaveBeenLastCalledWith(1);
|
||||
expect(worker.terminated).toBe(true);
|
||||
expect(runtime.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('terminates the worker on AbortSignal cancellation and remains reusable', async () => {
|
||||
const workers: FakeWorker[] = [];
|
||||
const runtime = createWaveformRuntime({
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const first = runtime.aggregate(rawInput(), {
|
||||
bucketCount: 2,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(first).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
expect(workers[0]?.terminated).toBe(true);
|
||||
expect(runtime.isActive).toBe(false);
|
||||
|
||||
const second = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
const request = workers[1]?.lastRequest;
|
||||
const peaks = aggregatePcmPeaks(rawInput(), { bucketCount: 2 });
|
||||
workers[1]?.emitMessage({
|
||||
type: 'result',
|
||||
requestId: request?.requestId,
|
||||
peaks,
|
||||
});
|
||||
await expect(second).resolves.toMatchObject({ bucketCount: 2 });
|
||||
});
|
||||
|
||||
it('supports explicit cancellation and rejects overlapping work', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const runtime = createWaveformRuntime({ workerFactory: () => worker });
|
||||
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
|
||||
await expect(
|
||||
runtime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).rejects.toBeInstanceOf(WaveformRuntimeBusyError);
|
||||
expect(runtime.cancelActive()).toBe(true);
|
||||
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
expect(runtime.cancelActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back synchronously when workers are unavailable or blocked', async () => {
|
||||
const runtime = createWaveformRuntime({ workerFactory: null });
|
||||
await expect(
|
||||
runtime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).resolves.toMatchObject({ bucketCount: 2 });
|
||||
|
||||
const blockedRuntime = createWaveformRuntime({
|
||||
workerFactory: () => {
|
||||
throw new Error('CSP blocked worker');
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
blockedRuntime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).resolves.toMatchObject({ bucketCount: 2 });
|
||||
expect(blockedRuntime.mode).toBe('synchronous');
|
||||
});
|
||||
|
||||
it('tracks and can cancel synchronous fallback before it starts', async () => {
|
||||
const runtime = createWaveformRuntime({ workerFactory: null });
|
||||
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
|
||||
expect(runtime.isActive).toBe(true);
|
||||
const overlapping = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
expect(runtime.cancelActive()).toBe(true);
|
||||
await expect(overlapping).rejects.toBeInstanceOf(WaveformRuntimeBusyError);
|
||||
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
expect(runtime.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('makes terminate permanent and cancels active work', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const runtime = createWaveformRuntime({ workerFactory: () => worker });
|
||||
const active = runtime.aggregate(rawInput(), { bucketCount: 2 });
|
||||
runtime.terminate();
|
||||
|
||||
await expect(active).rejects.toBeInstanceOf(WaveformCancellationError);
|
||||
await expect(
|
||||
runtime.aggregate(rawInput(), { bucketCount: 2 })
|
||||
).rejects.toBeInstanceOf(WaveformRuntimeTerminatedError);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeWorker implements WaveformWorkerLike {
|
||||
readonly #messageListeners = new Set<
|
||||
(event: MessageEvent<unknown>) => void
|
||||
>();
|
||||
readonly #errorListeners = new Set<(event: Event) => void>();
|
||||
readonly #messageErrorListeners = new Set<(event: Event) => void>();
|
||||
lastRequest: WaveformWorkerAggregateRequest | undefined;
|
||||
terminated = false;
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.lastRequest = message as WaveformWorkerAggregateRequest;
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
type: 'message' | 'error' | 'messageerror',
|
||||
listener:
|
||||
((event: MessageEvent<unknown>) => void) | ((event: Event) => void)
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
this.#messageListeners.add(
|
||||
listener as (event: MessageEvent<unknown>) => void
|
||||
);
|
||||
} else if (type === 'error') {
|
||||
this.#errorListeners.add(listener as (event: Event) => void);
|
||||
} else {
|
||||
this.#messageErrorListeners.add(listener as (event: Event) => void);
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
type: 'message' | 'error' | 'messageerror',
|
||||
listener:
|
||||
((event: MessageEvent<unknown>) => void) | ((event: Event) => void)
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
this.#messageListeners.delete(
|
||||
listener as (event: MessageEvent<unknown>) => void
|
||||
);
|
||||
} else if (type === 'error') {
|
||||
this.#errorListeners.delete(listener as (event: Event) => void);
|
||||
} else {
|
||||
this.#messageErrorListeners.delete(listener as (event: Event) => void);
|
||||
}
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
emitMessage(data: unknown): void {
|
||||
const event = { data } as MessageEvent<unknown>;
|
||||
for (const listener of this.#messageListeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rawInput() {
|
||||
return {
|
||||
container: 'raw' as const,
|
||||
data: new Int16Array([-100, 100, -200, 200]),
|
||||
encoding: 's16le' as const,
|
||||
sampleRate: 8_000,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user