diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 93d6e189f..88b0a4e89 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,45 @@ 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 +597,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 +719,7 @@ function ProjectItem({ variant="list" onRenameClick={handleRename} onDuplicateClick={handleDuplicate} + onExportClick={handleExport} onDeleteClick={handleDeleteClick} onInfoClick={handleInfoClick} /> @@ -715,6 +761,7 @@ function ProjectItem({ onOpenChange={setIsDropdownOpen} onRenameClick={handleRename} onDuplicateClick={handleDuplicate} + onExportClick={handleExport} onDeleteClick={handleDeleteClick} onInfoClick={handleInfoClick} /> @@ -728,6 +775,7 @@ function ProjectItem({ @@ -762,11 +810,13 @@ function ProjectItem({ function ProjectContextMenuContent({ onRenameClick, onDuplicateClick, + onExportClick, onDeleteClick, onInfoClick, }: { onRenameClick: () => void; onDuplicateClick: () => void; + onExportClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { @@ -784,6 +834,12 @@ function ProjectContextMenuContent({ > Duplicate + } + onClick={onExportClick} + > + Export + } onClick={onInfoClick} @@ -808,6 +864,7 @@ function ProjectMenu({ variant = "grid", onRenameClick, onDuplicateClick, + onExportClick, onDeleteClick, onInfoClick, }: { @@ -816,6 +873,7 @@ function ProjectMenu({ variant?: "grid" | "list"; onRenameClick: () => void; onDuplicateClick: () => void; + onExportClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { @@ -850,6 +908,11 @@ function ProjectMenu({ onOpenChange(false); }; + const handleExport = () => { + onExportClick(); + onOpenChange(false); + }; + const handleDeleteClick = () => { onDeleteClick(); onOpenChange(false); @@ -902,6 +965,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..dfa29e2c6 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"; @@ -30,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; @@ -640,6 +643,151 @@ 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: SUPPORTED_FORMAT_VERSION, + 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; + } + + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + toast.error("Invalid file format", { + description: "This does not appear to be an OpenCut project file", + }); + 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 ( + !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", + }); + 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..23983c314 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -517,6 +517,30 @@ 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 { + // 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, + ); + } + isFullySupported(): boolean { return this.isIndexedDBSupported() && this.isOPFSSupported(); }