feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { createEmptyProject } from '../../../src/project/project.schema';
import type {
AvProjectV1,
MediaAssetReference,
} from '../../../src/project/project.types';
export const CREATED_AT = '2026-01-02T03:04:05.000Z';
export const UPDATED_AT = '2026-01-02T04:00:00.000Z';
export function mediaAsset(
overrides: Partial<MediaAssetReference> = {}
): MediaAssetReference {
return {
id: 'asset-1',
originalName: 'source.mp4',
size: 1_024,
lastModified: 1_700_000_000_000,
mimeType: 'video/mp4',
sourceStatus: 'attached',
probe: {
durationSeconds: 10,
formatNames: ['mov', 'mp4'],
tags: {},
streams: [
{
index: 0,
type: 'video',
codecName: 'h264',
disposition: { default: true },
tags: {},
},
],
chapters: [],
warnings: [],
},
...overrides,
};
}
export function populatedProject(): AvProjectV1 {
const project = createEmptyProject({
id: 'project-1',
name: 'Demo project',
timestamp: CREATED_AT,
});
return {
...project,
assets: [mediaAsset()],
timeline: [
{
id: 'clip-1',
assetId: 'asset-1',
sourceInSeconds: 1,
sourceOutSeconds: 4.5,
audio: { enabled: true, fadeInSeconds: 0.2 },
video: { enabled: true },
},
],
ui: {
selectedAssetId: 'asset-1',
selectedClipId: 'clip-1',
inspectorPanel: 'clip',
timelineZoom: 1,
},
};
}

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest';
import {
ProjectReducerError,
projectReducer,
} from '../../../src/project/project.reducer';
import {
selectCanExport,
selectMissingAssets,
selectTimelineDuration,
selectTimelineEntries,
selectUnusedAssets,
} from '../../../src/project/project.selectors';
import { createEmptyProject } from '../../../src/project/project.schema';
import {
CREATED_AT,
UPDATED_AT,
mediaAsset,
populatedProject,
} from './project-fixture';
describe('project reducer', () => {
it('adds assets and sequential clips without mutating prior state', () => {
const empty = createEmptyProject({
id: 'p',
timestamp: CREATED_AT,
});
const withAsset = projectReducer(empty, {
type: 'asset/add',
asset: mediaAsset(),
updatedAt: UPDATED_AT,
});
const withClip = projectReducer(withAsset, {
type: 'clip/add',
clip: {
id: 'clip-1',
assetId: 'asset-1',
sourceInSeconds: 0,
sourceOutSeconds: 2,
},
updatedAt: UPDATED_AT,
});
expect(empty.assets).toEqual([]);
expect(withAsset.timeline).toEqual([]);
expect(withClip.timeline).toHaveLength(1);
expect(withClip.updatedAt).toBe(UPDATED_AT);
});
it('reorders clips deterministically and returns identity for a no-op', () => {
const project = {
...populatedProject(),
timeline: [
populatedProject().timeline[0]!,
{
id: 'clip-2',
assetId: 'asset-1',
sourceInSeconds: 4.5,
sourceOutSeconds: 6,
},
],
};
const moved = projectReducer(project, {
type: 'clip/move',
clipId: 'clip-2',
toIndex: 0,
updatedAt: UPDATED_AT,
});
expect(moved.timeline.map((clip) => clip.id)).toEqual(['clip-2', 'clip-1']);
expect(
projectReducer(moved, {
type: 'clip/move',
clipId: 'clip-2',
toIndex: 0,
updatedAt: UPDATED_AT,
})
).toBe(moved);
});
it('removes dependent clips/subtitles and clears stale UI selection', () => {
const project = {
...populatedProject(),
subtitles: [
{ id: 'subtitle-1', assetId: 'asset-1', mode: 'mux' as const },
],
};
const result = projectReducer(project, {
type: 'asset/remove',
assetId: 'asset-1',
updatedAt: UPDATED_AT,
});
expect(result.assets).toEqual([]);
expect(result.timeline).toEqual([]);
expect(result.subtitles).toEqual([]);
expect(result.ui).toMatchObject({
selectedAssetId: undefined,
selectedClipId: undefined,
});
});
it('guards duplicate IDs, missing assets and invalid ranges', () => {
const project = populatedProject();
expect(() =>
projectReducer(project, {
type: 'asset/add',
asset: mediaAsset(),
updatedAt: UPDATED_AT,
})
).toThrow(ProjectReducerError);
expect(() =>
projectReducer(project, {
type: 'clip/add',
clip: {
id: 'bad',
assetId: 'missing',
sourceInSeconds: 0,
sourceOutSeconds: 1,
},
updatedAt: UPDATED_AT,
})
).toThrow('unknown asset');
expect(() =>
projectReducer(project, {
type: 'clip/update',
clipId: 'clip-1',
changes: { sourceOutSeconds: 0 },
updatedAt: UPDATED_AT,
})
).toThrow();
});
it('does not mark the document updated for transient UI state', () => {
const project = populatedProject();
const result = projectReducer(project, {
type: 'ui/set',
ui: { inspectorPanel: 'output' },
});
expect(result.updatedAt).toBe(project.updatedAt);
});
});
describe('project selectors', () => {
it('derives timeline positions and total duration for a sequential edit', () => {
const project = {
...populatedProject(),
timeline: [
populatedProject().timeline[0]!,
{
id: 'clip-2',
assetId: 'asset-1',
sourceInSeconds: 5,
sourceOutSeconds: 6.25,
},
],
};
expect(selectTimelineDuration(project)).toBe(4.75);
expect(selectTimelineEntries(project)).toMatchObject([
{
timelineStartSeconds: 0,
timelineEndSeconds: 3.5,
durationSeconds: 3.5,
},
{
timelineStartSeconds: 3.5,
timelineEndSeconds: 4.75,
durationSeconds: 1.25,
},
]);
});
it('identifies missing, unused and export-blocking sources', () => {
const project = {
...populatedProject(),
assets: [
mediaAsset({ sourceStatus: 'missing' }),
mediaAsset({ id: 'asset-unused', originalName: 'unused.wav' }),
],
};
expect(selectMissingAssets(project).map((asset) => asset.id)).toEqual([
'asset-1',
]);
expect(selectUnusedAssets(project).map((asset) => asset.id)).toEqual([
'asset-unused',
]);
expect(selectCanExport(project)).toEqual({
canExport: false,
reasons: ['1 referenced source file is missing.'],
});
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
createEmptyProject,
createMediaAssetReference,
} from '../../../src/project/project.schema';
import { CREATED_AT } from './project-fixture';
describe('project factories', () => {
it('creates a complete, deterministic empty document when inputs are supplied', () => {
expect(
createEmptyProject({
id: 'project-1',
name: ' Test ',
timestamp: CREATED_AT,
})
).toMatchObject({
schemaVersion: 1,
id: 'project-1',
name: 'Test',
createdAt: CREATED_AT,
updatedAt: CREATED_AT,
assets: [],
timeline: [],
output: {
presetId: 'mp4-h264-balanced',
fileName: 'output.mp4',
container: 'mp4',
sampleRate: 48_000,
channelLayout: 'stereo',
missingAudioPolicy: 'insert-silence',
},
});
});
it('converts File-compatible identity metadata without retaining the File', () => {
const file = {
name: 'clip.mov',
size: 42,
lastModified: 123,
type: 'video/quicktime',
};
const asset = createMediaAssetReference(file, { id: 'asset-1' });
expect(asset).toEqual({
id: 'asset-1',
originalName: 'clip.mov',
size: 42,
lastModified: 123,
mimeType: 'video/quicktime',
sourceStatus: 'attached',
});
expect(asset).not.toHaveProperty('file');
});
it('rejects malformed file identity metadata', () => {
expect(() =>
createMediaAssetReference({
name: '',
size: -1,
lastModified: 0,
type: '',
})
).toThrow();
});
});

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
ProjectImportError,
importProjectDocument,
projectDocumentFileName,
serializeProject,
} from '../../../src/project/project.serialization';
import { ProjectValidationError } from '../../../src/project/project.validation';
import { populatedProject } from './project-fixture';
describe('project serialization', () => {
it('serializes readable, versioned JSON and can omit transient UI state', () => {
const project = populatedProject();
const json = serializeProject(project, { includeUiState: false });
const parsed = JSON.parse(json) as Record<string, unknown>;
expect(json.endsWith('\n')).toBe(true);
expect(parsed.schemaVersion).toBe(1);
expect(parsed.ui).toBeUndefined();
expect(project.ui).toBeDefined();
});
it('validates before serializing', () => {
const project = {
...populatedProject(),
output: { ...populatedProject().output, fileName: '' },
};
expect(() => serializeProject(project)).toThrow(ProjectValidationError);
});
it('imports a round trip but correctly marks browser sources missing', () => {
const project = populatedProject();
const result = importProjectDocument(serializeProject(project));
expect(result.fromVersion).toBe(1);
expect(result.project.assets[0]?.sourceStatus).toBe('missing');
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'source-reattachment-required' })
);
});
it('can retain runtime source status only when explicitly requested', () => {
const project = populatedProject();
const result = importProjectDocument(serializeProject(project), {
markSourcesMissing: false,
});
expect(result.project).toEqual(project);
expect(result.warnings).toEqual([]);
});
it('round-trips fade curves and editable chapter time bases', () => {
const project = populatedProject();
const persisted = {
...project,
timeline: [
{
...project.timeline[0]!,
audio: {
enabled: true,
fadeInSeconds: 0.2,
fadeCurve: 'qsin' as const,
},
video: {
enabled: true,
fadeOutSeconds: 0.2,
fadeCurve: 'exp' as const,
},
},
],
chapters: [
{
id: 'chapter-1',
title: 'Intro',
startSeconds: 0,
endSeconds: 1,
timeBase: '1/48000',
},
],
};
const result = importProjectDocument(serializeProject(persisted), {
markSourcesMissing: false,
});
expect(result.project.timeline[0]?.audio?.fadeCurve).toBe('qsin');
expect(result.project.timeline[0]?.video?.fadeCurve).toBe('exp');
expect(result.project.chapters[0]?.timeBase).toBe('1/48000');
});
it('still reports reattachment when an exported project was already missing sources', () => {
const project = {
...populatedProject(),
assets: [
{ ...populatedProject().assets[0]!, sourceStatus: 'missing' as const },
],
};
const result = importProjectDocument(serializeProject(project));
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'source-reattachment-required' })
);
});
it('migrates the documented legacy version zero shape', () => {
const legacy = {
schemaVersion: 0,
id: 'legacy-project',
title: 'Legacy project',
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
media: [
{
id: 'legacy-source',
fileName: 'legacy.mp4',
size: 42,
lastModified: 100,
type: 'video/mp4',
sourceStatus: 'attached',
},
],
clips: [
{
id: 'legacy-clip',
assetId: 'legacy-source',
inSeconds: 0,
outSeconds: 2,
},
],
};
const result = importProjectDocument(JSON.stringify(legacy));
expect(result.fromVersion).toBe(0);
expect(result.project).toMatchObject({
schemaVersion: 1,
name: 'Legacy project',
assets: [
{
originalName: 'legacy.mp4',
mimeType: 'video/mp4',
sourceStatus: 'missing',
},
],
timeline: [
{
sourceInSeconds: 0,
sourceOutSeconds: 2,
},
],
});
expect(result.warnings.map((warning) => warning.code)).toEqual(
expect.arrayContaining([
'renamed-field',
'defaulted-field',
'source-reattachment-required',
])
);
});
it('rejects malformed, unversioned, future and oversized documents', () => {
expect(() => importProjectDocument('{')).toThrow(ProjectImportError);
expect(() => importProjectDocument('{}')).toThrow(
'does not declare a schema version'
);
expect(() => importProjectDocument('{"schemaVersion":999}')).toThrow(
'is not supported'
);
expect(() =>
importProjectDocument('{"schemaVersion":1}', { maximumBytes: 5 })
).toThrow('exceeds');
});
it('creates a safe project-document filename', () => {
expect(
projectDocumentFileName({
...populatedProject(),
name: '../../My Project',
})
).toBe('My-Project.avproject.json');
});
});

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';
import {
ProjectValidationError,
assertValidProject,
validateProject,
} from '../../../src/project/project.validation';
import { populatedProject } from './project-fixture';
describe('project validation', () => {
it('returns an isolated, typed copy for a valid version-1 project', () => {
const input = populatedProject();
const result = validateProject(input);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.project).toEqual(input);
expect(result.project).not.toBe(input);
}
});
it('reports duplicate identities, missing references and bad ranges', () => {
const project = populatedProject();
const invalid = {
...project,
assets: [...project.assets, { ...project.assets[0] }],
timeline: [
{
...project.timeline[0],
assetId: 'missing',
sourceInSeconds: 5,
sourceOutSeconds: 3,
crop: { x: -1, y: 0, width: 0, height: 100 },
},
],
};
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining([
'duplicate-id',
'missing-reference',
'invalid-range',
'invalid-value',
])
);
}
});
it('checks clip bounds, fade bounds, chapter bounds and output streams', () => {
const project = populatedProject();
const invalid = {
...project,
timeline: [
{
...project.timeline[0],
sourceOutSeconds: 11,
audio: {
enabled: true,
fadeInSeconds: 8,
fadeOutSeconds: 8,
},
},
],
chapters: [
{
id: 'chapter-1',
startSeconds: 0,
endSeconds: 20,
title: 'Too long',
},
],
output: {
...project.output,
videoEnabled: false,
audioEnabled: false,
},
};
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'invalid-range',
path: 'timeline[0]',
}),
expect.objectContaining({
code: 'out-of-bounds',
path: 'timeline[0].audio',
}),
expect.objectContaining({
code: 'out-of-bounds',
path: 'chapters[0].endSeconds',
}),
expect.objectContaining({
code: 'invalid-value',
path: 'output',
}),
])
);
}
});
it('validates persisted fade curves and chapter time-base resolution', () => {
const project = populatedProject();
const invalid = {
...project,
timeline: [
{
...project.timeline[0],
audio: {
enabled: true,
fadeInSeconds: 0.2,
fadeCurve: 'log',
},
},
],
chapters: [
{
id: 'chapter-1',
startSeconds: 0,
endSeconds: 0.1,
title: 'Too short for this base',
timeBase: '1/1',
},
],
};
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'invalid-value',
path: 'timeline[0].audio.fadeCurve',
}),
expect.objectContaining({
code: 'invalid-value',
path: 'chapters[0].timeBase',
}),
])
);
}
});
it('rejects unsupported schema versions with a useful typed exception', () => {
const project = { ...populatedProject(), schemaVersion: 99 };
expect(() => assertValidProject(project)).toThrow(ProjectValidationError);
const result = validateProject(project);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues[0]).toMatchObject({
code: 'unsupported-version',
path: 'schemaVersion',
});
}
});
it('does not trust malformed normalized probe fields from an import', () => {
const project = populatedProject();
const invalid = structuredClone(project) as unknown as {
assets: Array<{
probe?: {
streams: Array<{
width?: unknown;
rotationDegrees?: unknown;
}>;
};
}>;
};
const stream = invalid.assets[0]?.probe?.streams[0];
if (stream) {
stream.width = '1920';
stream.rotationDegrees = '90';
}
const result = validateProject(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'invalid-type',
path: 'assets[0].probe.streams[0].width',
})
);
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'invalid-type',
path: 'assets[0].probe.streams[0].rotationDegrees',
})
);
}
});
});

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import {
SourceReattachmentError,
bestReattachmentCandidate,
confirmSourceReattachment,
rankReattachmentCandidates,
} from '../../../src/project/source-reattachment';
import type { SourceHash } from '../../../src/project/project.types';
import { UPDATED_AT, mediaAsset, populatedProject } from './project-fixture';
const HASH_A: SourceHash = {
algorithm: 'sha-256',
value: 'a'.repeat(64),
scope: 'full',
};
const HASH_B: SourceHash = {
algorithm: 'sha-256',
value: 'b'.repeat(64),
scope: 'full',
};
describe('source reattachment matching', () => {
it('ranks size, timestamp, filename, MIME type and optional hash evidence', () => {
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
const candidates = [
{
name: 'renamed.mp4',
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
hash: HASH_A,
},
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
hash: HASH_B,
},
];
const matches = rankReattachmentCandidates(asset, candidates);
expect(matches.map((match) => match.confidence)).toEqual([
'exact',
'likely',
'none',
]);
expect(matches[0]).toMatchObject({
score: 190,
requiresExplicitConfirmation: true,
});
expect(
matches[0]?.evidence.find((evidence) => evidence.field === 'hash')
).toMatchObject({ matches: true });
});
it('does not suggest ambiguous ties', () => {
const asset = mediaAsset({ sourceStatus: 'missing' });
const candidates = [
{
name: 'a.mp4',
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
{
name: 'b.mp4',
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
];
expect(bestReattachmentCandidate(asset, candidates)).toBeUndefined();
});
it('allows filesystem timestamp tolerance but treats size/hash mismatch as unsafe', () => {
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
expect(
rankReattachmentCandidates(asset, [
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified + 1_500,
mimeType: asset.mimeType,
},
])[0]?.confidence
).toBe('likely');
expect(
rankReattachmentCandidates(asset, [
{
name: asset.originalName,
size: asset.size + 1,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
},
])[0]?.confidence
).toBe('none');
});
it('does not treat incomparable partial and full hashes as a mismatch', () => {
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
const partial: SourceHash = {
algorithm: 'sha-256',
value: 'b'.repeat(64),
scope: 'partial',
bytesHashed: 1_024,
};
expect(
rankReattachmentCandidates(asset, [
{
name: asset.originalName,
size: asset.size,
lastModified: asset.lastModified,
mimeType: asset.mimeType,
hash: partial,
},
])[0]?.confidence
).toBe('likely');
});
it('rejects malformed file descriptors before scoring', () => {
expect(() =>
rankReattachmentCandidates(mediaAsset(), [
{
name: '',
size: -1,
lastModified: Number.NaN,
},
])
).toThrow(SourceReattachmentError);
});
it('only marks a source attached after an explicit confirmation call', () => {
const project = {
...populatedProject(),
assets: [mediaAsset({ sourceStatus: 'missing' })],
};
const candidate = {
name: 'renamed.mp4',
size: 1_024,
lastModified: 1_700_000_000_000,
mimeType: 'video/mp4',
};
const result = confirmSourceReattachment(
project,
'asset-1',
candidate,
UPDATED_AT
);
expect(project.assets[0]?.sourceStatus).toBe('missing');
expect(result.assets[0]?.sourceStatus).toBe('attached');
expect(() =>
confirmSourceReattachment(
project,
'asset-1',
{ ...candidate, size: 1 },
UPDATED_AT
)
).toThrow(SourceReattachmentError);
});
});