411 lines
11 KiB
TypeScript
411 lines
11 KiB
TypeScript
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: {},
|
|
},
|
|
],
|
|
};
|
|
}
|