feat: make media inspection progressive
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
startNestedStaticServer,
|
||||
type NestedStaticServer,
|
||||
} from './browser-helpers';
|
||||
@@ -119,6 +120,38 @@ test.describe('static application and Toolbox shell', () => {
|
||||
await expect(inspectorDrawer).toBeHidden();
|
||||
await expect(inspector).toBeFocused();
|
||||
});
|
||||
|
||||
test('shows native preview metadata without loading the FFmpeg runtime', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page
|
||||
.locator('input[type="file"][accept*="audio"]')
|
||||
.first()
|
||||
.setInputFiles(PRIMARY_FIXTURE);
|
||||
|
||||
await expect(page.locator('.quick-preview video')).toBeVisible();
|
||||
const card = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await expect(card.locator('.phase--idle')).toContainText(
|
||||
'Ready to play · Details on demand'
|
||||
);
|
||||
await expect(card).toContainText(/\d+:\d{2}\.\d{2}/u);
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Inspect & render timeline' })
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('nested static deployment and Toolbox context', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const REPOSITORY_ROOT = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -19,6 +20,20 @@ export const GENERATED_FIXTURES = join(
|
||||
);
|
||||
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
||||
|
||||
export async function inspectMediaDetails(
|
||||
page: Page,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const card = page.locator('.media-card').filter({ hasText: fileName });
|
||||
const inspect = page.getByRole('button', {
|
||||
name: `Inspect details for ${fileName}`,
|
||||
});
|
||||
if ((await inspect.count()) > 0) await inspect.click();
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
const NESTED_PREFIX = '/deep/nested/av/';
|
||||
const SECURITY_HEADERS = {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
createCancellationFixture,
|
||||
GENERATED_FIXTURES,
|
||||
inspectMediaDetails,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type TemporaryMediaFixture,
|
||||
@@ -99,8 +100,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBe(false);
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
@@ -184,8 +186,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBe(false);
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
@@ -275,6 +278,7 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await mediaInput(page)
|
||||
.first()
|
||||
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
||||
await inspectMediaDetails(page, 'compatible-a.mp4');
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
@@ -329,8 +333,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||
await inspectMediaDetails(page, basename(cancellationFixture.filePath));
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
|
||||
@@ -357,8 +362,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
})
|
||||
.click();
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
@@ -388,10 +394,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
const queuedCard = page.locator('.media-card').filter({
|
||||
hasText: 'pattern-av.mp4',
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Inspect details for ${activeName}`,
|
||||
})
|
||||
.click();
|
||||
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(queuedCard.locator('.phase--queued')).toBeVisible();
|
||||
await expect(queuedCard.locator('.phase--idle')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
|
||||
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
|
||||
@@ -401,8 +412,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBeEnabled({ timeout: 120_000 });
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
GENERATED_FIXTURES,
|
||||
inspectMediaDetails,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type RuntimeObservation,
|
||||
@@ -51,8 +52,9 @@ test.describe
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
||||
);
|
||||
await inspectMediaDetails(page, 'attached-cover.mp3');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 60_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
@@ -108,13 +110,23 @@ test.describe
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'truncated.mp4')
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Inspect details for truncated.mp4',
|
||||
})
|
||||
.click();
|
||||
const malformedCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'truncated.mp4' });
|
||||
await expect(malformedCard.locator('.phase--error')).toContainText(
|
||||
'ffprobe did not find any media streams.',
|
||||
'File ready · Details unavailable',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Retry detailed inspection for truncated.mp4',
|
||||
})
|
||||
).toHaveAttribute('title', 'ffprobe did not find any media streams.');
|
||||
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
||||
await expect(
|
||||
page
|
||||
@@ -123,6 +135,7 @@ test.describe
|
||||
).toBeVisible();
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
const validCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
const mediaPath = process.env.AV_TOOLS_LARGE_MEDIA;
|
||||
|
||||
test('plays a recognized large local video while inspection continues', async ({
|
||||
test('plays a recognized large local video before detailed inspection', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
@@ -27,7 +27,15 @@ test('plays a recognized large local video while inspection continues', async ({
|
||||
await expect(preview).toBeVisible({ timeout: 15_000 });
|
||||
const previewVisibleMilliseconds = Date.now() - startedAt;
|
||||
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
|
||||
await expect(card.locator('.phase--probing')).toBeVisible();
|
||||
await expect(card.locator('.phase--idle')).toContainText('Details on demand');
|
||||
await expect(card).toContainText(/43:42\.\d{2}/u, { timeout: 15_000 });
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
performance
|
||||
.getEntriesByType('resource')
|
||||
.some((entry) => entry.name.includes('/vendor/ffmpeg/'))
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
const elementIdentity = await preview.evaluate((element) => {
|
||||
element.dataset.evidenceIdentity = crypto.randomUUID();
|
||||
@@ -50,9 +58,11 @@ test('plays a recognized large local video while inspection continues', async ({
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 330_000,
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: `Inspect details for ${name}` })
|
||||
.click();
|
||||
await expect(card.locator('.phase--probing')).toBeVisible();
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({ timeout: 120_000 });
|
||||
await expect(preview).toHaveAttribute(
|
||||
'data-evidence-identity',
|
||||
elementIdentity
|
||||
|
||||
@@ -31,6 +31,9 @@ async function importMedia(
|
||||
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
|
||||
for (const name of names) {
|
||||
const card = page.locator('.media-card').filter({ hasText: name });
|
||||
await page
|
||||
.getByRole('button', { name: `Inspect details for ${name}` })
|
||||
.click();
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -210,6 +213,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
||||
const sourceName = basename(cancellationFixture.filePath);
|
||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Inspect details for ${sourceName}`,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
@@ -1055,6 +1063,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
mimeType: 'video/x-matroska',
|
||||
buffer: readFileSync(muxed.filePath),
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Inspect details for subtitled-source.mkv',
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
|
||||
@@ -103,8 +103,9 @@ async function runMode(
|
||||
|
||||
const probeStarted = await monotonicNow(page);
|
||||
await mediaInput(page).setInputFiles(fixturePath);
|
||||
await page.getByRole('button', { name: /^Inspect details for /u }).click();
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
const probeMs = (await monotonicNow(page)) - probeStarted;
|
||||
|
||||
@@ -21,8 +21,11 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
|
||||
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||
.click();
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
||||
@@ -47,6 +50,9 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
||||
await mediaInput(page).setInputFiles(compatibleB);
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||
.click();
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -127,6 +133,12 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
||||
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||
.click();
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
93
tests/unit/components/media-bin.test.tsx
Normal file
93
tests/unit/components/media-bin.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { cleanup, render, screen } 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 { MediaBin } from '../../../src/components/MediaBin';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('MediaBin', () => {
|
||||
it('shows browser metadata and offers optional detailed inspection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInspect = vi.fn();
|
||||
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||
const asset: ImportedMediaAsset = {
|
||||
id: 'lecture',
|
||||
file,
|
||||
objectUrl: 'blob:lecture',
|
||||
phase: 'idle',
|
||||
browserMetadata: {
|
||||
source: 'browser',
|
||||
file: {
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
declaredMimeType: file.type,
|
||||
inferredMimeType: file.type,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
kind: 'video',
|
||||
durationSeconds: 2_622.48,
|
||||
width: 640,
|
||||
height: 480,
|
||||
hasVideo: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<MediaBin
|
||||
assets={[asset]}
|
||||
selectedId={asset.id}
|
||||
onSelect={vi.fn()}
|
||||
onInspect={onInspect}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||
expect(screen.getByText('Ready to play · Details on demand')).toBeVisible();
|
||||
expect(screen.getByText('lecture.mp4').closest('button')).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Inspect details for lecture.mp4',
|
||||
})
|
||||
);
|
||||
expect(onInspect).toHaveBeenCalledWith(asset.id);
|
||||
});
|
||||
|
||||
it('keeps a source ready to play after detailed inspection fails', () => {
|
||||
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||
const asset: ImportedMediaAsset = {
|
||||
id: 'lecture',
|
||||
file,
|
||||
objectUrl: 'blob:lecture',
|
||||
phase: 'error',
|
||||
error: 'Media inspection exceeded its safety limit.',
|
||||
};
|
||||
|
||||
render(
|
||||
<MediaBin
|
||||
assets={[asset]}
|
||||
onSelect={vi.fn()}
|
||||
onInspect={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('File ready · Details unavailable')).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Retry detailed inspection for lecture.mp4',
|
||||
})
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Retry detailed inspection for lecture.mp4',
|
||||
})
|
||||
).toHaveAccessibleDescription(
|
||||
'Media inspection exceeded its safety limit.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('MediaPreview', () => {
|
||||
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local source preview · Media inspection continues in the background.'
|
||||
'Local source preview · Detailed inspection runs separately.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
@@ -61,6 +61,64 @@ describe('MediaPreview', () => {
|
||||
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||
});
|
||||
|
||||
it('keeps browser playback mounted after detailed inspection fails', () => {
|
||||
const asset: ImportedMediaAsset = {
|
||||
id: 'asset-probe-error',
|
||||
file: new File(['video'], 'lecture.mp4', { type: 'video/mp4' }),
|
||||
objectUrl: 'blob:probe-error',
|
||||
phase: 'error',
|
||||
error: 'Detailed inspection timed out.',
|
||||
};
|
||||
|
||||
const { container } = render(<MediaPreview asset={asset} />);
|
||||
|
||||
expect(container.querySelector('video')).toHaveAttribute(
|
||||
'src',
|
||||
asset.objectUrl
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local source preview · Detailed inspection unavailable.'
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the video-container player when browser metadata has no track hints', () => {
|
||||
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
const initial: ImportedMediaAsset = {
|
||||
id: 'asset-audio-only-container',
|
||||
file,
|
||||
objectUrl: 'blob:audio-only-container',
|
||||
phase: 'idle',
|
||||
};
|
||||
const { container, rerender } = render(<MediaPreview asset={initial} />);
|
||||
const player = requiredElement(container.querySelector('video'));
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={{
|
||||
...initial,
|
||||
browserMetadata: {
|
||||
source: 'browser',
|
||||
file: {
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
declaredMimeType: file.type,
|
||||
inferredMimeType: file.type,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
kind: 'video',
|
||||
durationSeconds: 8,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('video')).toBe(player);
|
||||
});
|
||||
|
||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateProxy = vi.fn();
|
||||
|
||||
@@ -16,6 +16,66 @@ import type { UserExportPreset } from '../../../src/presets';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('QuickConvert', () => {
|
||||
it('keeps an uninspected source usable and requests stream detection with export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInspect = vi.fn();
|
||||
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||
const file = new File(['media'], 'lecture.mp4', { type: 'video/mp4' });
|
||||
const asset: ImportedMediaAsset = {
|
||||
id: 'asset-basic',
|
||||
file,
|
||||
objectUrl: 'blob:asset-basic',
|
||||
phase: 'idle',
|
||||
browserMetadata: {
|
||||
source: 'browser',
|
||||
file: {
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
declaredMimeType: file.type,
|
||||
inferredMimeType: file.type,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
kind: 'video',
|
||||
durationSeconds: 2_622.48,
|
||||
width: 640,
|
||||
height: 480,
|
||||
hasVideo: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<QuickConvert
|
||||
asset={asset}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onInspect={onInspect}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||
expect(screen.getByText('Ready to play')).toBeVisible();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Inspect details now' })
|
||||
);
|
||||
expect(onInspect).toHaveBeenCalledOnce();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Inspect & convert' }));
|
||||
expect(onExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'convert',
|
||||
selectDetectedStreams: true,
|
||||
streamSelection: {
|
||||
video: [],
|
||||
audio: [],
|
||||
subtitles: [],
|
||||
attachments: [],
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('labels a submission honestly when another operation is active', () => {
|
||||
render(
|
||||
<QuickConvert
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||
canReuseProbeCapabilities,
|
||||
engineJobTimeoutMilliseconds,
|
||||
prepareEngineArguments,
|
||||
probeTimeoutMilliseconds,
|
||||
shouldRefreshCoreBeforeProbe,
|
||||
shouldRecycleExecutionCore,
|
||||
} from '../../src/ffmpeg/EngineManager';
|
||||
|
||||
@@ -127,6 +129,40 @@ describe('probeTimeoutMilliseconds', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox probe core refresh policy', () => {
|
||||
it('refreshes only affected Firefox-family runtimes', () => {
|
||||
expect(
|
||||
shouldRefreshCoreBeforeProbe(
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRefreshCoreBeforeProbe(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) FxiOS/142.0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRefreshCoreBeforeProbe(
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(shouldRefreshCoreBeforeProbe('')).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses cached capabilities only for the identical engine mode', () => {
|
||||
expect(canReuseProbeCapabilities('multithread', 'multithread')).toBe(true);
|
||||
expect(canReuseProbeCapabilities('single-thread', 'single-thread')).toBe(
|
||||
true
|
||||
);
|
||||
expect(canReuseProbeCapabilities('multithread', 'single-thread')).toBe(
|
||||
false
|
||||
);
|
||||
expect(canReuseProbeCapabilities('single-thread', 'multithread')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
187
tests/unit/media/browser-metadata.test.ts
Normal file
187
tests/unit/media/browser-metadata.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
BrowserMetadataError,
|
||||
browserMediaFileInfo,
|
||||
inferBrowserMediaKind,
|
||||
inspectBrowserMediaMetadata,
|
||||
} from '../../../src/media/browser-metadata';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('browser media metadata', () => {
|
||||
it('returns file information synchronously and infers useful media kinds', () => {
|
||||
const file = new File(['media'], 'lecture.MP4', {
|
||||
type: 'application/octet-stream',
|
||||
lastModified: 123,
|
||||
});
|
||||
|
||||
expect(browserMediaFileInfo(file)).toEqual({
|
||||
name: 'lecture.MP4',
|
||||
sizeBytes: 5,
|
||||
declaredMimeType: 'application/octet-stream',
|
||||
inferredMimeType: 'video/mp4',
|
||||
lastModified: 123,
|
||||
});
|
||||
expect(inferBrowserMediaKind(file)).toBe('video');
|
||||
expect(
|
||||
inferBrowserMediaKind(
|
||||
new File([], 'recording.bin', { type: 'audio/ogg; codecs=opus' })
|
||||
)
|
||||
).toBe('audio');
|
||||
expect(inferBrowserMediaKind(new File([], 'notes.txt'))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('loads duration and dimensions through a native video element', async () => {
|
||||
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||
const video = document.createElement('video');
|
||||
const load = vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||
defineNumber(video, 'duration', 2_622.48);
|
||||
defineNumber(video, 'videoWidth', 640);
|
||||
defineNumber(video, 'videoHeight', 480);
|
||||
Object.defineProperty(video, 'audioTracks', {
|
||||
configurable: true,
|
||||
value: { length: 1 },
|
||||
});
|
||||
|
||||
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:lecture', {
|
||||
createMediaElement: () => video,
|
||||
});
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
source: 'browser',
|
||||
file: {
|
||||
name: 'lecture.mp4',
|
||||
sizeBytes: 5,
|
||||
declaredMimeType: 'video/mp4',
|
||||
inferredMimeType: 'video/mp4',
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
kind: 'video',
|
||||
durationSeconds: 2_622.48,
|
||||
width: 640,
|
||||
height: 480,
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
});
|
||||
expect(video.preload).toBe('metadata');
|
||||
expect(video).not.toHaveAttribute('src');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses an audio element and leaves unavailable details unknown', async () => {
|
||||
const file = new File(['audio'], 'speech.flac', { type: 'audio/flac' });
|
||||
const audio = document.createElement('audio');
|
||||
vi.spyOn(audio, 'load').mockImplementation(() => undefined);
|
||||
defineNumber(audio, 'duration', 12.5);
|
||||
|
||||
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:speech', {
|
||||
createMediaElement: (kind) => {
|
||||
expect(kind).toBe('audio');
|
||||
return audio;
|
||||
},
|
||||
});
|
||||
audio.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
source: 'browser',
|
||||
kind: 'audio',
|
||||
durationSeconds: 12.5,
|
||||
hasAudio: true,
|
||||
hasVideo: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a video-container player viable when dimensions and audio hints are absent', async () => {
|
||||
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
const video = document.createElement('video');
|
||||
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||
defineNumber(video, 'duration', 8);
|
||||
defineNumber(video, 'videoWidth', 0);
|
||||
defineNumber(video, 'videoHeight', 0);
|
||||
Object.defineProperty(video, 'audioTracks', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:audio-only', {
|
||||
createMediaElement: () => video,
|
||||
});
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
kind: 'video',
|
||||
durationSeconds: 8,
|
||||
hasVideo: undefined,
|
||||
hasAudio: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports load errors without revoking the caller-owned object URL', async () => {
|
||||
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL');
|
||||
const video = document.createElement('video');
|
||||
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||
const resultPromise = inspectBrowserMediaMetadata(
|
||||
new File([], 'broken.mp4', { type: 'video/mp4' }),
|
||||
'blob:broken',
|
||||
{ createMediaElement: () => video }
|
||||
);
|
||||
|
||||
video.dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(resultPromise).rejects.toMatchObject({
|
||||
name: 'BrowserMetadataError',
|
||||
code: 'load-error',
|
||||
});
|
||||
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports cancellation and a bounded metadata timeout', async () => {
|
||||
const file = new File([], 'long.mp4', { type: 'video/mp4' });
|
||||
const controller = new AbortController();
|
||||
const firstVideo = document.createElement('video');
|
||||
vi.spyOn(firstVideo, 'load').mockImplementation(() => undefined);
|
||||
const cancelled = inspectBrowserMediaMetadata(file, 'blob:cancel', {
|
||||
createMediaElement: () => firstVideo,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(cancelled).rejects.toMatchObject({
|
||||
code: 'aborted',
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
const secondVideo = document.createElement('video');
|
||||
vi.spyOn(secondVideo, 'load').mockImplementation(() => undefined);
|
||||
const timedOut = inspectBrowserMediaMetadata(file, 'blob:timeout', {
|
||||
createMediaElement: () => secondVideo,
|
||||
timeoutMilliseconds: 25,
|
||||
});
|
||||
const timeoutExpectation = expect(timedOut).rejects.toEqual(
|
||||
new BrowserMetadataError(
|
||||
'timeout',
|
||||
'Browser metadata loading exceeded 1 seconds.'
|
||||
)
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await timeoutExpectation;
|
||||
});
|
||||
});
|
||||
|
||||
function defineNumber(
|
||||
target: HTMLMediaElement,
|
||||
property: 'duration' | 'videoHeight' | 'videoWidth',
|
||||
value: number
|
||||
): void {
|
||||
Object.defineProperty(target, property, {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user