feat: make media inspection progressive

This commit is contained in:
2026-07-27 18:25:47 +02:00
parent 6c68f9bfbd
commit f2c0fc12d2
32 changed files with 2169 additions and 462 deletions

View 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.'
);
});
});

View File

@@ -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();

View File

@@ -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