summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
committercashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
commit30a36e45686db503fded68e4da5d4c465a06f978 (patch)
tree2dbe5e2aaef543155b021f5f4c504fad77b40648 /apps
parentd55d2583a71ae35fa8d0d1bfbec975e67e66aafa (diff)
downloadopencut-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')
-rw-r--r--apps/web/package.json1
-rw-r--r--apps/web/src/app/projects/page.tsx89
-rw-r--r--apps/web/src/core/managers/project-manager.ts57
-rw-r--r--apps/web/src/project/transfer/__tests__/container.test.ts204
-rw-r--r--apps/web/src/project/transfer/__tests__/migrate.test.ts153
-rw-r--r--apps/web/src/project/transfer/__tests__/remap.test.ts166
-rw-r--r--apps/web/src/project/transfer/__tests__/round-trip.test.ts221
-rw-r--r--apps/web/src/project/transfer/archive.ts260
-rw-r--r--apps/web/src/project/transfer/container.ts245
-rw-r--r--apps/web/src/project/transfer/index.ts16
-rw-r--r--apps/web/src/project/transfer/migrate.ts75
-rw-r--r--apps/web/src/project/transfer/remap.ts105
12 files changed, 1590 insertions, 2 deletions
diff --git a/apps/web/package.json b/apps/web/package.json
index 90cd9fb5..816dfe01 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -45,6 +45,7 @@
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
+ "fflate": "^0.8.3",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index c39c4fd9..d238af9e 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -3,8 +3,8 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import type { KeyboardEvent, MouseEvent } from "react";
-import { useEffect, useState } from "react";
+import type { ChangeEvent, KeyboardEvent, MouseEvent } from "react";
+import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type { EditorCore } from "@/core";
import { MigrationDialog } from "@/project/components/migration-dialog";
@@ -45,6 +45,8 @@ import {
Edit03Icon,
ArrowDown02Icon,
InformationCircleIcon,
+ Download01Icon,
+ Upload01Icon,
} from "@hugeicons/core-free-icons";
import { OcVideoIcon } from "@/components/icons";
import { Label } from "@/components/ui/label";
@@ -184,6 +186,7 @@ function ProjectsHeader() {
<div className="flex items-center gap-3 md:gap-4">
<SearchBar className="hidden md:block" />
+ <ImportProjectButton />
<NewProjectButton />
</div>
</div>
@@ -527,6 +530,63 @@ function NewProjectButton() {
);
}
+function ImportProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [isImporting, setIsImporting] = useState(false);
+
+ const handleImportClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = async ({
+ event,
+ }: {
+ event: ChangeEvent<HTMLInputElement>;
+ }) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file || isImporting) return;
+
+ setIsImporting(true);
+ try {
+ const projectId = await editor.project.importArchive({ file });
+ if (projectId) {
+ router.push(`/editor/${projectId}`);
+ }
+ } finally {
+ setIsImporting(false);
+ }
+ };
+
+ return (
+ <>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".ocp"
+ className="hidden"
+ onChange={(event) => void handleFileChange({ event })}
+ />
+ <Button
+ type="button"
+ size="lg"
+ variant="outline"
+ className="flex px-5 md:px-6"
+ disabled={isImporting}
+ onClick={handleImportClick}
+ >
+ <HugeiconsIcon icon={Upload01Icon} className="md:hidden" />
+ <span className="text-sm font-medium hidden md:block">
+ {isImporting ? "Importing…" : "Import"}
+ </span>
+ <span className="text-sm font-medium block md:hidden">Import</span>
+ </Button>
+ </>
+ );
+}
+
function ProjectItem({
project,
allProjectIds,
@@ -558,6 +618,9 @@ function ProjectItem({
};
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
const handleInfoClick = () => setIsInfoDialogOpen(true);
+ const handleExport = async () => {
+ await editor.project.exportArchive({ id: project.id });
+ };
const handleDeleteConfirm = async () => {
await deleteProjects({ editor, ids: [project.id] });
setIsDeleteDialogOpen(false);
@@ -675,6 +738,7 @@ function ProjectItem({
variant="list"
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
+ onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
@@ -716,6 +780,7 @@ function ProjectItem({
onOpenChange={setIsDropdownOpen}
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
+ onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
@@ -729,6 +794,7 @@ function ProjectItem({
<ProjectContextMenuContent
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
+ onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
@@ -763,11 +829,13 @@ function ProjectItem({
function ProjectContextMenuContent({
onRenameClick,
onDuplicateClick,
+ onExportClick,
onDeleteClick,
onInfoClick,
}: {
onRenameClick: () => void;
onDuplicateClick: () => void;
+ onExportClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
}) {
@@ -786,6 +854,12 @@ function ProjectContextMenuContent({
Duplicate
</ContextMenuItem>
<ContextMenuItem
+ icon={<HugeiconsIcon icon={Download01Icon} />}
+ onClick={onExportClick}
+ >
+ Export
+ </ContextMenuItem>
+ <ContextMenuItem
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
onClick={onInfoClick}
>
@@ -809,6 +883,7 @@ function ProjectMenu({
variant = "grid",
onRenameClick,
onDuplicateClick,
+ onExportClick,
onDeleteClick,
onInfoClick,
}: {
@@ -817,6 +892,7 @@ function ProjectMenu({
variant?: "grid" | "list";
onRenameClick: () => void;
onDuplicateClick: () => void;
+ onExportClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
}) {
@@ -851,6 +927,11 @@ function ProjectMenu({
onOpenChange(false);
};
+ const handleExport = () => {
+ onExportClick();
+ onOpenChange(false);
+ };
+
const handleDeleteClick = () => {
onDeleteClick();
onOpenChange(false);
@@ -903,6 +984,10 @@ function ProjectMenu({
<HugeiconsIcon icon={Copy01Icon} />
Duplicate
</DropdownMenuItem>
+ <DropdownMenuItem onClick={handleExport}>
+ <HugeiconsIcon icon={Download01Icon} />
+ Export
+ </DropdownMenuItem>
<DropdownMenuItem onClick={handleInfoClick}>
<HugeiconsIcon icon={InformationCircleIcon} />
Info
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index b38fd50d..7755ab0f 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -8,6 +8,12 @@ import type {
TTimelineViewState,
} from "@/project/types";
import type { ExportOptions, ExportResult, ExportState } from "@/export";
+import {
+ ProjectArchiveError,
+ exportProjectArchive,
+ importProjectArchive,
+} from "@/project/transfer";
+import { downloadBlob } from "@/utils/browser";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@@ -283,6 +289,7 @@ export class ProjectManager {
Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProject({ id }),
+ this.editor.versions.deleteVersionsForProject({ projectId: id }),
]),
),
);
@@ -704,4 +711,54 @@ export class ProjectManager {
fn();
});
}
+
+ async exportArchive({ id }: { id: string }): Promise<void> {
+ try {
+ if (this.active?.metadata.id === id) {
+ await this.editor.save.flush();
+ }
+ const { blob, fileName, missingMediaIds } = await exportProjectArchive({
+ projectId: id,
+ });
+ if (missingMediaIds.length > 0) {
+ toast.warning(
+ missingMediaIds.length === 1
+ ? "1 media file is missing from storage and was not included in the export"
+ : `${missingMediaIds.length} media files are missing from storage and were not included in the export`,
+ );
+ }
+ downloadBlob({ blob, filename: fileName });
+ } catch (error) {
+ console.error("Failed to export project:", error);
+ toast.error("Failed to export project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ }
+
+ async importArchive({ file }: { file: File }): Promise<string | null> {
+ try {
+ const { project } = await importProjectArchive({
+ file,
+ existingNames: this.savedProjects.map((saved) => saved.name),
+ });
+ this.updateMetadata(project);
+ toast.success(`Imported "${project.metadata.name}"`);
+ return project.metadata.id;
+ } catch (error) {
+ console.error("Failed to import project:", error);
+ if (error instanceof ProjectArchiveError) {
+ toast.error("Couldn't import project", {
+ description: error.message,
+ });
+ } else {
+ toast.error("Failed to import project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ return null;
+ }
+ }
}
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;
+}