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();
}