summaryrefslogtreecommitdiff
path: root/apps/web/src/project/transfer/remap.ts
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/web/src/project/transfer/remap.ts
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/web/src/project/transfer/remap.ts')
-rw-r--r--apps/web/src/project/transfer/remap.ts105
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;
+}