feat: publish av-tools 0.1.0
This commit is contained in:
410
tests/unit/storage.test.ts
Normal file
410
tests/unit/storage.test.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_RESOURCE_POLICY,
|
||||
MemoryDerivedCache,
|
||||
ResilientDerivedCache,
|
||||
createCacheKey,
|
||||
createResourcePolicy,
|
||||
estimatePlanResources,
|
||||
estimateOutputBytes,
|
||||
policyForDeviceMemory,
|
||||
preflightResources,
|
||||
readStorageQuota,
|
||||
stableStringify,
|
||||
type DerivedCache,
|
||||
} from '../../src/storage';
|
||||
import type { FFmpegCommandPlan } from '../../src/commands/command-plan';
|
||||
import type { MediaProbe } from '../../src/media';
|
||||
|
||||
describe('cache identities', () => {
|
||||
it('canonicalizes settings and produces deterministic path-safe keys', () => {
|
||||
const one = createCacheKey({
|
||||
sourceFingerprint: 'source-123',
|
||||
operation: 'waveform',
|
||||
streamIndex: 1,
|
||||
settings: { zoom: 2, channels: ['left', 'right'] },
|
||||
});
|
||||
const two = createCacheKey({
|
||||
sourceFingerprint: 'source-123',
|
||||
operation: 'waveform',
|
||||
streamIndex: 1,
|
||||
settings: { channels: ['left', 'right'], zoom: 2 },
|
||||
});
|
||||
expect(one).toBe(two);
|
||||
expect(one).toMatch(/^waveform-[0-9a-f]{16}$/u);
|
||||
expect(stableStringify({ z: 1, a: { y: 2, x: 3 } })).toBe(
|
||||
'{"a":{"x":3,"y":2},"z":1}'
|
||||
);
|
||||
expect(() =>
|
||||
createCacheKey({
|
||||
sourceFingerprint: 'source',
|
||||
operation: '../outside',
|
||||
})
|
||||
).toThrow(/safe operation/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource preflight', () => {
|
||||
it('allows small jobs and labels estimates as non-guarantees', () => {
|
||||
const decision = preflightResources({
|
||||
inputBytes: [10 * 1024 * 1024],
|
||||
expectedOutputBytes: estimateOutputBytes(10, 2_000, 128),
|
||||
durationSeconds: 10,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 25,
|
||||
passes: 1,
|
||||
});
|
||||
expect(decision.status).toBe('allowed');
|
||||
expect(decision.totals.pixelWorkEstimate).toBe(518_400_000);
|
||||
expect(decision.estimateDisclaimer).toContain('not a guarantee');
|
||||
});
|
||||
|
||||
it('blocks hard limits and provides actionable suggestions', () => {
|
||||
const decision = preflightResources({
|
||||
inputBytes: [DEFAULT_RESOURCE_POLICY.hardSingleInputBytes + 1],
|
||||
width: 9000,
|
||||
height: 9000,
|
||||
thumbnailCount: 501,
|
||||
contactSheetCells: 501,
|
||||
expectedOutputBytes: DEFAULT_RESOURCE_POLICY.hardOutputEstimateBytes + 1,
|
||||
timelineClips: DEFAULT_RESOURCE_POLICY.maxTimelineClips + 1,
|
||||
streamsPerInput: [DEFAULT_RESOURCE_POLICY.maxStreamsPerInput + 1],
|
||||
});
|
||||
expect(decision.status).toBe('blocked');
|
||||
expect(decision.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'single-input-hard-limit',
|
||||
'total-input-hard-limit',
|
||||
'pixel-limit',
|
||||
'thumbnail-limit',
|
||||
'contact-sheet-limit',
|
||||
'output-hard-limit',
|
||||
'timeline-clip-limit',
|
||||
'stream-count-limit',
|
||||
])
|
||||
);
|
||||
expect(decision.issues.every((issue) => issue.suggestions.length > 0)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('lowers policy for reliable low-memory signals', () => {
|
||||
const policy = createResourcePolicy();
|
||||
expect(policyForDeviceMemory(policy, 2).softInputBytes).toBeLessThan(
|
||||
policy.softInputBytes
|
||||
);
|
||||
});
|
||||
|
||||
it('reserves collection and report ZIP capacity before running a batch', () => {
|
||||
const policy = createResourcePolicy();
|
||||
const decision = preflightResources(
|
||||
{
|
||||
inputBytes: [],
|
||||
generatedOutputs: 2,
|
||||
retainedOutputs: policy.maxGeneratedOutputs - 1,
|
||||
},
|
||||
policy
|
||||
);
|
||||
expect(decision.status).toBe('blocked');
|
||||
expect(decision.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'generated-output-limit',
|
||||
message: expect.stringMatching(/ZIP with an optional report/iu),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed generated-image estimates before comparing limits', () => {
|
||||
expect(() =>
|
||||
preflightResources({
|
||||
inputBytes: [],
|
||||
thumbnailCount: -1,
|
||||
})
|
||||
).toThrow(/thumbnailCount/u);
|
||||
expect(() =>
|
||||
preflightResources({
|
||||
inputBytes: [],
|
||||
contactSheetCells: 1.5,
|
||||
})
|
||||
).toThrow(/contactSheetCells/u);
|
||||
});
|
||||
|
||||
it('derives output size, geometry, frame rate, and generated-image semantics from plans', () => {
|
||||
const contactEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'contact-sheet',
|
||||
args: [
|
||||
'-vf',
|
||||
'fps=0.5,scale=320:-2:force_original_aspect_ratio=decrease,tile=3x2:padding=4:margin=4',
|
||||
'/work/contact.jpg',
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'contact',
|
||||
path: '/work/contact.jpg',
|
||||
fileName: 'contact.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
role: 'contact-sheet',
|
||||
},
|
||||
],
|
||||
expectedDurationSeconds: 12,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: videoProbe() }]
|
||||
);
|
||||
expect(contactEstimate).toMatchObject({
|
||||
width: 976,
|
||||
height: 372,
|
||||
frameRate: 0.5,
|
||||
contactSheetCells: 6,
|
||||
generatedOutputs: 1,
|
||||
});
|
||||
|
||||
const remuxEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'remux',
|
||||
args: ['-i', '/input/source.mp4', '-c', 'copy', '/work/output.mkv'],
|
||||
outputs: [
|
||||
{
|
||||
id: 'media',
|
||||
path: '/work/output.mkv',
|
||||
fileName: 'output.mkv',
|
||||
mediaType: 'video/x-matroska',
|
||||
role: 'media',
|
||||
},
|
||||
],
|
||||
expectedDurationSeconds: 10,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: videoProbe() }]
|
||||
);
|
||||
expect(remuxEstimate.expectedOutputBytes).toBe(2_000_000);
|
||||
|
||||
const bitrateEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'convert',
|
||||
args: [
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-b:v',
|
||||
'500k',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'128k',
|
||||
'/work/output.mp4',
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'media',
|
||||
path: '/work/output.mp4',
|
||||
fileName: 'output.mp4',
|
||||
mediaType: 'video/mp4',
|
||||
role: 'media',
|
||||
},
|
||||
],
|
||||
expectedDurationSeconds: 10,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: audioVideoProbe() }]
|
||||
);
|
||||
expect(bitrateEstimate.expectedOutputBytes).toBe(
|
||||
estimateOutputBytes(10, 628)
|
||||
);
|
||||
|
||||
const thumbnailEstimate = estimatePlanResources(
|
||||
plan({
|
||||
operation: 'thumbnail-series',
|
||||
args: ['-vf', 'scale=320:-2', '/work/thumb.png'],
|
||||
outputs: Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `thumb-${index}`,
|
||||
path: `/work/thumb-${index}.png`,
|
||||
fileName: `thumb-${index}.png`,
|
||||
mediaType: 'image/png',
|
||||
role: 'thumbnail' as const,
|
||||
})),
|
||||
expectedDurationSeconds: 8,
|
||||
}),
|
||||
[{ size: 2_000_000, probe: videoProbe() }]
|
||||
);
|
||||
expect(thumbnailEstimate).toMatchObject({
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 0.5,
|
||||
thumbnailCount: 4,
|
||||
generatedOutputs: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived cache lifecycle', () => {
|
||||
it('lists sizes, expires entries, clears projects and clears all', async () => {
|
||||
let now = new Date('2026-01-01T00:00:00.000Z');
|
||||
const cache = new MemoryDerivedCache({
|
||||
maxBytes: 1_024,
|
||||
now: () => now,
|
||||
});
|
||||
await cache.put(
|
||||
{
|
||||
key: 'waveform-a',
|
||||
kind: 'waveform-peaks',
|
||||
projectId: 'project-a',
|
||||
mediaType: 'application/octet-stream',
|
||||
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
new Uint8Array([1, 2, 3])
|
||||
);
|
||||
await cache.put(
|
||||
{
|
||||
key: 'thumb-b',
|
||||
kind: 'thumbnail',
|
||||
projectId: 'project-b',
|
||||
mediaType: 'image/png',
|
||||
},
|
||||
new Blob(['image'], { type: 'image/png' })
|
||||
);
|
||||
expect(await cache.sizeBytes()).toBe(8);
|
||||
expect((await cache.list()).map((entry) => entry.key)).toHaveLength(2);
|
||||
now = new Date('2026-01-03T00:00:00.000Z');
|
||||
expect(await cache.pruneExpired()).toBe(1);
|
||||
expect(await cache.get('waveform-a')).toBeUndefined();
|
||||
expect(await cache.clearProject('project-b')).toBe(1);
|
||||
expect(await cache.sizeBytes()).toBe(0);
|
||||
await cache.clear();
|
||||
expect(await cache.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('evicts least recently used memory entries under the configured limit', async () => {
|
||||
let tick = 0;
|
||||
const cache = new MemoryDerivedCache({
|
||||
maxBytes: 6,
|
||||
now: () => new Date(1_000 + tick++),
|
||||
});
|
||||
await cache.put({ key: 'one', kind: 'project' }, new Blob(['123']));
|
||||
await cache.put({ key: 'two', kind: 'project' }, new Blob(['456']));
|
||||
await cache.get('one');
|
||||
await cache.put({ key: 'three', kind: 'project' }, new Blob(['789']));
|
||||
expect(await cache.get('two')).toBeUndefined();
|
||||
expect(await cache.get('one')).toBeDefined();
|
||||
expect(await cache.get('three')).toBeDefined();
|
||||
});
|
||||
|
||||
it('switches explicitly to memory fallback when OPFS operations fail', async () => {
|
||||
const primary = new ThrowingCache();
|
||||
const fallback = new MemoryDerivedCache();
|
||||
const cache = new ResilientDerivedCache(primary, fallback);
|
||||
expect(cache.mode).toBe('opfs');
|
||||
await cache.put({ key: 'fallback', kind: 'project' }, new Blob(['ok']));
|
||||
expect(cache.mode).toBe('memory');
|
||||
expect(cache.fallbackReason).toContain('quota denied');
|
||||
expect(await cache.get('fallback')).toBeDefined();
|
||||
await cache.clear();
|
||||
expect(await cache.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports quota and unavailable estimate failures', async () => {
|
||||
expect(
|
||||
await readStorageQuota({
|
||||
estimate: async () => ({ usage: 25, quota: 100 }),
|
||||
persisted: async () => true,
|
||||
})
|
||||
).toEqual({
|
||||
available: true,
|
||||
usageBytes: 25,
|
||||
quotaBytes: 100,
|
||||
remainingBytes: 75,
|
||||
usageFraction: 0.25,
|
||||
persisted: true,
|
||||
});
|
||||
expect(
|
||||
await readStorageQuota({
|
||||
estimate: async () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
})
|
||||
).toEqual({ available: false, error: 'denied' });
|
||||
});
|
||||
});
|
||||
|
||||
class ThrowingCache implements DerivedCache {
|
||||
readonly mode = 'opfs' as const;
|
||||
async list(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async get(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async put(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async delete(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async clear(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async clearProject(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async pruneExpired(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
async sizeBytes(): Promise<never> {
|
||||
throw new Error('quota denied');
|
||||
}
|
||||
}
|
||||
|
||||
function plan(
|
||||
options: Pick<
|
||||
FFmpegCommandPlan,
|
||||
'operation' | 'args' | 'outputs' | 'expectedDurationSeconds'
|
||||
>
|
||||
): FFmpegCommandPlan {
|
||||
return {
|
||||
id: `job:${options.operation}`,
|
||||
operation: options.operation,
|
||||
inputs: [],
|
||||
temporaryFiles: [],
|
||||
args: options.args,
|
||||
outputs: options.outputs,
|
||||
expectedDurationSeconds: options.expectedDurationSeconds,
|
||||
requiredCapabilities: {},
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function videoProbe(): MediaProbe {
|
||||
return {
|
||||
durationSeconds: 10,
|
||||
bitRate: 800_000,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 25,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function audioVideoProbe(): MediaProbe {
|
||||
const probe = videoProbe();
|
||||
return {
|
||||
...probe,
|
||||
streams: [
|
||||
...probe.streams,
|
||||
{
|
||||
index: 1,
|
||||
type: 'audio',
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user