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

8
src/project/index.ts Normal file
View 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';

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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