From 30a36e45686db503fded68e4da5d4c465a06f978 Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: 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. --- apps/web/src/project/transfer/remap.ts | 105 +++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 apps/web/src/project/transfer/remap.ts (limited to 'apps/web/src/project/transfer/remap.ts') 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(); + 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; +}): 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; +} -- cgit v1.3.1