From e35635db4f77d9015b06454acb1edca92a66ead7 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Mon, 13 Apr 2026 10:26:51 +0000 Subject: [PATCH] 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 93d6e189..3bb849b7 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 6324b988..4ea00dc8 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 1aea0297..e29be15b 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(); }