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/remap.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/remap.ts')
| -rw-r--r-- | apps/web/src/project/transfer/remap.ts | 105 |
1 files changed, 105 insertions, 0 deletions
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; +} |
