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/__tests__/round-trip.test.ts | |
| 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/__tests__/round-trip.test.ts')
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/round-trip.test.ts | 221 |
1 files changed, 221 insertions, 0 deletions
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, + ]); + } + }); +}); |
