feat: publish av-tools 0.2.0
This commit is contained in:
@@ -191,12 +191,12 @@ test.describe('nested static deployment and Toolbox context', () => {
|
||||
|
||||
const coreScript = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js'
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/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'
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(coreScript).toMatchObject({
|
||||
|
||||
@@ -75,9 +75,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
);
|
||||
|
||||
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',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||
];
|
||||
for (const suffix of expectedAssets) {
|
||||
expect(
|
||||
@@ -87,11 +87,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
const wasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm')
|
||||
response.url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(wasm?.contentType).toContain('application/wasm');
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/'))
|
||||
runtime.requests.some((url) =>
|
||||
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/')
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
@@ -119,8 +123,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
|
||||
route.abort('failed')
|
||||
await page.route(
|
||||
'**/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/**',
|
||||
(route) => route.abort('failed')
|
||||
);
|
||||
await page.goto('/');
|
||||
const environment = await page.evaluate(() => ({
|
||||
@@ -147,13 +152,17 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/'))
|
||||
runtime.requests.some((url) =>
|
||||
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.responses.some(
|
||||
(response) =>
|
||||
response.status === 200 &&
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
response.url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
@@ -247,7 +256,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
@@ -303,7 +314,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toHaveCount(4);
|
||||
expect(
|
||||
runtime.requests.filter((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toHaveLength(2);
|
||||
assertLocalOnly(runtime);
|
||||
@@ -356,4 +369,43 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('removes active and queued inspections without applying stale probe results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
|
||||
await mediaInput(page).setInputFiles([
|
||||
cancellationFixture.filePath,
|
||||
PRIMARY_FIXTURE,
|
||||
]);
|
||||
const activeName = basename(cancellationFixture.filePath);
|
||||
const activeCard = page.locator('.media-card').filter({
|
||||
hasText: activeName,
|
||||
});
|
||||
const queuedCard = page.locator('.media-card').filter({
|
||||
hasText: 'pattern-av.mp4',
|
||||
});
|
||||
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(queuedCard.locator('.phase--queued')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
|
||||
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
|
||||
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled({ timeout: 120_000 });
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
|
||||
61
tests/browser/large-media-preview.spec.ts
Normal file
61
tests/browser/large-media-preview.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!mediaPath || !existsSync(mediaPath),
|
||||
'Set AV_TOOLS_LARGE_MEDIA to a local video for this opt-in evidence run.'
|
||||
);
|
||||
test.setTimeout(360_000);
|
||||
await page.goto('/');
|
||||
|
||||
const startedAt = Date.now();
|
||||
await page
|
||||
.locator('input[type="file"][accept*="audio"]')
|
||||
.first()
|
||||
.setInputFiles(mediaPath as string);
|
||||
const name = basename(mediaPath as string);
|
||||
const card = page.locator('.media-card').filter({ hasText: name });
|
||||
const preview = page.locator('.preview video');
|
||||
await expect(preview).toBeVisible({ timeout: 15_000 });
|
||||
const previewVisibleMilliseconds = Date.now() - startedAt;
|
||||
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
|
||||
await expect(card.locator('.phase--probing')).toBeVisible();
|
||||
|
||||
const elementIdentity = await preview.evaluate((element) => {
|
||||
element.dataset.evidenceIdentity = crypto.randomUUID();
|
||||
return element.dataset.evidenceIdentity;
|
||||
});
|
||||
await preview.evaluate(async (element) => {
|
||||
const video = element as HTMLVideoElement;
|
||||
video.muted = true;
|
||||
await video.play();
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
preview.evaluate(
|
||||
(element) => (element as HTMLVideoElement).currentTime
|
||||
),
|
||||
{
|
||||
timeout: 15_000,
|
||||
}
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 330_000,
|
||||
});
|
||||
await expect(preview).toHaveAttribute(
|
||||
'data-evidence-identity',
|
||||
elementIdentity
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
@@ -149,7 +149,7 @@ function probe(filePath: string): {
|
||||
tags?: Record<string, string>;
|
||||
disposition?: Record<string, number>;
|
||||
}>;
|
||||
format?: { tags?: Record<string, string> };
|
||||
format?: { duration?: string; tags?: Record<string, string> };
|
||||
chapters?: Array<{ tags?: Record<string, string> }>;
|
||||
} {
|
||||
return JSON.parse(
|
||||
@@ -547,6 +547,31 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
|
||||
await structural.getByLabel('Crossfade duration').fill('0.2');
|
||||
const crossfadeAction = structural.getByRole('button', {
|
||||
name: 'Export normalized concat',
|
||||
});
|
||||
if (await crossfadeAction.isEnabled()) {
|
||||
const [crossfadeCard] = await runForResults(
|
||||
page,
|
||||
() => crossfadeAction.click(),
|
||||
['compatible-a-concat.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const crossfadeDuration = Number(
|
||||
probe((await downloadResult(page, crossfadeCard as Locator)).filePath)
|
||||
.format?.duration
|
||||
);
|
||||
expect(crossfadeDuration).toBeGreaterThan(1.35);
|
||||
expect(crossfadeDuration).toBeLessThan(1.75);
|
||||
} else {
|
||||
await expect(
|
||||
structural.getByText(
|
||||
/required browser and FFmpeg capabilities have not been verified|filter (?:x|a)fade/iu
|
||||
)
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
@@ -687,6 +712,72 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
|
||||
.size
|
||||
).toBeGreaterThan(500);
|
||||
|
||||
await importMedia(page, ['pattern-av.mp4']);
|
||||
const patternClip = page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await patternClip.locator('.timeline-clip__main').click();
|
||||
const imageSelection = advanced.getByLabel('Frame selection').first();
|
||||
const sceneOption = imageSelection.locator(
|
||||
'option[value="scene-changes"]'
|
||||
);
|
||||
if (!(await sceneOption.isDisabled())) {
|
||||
await imageSelection.selectOption('scene-changes');
|
||||
await advanced.getByLabel('Number of frames').fill('2');
|
||||
await advanced.getByLabel('Scene sensitivity threshold').fill('0.01');
|
||||
await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
.click(),
|
||||
[
|
||||
'pattern-av-thumbnail-001-83ms.jpg',
|
||||
'pattern-av-thumbnail-002-1000ms.jpg',
|
||||
]
|
||||
);
|
||||
|
||||
const contactSelection = advanced.getByLabel('Frame selection').nth(1);
|
||||
await contactSelection.selectOption('scene-changes');
|
||||
await advanced.getByLabel('Contact-sheet scene threshold').fill('0.01');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Frames', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Rows', exact: true })
|
||||
.fill('1');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Columns', exact: true })
|
||||
.fill('2');
|
||||
const timestampLabels = advanced.getByLabel('Timestamp labels');
|
||||
if (await timestampLabels.isEnabled()) {
|
||||
await timestampLabels.check();
|
||||
await advanced.getByLabel('Source filename label').check();
|
||||
}
|
||||
const [sceneSheetCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate contact sheet' })
|
||||
.click(),
|
||||
['pattern-av-contact-sheet.jpg']
|
||||
);
|
||||
expect(
|
||||
statSync(
|
||||
(await downloadResult(page, sceneSheetCard as Locator)).filePath
|
||||
).size
|
||||
).toBeGreaterThan(500);
|
||||
}
|
||||
|
||||
await patternClip.getByRole('button', { name: 'Remove clip' }).click();
|
||||
await expect(patternClip).toHaveCount(0);
|
||||
await page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'compatible-a.mp4' })
|
||||
.first()
|
||||
.locator('.timeline-clip__main')
|
||||
.click();
|
||||
});
|
||||
|
||||
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
|
||||
@@ -988,7 +1079,9 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
buildPeakNormalizationPlan,
|
||||
buildPreviewProxyPlan,
|
||||
buildRemuxPlan,
|
||||
buildSceneChangeAnalysisPlan,
|
||||
buildSceneThumbnailSeriesPlan,
|
||||
buildSingleThumbnailPlan,
|
||||
buildSplitPlan,
|
||||
buildStaticWaveformPlan,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
createConcatListEntries,
|
||||
parseLoudnessMeasurement,
|
||||
parsePeakMeasurement,
|
||||
parseSceneChangeFrames,
|
||||
resolveThumbnailTimes,
|
||||
streamMapArguments,
|
||||
type ConcatSource,
|
||||
@@ -63,8 +66,16 @@ const NATIVE_SUBTITLES_FILTER_AVAILABLE =
|
||||
encoding: 'utf8',
|
||||
}).stdout ?? ''
|
||||
);
|
||||
const NATIVE_DRAWTEXT_FILTER_AVAILABLE =
|
||||
NATIVE_FFMPEG_AVAILABLE &&
|
||||
/\bdrawtext\b/u.test(
|
||||
spawnSync('ffmpeg', ['-hide_banner', '-filters'], {
|
||||
encoding: 'utf8',
|
||||
}).stdout ?? ''
|
||||
);
|
||||
const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip;
|
||||
const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
|
||||
const itNativeDrawtext = NATIVE_DRAWTEXT_FILTER_AVAILABLE ? it : it.skip;
|
||||
|
||||
interface NativeExecution {
|
||||
readonly outputPaths: readonly string[];
|
||||
@@ -752,6 +763,33 @@ describeNative('native execution of structured command plans', () => {
|
||||
Number(probe(normalized.outputPaths[0]!).format?.duration)
|
||||
).toBeCloseTo(1.6, 1);
|
||||
|
||||
const crossfaded = runNative(
|
||||
buildNormalizedConcatPlan({
|
||||
jobId: 'crossfade-concat-native',
|
||||
sources: [
|
||||
concatSource('first', 0, 'compatible-a.mp4', 128, 72),
|
||||
concatSource('second', 1, 'compatible-b.mp4', 128, 72),
|
||||
],
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
preset: preset('mp4-h264-balanced'),
|
||||
width: 128,
|
||||
height: 72,
|
||||
frameRate: 10,
|
||||
sampleRate: 16_000,
|
||||
channelLayout: 'mono',
|
||||
missingAudioPolicy: 'reject',
|
||||
crossfadeSeconds: 0.2,
|
||||
}),
|
||||
{
|
||||
first: fixture('compatible-a.mp4'),
|
||||
second: fixture('compatible-b.mp4'),
|
||||
}
|
||||
);
|
||||
expect(
|
||||
Number(probe(crossfaded.outputPaths[0]!).format?.duration)
|
||||
).toBeCloseTo(1.8, 1);
|
||||
|
||||
const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4');
|
||||
nativeCommand(
|
||||
[
|
||||
@@ -788,6 +826,7 @@ describeNative('native execution of structured command plans', () => {
|
||||
sampleRate: 16_000,
|
||||
channelLayout: 'mono',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
crossfadeSeconds: 0.2,
|
||||
}),
|
||||
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
|
||||
);
|
||||
@@ -1185,8 +1224,80 @@ describeNative('native execution of structured command plans', () => {
|
||||
codec_name: 'png',
|
||||
width: 166,
|
||||
});
|
||||
|
||||
const sceneAnalysis = runNative(
|
||||
buildSceneChangeAnalysisPlan({
|
||||
jobId: 'scene-analysis-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
threshold: 0.01,
|
||||
maxFrames: 4,
|
||||
}),
|
||||
{ media: mediaFile },
|
||||
'info'
|
||||
);
|
||||
const sceneFrames = parseSceneChangeFrames(sceneAnalysis.stderr);
|
||||
expect(sceneFrames.length).toBeGreaterThan(0);
|
||||
const sceneSeries = runNative(
|
||||
buildSceneThumbnailSeriesPlan({
|
||||
jobId: 'scene-series-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
frames: sceneFrames,
|
||||
size: { width: 80 },
|
||||
format: 'png',
|
||||
}),
|
||||
{ media: mediaFile }
|
||||
);
|
||||
expect(sceneSeries.outputPaths).toHaveLength(sceneFrames.length);
|
||||
|
||||
const sceneSheet = runNative(
|
||||
buildContactSheetPlan({
|
||||
jobId: 'scene-contact-sheet-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
frameCount: sceneFrames.length,
|
||||
rows: 1,
|
||||
columns: sceneFrames.length,
|
||||
cellWidth: 80,
|
||||
spacing: 2,
|
||||
sceneFrames,
|
||||
format: 'png',
|
||||
}),
|
||||
{ media: mediaFile }
|
||||
);
|
||||
expect(statSync(sceneSheet.outputPaths[0]!).size).toBeGreaterThan(500);
|
||||
}, 120_000);
|
||||
|
||||
itNativeDrawtext(
|
||||
'executes contact-sheet labels with the reviewed bundled font',
|
||||
() => {
|
||||
const media = source('media', 0, 'pattern-av.mp4');
|
||||
const labeledSheet = runNative(
|
||||
buildContactSheetPlan({
|
||||
jobId: 'labeled-contact-sheet-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
frameCount: 2,
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
cellWidth: 120,
|
||||
timestampLabels: true,
|
||||
sourceLabel: true,
|
||||
drawtextAvailable: true,
|
||||
font: source('font', 1, 'DejaVuSans.ttf'),
|
||||
format: 'png',
|
||||
}),
|
||||
{
|
||||
media: fixture('pattern-av.mp4'),
|
||||
font: resolve(process.cwd(), 'public/fonts/DejaVuSans.ttf'),
|
||||
}
|
||||
);
|
||||
expect(statSync(labeledSheet.outputPaths[0]!).size).toBeGreaterThan(500);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
|
||||
it('executes a transformed, faded, normalized sequential timeline', () => {
|
||||
const execution = runNative(
|
||||
buildTimelineExportPlan({
|
||||
|
||||
@@ -283,6 +283,72 @@ describe('AdvancedOperations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('emits bounded scene analysis settings for series and contact sheets', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onThumbnailSeries = vi.fn();
|
||||
const onContactSheet = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
availability={capabilities(
|
||||
'thumbnail-series',
|
||||
'contact-sheet',
|
||||
'scene-change-analysis',
|
||||
'contact-sheet-labels'
|
||||
)}
|
||||
imageFormatAvailability={{ jpeg: AVAILABLE }}
|
||||
onThumbnailSeries={onThumbnailSeries}
|
||||
onContactSheet={onContactSheet}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: 'Thumbnails and contact sheets',
|
||||
})
|
||||
);
|
||||
const selectionFields = screen.getAllByLabelText('Frame selection');
|
||||
await user.selectOptions(selectionFields[0]!, 'scene-changes');
|
||||
await user.clear(screen.getByLabelText('Scene sensitivity threshold'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Scene sensitivity threshold'),
|
||||
'0.42'
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onThumbnailSeries).toHaveBeenCalledWith({
|
||||
distribution: {
|
||||
type: 'scene-changes',
|
||||
count: 12,
|
||||
threshold: 0.42,
|
||||
},
|
||||
width: 480,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(selectionFields[1]!, 'scene-changes');
|
||||
await user.clear(screen.getByLabelText('Contact-sheet scene threshold'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Contact-sheet scene threshold'),
|
||||
'0.3'
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Generate contact sheet' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onContactSheet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
frameCount: 12,
|
||||
selection: { type: 'scene-changes', threshold: 0.3 },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubtitleAction = vi.fn();
|
||||
|
||||
31
tests/unit/bundled-font-assets.test.ts
Normal file
31
tests/unit/bundled-font-assets.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUNDLED_CONTACT_SHEET_FONT } from '../../src/assets/bundled-font';
|
||||
|
||||
describe('bundled contact-sheet font', () => {
|
||||
it('matches its reviewed byte identity and ships the embedded licence', () => {
|
||||
const bytes = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'public/fonts',
|
||||
BUNDLED_CONTACT_SHEET_FONT.fileName
|
||||
)
|
||||
);
|
||||
expect(bytes.byteLength).toBe(BUNDLED_CONTACT_SHEET_FONT.byteLength);
|
||||
expect(createHash('sha256').update(bytes).digest('hex')).toBe(
|
||||
BUNDLED_CONTACT_SHEET_FONT.sha256
|
||||
);
|
||||
|
||||
const licence = readFileSync(
|
||||
resolve(process.cwd(), BUNDLED_CONTACT_SHEET_FONT.licenseFile),
|
||||
'utf8'
|
||||
);
|
||||
expect(licence).toContain('Bitstream Vera Fonts Copyright');
|
||||
expect(licence).toContain('Arev Fonts Copyright');
|
||||
expect(licence).toContain(
|
||||
'The above copyright and trademark notices and this permission notice'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
buildPeakAnalysisPlan,
|
||||
buildPeakNormalizationFilter,
|
||||
buildPeakNormalizationPlan,
|
||||
buildSceneChangeAnalysisPlan,
|
||||
buildSceneThumbnailSeriesPlan,
|
||||
buildSubtitleBurnPlan,
|
||||
buildSubtitleExtractionPlan,
|
||||
buildSubtitleMuxPlan,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
parseChapterJson,
|
||||
parseLoudnessMeasurement,
|
||||
parsePeakMeasurement,
|
||||
parseSceneChangeFrames,
|
||||
parseMetadataJson,
|
||||
resolveThumbnailTimes,
|
||||
serializeChapterJson,
|
||||
@@ -360,6 +363,65 @@ describe('thumbnail and contact sheet plans', () => {
|
||||
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
|
||||
});
|
||||
|
||||
it('analyzes a bounded number of scene changes and parses exact timestamps', () => {
|
||||
const plan = buildSceneChangeAnalysisPlan({
|
||||
jobId: 'scenes',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
threshold: 0.35,
|
||||
maxFrames: 12,
|
||||
});
|
||||
expect(plan.outputs).toEqual([]);
|
||||
expect(plan.args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"select='gt(scene\\,0.35)',showinfo",
|
||||
'-frames:v',
|
||||
'12',
|
||||
'-f',
|
||||
'null',
|
||||
])
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).toEqual(['select', 'showinfo']);
|
||||
expect(
|
||||
parseSceneChangeFrames(`
|
||||
[Parsed_showinfo_1 @ 0x1] n: 0 pts: 4096 pts_time:0.25 duration:512
|
||||
n: 1 pts: 16384 pts_time:1 duration:512
|
||||
[Parsed_showinfo_1 @ 0x1] n: 2 pts: 16384 pts_time:1 duration:512
|
||||
invalid showinfo n: 3 pts: nope pts_time:2
|
||||
`)
|
||||
).toEqual([
|
||||
{ pts: 4096, timeSeconds: 0.25 },
|
||||
{ pts: 16384, timeSeconds: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds exact scene-selected thumbnails from analyzed PTS values', () => {
|
||||
const plan = buildSceneThumbnailSeriesPlan({
|
||||
jobId: 'scene-thumbs',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
frames: [
|
||||
{ pts: 4096, timeSeconds: 0.25 },
|
||||
{ pts: 16384, timeSeconds: 1 },
|
||||
],
|
||||
format: 'png',
|
||||
size: { width: 320 },
|
||||
});
|
||||
expect(plan.outputs.map((output) => output.fileName)).toEqual([
|
||||
'source-thumbnail-001-250ms.png',
|
||||
'source-thumbnail-002-1000ms.png',
|
||||
]);
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
"select='eq(pts\\,4096)+eq(pts\\,16384)',split=2"
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).toEqual([
|
||||
'select',
|
||||
'split',
|
||||
'setpts',
|
||||
'scale',
|
||||
]);
|
||||
});
|
||||
|
||||
it('degrades contact-sheet labels when drawtext is unavailable', () => {
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: 'sheet',
|
||||
@@ -381,6 +443,49 @@ describe('thumbnail and contact sheet plans', () => {
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
|
||||
});
|
||||
|
||||
it('uses a mounted reviewed font and exact scene frames for labels', () => {
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: 'scene-sheet',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
frameCount: 2,
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
cellWidth: 240,
|
||||
timestampLabels: true,
|
||||
sourceLabel: true,
|
||||
format: 'jpeg',
|
||||
drawtextAvailable: true,
|
||||
font: {
|
||||
id: 'bundled-font',
|
||||
sourceIndex: 1,
|
||||
fileName: 'DejaVuSans.ttf',
|
||||
},
|
||||
sceneFrames: [
|
||||
{ pts: 4096, timeSeconds: 0.25 },
|
||||
{ pts: 16384, timeSeconds: 1 },
|
||||
],
|
||||
});
|
||||
expect(plan.inputs[1]).toMatchObject({
|
||||
kind: 'font',
|
||||
sourceIndex: 1,
|
||||
});
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
"select='eq(pts\\,4096)+eq(pts\\,16384)'"
|
||||
);
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
"drawtext=fontfile='/input/job-scene-sheet/source-1.ttf'"
|
||||
);
|
||||
expect(plan.args.join(' ')).toContain('nb_frames=2');
|
||||
expect(plan.requiredCapabilities.filters).toEqual([
|
||||
'select',
|
||||
'scale',
|
||||
'setpts',
|
||||
'tile',
|
||||
'drawtext',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composed visual operations', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildConvertPlan,
|
||||
buildFastConcatPlan,
|
||||
buildNormalizedConcatPlan,
|
||||
buildPreviewProxyPlan,
|
||||
buildRemuxPlan,
|
||||
buildSplitPlan,
|
||||
@@ -276,6 +277,64 @@ describe('concat and transform validation', () => {
|
||||
expect(plan.expectedDurationSeconds).toBe(5);
|
||||
});
|
||||
|
||||
it('builds bounded chained audio/video crossfades for normalized concat', () => {
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced');
|
||||
const sources = [
|
||||
{ ...SOURCES[0]!, durationSeconds: 4 },
|
||||
{ ...SOURCES[1]!, durationSeconds: 5 },
|
||||
{
|
||||
...SOURCES[1]!,
|
||||
id: 'three',
|
||||
sourceIndex: 2,
|
||||
fileName: 'three.mp4',
|
||||
durationSeconds: 3,
|
||||
},
|
||||
];
|
||||
const plan = buildNormalizedConcatPlan({
|
||||
jobId: 'crossfade',
|
||||
sources,
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
preset: preset!,
|
||||
width: 640,
|
||||
height: 360,
|
||||
frameRate: 25,
|
||||
sampleRate: 48_000,
|
||||
channelLayout: 'stereo',
|
||||
missingAudioPolicy: 'reject',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 0.5,
|
||||
});
|
||||
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1] ?? '';
|
||||
expect(graph).toContain(
|
||||
'[v0][v1]xfade=transition=fade:duration=0.5:offset=3.5[vxf1]'
|
||||
);
|
||||
expect(graph).toContain(
|
||||
'[vxf1][v2]xfade=transition=fade:duration=0.5:offset=8[vout]'
|
||||
);
|
||||
expect(graph).toContain('[a0][a1]acrossfade=d=0.5:c1=tri:c2=tri[axf1]');
|
||||
expect(graph).not.toContain('concat=n=3');
|
||||
expect(plan.expectedDurationSeconds).toBe(11);
|
||||
expect(plan.requiredCapabilities.filters).toEqual(
|
||||
expect.arrayContaining(['xfade', 'acrossfade'])
|
||||
);
|
||||
expect(() =>
|
||||
buildNormalizedConcatPlan({
|
||||
jobId: 'too-long-crossfade',
|
||||
sources: SOURCES,
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
preset: preset!,
|
||||
width: 640,
|
||||
height: 360,
|
||||
frameRate: 25,
|
||||
missingAudioPolicy: 'reject',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 2.5,
|
||||
})
|
||||
).toThrow(/Clip 1 needs at least/iu);
|
||||
});
|
||||
|
||||
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
|
||||
expect(
|
||||
validateCrop({
|
||||
|
||||
@@ -16,7 +16,9 @@ describe('HelpDialog', () => {
|
||||
).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('0.12.10-reviewed-stack5m.1 · ST & MT')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/copyright © 2026 Albrecht Degering/i)
|
||||
).toBeInTheDocument();
|
||||
@@ -29,6 +31,9 @@ describe('HelpDialog', () => {
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'GPL text', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Font licence', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('DEJAVU_FONTS'));
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Complete inventory',
|
||||
|
||||
@@ -26,6 +26,41 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('MediaPreview', () => {
|
||||
it('starts declared local video playback while inspection continues and preserves the element', () => {
|
||||
const probingAsset: ImportedMediaAsset = {
|
||||
id: 'asset-probing-video',
|
||||
file: new File(['video'], 'lecture.mp4', {
|
||||
type: 'application/octet-stream',
|
||||
}),
|
||||
objectUrl: 'blob:probing-video',
|
||||
phase: 'probing',
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<MediaPreview asset={probingAsset} />
|
||||
);
|
||||
|
||||
const progressiveVideo = requiredElement(container.querySelector('video'));
|
||||
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local source preview · Media inspection continues in the background.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={{
|
||||
...videoAsset(),
|
||||
id: probingAsset.id,
|
||||
file: probingAsset.file,
|
||||
objectUrl: probingAsset.objectUrl,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||
});
|
||||
|
||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateProxy = vi.fn();
|
||||
|
||||
@@ -138,6 +138,28 @@ describe('QuickConvert', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Opus after the reviewed core passed both regression modes', () => {
|
||||
const engineState: EngineState = {
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
...capabilities(['mp4', 'opus']),
|
||||
encoders: new Set(['libx264', 'aac', 'libopus']),
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<QuickConvert
|
||||
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||
engineState={engineState}
|
||||
busy={false}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('exposes a validated user preset and returns it unchanged on export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||
|
||||
@@ -274,6 +274,7 @@ describe('StructuralOperations', () => {
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -328,6 +329,55 @@ describe('StructuralOperations', () => {
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'drop-all',
|
||||
crossfadeSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a bounded crossfade only after its filters are verified', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConcat = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{
|
||||
...TIMELINE,
|
||||
clips: TIMELINE.clips.map((clip) => ({
|
||||
...clip,
|
||||
durationSeconds: 3,
|
||||
})),
|
||||
}}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('concat-normalized', 'concat-crossfade')}
|
||||
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 })
|
||||
);
|
||||
await user.clear(screen.getByLabelText(/^Crossfade duration/iu));
|
||||
await user.type(screen.getByLabelText(/^Crossfade duration/iu), '0.5');
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Export normalized concat' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConcat).toHaveBeenCalledWith({
|
||||
operation: 'concat',
|
||||
mode: 'normalized',
|
||||
clipIds: ['clip-a', 'clip-b'],
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 0.5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
|
||||
EngineManager,
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE,
|
||||
MAX_EXECUTIONS_PER_ENGINE,
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||
engineJobTimeoutMilliseconds,
|
||||
prepareEngineArguments,
|
||||
probeTimeoutMilliseconds,
|
||||
shouldRecycleExecutionCore,
|
||||
} from '../../src/ffmpeg/EngineManager';
|
||||
|
||||
@@ -90,6 +94,39 @@ describe('engineJobTimeoutMilliseconds', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeTimeoutMilliseconds', () => {
|
||||
it('keeps small probes responsive, scales real media, and caps the watchdog', () => {
|
||||
expect(probeTimeoutMilliseconds(34_057)).toBe(
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS
|
||||
);
|
||||
expect(probeTimeoutMilliseconds(153_019_068)).toBe(72_966);
|
||||
expect(probeTimeoutMilliseconds(2 * 1024 * 1024 * 1024)).toBe(
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an already-cancelled probe before loading an engine', async () => {
|
||||
const factory = vi.fn(async () => {
|
||||
throw new Error('The engine factory must not be called.');
|
||||
});
|
||||
const manager = new EngineManager(factory);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
manager.probe(
|
||||
new File(['media'], 'cancelled.mp4', { type: 'video/mp4' }),
|
||||
undefined,
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'cancelled',
|
||||
message: 'The media probe was cancelled.',
|
||||
});
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -20,8 +20,12 @@ describe('FFmpeg runtime utilities', () => {
|
||||
'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');
|
||||
expect(mt.coreURL).toContain(
|
||||
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js'
|
||||
);
|
||||
expect(mt.workerURL).toContain(
|
||||
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps experimental progress and removes invalid numbers', () => {
|
||||
|
||||
@@ -165,30 +165,30 @@ describe('licence release gate', () => {
|
||||
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`);
|
||||
const line = checksumLine(digest, 'av-tools-0.2.0.zip');
|
||||
expect(line).toBe(`${digest} av-tools-0.2.0.zip\n`);
|
||||
expect(parseChecksumLine(line)).toEqual({
|
||||
archiveName: 'av-tools-0.1.0.zip',
|
||||
archiveName: 'av-tools-0.2.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');
|
||||
const archive = path.join(directory, 'av-tools-0.2.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'
|
||||
' av-tools-0.2.0.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects checksum mismatches', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
||||
const archive = path.join(directory, 'av-tools-0.2.0.zip');
|
||||
const sidecar = `${archive}.sha256`;
|
||||
await writeFile(archive, 'changed');
|
||||
await mkdir(path.dirname(sidecar), { recursive: true });
|
||||
@@ -207,7 +207,7 @@ describe('portal assembly smoke validation', () => {
|
||||
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
|
||||
"~^/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/(?:st|mt)/ffmpeg-core\\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
||||
`;
|
||||
|
||||
it('accepts the required isolation, CSP and MIME profile', () => {
|
||||
@@ -226,6 +226,15 @@ describe('portal assembly smoke validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mistake a plain-semver cache rule for reviewed-build coverage', () => {
|
||||
const inspection = inspectPortalHeaders(
|
||||
isolatedHeaders.replace('0.12.10-reviewed-stack5m.1', '0.12.10')
|
||||
);
|
||||
expect(inspection.warnings).toContainEqual(
|
||||
expect.stringMatching(/reviewed FFmpeg build/u)
|
||||
);
|
||||
});
|
||||
|
||||
it('parses only explicit smoke overrides and paths', () => {
|
||||
const parsed = parsePortalArguments(
|
||||
[
|
||||
|
||||
52
tests/unit/source-inspection-registry.test.ts
Normal file
52
tests/unit/source-inspection-registry.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SourceInspectionRegistry } from '../../src/app/source-inspection-registry';
|
||||
|
||||
describe('SourceInspectionRegistry', () => {
|
||||
it('invalidates queued work synchronously and does not request cancellation', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const token = registry.register('queued');
|
||||
|
||||
expect(registry.remove('queued')).toBe(false);
|
||||
expect(registry.isCurrent(token)).toBe(false);
|
||||
expect(registry.activate(token)).toBe(false);
|
||||
expect(token.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('requests cancellation for the active asset and rejects its late result', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const token = registry.register('active');
|
||||
|
||||
expect(registry.activate(token)).toBe(true);
|
||||
expect(registry.remove('active')).toBe(true);
|
||||
expect(registry.isCurrent(token)).toBe(false);
|
||||
expect(token.signal.aborted).toBe(true);
|
||||
registry.finish(token);
|
||||
expect(registry.remove('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a late generation overwrite a replacement with the same ID', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const stale = registry.register('reattached');
|
||||
expect(registry.activate(stale)).toBe(true);
|
||||
|
||||
const replacement = registry.register('reattached');
|
||||
expect(registry.isCurrent(stale)).toBe(false);
|
||||
expect(registry.isCurrent(replacement)).toBe(true);
|
||||
expect(stale.signal.aborted).toBe(true);
|
||||
expect(replacement.signal.aborted).toBe(false);
|
||||
registry.finish(stale);
|
||||
expect(registry.activate(replacement)).toBe(true);
|
||||
});
|
||||
|
||||
it('invalidates all queued work and identifies an active probe on clear', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const active = registry.register('active');
|
||||
const queued = registry.register('queued');
|
||||
registry.activate(active);
|
||||
|
||||
expect(registry.clear()).toBe(true);
|
||||
expect(registry.isCurrent(active)).toBe(false);
|
||||
expect(registry.isCurrent(queued)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user