From e35635db4f77d9015b06454acb1edca92a66ead7 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Mon, 13 Apr 2026 10:26:51 +0000 Subject: [PATCH 1/2] feat: add project export/import for cross-device transfers Add the ability to export projects as .opencut JSON files and import them on another device. Export is available from the project context menu and dropdown menu. Import is available via a button in the projects header. The export format includes a version field for future compatibility. Imported projects receive a new ID to avoid collisions. --- apps/web/src/app/projects/page.tsx | 70 +++++++++- apps/web/src/core/managers/project-manager.ts | 123 ++++++++++++++++++ apps/web/src/services/storage/service.ts | 21 +++ 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 93d6e189f..3bb849b76 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 "@/components/editor/dialogs/migration-dialog"; @@ -45,6 +45,8 @@ import { Edit03Icon, ArrowDown02Icon, InformationCircleIcon, + Download04Icon, + Upload04Icon, } from "@hugeicons/core-free-icons"; import { OcVideoIcon } from "@/components/icons"; import { Label } from "@/components/ui/label"; @@ -183,6 +185,7 @@ function ProjectsHeader() {
+
@@ -526,6 +529,44 @@ function NewProjectButton() { ); } +function ImportProjectButton() { + const editor = useEditor(); + const fileInputRef = useRef(null); + + const handleImport = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + await editor.project.importProjectFromFile({ file }); + + // Reset the input so the same file can be imported again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( + <> + + + + ); +} + function ProjectItem({ project, allProjectIds, @@ -555,6 +596,9 @@ function ProjectItem({ const handleDuplicate = async () => { await duplicateProjects({ editor, ids: [project.id] }); }; + const handleExport = async () => { + await editor.project.exportProjectToFile({ id: project.id }); + }; const handleDeleteClick = () => setIsDeleteDialogOpen(true); const handleInfoClick = () => setIsInfoDialogOpen(true); const handleDeleteConfirm = async () => { @@ -674,6 +718,7 @@ function ProjectItem({ variant="list" onRenameClick={handleRename} onDuplicateClick={handleDuplicate} + onExportClick={handleExport} onDeleteClick={handleDeleteClick} onInfoClick={handleInfoClick} /> @@ -715,6 +760,7 @@ function ProjectItem({ onOpenChange={setIsDropdownOpen} onRenameClick={handleRename} onDuplicateClick={handleDuplicate} + onExportClick={handleExport} onDeleteClick={handleDeleteClick} onInfoClick={handleInfoClick} /> @@ -728,6 +774,7 @@ function ProjectItem({ @@ -762,11 +809,13 @@ function ProjectItem({ function ProjectContextMenuContent({ onRenameClick, onDuplicateClick, + onExportClick, onDeleteClick, onInfoClick, }: { onRenameClick: () => void; onDuplicateClick: () => void; + onExportClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { @@ -784,6 +833,12 @@ function ProjectContextMenuContent({ > Duplicate + } + onClick={onExportClick} + > + Export + } onClick={onInfoClick} @@ -808,6 +863,7 @@ function ProjectMenu({ variant = "grid", onRenameClick, onDuplicateClick, + onExportClick, onDeleteClick, onInfoClick, }: { @@ -816,6 +872,7 @@ function ProjectMenu({ variant?: "grid" | "list"; onRenameClick: () => void; onDuplicateClick: () => void; + onExportClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { @@ -850,6 +907,11 @@ function ProjectMenu({ onOpenChange(false); }; + const handleExport = () => { + onExportClick(); + onOpenChange(false); + }; + const handleDeleteClick = () => { onDeleteClick(); onOpenChange(false); @@ -902,6 +964,10 @@ function ProjectMenu({ Duplicate + + + Export + Info diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 6324b9887..4ea00dc81 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -9,6 +9,7 @@ import type { } from "@/lib/project/types"; import type { ExportOptions, ExportResult, ExportState } from "@/lib/export"; import { storageService } from "@/services/storage/service"; +import type { SerializedProject } from "@/services/storage/types"; import { toast } from "sonner"; import { generateUUID } from "@/utils/id"; import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; @@ -640,6 +641,128 @@ export class ProjectManager { this.notify(); } + async exportProjectToFile({ id }: { id: string }): Promise { + try { + const serializedProject = + await storageService.exportProjectToJSON({ id }); + if (!serializedProject) { + toast.error("Project not found"); + return; + } + + const exportData = { + formatVersion: 1, + exportedAt: new Date().toISOString(), + project: serializedProject, + }; + + const json = JSON.stringify(exportData, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const safeName = serializedProject.metadata.name + .replace(/[^a-zA-Z0-9_-]/g, "_") + .substring(0, 100); + const filename = `${safeName}.opencut`; + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success("Project exported successfully"); + } 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 importProjectFromFile({ + file, + }: { + file: File; + }): Promise { + try { + const text = await file.text(); + let parsed: unknown; + + try { + parsed = JSON.parse(text); + } catch { + toast.error("Invalid file", { + description: "The file is not valid JSON", + }); + return null; + } + + const data = parsed as Record; + + if ( + !data || + typeof data !== "object" || + !("formatVersion" in data) || + !("project" in data) + ) { + toast.error("Invalid file format", { + description: "This does not appear to be an OpenCut project file", + }); + return null; + } + + const serializedProject = data.project as SerializedProject; + + if ( + !serializedProject?.metadata?.id || + !serializedProject?.metadata?.name || + !Array.isArray(serializedProject?.scenes) + ) { + toast.error("Invalid project data", { + description: "The project data is incomplete or corrupted", + }); + return null; + } + + // Assign a new ID to avoid collisions with existing projects + const newId = generateUUID(); + serializedProject.metadata.id = newId; + serializedProject.metadata.updatedAt = new Date().toISOString(); + + await storageService.importProjectFromJSON({ serializedProject }); + + // Reload projects list + const metadata = { + id: serializedProject.metadata.id, + name: serializedProject.metadata.name, + thumbnail: serializedProject.metadata.thumbnail, + duration: serializedProject.metadata.duration ?? 0, + createdAt: new Date(serializedProject.metadata.createdAt), + updatedAt: new Date(serializedProject.metadata.updatedAt), + }; + + this.savedProjects = [metadata, ...this.savedProjects]; + this.notify(); + + toast.success("Project imported successfully", { + description: serializedProject.metadata.name, + }); + + return newId; + } catch (error) { + console.error("Failed to import project:", error); + toast.error("Failed to import project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + return null; + } + } + subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 1aea02970..e29be15bf 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -517,6 +517,27 @@ class StorageService { return "indexedDB" in window; } + async exportProjectToJSON({ + id, + }: { + id: string; + }): Promise { + await this.ensureMigrations(); + const serializedProject = await this.projectsAdapter.get(id); + return serializedProject ?? null; + } + + async importProjectFromJSON({ + serializedProject, + }: { + serializedProject: SerializedProject; + }): Promise { + await this.projectsAdapter.set( + serializedProject.metadata.id, + serializedProject, + ); + } + isFullySupported(): boolean { return this.isIndexedDBSupported() && this.isOPFSSupported(); } From 55d4e52d864a034d389866dfc627cc4010b08ce8 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Wed, 20 May 2026 06:49:34 +0000 Subject: [PATCH 2/2] fix: address CodeRabbit review feedback on import/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projects/page.tsx: add aria-label="Import project" to the import button so screen readers have a name when the visible label is hidden on small screens. - project-manager.ts: tighten import schema validation — accept only formatVersion === SUPPORTED_FORMAT_VERSION (1), reject non-string metadata.id/name (previously a numeric value passed the truthy check), require non-empty trimmed strings, and reject arrays/null payloads. Constant shared with the export path. - storage/service.ts: await ensureMigrations() at the top of importProjectFromJSON so an import can't race with the memoized migration run and land in a half-migrated store. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/projects/page.tsx | 1 + apps/web/src/core/managers/project-manager.ts | 45 ++++++++++++++----- apps/web/src/services/storage/service.ts | 3 ++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 3bb849b76..88b0a4e89 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -559,6 +559,7 @@ function ImportProjectButton() { variant="outline" className="flex px-5 md:px-6" onClick={() => fileInputRef.current?.click()} + aria-label="Import project" > Import diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 4ea00dc81..dfa29e2c6 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -31,6 +31,8 @@ import { getElementFontFamilies } from "@/lib/timeline/element-utils"; import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils"; import type { MediaAsset } from "@/lib/media/types"; +const SUPPORTED_FORMAT_VERSION = 1; + export interface MigrationState { isMigrating: boolean; fromVersion: number | null; @@ -651,7 +653,7 @@ export class ProjectManager { } const exportData = { - formatVersion: 1, + formatVersion: SUPPORTED_FORMAT_VERSION, exportedAt: new Date().toISOString(), project: serializedProject, }; @@ -701,13 +703,10 @@ export class ProjectManager { return null; } - const data = parsed as Record; - if ( - !data || - typeof data !== "object" || - !("formatVersion" in data) || - !("project" in data) + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) ) { toast.error("Invalid file format", { description: "This does not appear to be an OpenCut project file", @@ -715,12 +714,38 @@ export class ProjectManager { return null; } + const data = parsed as Record; + + if (data.formatVersion !== SUPPORTED_FORMAT_VERSION) { + toast.error("Unsupported file version", { + description: `Expected formatVersion ${SUPPORTED_FORMAT_VERSION}, got ${JSON.stringify(data.formatVersion)}`, + }); + return null; + } + + if ( + !data.project || + typeof data.project !== "object" || + Array.isArray(data.project) + ) { + toast.error("Invalid project data", { + description: "The project data is incomplete or corrupted", + }); + return null; + } + const serializedProject = data.project as SerializedProject; + const meta = serializedProject.metadata as unknown as + | Record + | undefined; if ( - !serializedProject?.metadata?.id || - !serializedProject?.metadata?.name || - !Array.isArray(serializedProject?.scenes) + !meta || + typeof meta.id !== "string" || + meta.id.trim() === "" || + typeof meta.name !== "string" || + meta.name.trim() === "" || + !Array.isArray(serializedProject.scenes) ) { toast.error("Invalid project data", { description: "The project data is incomplete or corrupted", diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index e29be15bf..23983c314 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -532,6 +532,9 @@ class StorageService { }: { serializedProject: SerializedProject; }): Promise { + // Migrations must complete before any write — otherwise the import races + // with the memoized ensureMigrations() and may end up in a half-migrated store. + await this.ensureMigrations(); await this.projectsAdapter.set( serializedProject.metadata.id, serializedProject,