diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:20 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:20 +0200 |
| commit | 30a36e45686db503fded68e4da5d4c465a06f978 (patch) | |
| tree | 2dbe5e2aaef543155b021f5f4c504fad77b40648 /apps/web/src/project/transfer | |
| parent | d55d2583a71ae35fa8d0d1bfbec975e67e66aafa (diff) | |
| download | opencut-30a36e45686db503fded68e4da5d4c465a06f978.tar.gz | |
feat: single-file project import/export (.ocp)
fflate zip container: project.json (serialized project incl. schema
version) + media files with a manifest. Export gathers all project
media (missing blobs warn, never abort) after save-flush; import
validates everything before any write, migrates older archives through
the storage migration registry, remaps media ids, and creates a new
project with rollback on failure. Entry points: projects page
import/export, editor header menu, export-project-archive action.
29 tests incl. full round-trip.
Diffstat (limited to 'apps/web/src/project/transfer')
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/container.test.ts | 204 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/migrate.test.ts | 153 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/remap.test.ts | 166 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/round-trip.test.ts | 221 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/archive.ts | 260 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/container.ts | 245 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/index.ts | 16 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/migrate.ts | 75 | ||||
| -rw-r--r-- | apps/web/src/project/transfer/remap.ts | 105 |
9 files changed, 1445 insertions, 0 deletions
diff --git a/apps/web/src/project/transfer/__tests__/container.test.ts b/apps/web/src/project/transfer/__tests__/container.test.ts new file mode 100644 index 00000000..27c3335d --- /dev/null +++ b/apps/web/src/project/transfer/__tests__/container.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; +import { zipSync, strToU8 } from "fflate"; +import { + MEDIA_MANIFEST_ENTRY, + PROJECT_JSON_ENTRY, + ProjectArchiveError, + buildMediaEntryName, + buildProjectArchive, + parseProjectArchive, + sanitizeFileName, +} from "../container"; + +const projectJson = JSON.stringify({ + version: 31, + metadata: { + id: "project-1", + name: "Demo", + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + scenes: [], + currentSceneId: "scene-1", + settings: { fps: 30 }, +}); + +function buildMediaInputs() { + return [ + { + metadata: { + id: "media-1", + name: "clip one.mp4", + mediaType: "video" as const, + fileType: "video/mp4", + lastModified: 1_700_000_000_000, + width: 1920, + height: 1080, + duration: 3.5, + }, + data: new Uint8Array([1, 2, 3, 4, 5]), + }, + { + metadata: { + id: "media-2", + name: "picturé/odd:name.png", + mediaType: "image" as const, + fileType: "image/png", + lastModified: 1_700_000_000_001, + thumbnailUrl: "data:image/png;base64,abc", + }, + data: new Uint8Array([9, 8, 7]), + }, + ]; +} + +describe("sanitizeFileName", () => { + test("replaces unsafe characters and keeps the result stable", () => { + expect(sanitizeFileName({ name: "my clip.mp4" })).toBe("my_clip.mp4"); + expect(sanitizeFileName({ name: "a/b\\c:d" })).toBe("a_b_c_d"); + expect(sanitizeFileName({ name: "picturé.png" })).toBe("pictur_.png"); + }); + + test("falls back for empty or dot-only names", () => { + expect(sanitizeFileName({ name: " " })).toBe("file"); + expect(sanitizeFileName({ name: "..." })).toBe("file"); + }); +}); + +describe("buildMediaEntryName", () => { + test("prefixes the asset id and sanitizes the name", () => { + expect(buildMediaEntryName({ assetId: "abc", name: "my clip.mp4" })).toBe( + "media/abc-my_clip.mp4", + ); + }); +}); + +describe("buildProjectArchive + parseProjectArchive", () => { + test("round-trips project document, manifest, and media bytes", () => { + const media = buildMediaInputs(); + const archive = buildProjectArchive({ projectJson, media }); + const parsed = parseProjectArchive({ archive }); + + expect(JSON.parse(JSON.stringify(parsed.project))).toEqual( + JSON.parse(projectJson), + ); + + expect(parsed.media).toHaveLength(2); + const [first, second] = parsed.media; + expect(first).toMatchObject({ + id: "media-1", + name: "clip one.mp4", + mediaType: "video", + fileType: "video/mp4", + lastModified: 1_700_000_000_000, + width: 1920, + height: 1080, + duration: 3.5, + entry: "media/media-1-clip_one.mp4", + }); + expect(second).toMatchObject({ + id: "media-2", + name: "picturé/odd:name.png", + mediaType: "image", + fileType: "image/png", + thumbnailUrl: "data:image/png;base64,abc", + }); + + expect([...parsed.readMediaData({ entry: first.entry })]).toEqual([ + 1, 2, 3, 4, 5, + ]); + expect([...parsed.readMediaData({ entry: second.entry })]).toEqual([9, 8, 7]); + }); + + test("supports archives without media", () => { + const archive = buildProjectArchive({ projectJson, media: [] }); + const parsed = parseProjectArchive({ archive }); + expect(parsed.media).toEqual([]); + }); +}); + +describe("parseProjectArchive validation", () => { + test("rejects non-zip data", () => { + expect(() => + parseProjectArchive({ archive: new Uint8Array([1, 2, 3]) }), + ).toThrow(ProjectArchiveError); + }); + + test("rejects archives without project.json", () => { + const archive = zipSync({ "other.txt": strToU8("hello") }); + expect(() => parseProjectArchive({ archive })).toThrow(/project\.json/); + }); + + test("rejects invalid project.json", () => { + const archive = zipSync({ + [PROJECT_JSON_ENTRY]: strToU8("not json{"), + }); + expect(() => parseProjectArchive({ archive })).toThrow(/not valid JSON/); + }); + + test("rejects non-object project.json", () => { + const archive = zipSync({ [PROJECT_JSON_ENTRY]: strToU8("[1,2,3]") }); + expect(() => parseProjectArchive({ archive })).toThrow(/not a project/); + }); + + test("rejects media entries without a manifest", () => { + const archive = zipSync({ + [PROJECT_JSON_ENTRY]: strToU8(projectJson), + "media/media-1-clip.mp4": new Uint8Array([1]), + }); + expect(() => parseProjectArchive({ archive })).toThrow(/manifest/); + }); + + test("rejects manifest entries whose media file is missing", () => { + const archive = zipSync({ + [PROJECT_JSON_ENTRY]: strToU8(projectJson), + [MEDIA_MANIFEST_ENTRY]: strToU8( + JSON.stringify({ + version: 1, + media: [ + { + id: "media-1", + name: "clip.mp4", + entry: "media/media-1-clip.mp4", + mediaType: "video", + fileType: "video/mp4", + lastModified: 1, + }, + ], + }), + ), + }); + expect(() => parseProjectArchive({ archive })).toThrow(/is missing/); + }); + + test("rejects manifest entries with paths outside media/", () => { + const archive = zipSync({ + [PROJECT_JSON_ENTRY]: strToU8(projectJson), + [MEDIA_MANIFEST_ENTRY]: strToU8( + JSON.stringify({ + version: 1, + media: [ + { + id: "media-1", + name: "evil", + entry: "../evil", + mediaType: "video", + fileType: "video/mp4", + lastModified: 1, + }, + ], + }), + ), + "media/placeholder": new Uint8Array([1]), + }); + expect(() => parseProjectArchive({ archive })).toThrow(/invalid file path/); + }); + + test("readMediaData throws for unknown entries", () => { + const archive = buildProjectArchive({ projectJson, media: [] }); + const parsed = parseProjectArchive({ archive }); + expect(() => parsed.readMediaData({ entry: "media/nope" })).toThrow( + ProjectArchiveError, + ); + }); +}); diff --git a/apps/web/src/project/transfer/__tests__/migrate.test.ts b/apps/web/src/project/transfer/__tests__/migrate.test.ts new file mode 100644 index 00000000..af4bb37e --- /dev/null +++ b/apps/web/src/project/transfer/__tests__/migrate.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { + StorageMigration, + type StorageMigrationRunArgs, +} from "@/services/storage/migrations/base"; +import type { + MigrationResult, + ProjectRecord, +} from "@/services/storage/migrations/transformers/types"; +import { + CURRENT_PROJECT_VERSION, + migrations, +} from "@/services/storage/migrations"; +import { v5Project } from "@/services/storage/migrations/__tests__/fixtures"; +import { getRecordVersion, migrateProjectRecord } from "../migrate"; + +class StepMigration extends StorageMigration { + from: number; + to: number; + private transform: ({ project }: { project: ProjectRecord }) => ProjectRecord; + private shouldSkip: boolean; + + constructor({ + from, + to, + transform, + shouldSkip = false, + }: { + from: number; + to: number; + transform: ({ project }: { project: ProjectRecord }) => ProjectRecord; + shouldSkip?: boolean; + }) { + super(); + this.from = from; + this.to = to; + this.transform = transform; + this.shouldSkip = shouldSkip; + } + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + if (this.shouldSkip) { + return { project, skipped: true, reason: "skip requested" }; + } + return { project: this.transform({ project }), skipped: false }; + } +} + +function setVersion({ + project, + version, +}: { + project: ProjectRecord; + version: number; +}): ProjectRecord { + return { ...project, version }; +} + +describe("getRecordVersion", () => { + test("uses the explicit version field", () => { + expect(getRecordVersion({ project: { version: 7 } })).toBe(7); + }); + + test("detects v1 projects by their scenes array", () => { + expect(getRecordVersion({ project: { scenes: [{ id: "s" }] } })).toBe(1); + }); + + test("falls back to v0", () => { + expect(getRecordVersion({ project: {} })).toBe(0); + expect(getRecordVersion({ project: { scenes: [] } })).toBe(0); + }); +}); + +describe("migrateProjectRecord", () => { + test("applies migrations in version order regardless of registration order", async () => { + const registry = [ + new StepMigration({ + from: 1, + to: 2, + transform: ({ project }) => setVersion({ project, version: 2 }), + }), + new StepMigration({ + from: 0, + to: 1, + transform: ({ project }) => setVersion({ project, version: 1 }), + }), + ]; + + const result = await migrateProjectRecord({ + project: { version: 0, steps: [] }, + migrations: registry, + }); + + expect(result.fromVersion).toBe(0); + expect(result.toVersion).toBe(2); + expect(result.complete).toBe(true); + expect(result.project.version).toBe(2); + }); + + test("reports incomplete when a migration skips", async () => { + const registry = [ + new StepMigration({ + from: 0, + to: 1, + transform: ({ project }) => project, + shouldSkip: true, + }), + new StepMigration({ + from: 1, + to: 2, + transform: ({ project }) => setVersion({ project, version: 2 }), + }), + ]; + + const result = await migrateProjectRecord({ + project: { version: 0 }, + migrations: registry, + }); + + expect(result.toVersion).toBe(0); + expect(result.complete).toBe(false); + }); + + test("is a no-op for current-version documents", async () => { + const registry = [ + new StepMigration({ + from: 0, + to: 1, + transform: ({ project }) => setVersion({ project, version: 1 }), + }), + ]; + + const project = { version: 1, marker: "untouched" }; + const result = await migrateProjectRecord({ project, migrations: registry }); + + expect(result.complete).toBe(true); + expect(result.project).toBe(project); + }); + + test("migrates a real v5 fixture to the current version with the app registry", async () => { + const result = await migrateProjectRecord({ + project: structuredClone(v5Project), + migrations, + }); + + expect(result.fromVersion).toBe(5); + expect(result.complete).toBe(true); + expect(result.toVersion).toBe(CURRENT_PROJECT_VERSION); + expect(result.project.version).toBe(CURRENT_PROJECT_VERSION); + }); +}); diff --git a/apps/web/src/project/transfer/__tests__/remap.test.ts b/apps/web/src/project/transfer/__tests__/remap.test.ts new file mode 100644 index 00000000..356a91ab --- /dev/null +++ b/apps/web/src/project/transfer/__tests__/remap.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRecord } from "@/services/storage/migrations/transformers/types"; +import { + collectReferencedMediaIds, + remapProjectMediaIds, + resolveImportedProjectName, +} from "../remap"; + +function buildProject(): ProjectRecord { + return { + version: 31, + metadata: { id: "project-1", name: "Demo" }, + currentSceneId: "scene-1", + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "track-main", + elements: [ + { id: "el-video", type: "video", mediaId: "media-video" }, + { id: "el-image", type: "image", mediaId: "media-image" }, + ], + }, + overlay: [ + { + id: "track-text", + elements: [{ id: "el-text", type: "text", content: "hello" }], + }, + ], + audio: [ + { + id: "track-audio", + elements: [ + { + id: "el-upload", + type: "audio", + sourceType: "upload", + mediaId: "media-audio", + }, + { + id: "el-library", + type: "audio", + sourceType: "library", + sourceUrl: "https://example.com/sound.mp3", + }, + { + id: "el-shared", + type: "audio", + sourceType: "upload", + mediaId: "media-video", + }, + ], + }, + ], + }, + }, + { + id: "scene-2", + tracks: { + main: { + id: "track-main-2", + elements: [ + { id: "el-video-2", type: "video", mediaId: "media-video-2" }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }; +} + +describe("collectReferencedMediaIds", () => { + test("collects media ids across scenes and track groups, deduplicated", () => { + const ids = collectReferencedMediaIds({ project: buildProject() }); + expect(ids.sort()).toEqual([ + "media-audio", + "media-image", + "media-video", + "media-video-2", + ]); + }); + + test("tolerates malformed shapes", () => { + expect(collectReferencedMediaIds({ project: null })).toEqual([]); + expect(collectReferencedMediaIds({ project: { scenes: "nope" } })).toEqual( + [], + ); + expect( + collectReferencedMediaIds({ + project: { scenes: [{ tracks: { main: { elements: "nope" } } }] }, + }), + ).toEqual([]); + }); +}); + +describe("remapProjectMediaIds", () => { + test("replaces mapped ids only, across every track group", () => { + const project = buildProject(); + const idMap = new Map([ + ["media-video", "new-video"], + ["media-image", "new-image"], + ["media-audio", "new-audio"], + ]); + + const remapped = remapProjectMediaIds({ project, idMap }); + const collected = collectReferencedMediaIds({ project: remapped }); + expect(collected.sort()).toEqual([ + "media-video-2", + "new-audio", + "new-image", + "new-video", + ]); + + // The shared media id is remapped in every referencing element. + const scenes = remapped.scenes; + expect(JSON.stringify(scenes)).not.toContain("media-video\""); + expect(JSON.stringify(scenes)).toContain("new-video"); + }); + + test("does not mutate the input document", () => { + const project = buildProject(); + const before = JSON.stringify(project); + remapProjectMediaIds({ + project, + idMap: new Map([["media-video", "new-video"]]), + }); + expect(JSON.stringify(project)).toBe(before); + }); + + test("returns the input unchanged when the map is empty", () => { + const project = buildProject(); + expect(remapProjectMediaIds({ project, idMap: new Map() })).toBe(project); + }); + + test("is fully reversible with the inverse map", () => { + const project = buildProject(); + const idMap = new Map([ + ["media-video", "new-video"], + ["media-image", "new-image"], + ["media-audio", "new-audio"], + ["media-video-2", "new-video-2"], + ]); + const inverse = new Map([...idMap].map(([from, to]) => [to, from])); + + const remapped = remapProjectMediaIds({ project, idMap }); + const restored = remapProjectMediaIds({ project: remapped, idMap: inverse }); + expect(restored).toEqual(project); + }); +}); + +describe("resolveImportedProjectName", () => { + test("keeps the name when there is no collision", () => { + expect( + resolveImportedProjectName({ name: "Demo", existingNames: ["Other"] }), + ).toBe("Demo"); + }); + + test("suffixes the name when it collides", () => { + expect( + resolveImportedProjectName({ name: "Demo", existingNames: ["Demo"] }), + ).toBe("Demo (imported)"); + }); +}); diff --git a/apps/web/src/project/transfer/__tests__/round-trip.test.ts b/apps/web/src/project/transfer/__tests__/round-trip.test.ts new file mode 100644 index 00000000..4eb2e8bf --- /dev/null +++ b/apps/web/src/project/transfer/__tests__/round-trip.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { + CURRENT_PROJECT_VERSION, + migrations, +} from "@/services/storage/migrations"; +import type { ProjectRecord } from "@/services/storage/migrations/transformers/types"; +import { buildProjectArchive, parseProjectArchive } from "../container"; +import { migrateProjectRecord } from "../migrate"; +import { + collectReferencedMediaIds, + remapProjectMediaIds, +} from "../remap"; + +/** + * The transfer contract, proven at the pure-function level: + * export -> import keeps the project document deep-equal (modulo the media + * id remap) and every media file byte-identical. + */ + +function buildCurrentProject(): ProjectRecord { + return { + version: CURRENT_PROJECT_VERSION, + metadata: { + id: "project-1", + name: "Round Trip", + thumbnail: "data:image/png;base64,thumb", + duration: 240_000, + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + currentSceneId: "scene-1", + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + canvasSizeMode: "preset", + lastCustomCanvasSize: null, + originalCanvasSize: null, + background: { type: "color", color: "#000000" }, + }, + timelineViewState: { zoomLevel: 1, scrollLeft: 0, playheadTime: 0 }, + scenes: [ + { + id: "scene-1", + name: "Main scene", + isMain: true, + bookmarks: [{ time: 120_000, note: "mid", color: "#fff" }], + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + tracks: { + main: { + id: "track-main", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "el-video", + type: "video", + mediaId: "media-video", + name: "clip.mp4", + duration: 240_000, + startTime: 0, + trimStart: 0, + trimEnd: 0, + params: { opacity: 1 }, + }, + { + id: "el-image", + type: "image", + mediaId: "media-image", + name: "still.png", + duration: 120_000, + startTime: 240_000, + trimStart: 0, + trimEnd: 0, + params: {}, + }, + ], + }, + overlay: [ + { + id: "track-text", + type: "text", + name: "Text", + hidden: false, + elements: [ + { + id: "el-text", + type: "text", + name: "Title", + duration: 60_000, + startTime: 0, + trimStart: 0, + trimEnd: 0, + params: { content: "Hello" }, + }, + ], + }, + ], + audio: [ + { + id: "track-audio", + type: "audio", + name: "Audio", + muted: false, + elements: [ + { + id: "el-audio", + type: "audio", + sourceType: "upload", + mediaId: "media-audio", + name: "song.mp3", + duration: 240_000, + startTime: 0, + trimStart: 0, + trimEnd: 0, + params: { volume: 0.8 }, + }, + ], + }, + ], + }, + }, + ], + }; +} + +function buildMedia() { + return [ + { + metadata: { + id: "media-video", + name: "clip.mp4", + mediaType: "video" as const, + fileType: "video/mp4", + lastModified: 1_700_000_000_000, + width: 1920, + height: 1080, + duration: 2, + }, + data: new Uint8Array([0, 1, 2, 3, 250, 251, 252, 253, 254, 255]), + }, + { + metadata: { + id: "media-image", + name: "still.png", + mediaType: "image" as const, + fileType: "image/png", + lastModified: 1_700_000_000_001, + width: 800, + height: 600, + thumbnailUrl: "data:image/png;base64,still", + }, + data: new Uint8Array([137, 80, 78, 71]), + }, + { + metadata: { + id: "media-audio", + name: "song.mp3", + mediaType: "audio" as const, + fileType: "audio/mpeg", + lastModified: 1_700_000_000_002, + duration: 2, + }, + data: new Uint8Array([255, 251, 144, 0]), + }, + ]; +} + +describe("project transfer round trip", () => { + test("export -> import keeps the document deep-equal and media byte-identical", async () => { + const project = buildCurrentProject(); + const media = buildMedia(); + + // "Export": pack the serialized document plus original media bytes. + const archive = buildProjectArchive({ + projectJson: JSON.stringify(project), + media, + }); + + // "Import": parse, run the same migration pipeline, remap media ids. + const parsed = parseProjectArchive({ archive }); + const migrated = await migrateProjectRecord({ + project: parsed.project, + migrations, + }); + expect(migrated.complete).toBe(true); + + const referenced = collectReferencedMediaIds({ project }); + expect(referenced.sort()).toEqual( + media.map(({ metadata }) => metadata.id).sort(), + ); + + const idMap = new Map( + parsed.media.map((entry) => [entry.id, `imported-${entry.id}`]), + ); + const imported = remapProjectMediaIds({ project: migrated.project, idMap }); + + // Every media reference points at a fresh id, nothing else changed: + // remapping back with the inverse map restores the original document. + expect(collectReferencedMediaIds({ project: imported }).sort()).toEqual( + [...idMap.values()].sort(), + ); + const restored = remapProjectMediaIds({ + project: imported, + idMap: new Map([...idMap].map(([from, to]) => [to, from])), + }); + expect(restored).toEqual(project); + + // Media comes back byte-identical with its full metadata. + for (const { metadata, data } of media) { + const entry = parsed.media.find(({ id }) => id === metadata.id); + expect(entry).toBeDefined(); + expect(entry).toMatchObject(metadata); + expect([...parsed.readMediaData({ entry: entry?.entry ?? "" })]).toEqual([ + ...data, + ]); + } + }); +}); diff --git a/apps/web/src/project/transfer/archive.ts b/apps/web/src/project/transfer/archive.ts new file mode 100644 index 00000000..96b86715 --- /dev/null +++ b/apps/web/src/project/transfer/archive.ts @@ -0,0 +1,260 @@ +import type { TProject } from "@/project/types"; +import type { MediaAsset } from "@/media/types"; +import { + CURRENT_PROJECT_VERSION, + migrations, +} from "@/services/storage/migrations"; +import { + deserializeProject, + serializeProject, + storageService, +} from "@/services/storage/service"; +import type { SerializedProject } from "@/services/storage/types"; +import { isRecord } from "@/services/storage/migrations/transformers/utils"; +import { generateUUID } from "@/utils/id"; +import { + type ArchiveMediaInput, + PROJECT_ARCHIVE_EXTENSION, + ProjectArchiveError, + buildProjectArchive, + parseProjectArchive, + sanitizeFileName, +} from "./container"; +import { getRecordVersion, migrateProjectRecord } from "./migrate"; +import { + collectReferencedMediaIds, + remapProjectMediaIds, + resolveImportedProjectName, +} from "./remap"; + +/** + * Browser-facing project transfer: build a single-file `.ocp` archive from a + * stored project (export) and restore an archive as a brand-new project + * (import). Storage writes go through the same storage service path the app + * already uses; imported documents older than the current version go through + * the same migration registry as storage migrations. + */ + +export interface ExportProjectArchiveResult { + blob: Blob; + fileName: string; + missingMediaIds: string[]; +} + +export interface ImportProjectArchiveResult { + project: TProject; + importedMediaCount: number; +} + +export async function exportProjectArchive({ + projectId, +}: { + projectId: string; +}): Promise<ExportProjectArchiveResult> { + const result = await storageService.loadProject({ id: projectId }); + if (!result) { + throw new ProjectArchiveError({ message: "Project not found" }); + } + + const serialized = serializeProject({ project: result.project }); + const referencedMediaIds = collectReferencedMediaIds({ + project: serialized, + }); + + const assets = await storageService.loadAllMediaAssets({ projectId }); + const assetIds = new Set(assets.map((asset) => asset.id)); + const missingMediaIds = referencedMediaIds.filter((id) => !assetIds.has(id)); + + const media: ArchiveMediaInput[] = []; + for (const asset of assets) { + media.push({ + metadata: toArchivedMediaMetadata({ asset }), + data: new Uint8Array(await asset.file.arrayBuffer()), + }); + } + + const archive = buildProjectArchive({ + projectJson: JSON.stringify(serialized), + media, + }); + + return { + blob: new Blob([new Uint8Array(archive)], { type: "application/zip" }), + fileName: `${sanitizeFileName({ name: result.project.metadata.name })}${PROJECT_ARCHIVE_EXTENSION}`, + missingMediaIds, + }; +} + +function toArchivedMediaMetadata({ + asset, +}: { + asset: MediaAsset; +}): ArchiveMediaInput["metadata"] { + // Object-URL thumbnails only live for the exporting session; data-URL + // thumbnails are the persisted form and survive the transfer. + const thumbnailUrl = asset.thumbnailUrl?.startsWith("blob:") + ? undefined + : asset.thumbnailUrl; + + return { + id: asset.id, + name: asset.name, + mediaType: asset.type, + fileType: asset.file.type, + lastModified: asset.file.lastModified, + ...(asset.width !== undefined && { width: asset.width }), + ...(asset.height !== undefined && { height: asset.height }), + ...(asset.duration !== undefined && { duration: asset.duration }), + ...(asset.ephemeral !== undefined && { ephemeral: asset.ephemeral }), + ...(thumbnailUrl !== undefined && { thumbnailUrl }), + }; +} + +export async function importProjectArchive({ + file, + existingNames, +}: { + file: File; + existingNames: string[]; +}): Promise<ImportProjectArchiveResult> { + // Parse and validate everything before writing a single byte to storage, + // so a bad archive can never leave a half-written project behind. + const parsed = parseProjectArchive({ + archive: new Uint8Array(await file.arrayBuffer()), + }); + + const fromVersion = getRecordVersion({ project: parsed.project }); + if (fromVersion > CURRENT_PROJECT_VERSION) { + throw new ProjectArchiveError({ + message: `This project was exported from a newer version (project v${fromVersion}, app supports v${CURRENT_PROJECT_VERSION})`, + }); + } + + const migrated = await migrateProjectRecord({ project: parsed.project, migrations }); + if (!migrated.complete) { + throw new ProjectArchiveError({ + message: `This project (v${fromVersion}) could not be migrated to the current version`, + }); + } + + const idMap = new Map<string, string>(); + for (const entry of parsed.media) { + idMap.set(entry.id, generateUUID()); + } + const remapped = remapProjectMediaIds({ + project: migrated.project, + idMap, + }); + assertSerializedProject(remapped); + + const projectId = generateUUID(); + const serialized: SerializedProject = { + ...remapped, + metadata: { + ...remapped.metadata, + id: projectId, + name: resolveImportedProjectName({ + name: remapped.metadata.name, + existingNames, + }), + }, + }; + const project = deserializeProject({ serialized }); + + try { + for (const entry of parsed.media) { + const assetId = idMap.get(entry.id); + if (!assetId) { + throw new ProjectArchiveError({ + message: `Media asset ${entry.id} could not be remapped`, + }); + } + const mediaFile = new File( + [new Uint8Array(parsed.readMediaData({ entry: entry.entry }))], + entry.name, + { + type: entry.fileType, + lastModified: entry.lastModified, + }, + ); + await storageService.saveMediaAsset({ + projectId, + mediaAsset: { + id: assetId, + name: entry.name, + type: entry.mediaType, + file: mediaFile, + ...(entry.width !== undefined && { width: entry.width }), + ...(entry.height !== undefined && { height: entry.height }), + ...(entry.duration !== undefined && { duration: entry.duration }), + ...(entry.ephemeral !== undefined && { ephemeral: entry.ephemeral }), + ...(entry.thumbnailUrl !== undefined && { + thumbnailUrl: entry.thumbnailUrl, + }), + }, + }); + } + + await storageService.saveProject({ project }); + } catch (error) { + // Roll back anything already written under the fresh project id. + try { + await storageService.deleteProjectMedia({ projectId }); + await storageService.deleteProject({ id: projectId }); + } catch { + // Cleanup is best-effort; surface the original failure. + } + throw error; + } + + return { project, importedMediaCount: parsed.media.length }; +} + +function assertSerializedProject( + record: unknown, +): asserts record is SerializedProject { + if (!isRecord(record) || Array.isArray(record)) { + throw new ProjectArchiveError({ + message: "Invalid project data (not a project object)", + }); + } + const { metadata, scenes, currentSceneId, settings, version } = record; + if ( + !isRecord(metadata) || + Array.isArray(metadata) || + typeof metadata.name !== "string" || + metadata.name.length === 0 + ) { + throw new ProjectArchiveError({ + message: "Invalid project data (missing project name)", + }); + } + if ( + typeof metadata.createdAt !== "string" || + typeof metadata.updatedAt !== "string" + ) { + throw new ProjectArchiveError({ + message: "Invalid project data (missing timestamps)", + }); + } + if (!Array.isArray(scenes)) { + throw new ProjectArchiveError({ + message: "Invalid project data (missing scenes)", + }); + } + if (typeof currentSceneId !== "string") { + throw new ProjectArchiveError({ + message: "Invalid project data (missing current scene)", + }); + } + if (!isRecord(settings) || Array.isArray(settings)) { + throw new ProjectArchiveError({ + message: "Invalid project data (missing settings)", + }); + } + if (typeof version !== "number") { + throw new ProjectArchiveError({ + message: "Invalid project data (missing version)", + }); + } +} diff --git a/apps/web/src/project/transfer/container.ts b/apps/web/src/project/transfer/container.ts new file mode 100644 index 00000000..8aee8cf8 --- /dev/null +++ b/apps/web/src/project/transfer/container.ts @@ -0,0 +1,245 @@ +import { strFromU8, strToU8, unzipSync, zipSync } from "fflate"; +import type { MediaType } from "@/media/types"; +import { isRecord } from "@/services/storage/migrations/transformers/utils"; + +/** + * Single-file project container (`.ocp`) — a zip archive holding: + * + * - `project.json` — the SerializedProject document exactly as the storage + * layer persists it (including its `version` number). + * - `media/manifest.json` — metadata needed to rebuild every media File + * byte-identically (original name, MIME type, lastModified) plus the + * media-store metadata (dimensions, duration, thumbnail, …). + * - `media/<assetId>-<sanitized name>` — one entry per media asset holding + * the original File bytes (stored, never re-encoded). + */ + +export const PROJECT_ARCHIVE_EXTENSION = ".ocp"; +export const PROJECT_JSON_ENTRY = "project.json"; +export const MEDIA_MANIFEST_ENTRY = "media/manifest.json"; +export const MEDIA_ENTRY_PREFIX = "media/"; + +export class ProjectArchiveError extends Error { + constructor({ message }: { message: string }) { + super(message); + this.name = "ProjectArchiveError"; + } +} + +export interface ArchivedMediaMetadata { + id: string; + name: string; + mediaType: MediaType; + fileType: string; + lastModified: number; + width?: number; + height?: number; + duration?: number; + ephemeral?: boolean; + thumbnailUrl?: string; +} + +export interface ArchivedMediaEntry extends ArchivedMediaMetadata { + entry: string; +} + +export interface ArchiveMediaInput { + metadata: ArchivedMediaMetadata; + data: Uint8Array; +} + +export interface ParsedProjectArchive { + project: Record<string, unknown>; + media: ArchivedMediaEntry[]; + readMediaData: ({ entry }: { entry: string }) => Uint8Array; +} + +const MEDIA_MANIFEST_VERSION = 1; + +export function sanitizeFileName({ name }: { name: string }): string { + const sanitized = name + .trim() + .replace(/[^\w.-]+/g, "_") + .replace(/^\.+/, ""); + return sanitized.length > 0 ? sanitized : "file"; +} + +export function buildMediaEntryName({ + assetId, + name, +}: { + assetId: string; + name: string; +}): string { + return `${MEDIA_ENTRY_PREFIX}${assetId}-${sanitizeFileName({ name })}`; +} + +export function buildProjectArchive({ + projectJson, + media, +}: { + projectJson: string; + media: ArchiveMediaInput[]; +}): Uint8Array { + const manifest: ArchivedMediaEntry[] = media.map(({ metadata }) => ({ + ...metadata, + entry: buildMediaEntryName({ + assetId: metadata.id, + name: metadata.name, + }), + })); + + const files: Record<string, [Uint8Array, { level: 0 }]> = { + [PROJECT_JSON_ENTRY]: [strToU8(projectJson), { level: 0 }], + [MEDIA_MANIFEST_ENTRY]: [ + strToU8(JSON.stringify({ version: MEDIA_MANIFEST_VERSION, media: manifest })), + { level: 0 }, + ], + }; + for (const [index, { data }] of media.entries()) { + const entry = manifest.at(index)?.entry; + if (!entry) continue; + files[entry] = [data, { level: 0 }]; + } + + return zipSync(files); +} + +export function parseProjectArchive({ + archive, +}: { + archive: Uint8Array; +}): ParsedProjectArchive { + let files: Record<string, Uint8Array>; + try { + files = unzipSync(archive); + } catch { + throw new ProjectArchiveError({ + message: "Not a valid project archive (unreadable zip file)", + }); + } + + const projectBytes = files[PROJECT_JSON_ENTRY]; + if (!projectBytes) { + throw new ProjectArchiveError({ + message: "Not a valid project archive (project.json is missing)", + }); + } + + let project: unknown; + try { + project = JSON.parse(strFromU8(projectBytes)); + } catch { + throw new ProjectArchiveError({ + message: "Not a valid project archive (project.json is not valid JSON)", + }); + } + if (!isRecord(project) || Array.isArray(project)) { + throw new ProjectArchiveError({ + message: "Not a valid project archive (project.json is not a project)", + }); + } + + const media = parseMediaManifest({ files }); + + return { + project, + media, + readMediaData: ({ entry }: { entry: string }) => { + const data = files[entry]; + if (!data) { + throw new ProjectArchiveError({ + message: `Media file is missing from the archive (${entry})`, + }); + } + return data; + }, + }; +} + +function parseMediaManifest({ + files, +}: { + files: Record<string, Uint8Array>; +}): ArchivedMediaEntry[] { + const manifestBytes = files[MEDIA_MANIFEST_ENTRY]; + if (!manifestBytes) { + const hasMediaEntries = Object.keys(files).some((name) => + name.startsWith(MEDIA_ENTRY_PREFIX), + ); + if (hasMediaEntries) { + throw new ProjectArchiveError({ + message: "Not a valid project archive (media manifest is missing)", + }); + } + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(strFromU8(manifestBytes)); + } catch { + throw new ProjectArchiveError({ + message: "Not a valid project archive (media manifest is not valid JSON)", + }); + } + if (!isRecord(parsed) || Array.isArray(parsed) || !Array.isArray(parsed.media)) { + throw new ProjectArchiveError({ + message: "Not a valid project archive (media manifest is malformed)", + }); + } + + return parsed.media.map((value) => parseManifestEntry({ value, files })); +} + +function parseManifestEntry({ + value, + files, +}: { + value: unknown; + files: Record<string, Uint8Array>; +}): ArchivedMediaEntry { + const invalid = (reason: string): ProjectArchiveError => + new ProjectArchiveError({ + message: `Not a valid project archive (media manifest entry ${reason})`, + }); + + if (!isRecord(value) || Array.isArray(value)) throw invalid("is malformed"); + const { id, name, entry, mediaType, fileType, lastModified } = value; + if (typeof id !== "string" || id.length === 0) throw invalid("has no id"); + if (typeof name !== "string") throw invalid(`(${id}) has no name`); + if ( + typeof entry !== "string" || + !entry.startsWith(MEDIA_ENTRY_PREFIX) || + entry.includes("..") + ) { + throw invalid(`(${id}) has an invalid file path`); + } + if (mediaType !== "image" && mediaType !== "video" && mediaType !== "audio") { + throw invalid(`(${id}) has an invalid media type`); + } + if (typeof fileType !== "string") throw invalid(`(${id}) has no file type`); + if (typeof lastModified !== "number" || !Number.isFinite(lastModified)) { + throw invalid(`(${id}) has no last-modified timestamp`); + } + if (!files[entry]) { + throw new ProjectArchiveError({ + message: `Not a valid project archive (media file ${entry} is missing)`, + }); + } + + const { width, height, duration, ephemeral, thumbnailUrl } = value; + return { + id, + name, + entry, + mediaType, + fileType, + lastModified, + ...(typeof width === "number" && { width }), + ...(typeof height === "number" && { height }), + ...(typeof duration === "number" && { duration }), + ...(typeof ephemeral === "boolean" && { ephemeral }), + ...(typeof thumbnailUrl === "string" && { thumbnailUrl }), + }; +} diff --git a/apps/web/src/project/transfer/index.ts b/apps/web/src/project/transfer/index.ts new file mode 100644 index 00000000..81d97a6a --- /dev/null +++ b/apps/web/src/project/transfer/index.ts @@ -0,0 +1,16 @@ +export { + PROJECT_ARCHIVE_EXTENSION, + ProjectArchiveError, +} from "./container"; +export type { + ArchivedMediaEntry, + ArchivedMediaMetadata, +} from "./container"; +export { + exportProjectArchive, + importProjectArchive, +} from "./archive"; +export type { + ExportProjectArchiveResult, + ImportProjectArchiveResult, +} from "./archive"; diff --git a/apps/web/src/project/transfer/migrate.ts b/apps/web/src/project/transfer/migrate.ts new file mode 100644 index 00000000..14f59790 --- /dev/null +++ b/apps/web/src/project/transfer/migrate.ts @@ -0,0 +1,75 @@ +import type { StorageMigration } from "@/services/storage/migrations/base"; +import type { ProjectRecord } from "@/services/storage/migrations/transformers/types"; +import { getProjectId } from "@/services/storage/migrations/transformers/utils"; + +/** + * In-memory counterpart to `runStorageMigrations`: applies the same migration + * registry to a single project record (e.g. parsed from an imported project + * archive) without touching IndexedDB. + */ + +export function getRecordVersion({ + project, +}: { + project: ProjectRecord; +}): number { + const versionValue = project.version; + if (typeof versionValue === "number") { + return versionValue; + } + + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + return 1; + } + + return 0; +} + +export interface MigratedProjectRecord { + project: ProjectRecord; + fromVersion: number; + toVersion: number; + complete: boolean; +} + +export async function migrateProjectRecord({ + project, + migrations, +}: { + project: ProjectRecord; + migrations: StorageMigration[]; +}): Promise<MigratedProjectRecord> { + const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from); + const fromVersion = getRecordVersion({ project }); + const targetVersion = orderedMigrations.at(-1)?.to ?? fromVersion; + + let currentVersion = fromVersion; + let record = project; + const projectId = getProjectId({ project: record }) ?? "imported-project"; + + for (const migration of orderedMigrations) { + if (migration.from !== currentVersion) { + continue; + } + + const result = await migration.run({ + projectId, + project: record, + }); + + if (result.skipped) { + break; + } + + record = result.project; + currentVersion = migration.to; + } + + return { + project: record, + fromVersion, + toVersion: currentVersion, + complete: currentVersion >= targetVersion, + }; +} diff --git a/apps/web/src/project/transfer/remap.ts b/apps/web/src/project/transfer/remap.ts new file mode 100644 index 00000000..748960aa --- /dev/null +++ b/apps/web/src/project/transfer/remap.ts @@ -0,0 +1,105 @@ +import type { ProjectRecord } from "@/services/storage/migrations/transformers/types"; +import { isRecord } from "@/services/storage/migrations/transformers/utils"; + +/** + * Pure document helpers for project transfer: walking the media references of + * a serialized project, remapping them to fresh media-store ids on import, + * and resolving imported project names. + * + * All functions tolerate malformed shapes (they operate on untrusted parsed + * JSON) and never mutate their input. + */ + +function collectTracks({ tracks }: { tracks: ProjectRecord }): unknown[] { + const collected: unknown[] = []; + if (isRecord(tracks.main)) { + collected.push(tracks.main); + } + for (const key of ["overlay", "audio"] as const) { + const group = tracks[key]; + if (Array.isArray(group)) { + collected.push(...group); + } + } + return collected; +} + +function walkElements({ + project, + visit, +}: { + project: unknown; + visit: ({ element }: { element: ProjectRecord }) => void; +}): void { + if (!isRecord(project)) return; + const scenes = project.scenes; + if (!Array.isArray(scenes)) return; + + for (const scene of scenes) { + if (!isRecord(scene)) continue; + const tracks = scene.tracks; + if (!isRecord(tracks)) continue; + for (const track of collectTracks({ tracks })) { + if (!isRecord(track)) continue; + const elements = track.elements; + if (!Array.isArray(elements)) continue; + for (const element of elements) { + if (isRecord(element)) { + visit({ element }); + } + } + } + } +} + +export function collectReferencedMediaIds({ + project, +}: { + project: unknown; +}): string[] { + const ids = new Set<string>(); + walkElements({ + project, + visit: ({ element }) => { + const mediaId = element.mediaId; + if (typeof mediaId === "string" && mediaId.length > 0) { + ids.add(mediaId); + } + }, + }); + return [...ids]; +} + +export function remapProjectMediaIds({ + project, + idMap, +}: { + project: ProjectRecord; + idMap: ReadonlyMap<string, string>; +}): ProjectRecord { + if (idMap.size === 0) return project; + + const remapped = structuredClone(project); + walkElements({ + project: remapped, + visit: ({ element }) => { + const mediaId = element.mediaId; + if (typeof mediaId !== "string") return; + const nextId = idMap.get(mediaId); + if (nextId !== undefined) { + element.mediaId = nextId; + } + }, + }); + return remapped; +} + +export function resolveImportedProjectName({ + name, + existingNames, +}: { + name: string; + existingNames: string[]; +}): string { + return existingNames.includes(name) ? `${name} (imported)` : name; +} |
