feat: publish av-tools 0.2.0

This commit is contained in:
2026-07-27 01:04:21 +02:00
parent fcc537b024
commit 0a4548851c
68 changed files with 4130 additions and 605 deletions

View File

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

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
})
);
});

View File

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

View File

@@ -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', () => {

View File

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

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