feat: publish av-tools 0.1.0
This commit is contained in:
8
src/project/index.ts
Normal file
8
src/project/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './migrations';
|
||||
export * from './project.reducer';
|
||||
export * from './project.schema';
|
||||
export * from './project.selectors';
|
||||
export * from './project.serialization';
|
||||
export * from './project.types';
|
||||
export * from './project.validation';
|
||||
export * from './source-reattachment';
|
||||
67
src/project/migrations/index.ts
Normal file
67
src/project/migrations/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { AvProjectV1, LegacyAvProjectV0 } from '../project.types';
|
||||
import { migrateV0ToV1, type ProjectMigrationWarning } from './v0-to-v1';
|
||||
|
||||
export { migrateV0ToV1 };
|
||||
export type { ProjectMigrationWarning };
|
||||
|
||||
export interface ProjectMigrationResult {
|
||||
project: AvProjectV1;
|
||||
fromVersion: 0 | 1;
|
||||
toVersion: 1;
|
||||
warnings: ProjectMigrationWarning[];
|
||||
}
|
||||
|
||||
export class ProjectMigrationError extends Error {
|
||||
readonly schemaVersion: unknown;
|
||||
|
||||
constructor(message: string, schemaVersion: unknown) {
|
||||
super(message);
|
||||
this.name = 'ProjectMigrationError';
|
||||
this.schemaVersion = schemaVersion;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateProjectDocument(value: unknown): ProjectMigrationResult {
|
||||
if (!isRecord(value)) {
|
||||
throw new ProjectMigrationError(
|
||||
'A project document must be a JSON object.',
|
||||
undefined
|
||||
);
|
||||
}
|
||||
if (value.schemaVersion === 1) {
|
||||
let project: AvProjectV1;
|
||||
try {
|
||||
project = structuredClone(value) as unknown as AvProjectV1;
|
||||
} catch {
|
||||
throw new ProjectMigrationError(
|
||||
'The project contains a value that cannot be migrated.',
|
||||
value.schemaVersion
|
||||
);
|
||||
}
|
||||
return {
|
||||
project,
|
||||
fromVersion: 1,
|
||||
toVersion: 1,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (value.schemaVersion === 0) {
|
||||
const migration = migrateV0ToV1(value as unknown as LegacyAvProjectV0);
|
||||
return {
|
||||
project: migration.project,
|
||||
fromVersion: 0,
|
||||
toVersion: 1,
|
||||
warnings: migration.warnings,
|
||||
};
|
||||
}
|
||||
throw new ProjectMigrationError(
|
||||
value.schemaVersion === undefined
|
||||
? 'The project document does not declare a schema version.'
|
||||
: `Project schema version ${String(value.schemaVersion)} is not supported.`,
|
||||
value.schemaVersion
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
298
src/project/migrations/v0-to-v1.ts
Normal file
298
src/project/migrations/v0-to-v1.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { AvProjectV1, LegacyAvProjectV0 } from '../project.types';
|
||||
|
||||
export interface ProjectMigrationWarning {
|
||||
code: 'renamed-field' | 'defaulted-field' | 'source-reattachment-required';
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface V0MigrationResult {
|
||||
project: AvProjectV1;
|
||||
warnings: ProjectMigrationWarning[];
|
||||
}
|
||||
|
||||
export function migrateV0ToV1(input: LegacyAvProjectV0): V0MigrationResult {
|
||||
const warnings: ProjectMigrationWarning[] = [];
|
||||
const rawAssets = Array.isArray(input.assets)
|
||||
? input.assets
|
||||
: Array.isArray(input.media)
|
||||
? input.media
|
||||
: [];
|
||||
const rawTimeline = Array.isArray(input.timeline)
|
||||
? input.timeline
|
||||
: Array.isArray(input.clips)
|
||||
? input.clips
|
||||
: [];
|
||||
if (input.title !== undefined && input.name === undefined) {
|
||||
warnings.push({
|
||||
code: 'renamed-field',
|
||||
path: 'title',
|
||||
message: 'Migrated the legacy "title" field to "name".',
|
||||
});
|
||||
}
|
||||
if (input.media !== undefined && input.assets === undefined) {
|
||||
warnings.push({
|
||||
code: 'renamed-field',
|
||||
path: 'media',
|
||||
message: 'Migrated the legacy "media" collection to "assets".',
|
||||
});
|
||||
}
|
||||
if (input.clips !== undefined && input.timeline === undefined) {
|
||||
warnings.push({
|
||||
code: 'renamed-field',
|
||||
path: 'clips',
|
||||
message: 'Migrated the legacy "clips" collection to "timeline".',
|
||||
});
|
||||
}
|
||||
|
||||
const project: AvProjectV1 = {
|
||||
schemaVersion: 1,
|
||||
id: stringValue(input.id),
|
||||
name: stringValue(input.name ?? input.title ?? 'Untitled project'),
|
||||
createdAt: stringValue(input.createdAt),
|
||||
updatedAt: stringValue(input.updatedAt),
|
||||
assets: rawAssets.map((asset, index) =>
|
||||
migrateAsset(asset, index, warnings)
|
||||
),
|
||||
timeline: rawTimeline.map((clip, index) => migrateClip(clip, index)),
|
||||
output: migrateOutput(input.output, warnings),
|
||||
metadata: migrateMetadata(input.metadata),
|
||||
chapters: (Array.isArray(input.chapters) ? input.chapters : []).map(
|
||||
migrateChapter
|
||||
),
|
||||
subtitles: (Array.isArray(input.subtitles) ? input.subtitles : []).map(
|
||||
migrateSubtitle
|
||||
),
|
||||
...(isRecord(input.ui) ? { ui: input.ui as AvProjectV1['ui'] } : {}),
|
||||
};
|
||||
|
||||
return { project, warnings };
|
||||
}
|
||||
|
||||
function migrateAsset(
|
||||
value: unknown,
|
||||
index: number,
|
||||
warnings: ProjectMigrationWarning[]
|
||||
): AvProjectV1['assets'][number] {
|
||||
const asset = isRecord(value) ? value : {};
|
||||
const originalName = stringValue(
|
||||
asset.originalName ?? asset.fileName ?? asset.name ?? `source-${index}`
|
||||
);
|
||||
if (asset.originalName === undefined) {
|
||||
warnings.push({
|
||||
code: 'renamed-field',
|
||||
path: `assets[${index}].originalName`,
|
||||
message: 'Migrated a legacy source filename field.',
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: stringValue(asset.id ?? `asset-${index}`),
|
||||
originalName,
|
||||
size: numberValue(asset.size),
|
||||
lastModified: numberValue(asset.lastModified),
|
||||
mimeType: stringValue(asset.mimeType ?? asset.type ?? ''),
|
||||
sourceStatus: asset.sourceStatus === 'attached' ? 'attached' : 'missing',
|
||||
...(isRecord(asset.probe)
|
||||
? {
|
||||
probe:
|
||||
asset.probe as unknown as AvProjectV1['assets'][number]['probe'],
|
||||
}
|
||||
: {}),
|
||||
...(isRecord(asset.hash)
|
||||
? {
|
||||
hash: asset.hash as unknown as AvProjectV1['assets'][number]['hash'],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateClip(
|
||||
value: unknown,
|
||||
index: number
|
||||
): AvProjectV1['timeline'][number] {
|
||||
const clip = isRecord(value) ? value : {};
|
||||
return {
|
||||
id: stringValue(clip.id ?? `clip-${index}`),
|
||||
assetId: stringValue(clip.assetId),
|
||||
sourceInSeconds: numberValue(
|
||||
clip.sourceInSeconds ?? clip.inSeconds ?? clip.startSeconds
|
||||
),
|
||||
sourceOutSeconds: numberValue(
|
||||
clip.sourceOutSeconds ?? clip.outSeconds ?? clip.endSeconds
|
||||
),
|
||||
...(isRecord(clip.crop)
|
||||
? {
|
||||
crop: clip.crop as unknown as AvProjectV1['timeline'][number]['crop'],
|
||||
}
|
||||
: {}),
|
||||
...(isRecord(clip.resize)
|
||||
? {
|
||||
resize:
|
||||
clip.resize as unknown as AvProjectV1['timeline'][number]['resize'],
|
||||
}
|
||||
: {}),
|
||||
...(clip.rotation !== undefined
|
||||
? {
|
||||
rotation:
|
||||
clip.rotation as AvProjectV1['timeline'][number]['rotation'],
|
||||
}
|
||||
: {}),
|
||||
...(clip.frameRate !== undefined
|
||||
? { frameRate: numberValue(clip.frameRate) }
|
||||
: {}),
|
||||
...(isRecord(clip.audio)
|
||||
? {
|
||||
audio:
|
||||
clip.audio as unknown as AvProjectV1['timeline'][number]['audio'],
|
||||
}
|
||||
: {}),
|
||||
...(isRecord(clip.video)
|
||||
? {
|
||||
video:
|
||||
clip.video as unknown as AvProjectV1['timeline'][number]['video'],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateOutput(
|
||||
value: unknown,
|
||||
warnings: ProjectMigrationWarning[]
|
||||
): AvProjectV1['output'] {
|
||||
const output = isRecord(value) ? value : {};
|
||||
const defaultsUsed = [
|
||||
'presetId',
|
||||
'fileName',
|
||||
'container',
|
||||
'videoEnabled',
|
||||
'audioEnabled',
|
||||
].some((field) => output[field] === undefined);
|
||||
if (defaultsUsed) {
|
||||
warnings.push({
|
||||
code: 'defaulted-field',
|
||||
path: 'output',
|
||||
message: 'Filled missing legacy output settings with safe defaults.',
|
||||
});
|
||||
}
|
||||
return {
|
||||
presetId: stringValue(output.presetId ?? 'mp4-h264-balanced'),
|
||||
fileName: stringValue(output.fileName ?? 'output.mp4'),
|
||||
container: stringValue(output.container ?? 'mp4'),
|
||||
videoEnabled:
|
||||
typeof output.videoEnabled === 'boolean' ? output.videoEnabled : true,
|
||||
audioEnabled:
|
||||
typeof output.audioEnabled === 'boolean' ? output.audioEnabled : true,
|
||||
...(finiteNumber(output.width) ? { width: Number(output.width) } : {}),
|
||||
...(finiteNumber(output.height) ? { height: Number(output.height) } : {}),
|
||||
...(finiteNumber(output.frameRate)
|
||||
? { frameRate: Number(output.frameRate) }
|
||||
: {}),
|
||||
sampleRate: finiteNumber(output.sampleRate)
|
||||
? Number(output.sampleRate)
|
||||
: 48_000,
|
||||
channelLayout:
|
||||
output.channelLayout === 'mono' || output.channelLayout === '5.1'
|
||||
? output.channelLayout
|
||||
: 'stereo',
|
||||
missingAudioPolicy:
|
||||
output.missingAudioPolicy === 'drop-all' ||
|
||||
output.missingAudioPolicy === 'reject'
|
||||
? output.missingAudioPolicy
|
||||
: 'insert-silence',
|
||||
};
|
||||
}
|
||||
|
||||
function migrateMetadata(value: unknown): AvProjectV1['metadata'] {
|
||||
const metadata = isRecord(value) ? value : {};
|
||||
return {
|
||||
...(typeof metadata.title === 'string' ? { title: metadata.title } : {}),
|
||||
...(typeof metadata.artist === 'string' ? { artist: metadata.artist } : {}),
|
||||
...(typeof metadata.album === 'string' ? { album: metadata.album } : {}),
|
||||
...(typeof metadata.comment === 'string'
|
||||
? { comment: metadata.comment }
|
||||
: {}),
|
||||
custom: isStringRecord(metadata.custom) ? metadata.custom : {},
|
||||
};
|
||||
}
|
||||
|
||||
function migrateChapter(
|
||||
value: unknown,
|
||||
index: number
|
||||
): AvProjectV1['chapters'][number] {
|
||||
const chapter = isRecord(value) ? value : {};
|
||||
return {
|
||||
id: stringValue(chapter.id ?? `chapter-${index}`),
|
||||
startSeconds: numberValue(chapter.startSeconds ?? chapter.start),
|
||||
endSeconds: numberValue(chapter.endSeconds ?? chapter.end),
|
||||
title: stringValue(chapter.title ?? `Chapter ${index + 1}`),
|
||||
timeBase:
|
||||
typeof chapter.timeBase === 'string' ? chapter.timeBase : '1/1000',
|
||||
...(isStringRecord(chapter.metadata) ? { metadata: chapter.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateSubtitle(
|
||||
value: unknown,
|
||||
index: number
|
||||
): AvProjectV1['subtitles'][number] {
|
||||
const subtitle = isRecord(value) ? value : {};
|
||||
return {
|
||||
id: stringValue(subtitle.id ?? `subtitle-${index}`),
|
||||
...(typeof subtitle.assetId === 'string'
|
||||
? { assetId: subtitle.assetId }
|
||||
: {}),
|
||||
...(typeof subtitle.sourceName === 'string'
|
||||
? { sourceName: subtitle.sourceName }
|
||||
: {}),
|
||||
...(subtitle.sourceFormat === 'srt' ||
|
||||
subtitle.sourceFormat === 'vtt' ||
|
||||
subtitle.sourceFormat === 'ass'
|
||||
? { sourceFormat: subtitle.sourceFormat }
|
||||
: {}),
|
||||
...(typeof subtitle.language === 'string'
|
||||
? { language: subtitle.language }
|
||||
: {}),
|
||||
...(typeof subtitle.title === 'string' ? { title: subtitle.title } : {}),
|
||||
...(typeof subtitle.offsetSeconds === 'number' &&
|
||||
Number.isFinite(subtitle.offsetSeconds)
|
||||
? { offsetSeconds: subtitle.offsetSeconds }
|
||||
: {}),
|
||||
...(typeof subtitle.default === 'boolean'
|
||||
? { default: subtitle.default }
|
||||
: {}),
|
||||
...(typeof subtitle.forced === 'boolean'
|
||||
? { forced: subtitle.forced }
|
||||
: {}),
|
||||
mode:
|
||||
subtitle.mode === 'mux' || subtitle.mode === 'burn-in'
|
||||
? subtitle.mode
|
||||
: 'exclude',
|
||||
...(subtitle.sourceStatus === 'attached' ||
|
||||
subtitle.sourceStatus === 'missing'
|
||||
? { sourceStatus: subtitle.sourceStatus }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return typeof value === 'number' ? value : Number(value ?? 0);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : String(value ?? '');
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Object.values(value).every((entry) => typeof entry === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
306
src/project/project.reducer.ts
Normal file
306
src/project/project.reducer.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { validateSourceRange } from '../media/duration';
|
||||
import type {
|
||||
AvProjectV1,
|
||||
MediaAssetReference,
|
||||
OutputSettings,
|
||||
ProjectChapter,
|
||||
ProjectMetadataSettings,
|
||||
ProjectSubtitleTrack,
|
||||
ProjectUiState,
|
||||
TimelineClip,
|
||||
} from './project.types';
|
||||
import { assertValidProject } from './project.validation';
|
||||
|
||||
export type ProjectAction =
|
||||
| { type: 'project/replace'; project: AvProjectV1 }
|
||||
| { type: 'project/rename'; name: string; updatedAt: string }
|
||||
| {
|
||||
type: 'asset/add';
|
||||
asset: MediaAssetReference;
|
||||
updatedAt: string;
|
||||
}
|
||||
| { type: 'asset/remove'; assetId: string; updatedAt: string }
|
||||
| {
|
||||
type: 'asset/set-source-status';
|
||||
assetId: string;
|
||||
sourceStatus: 'attached' | 'missing';
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'asset/update';
|
||||
assetId: string;
|
||||
changes: Partial<
|
||||
Pick<MediaAssetReference, 'probe' | 'mimeType' | 'hash'>
|
||||
>;
|
||||
updatedAt: string;
|
||||
}
|
||||
| { type: 'clip/add'; clip: TimelineClip; index?: number; updatedAt: string }
|
||||
| {
|
||||
type: 'clip/update';
|
||||
clipId: string;
|
||||
changes: Partial<Omit<TimelineClip, 'id'>>;
|
||||
updatedAt: string;
|
||||
}
|
||||
| { type: 'clip/remove'; clipId: string; updatedAt: string }
|
||||
| {
|
||||
type: 'clip/move';
|
||||
clipId: string;
|
||||
toIndex: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'output/set';
|
||||
output: OutputSettings;
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'metadata/set';
|
||||
metadata: ProjectMetadataSettings;
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'chapters/set';
|
||||
chapters: ProjectChapter[];
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'subtitles/set';
|
||||
subtitles: ProjectSubtitleTrack[];
|
||||
updatedAt: string;
|
||||
}
|
||||
| { type: 'ui/set'; ui: ProjectUiState | undefined };
|
||||
|
||||
export function projectReducer(
|
||||
project: AvProjectV1,
|
||||
action: ProjectAction
|
||||
): AvProjectV1 {
|
||||
switch (action.type) {
|
||||
case 'project/replace':
|
||||
return assertValidProject(action.project);
|
||||
case 'project/rename': {
|
||||
const name = action.name.trim();
|
||||
if (!name) {
|
||||
throw new ProjectReducerError('A project name cannot be empty.');
|
||||
}
|
||||
return touch(project, action.updatedAt, { name });
|
||||
}
|
||||
case 'asset/add': {
|
||||
assertUniqueId(project.assets, action.asset.id, 'asset');
|
||||
return touch(project, action.updatedAt, {
|
||||
assets: [...project.assets, action.asset],
|
||||
});
|
||||
}
|
||||
case 'asset/remove': {
|
||||
assertExists(project.assets, action.assetId, 'asset');
|
||||
const removedClipIds = new Set(
|
||||
project.timeline
|
||||
.filter((clip) => clip.assetId === action.assetId)
|
||||
.map((clip) => clip.id)
|
||||
);
|
||||
return touch(project, action.updatedAt, {
|
||||
assets: project.assets.filter((asset) => asset.id !== action.assetId),
|
||||
timeline: project.timeline.filter(
|
||||
(clip) => clip.assetId !== action.assetId
|
||||
),
|
||||
subtitles: project.subtitles.filter(
|
||||
(track) => track.assetId !== action.assetId
|
||||
),
|
||||
ui: cleanUiAfterRemoval(project.ui, action.assetId, removedClipIds),
|
||||
});
|
||||
}
|
||||
case 'asset/set-source-status':
|
||||
assertExists(project.assets, action.assetId, 'asset');
|
||||
return touch(project, action.updatedAt, {
|
||||
assets: project.assets.map((asset) =>
|
||||
asset.id === action.assetId
|
||||
? { ...asset, sourceStatus: action.sourceStatus }
|
||||
: asset
|
||||
),
|
||||
});
|
||||
case 'asset/update':
|
||||
assertExists(project.assets, action.assetId, 'asset');
|
||||
return touch(project, action.updatedAt, {
|
||||
assets: project.assets.map((asset) =>
|
||||
asset.id === action.assetId ? { ...asset, ...action.changes } : asset
|
||||
),
|
||||
});
|
||||
case 'clip/add': {
|
||||
assertUniqueId(project.timeline, action.clip.id, 'clip');
|
||||
assertAssetReference(project, action.clip.assetId);
|
||||
assertClipRange(action.clip);
|
||||
const index = normalizeInsertionIndex(
|
||||
action.index,
|
||||
project.timeline.length
|
||||
);
|
||||
const timeline = [...project.timeline];
|
||||
timeline.splice(index, 0, action.clip);
|
||||
return touch(project, action.updatedAt, { timeline });
|
||||
}
|
||||
case 'clip/update': {
|
||||
assertExists(project.timeline, action.clipId, 'clip');
|
||||
const timeline = project.timeline.map((clip) => {
|
||||
if (clip.id !== action.clipId) {
|
||||
return clip;
|
||||
}
|
||||
const updated = { ...clip, ...action.changes, id: clip.id };
|
||||
assertAssetReference(project, updated.assetId);
|
||||
assertClipRange(updated);
|
||||
return updated;
|
||||
});
|
||||
return touch(project, action.updatedAt, { timeline });
|
||||
}
|
||||
case 'clip/remove':
|
||||
assertExists(project.timeline, action.clipId, 'clip');
|
||||
return touch(project, action.updatedAt, {
|
||||
timeline: project.timeline.filter((clip) => clip.id !== action.clipId),
|
||||
ui:
|
||||
project.ui?.selectedClipId === action.clipId
|
||||
? { ...project.ui, selectedClipId: undefined }
|
||||
: project.ui,
|
||||
});
|
||||
case 'clip/move': {
|
||||
const fromIndex = project.timeline.findIndex(
|
||||
(clip) => clip.id === action.clipId
|
||||
);
|
||||
if (fromIndex < 0) {
|
||||
throw new ProjectReducerError(
|
||||
`Cannot move unknown clip "${action.clipId}".`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(action.toIndex) ||
|
||||
action.toIndex < 0 ||
|
||||
action.toIndex >= project.timeline.length
|
||||
) {
|
||||
throw new ProjectReducerError(
|
||||
'Clip destination must be a valid timeline index.'
|
||||
);
|
||||
}
|
||||
if (fromIndex === action.toIndex) {
|
||||
return project;
|
||||
}
|
||||
const timeline = [...project.timeline];
|
||||
const [clip] = timeline.splice(fromIndex, 1);
|
||||
if (clip === undefined) {
|
||||
return project;
|
||||
}
|
||||
timeline.splice(action.toIndex, 0, clip);
|
||||
return touch(project, action.updatedAt, { timeline });
|
||||
}
|
||||
case 'output/set':
|
||||
return touch(project, action.updatedAt, { output: action.output });
|
||||
case 'metadata/set':
|
||||
return touch(project, action.updatedAt, {
|
||||
metadata: action.metadata,
|
||||
});
|
||||
case 'chapters/set':
|
||||
return touch(project, action.updatedAt, {
|
||||
chapters: action.chapters,
|
||||
});
|
||||
case 'subtitles/set':
|
||||
return touch(project, action.updatedAt, {
|
||||
subtitles: action.subtitles,
|
||||
});
|
||||
case 'ui/set':
|
||||
return { ...project, ui: action.ui };
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectReducerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProjectReducerError';
|
||||
}
|
||||
}
|
||||
|
||||
function touch(
|
||||
project: AvProjectV1,
|
||||
updatedAt: string,
|
||||
changes: Partial<AvProjectV1>
|
||||
): AvProjectV1 {
|
||||
const timestamp = Date.parse(updatedAt);
|
||||
if (!/^\d{4}-\d{2}-\d{2}T/.test(updatedAt) || !Number.isFinite(timestamp)) {
|
||||
throw new ProjectReducerError('updatedAt must be an ISO-8601 date.');
|
||||
}
|
||||
if (timestamp < Date.parse(project.createdAt)) {
|
||||
throw new ProjectReducerError(
|
||||
'updatedAt cannot be before the project creation time.'
|
||||
);
|
||||
}
|
||||
if (timestamp < Date.parse(project.updatedAt)) {
|
||||
throw new ProjectReducerError(
|
||||
'updatedAt cannot be before the previous project update time.'
|
||||
);
|
||||
}
|
||||
return { ...project, ...changes, updatedAt };
|
||||
}
|
||||
|
||||
function assertClipRange(clip: TimelineClip): void {
|
||||
const range = validateSourceRange(
|
||||
clip.sourceInSeconds,
|
||||
clip.sourceOutSeconds
|
||||
);
|
||||
if (!range.valid) {
|
||||
throw new ProjectReducerError(range.issues[0]?.message ?? 'Invalid clip.');
|
||||
}
|
||||
}
|
||||
|
||||
function assertAssetReference(project: AvProjectV1, assetId: string): void {
|
||||
if (!project.assets.some((asset) => asset.id === assetId)) {
|
||||
throw new ProjectReducerError(
|
||||
`Clip references unknown asset "${assetId}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExists(
|
||||
entries: readonly { id: string }[],
|
||||
id: string,
|
||||
kind: string
|
||||
): void {
|
||||
if (!entries.some((entry) => entry.id === id)) {
|
||||
throw new ProjectReducerError(`Unknown ${kind} "${id}".`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueId(
|
||||
entries: readonly { id: string }[],
|
||||
id: string,
|
||||
kind: string
|
||||
): void {
|
||||
if (entries.some((entry) => entry.id === id)) {
|
||||
throw new ProjectReducerError(`Duplicate ${kind} ID "${id}".`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInsertionIndex(
|
||||
index: number | undefined,
|
||||
length: number
|
||||
): number {
|
||||
if (index === undefined) {
|
||||
return length;
|
||||
}
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index > length) {
|
||||
throw new ProjectReducerError(
|
||||
'Clip insertion index must be within the timeline.'
|
||||
);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function cleanUiAfterRemoval(
|
||||
ui: ProjectUiState | undefined,
|
||||
assetId: string,
|
||||
removedClipIds: ReadonlySet<string>
|
||||
): ProjectUiState | undefined {
|
||||
if (ui === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...ui,
|
||||
...(ui.selectedAssetId === assetId ? { selectedAssetId: undefined } : {}),
|
||||
...(ui.selectedClipId !== undefined && removedClipIds.has(ui.selectedClipId)
|
||||
? { selectedClipId: undefined }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
101
src/project/project.schema.ts
Normal file
101
src/project/project.schema.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
AV_PROJECT_SCHEMA_VERSION,
|
||||
type AvProjectV1,
|
||||
type BrowserFileIdentity,
|
||||
type CreateMediaAssetOptions,
|
||||
type CreateProjectOptions,
|
||||
type MediaAssetReference,
|
||||
} from './project.types';
|
||||
|
||||
export function createEmptyProject(
|
||||
options: CreateProjectOptions = {}
|
||||
): AvProjectV1 {
|
||||
const timestamp = options.timestamp ?? new Date().toISOString();
|
||||
if (!isIsoDate(timestamp)) {
|
||||
throw new TypeError('Project timestamp must be a valid ISO-8601 date.');
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: AV_PROJECT_SCHEMA_VERSION,
|
||||
id: options.id ?? createProjectId(),
|
||||
name: options.name?.trim() || 'Untitled project',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
assets: [],
|
||||
timeline: [],
|
||||
output: {
|
||||
presetId: options.output?.presetId ?? 'mp4-h264-balanced',
|
||||
fileName: options.output?.fileName ?? 'output.mp4',
|
||||
container: options.output?.container ?? 'mp4',
|
||||
videoEnabled: options.output?.videoEnabled ?? true,
|
||||
audioEnabled: options.output?.audioEnabled ?? true,
|
||||
...(options.output?.width === undefined
|
||||
? {}
|
||||
: { width: options.output.width }),
|
||||
...(options.output?.height === undefined
|
||||
? {}
|
||||
: { height: options.output.height }),
|
||||
...(options.output?.frameRate === undefined
|
||||
? {}
|
||||
: { frameRate: options.output.frameRate }),
|
||||
sampleRate: options.output?.sampleRate ?? 48_000,
|
||||
channelLayout: options.output?.channelLayout ?? 'stereo',
|
||||
missingAudioPolicy:
|
||||
options.output?.missingAudioPolicy ?? 'insert-silence',
|
||||
},
|
||||
metadata: {
|
||||
custom: {},
|
||||
},
|
||||
chapters: [],
|
||||
subtitles: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createMediaAssetReference(
|
||||
file: BrowserFileIdentity,
|
||||
options: CreateMediaAssetOptions = {}
|
||||
): MediaAssetReference {
|
||||
if (!file.name.trim()) {
|
||||
throw new TypeError('A source file must have a name.');
|
||||
}
|
||||
if (!Number.isSafeInteger(file.size) || file.size < 0) {
|
||||
throw new TypeError('A source file size must be a non-negative integer.');
|
||||
}
|
||||
if (!Number.isSafeInteger(file.lastModified) || file.lastModified < 0) {
|
||||
throw new TypeError(
|
||||
'A source last-modified time must be a non-negative integer.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: options.id ?? createId('asset'),
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
mimeType: file.type,
|
||||
sourceStatus: options.sourceStatus ?? 'attached',
|
||||
...(options.probe === undefined ? {} : { probe: options.probe }),
|
||||
...(options.hash === undefined ? {} : { hash: options.hash }),
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectId(): string {
|
||||
return createId('project');
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
if (
|
||||
typeof globalThis.crypto !== 'undefined' &&
|
||||
typeof globalThis.crypto.randomUUID === 'function'
|
||||
) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function isIsoDate(value: string): boolean {
|
||||
return (
|
||||
/^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value))
|
||||
);
|
||||
}
|
||||
107
src/project/project.selectors.ts
Normal file
107
src/project/project.selectors.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { addDurations, subtractDurations } from '../media/duration';
|
||||
import type {
|
||||
AvProjectV1,
|
||||
MediaAssetReference,
|
||||
TimelineClip,
|
||||
} from './project.types';
|
||||
|
||||
export interface TimelineEntry {
|
||||
clip: TimelineClip;
|
||||
asset?: MediaAssetReference;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export function selectAssetById(
|
||||
project: AvProjectV1,
|
||||
assetId: string
|
||||
): MediaAssetReference | undefined {
|
||||
return project.assets.find((asset) => asset.id === assetId);
|
||||
}
|
||||
|
||||
export function selectClipById(
|
||||
project: AvProjectV1,
|
||||
clipId: string
|
||||
): TimelineClip | undefined {
|
||||
return project.timeline.find((clip) => clip.id === clipId);
|
||||
}
|
||||
|
||||
export function selectClipDuration(clip: TimelineClip): number {
|
||||
return Math.max(
|
||||
0,
|
||||
subtractDurations(clip.sourceOutSeconds, clip.sourceInSeconds)
|
||||
);
|
||||
}
|
||||
|
||||
export function selectTimelineDuration(project: AvProjectV1): number {
|
||||
return addDurations(project.timeline.map(selectClipDuration));
|
||||
}
|
||||
|
||||
export function selectTimelineEntries(project: AvProjectV1): TimelineEntry[] {
|
||||
let cursor = 0;
|
||||
return project.timeline.map((clip) => {
|
||||
const durationSeconds = selectClipDuration(clip);
|
||||
const timelineStartSeconds = cursor;
|
||||
cursor = addDurations([cursor, durationSeconds]);
|
||||
return {
|
||||
clip,
|
||||
asset: selectAssetById(project, clip.assetId),
|
||||
timelineStartSeconds,
|
||||
timelineEndSeconds: cursor,
|
||||
durationSeconds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function selectMissingAssets(
|
||||
project: AvProjectV1
|
||||
): MediaAssetReference[] {
|
||||
return project.assets.filter((asset) => asset.sourceStatus === 'missing');
|
||||
}
|
||||
|
||||
export function selectAttachedAssets(
|
||||
project: AvProjectV1
|
||||
): MediaAssetReference[] {
|
||||
return project.assets.filter((asset) => asset.sourceStatus === 'attached');
|
||||
}
|
||||
|
||||
export function selectReferencedAssetIds(project: AvProjectV1): Set<string> {
|
||||
return new Set([
|
||||
...project.timeline.map((clip) => clip.assetId),
|
||||
...project.subtitles.flatMap((track) =>
|
||||
track.assetId === undefined ? [] : [track.assetId]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export function selectUnusedAssets(
|
||||
project: AvProjectV1
|
||||
): MediaAssetReference[] {
|
||||
const referenced = selectReferencedAssetIds(project);
|
||||
return project.assets.filter((asset) => !referenced.has(asset.id));
|
||||
}
|
||||
|
||||
export function selectCanExport(project: AvProjectV1): {
|
||||
canExport: boolean;
|
||||
reasons: string[];
|
||||
} {
|
||||
const reasons: string[] = [];
|
||||
if (project.timeline.length === 0) {
|
||||
reasons.push('The timeline is empty.');
|
||||
}
|
||||
const missingReferenced = selectMissingAssets(project).filter((asset) =>
|
||||
selectReferencedAssetIds(project).has(asset.id)
|
||||
);
|
||||
if (missingReferenced.length > 0) {
|
||||
reasons.push(
|
||||
`${missingReferenced.length} referenced source ${
|
||||
missingReferenced.length === 1 ? 'file is' : 'files are'
|
||||
} missing.`
|
||||
);
|
||||
}
|
||||
if (!project.output.videoEnabled && !project.output.audioEnabled) {
|
||||
reasons.push('No output stream type is enabled.');
|
||||
}
|
||||
return { canExport: reasons.length === 0, reasons };
|
||||
}
|
||||
146
src/project/project.serialization.ts
Normal file
146
src/project/project.serialization.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { safeOutputFileName } from '../media/safe-file-name';
|
||||
import {
|
||||
migrateProjectDocument,
|
||||
type ProjectMigrationWarning,
|
||||
} from './migrations';
|
||||
import type { AvProjectV1 } from './project.types';
|
||||
import {
|
||||
ProjectValidationError,
|
||||
assertValidProject,
|
||||
} from './project.validation';
|
||||
|
||||
export interface SerializeProjectOptions {
|
||||
pretty?: boolean;
|
||||
includeUiState?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportProjectOptions {
|
||||
markSourcesMissing?: boolean;
|
||||
maximumBytes?: number;
|
||||
}
|
||||
|
||||
export interface ProjectImportWarning {
|
||||
code: 'source-reattachment-required' | ProjectMigrationWarning['code'];
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectImportResult {
|
||||
project: AvProjectV1;
|
||||
fromVersion: 0 | 1;
|
||||
warnings: ProjectImportWarning[];
|
||||
}
|
||||
|
||||
export class ProjectImportError extends Error {
|
||||
readonly causeValue?: unknown;
|
||||
|
||||
constructor(message: string, causeValue?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ProjectImportError';
|
||||
this.causeValue = causeValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeProject(
|
||||
project: AvProjectV1,
|
||||
options: SerializeProjectOptions = {}
|
||||
): string {
|
||||
const validProject = assertValidProject(project);
|
||||
const document =
|
||||
options.includeUiState === false ? omitUi(validProject) : validProject;
|
||||
const indentation = options.pretty === false ? undefined : 2;
|
||||
return `${JSON.stringify(document, null, indentation)}\n`;
|
||||
}
|
||||
|
||||
export function importProjectDocument(
|
||||
input: string | unknown,
|
||||
options: ImportProjectOptions = {}
|
||||
): ProjectImportResult {
|
||||
const maximumBytes = options.maximumBytes ?? 10 * 1024 * 1024;
|
||||
if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) {
|
||||
throw new RangeError('Maximum project size must be a positive integer.');
|
||||
}
|
||||
|
||||
let parsed: unknown = input;
|
||||
if (typeof input === 'string') {
|
||||
if (new TextEncoder().encode(input).byteLength > maximumBytes) {
|
||||
throw new ProjectImportError(
|
||||
`The project document exceeds the ${maximumBytes}-byte import limit.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new ProjectImportError(
|
||||
'The project document is not valid JSON.',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const migration = migrateProjectDocument(parsed);
|
||||
let project: AvProjectV1;
|
||||
try {
|
||||
project = assertValidProject(migration.project);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectValidationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ProjectImportError(
|
||||
'The project document could not be validated.',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: ProjectImportWarning[] = [...migration.warnings];
|
||||
if (options.markSourcesMissing !== false) {
|
||||
project = {
|
||||
...project,
|
||||
assets: project.assets.map((asset) => ({
|
||||
...asset,
|
||||
sourceStatus: 'missing',
|
||||
})),
|
||||
subtitles: project.subtitles.map((track) =>
|
||||
track.sourceStatus === undefined
|
||||
? track
|
||||
: { ...track, sourceStatus: 'missing' }
|
||||
),
|
||||
};
|
||||
if (
|
||||
project.assets.length +
|
||||
project.subtitles.filter((track) => track.sourceStatus !== undefined)
|
||||
.length >
|
||||
0
|
||||
) {
|
||||
warnings.push({
|
||||
code: 'source-reattachment-required',
|
||||
path: 'assets',
|
||||
message:
|
||||
'Browser File objects are not stored in a project document. Reattach source files before export.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
fromVersion: migration.fromVersion,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeProject(
|
||||
json: string,
|
||||
options?: ImportProjectOptions
|
||||
): AvProjectV1 {
|
||||
return importProjectDocument(json, options).project;
|
||||
}
|
||||
|
||||
export function projectDocumentFileName(project: AvProjectV1): string {
|
||||
return safeOutputFileName(project.name, 'avproject.json', 'av-tools-project');
|
||||
}
|
||||
|
||||
function omitUi(project: AvProjectV1): AvProjectV1 {
|
||||
const document = { ...project };
|
||||
delete document.ui;
|
||||
return document;
|
||||
}
|
||||
184
src/project/project.types.ts
Normal file
184
src/project/project.types.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { MediaProbe } from '../media/media.types';
|
||||
|
||||
export const AV_PROJECT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type SourceStatus = 'attached' | 'missing';
|
||||
|
||||
export interface SourceHash {
|
||||
algorithm: 'sha-256';
|
||||
value: string;
|
||||
scope: 'full' | 'partial';
|
||||
bytesHashed?: number;
|
||||
}
|
||||
|
||||
export interface MediaAssetReference {
|
||||
id: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
mimeType: string;
|
||||
probe?: MediaProbe;
|
||||
sourceStatus: SourceStatus;
|
||||
hash?: SourceHash;
|
||||
}
|
||||
|
||||
export interface CropSettings {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ResizeSettings {
|
||||
width?: number;
|
||||
height?: number;
|
||||
mode: 'fit' | 'fill' | 'stretch';
|
||||
allowUpscale?: boolean;
|
||||
algorithm?: 'fast_bilinear' | 'bilinear' | 'bicubic' | 'lanczos';
|
||||
paddingColor?: string;
|
||||
}
|
||||
|
||||
export interface NormalizationSettings {
|
||||
targetLufs: number;
|
||||
truePeakDb: number;
|
||||
loudnessRangeLufs?: number;
|
||||
}
|
||||
|
||||
export type FadeCurve = 'tri' | 'qsin' | 'exp';
|
||||
|
||||
export interface TimelineClipAudioSettings {
|
||||
enabled: boolean;
|
||||
gainDb?: number;
|
||||
normalization?: NormalizationSettings;
|
||||
fadeInSeconds?: number;
|
||||
fadeOutSeconds?: number;
|
||||
fadeCurve?: FadeCurve;
|
||||
}
|
||||
|
||||
export interface TimelineClipVideoSettings {
|
||||
enabled: boolean;
|
||||
fadeInSeconds?: number;
|
||||
fadeOutSeconds?: number;
|
||||
fadeCurve?: FadeCurve;
|
||||
}
|
||||
|
||||
export interface TimelineClip {
|
||||
id: string;
|
||||
assetId: string;
|
||||
sourceInSeconds: number;
|
||||
sourceOutSeconds: number;
|
||||
crop?: CropSettings;
|
||||
resize?: ResizeSettings;
|
||||
rotation?: 0 | 90 | 180 | 270;
|
||||
frameRate?: number;
|
||||
audio?: TimelineClipAudioSettings;
|
||||
video?: TimelineClipVideoSettings;
|
||||
}
|
||||
|
||||
export interface OutputSettings {
|
||||
presetId: string;
|
||||
fileName: string;
|
||||
container: string;
|
||||
videoEnabled: boolean;
|
||||
audioEnabled: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
sampleRate?: number;
|
||||
channelLayout?: 'mono' | 'stereo' | '5.1';
|
||||
missingAudioPolicy?: 'insert-silence' | 'drop-all' | 'reject';
|
||||
}
|
||||
|
||||
export interface ProjectMetadataSettings {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
comment?: string;
|
||||
custom: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProjectChapter {
|
||||
id: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
title: string;
|
||||
timeBase?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProjectSubtitleTrack {
|
||||
id: string;
|
||||
assetId?: string;
|
||||
sourceName?: string;
|
||||
sourceFormat?: 'srt' | 'vtt' | 'ass';
|
||||
language?: string;
|
||||
title?: string;
|
||||
offsetSeconds?: number;
|
||||
default?: boolean;
|
||||
forced?: boolean;
|
||||
mode: 'exclude' | 'mux' | 'burn-in';
|
||||
sourceStatus?: SourceStatus;
|
||||
}
|
||||
|
||||
export interface ProjectUiState {
|
||||
selectedAssetId?: string;
|
||||
selectedClipId?: string;
|
||||
inspectorPanel?: 'media' | 'clip' | 'output';
|
||||
timelineZoom?: number;
|
||||
}
|
||||
|
||||
export interface AvProjectV1 {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
assets: MediaAssetReference[];
|
||||
timeline: TimelineClip[];
|
||||
output: OutputSettings;
|
||||
metadata: ProjectMetadataSettings;
|
||||
chapters: ProjectChapter[];
|
||||
subtitles: ProjectSubtitleTrack[];
|
||||
ui?: ProjectUiState;
|
||||
}
|
||||
|
||||
export type AvProject = AvProjectV1;
|
||||
|
||||
export interface CreateProjectOptions {
|
||||
id?: string;
|
||||
name?: string;
|
||||
timestamp?: string;
|
||||
output?: Partial<OutputSettings>;
|
||||
}
|
||||
|
||||
export interface BrowserFileIdentity {
|
||||
name: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface CreateMediaAssetOptions {
|
||||
id?: string;
|
||||
probe?: MediaProbe;
|
||||
hash?: SourceHash;
|
||||
sourceStatus?: SourceStatus;
|
||||
}
|
||||
|
||||
export interface LegacyAvProjectV0 {
|
||||
schemaVersion: 0;
|
||||
id: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
media?: unknown[];
|
||||
assets?: unknown[];
|
||||
clips?: unknown[];
|
||||
timeline?: unknown[];
|
||||
output?: unknown;
|
||||
metadata?: unknown;
|
||||
chapters?: unknown[];
|
||||
subtitles?: unknown[];
|
||||
ui?: unknown;
|
||||
}
|
||||
1305
src/project/project.validation.ts
Normal file
1305
src/project/project.validation.ts
Normal file
File diff suppressed because it is too large
Load Diff
248
src/project/source-reattachment.ts
Normal file
248
src/project/source-reattachment.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { projectReducer } from './project.reducer';
|
||||
import type {
|
||||
AvProjectV1,
|
||||
MediaAssetReference,
|
||||
SourceHash,
|
||||
} from './project.types';
|
||||
|
||||
export interface SourceFileDescriptor {
|
||||
name: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
mimeType?: string;
|
||||
hash?: SourceHash;
|
||||
}
|
||||
|
||||
export type ReattachmentConfidence = 'exact' | 'likely' | 'possible' | 'none';
|
||||
|
||||
export interface ReattachmentEvidence {
|
||||
field: 'size' | 'lastModified' | 'name' | 'mimeType' | 'hash';
|
||||
matches: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ReattachmentMatch {
|
||||
candidate: SourceFileDescriptor;
|
||||
confidence: ReattachmentConfidence;
|
||||
score: number;
|
||||
evidence: ReattachmentEvidence[];
|
||||
requiresExplicitConfirmation: true;
|
||||
}
|
||||
|
||||
export interface ReattachmentMatchOptions {
|
||||
lastModifiedToleranceMs?: number;
|
||||
}
|
||||
|
||||
export function rankReattachmentCandidates(
|
||||
asset: MediaAssetReference,
|
||||
candidates: readonly SourceFileDescriptor[],
|
||||
options: ReattachmentMatchOptions = {}
|
||||
): ReattachmentMatch[] {
|
||||
const tolerance = options.lastModifiedToleranceMs ?? 2_000;
|
||||
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
||||
throw new RangeError(
|
||||
'Last-modified tolerance must be finite and non-negative.'
|
||||
);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
assertValidDescriptor(candidate);
|
||||
}
|
||||
|
||||
return candidates
|
||||
.map((candidate) => matchSource(asset, candidate, tolerance))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
left.candidate.name.localeCompare(right.candidate.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function bestReattachmentCandidate(
|
||||
asset: MediaAssetReference,
|
||||
candidates: readonly SourceFileDescriptor[],
|
||||
options?: ReattachmentMatchOptions
|
||||
): ReattachmentMatch | undefined {
|
||||
const matches = rankReattachmentCandidates(asset, candidates, options);
|
||||
const best = matches[0];
|
||||
if (best === undefined || best.confidence === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
const runnerUp = matches[1];
|
||||
if (runnerUp !== undefined && runnerUp.score === best.score) {
|
||||
return undefined;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function confirmSourceReattachment(
|
||||
project: AvProjectV1,
|
||||
assetId: string,
|
||||
candidate: SourceFileDescriptor,
|
||||
updatedAt: string,
|
||||
options?: ReattachmentMatchOptions
|
||||
): AvProjectV1 {
|
||||
const asset = project.assets.find((entry) => entry.id === assetId);
|
||||
if (asset === undefined) {
|
||||
throw new SourceReattachmentError(`Unknown asset "${assetId}".`);
|
||||
}
|
||||
const match = rankReattachmentCandidates(asset, [candidate], options)[0];
|
||||
if (match === undefined || match.confidence === 'none') {
|
||||
throw new SourceReattachmentError(
|
||||
'The selected source does not match the stored source fingerprint.'
|
||||
);
|
||||
}
|
||||
return projectReducer(project, {
|
||||
type: 'asset/set-source-status',
|
||||
assetId,
|
||||
sourceStatus: 'attached',
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export class SourceReattachmentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SourceReattachmentError';
|
||||
}
|
||||
}
|
||||
|
||||
function matchSource(
|
||||
asset: MediaAssetReference,
|
||||
candidate: SourceFileDescriptor,
|
||||
lastModifiedToleranceMs: number
|
||||
): ReattachmentMatch {
|
||||
const evidence: ReattachmentEvidence[] = [];
|
||||
let score = 0;
|
||||
const sizeMatches = candidate.size === asset.size;
|
||||
evidence.push({
|
||||
field: 'size',
|
||||
matches: sizeMatches,
|
||||
message: sizeMatches ? 'File size matches.' : 'File size differs.',
|
||||
});
|
||||
if (sizeMatches) {
|
||||
score += 50;
|
||||
}
|
||||
|
||||
const modifiedMatches =
|
||||
Math.abs(candidate.lastModified - asset.lastModified) <=
|
||||
lastModifiedToleranceMs;
|
||||
evidence.push({
|
||||
field: 'lastModified',
|
||||
matches: modifiedMatches,
|
||||
message: modifiedMatches
|
||||
? 'Last-modified time matches within tolerance.'
|
||||
: 'Last-modified time differs.',
|
||||
});
|
||||
if (modifiedMatches) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
const nameMatches = candidate.name === asset.originalName;
|
||||
evidence.push({
|
||||
field: 'name',
|
||||
matches: nameMatches,
|
||||
message: nameMatches
|
||||
? 'Original filename matches.'
|
||||
: 'Original filename differs.',
|
||||
});
|
||||
if (nameMatches) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
const mimeMatches =
|
||||
!asset.mimeType ||
|
||||
!candidate.mimeType ||
|
||||
candidate.mimeType === asset.mimeType;
|
||||
evidence.push({
|
||||
field: 'mimeType',
|
||||
matches: mimeMatches,
|
||||
message: mimeMatches ? 'MIME type is compatible.' : 'MIME type differs.',
|
||||
});
|
||||
if (mimeMatches && candidate.mimeType && asset.mimeType) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
const hashComparison = compareHashes(asset.hash, candidate.hash);
|
||||
if (hashComparison !== undefined) {
|
||||
evidence.push({
|
||||
field: 'hash',
|
||||
matches: hashComparison,
|
||||
message: hashComparison
|
||||
? 'Stored and candidate hashes match.'
|
||||
: 'Stored and candidate hashes differ.',
|
||||
});
|
||||
if (hashComparison) {
|
||||
score += 100;
|
||||
}
|
||||
}
|
||||
|
||||
const incompatible = !sizeMatches || !mimeMatches || hashComparison === false;
|
||||
const confidence: ReattachmentConfidence = incompatible
|
||||
? 'none'
|
||||
: hashComparison === true
|
||||
? 'exact'
|
||||
: modifiedMatches
|
||||
? 'likely'
|
||||
: nameMatches
|
||||
? 'possible'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
candidate,
|
||||
confidence,
|
||||
score: incompatible ? 0 : score,
|
||||
evidence,
|
||||
requiresExplicitConfirmation: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compareHashes(
|
||||
expected: SourceHash | undefined,
|
||||
actual: SourceHash | undefined
|
||||
): boolean | undefined {
|
||||
if (expected === undefined || actual === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
expected.algorithm !== actual.algorithm ||
|
||||
expected.scope !== actual.scope
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
expected.scope === 'partial' &&
|
||||
expected.bytesHashed !== actual.bytesHashed
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return expected.value.toLowerCase() === actual.value.toLowerCase();
|
||||
}
|
||||
|
||||
function assertValidDescriptor(candidate: SourceFileDescriptor): void {
|
||||
if (
|
||||
!candidate.name ||
|
||||
!Number.isSafeInteger(candidate.size) ||
|
||||
candidate.size < 0 ||
|
||||
!Number.isSafeInteger(candidate.lastModified) ||
|
||||
candidate.lastModified < 0
|
||||
) {
|
||||
throw new SourceReattachmentError(
|
||||
'A source descriptor requires a name, non-negative size and non-negative last-modified time.'
|
||||
);
|
||||
}
|
||||
if (candidate.hash !== undefined) {
|
||||
const hash = candidate.hash;
|
||||
if (
|
||||
hash.algorithm !== 'sha-256' ||
|
||||
!/^[a-fA-F0-9]{64}$/.test(hash.value) ||
|
||||
(hash.scope !== 'full' && hash.scope !== 'partial') ||
|
||||
(hash.scope === 'partial' &&
|
||||
(!Number.isSafeInteger(hash.bytesHashed) ||
|
||||
(hash.bytesHashed ?? 0) <= 0))
|
||||
) {
|
||||
throw new SourceReattachmentError(
|
||||
'A source hash must be a valid full or partial SHA-256 fingerprint.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user