feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions
+256
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.1000.700 s; UTF-8 text | cue text `Generated subtitle` | literal generated text; SRT import and shift |
| `subtitle.vtt` | `6e19fc5920479512739e126433c7d80fbd168fc8a34786a30d2e270f48da8bff` | cue 0.1000.700 s; UTF-8 text | cue text `Generated subtitle` | literal generated text; WebVTT import and shift |
| `subtitle.ass` | `cb1c44b5808a5aaa173d756d0b819b282138f73ce50ca9c883ee377eb646ddd8` | dialogue 0.1000.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 00.5 s and 0.51.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
View 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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12
View 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
View File
@@ -0,0 +1,3 @@
1
00:00:00,100 --> 00:00:00,700
Generated subtitle
+4
View File
@@ -0,0 +1,4 @@
WEBVTT
00:00:00.100 --> 00:00:00.700
Generated subtitle
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+555
View 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 s30 s')).toBeVisible();
expect(within(ranges).getByText('30 s60 s')).toBeVisible();
expect(within(ranges).getByText('60 s90 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 s30 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
View 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
View 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('..');
});
});
@@ -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
View 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,
});
});
});
@@ -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;
}
@@ -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
View 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.25000: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',
};
}
@@ -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;
}
@@ -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;
}
@@ -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.25000: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());
});
});
@@ -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.00000: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.00000: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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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));
}
}
@@ -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
View 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
View 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
View 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
View 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
View 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,
},
};
}
@@ -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
View 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();
});
});
@@ -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');
});
});
@@ -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',
})
);
}
});
});
@@ -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
View 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
View 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: {},
},
],
};
}
@@ -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
View 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
View 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
View 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,
};
}