summaryrefslogtreecommitdiff
path: root/apps/web/src/project/transfer/container.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/container.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/container.ts')
-rw-r--r--apps/web/src/project/transfer/container.ts245
1 files changed, 245 insertions, 0 deletions
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 }),
+ };
+}