diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 93d6e189f..fe3c14e51 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -15,6 +15,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useEditor } from "@/hooks/use-editor";
+import { useFileUpload } from "@/hooks/use-file-upload";
import { useProjectsStore } from "./store";
import type {
TProjectMetadata,
@@ -45,6 +46,8 @@ import {
Edit03Icon,
ArrowDown02Icon,
InformationCircleIcon,
+ Download01Icon,
+ Upload01Icon,
} from "@hugeicons/core-free-icons";
import { OcVideoIcon } from "@/components/icons";
import { Label } from "@/components/ui/label";
@@ -183,6 +186,7 @@ function ProjectsHeader() {
+
@@ -526,6 +530,48 @@ function NewProjectButton() {
);
}
+function ImportProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+
+ const { openFilePicker, fileInputProps } = useFileUpload({
+ accept: ".json,.opencut.json",
+ multiple: false,
+ onFilesSelected: async (files) => {
+ const file = files[0];
+ if (!file) return;
+
+ try {
+ const json = await file.text();
+ const projectId = await editor.project.importProjectFromJSON({ json });
+ if (projectId) {
+ router.push(`/editor/${projectId}`);
+ }
+ } catch (error) {
+ toast.error("Failed to read file", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ },
+ });
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
function ProjectItem({
project,
allProjectIds,
@@ -557,6 +603,9 @@ function ProjectItem({
};
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
const handleInfoClick = () => setIsInfoDialogOpen(true);
+ const handleExportJSON = async () => {
+ await editor.project.exportProjectByIdAsJSON({ id: project.id });
+ };
const handleDeleteConfirm = async () => {
await deleteProjects({ editor, ids: [project.id] });
setIsDeleteDialogOpen(false);
@@ -676,6 +725,7 @@ function ProjectItem({
onDuplicateClick={handleDuplicate}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
+ onExportJSONClick={handleExportJSON}
/>
)}
@@ -717,6 +767,7 @@ function ProjectItem({
onDuplicateClick={handleDuplicate}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
+ onExportJSONClick={handleExportJSON}
/>
)}
>
@@ -730,6 +781,7 @@ function ProjectItem({
onDuplicateClick={handleDuplicate}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
+ onExportJSONClick={handleExportJSON}
/>
@@ -764,11 +816,13 @@ function ProjectContextMenuContent({
onDuplicateClick,
onDeleteClick,
onInfoClick,
+ onExportJSONClick,
}: {
onRenameClick: () => void;
onDuplicateClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
+ onExportJSONClick: () => void;
}) {
return (
@@ -784,6 +838,12 @@ function ProjectContextMenuContent({
>
Duplicate
+ }
+ onClick={onExportJSONClick}
+ >
+ Export as JSON
+
}
onClick={onInfoClick}
@@ -810,6 +870,7 @@ function ProjectMenu({
onDuplicateClick,
onDeleteClick,
onInfoClick,
+ onExportJSONClick,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@@ -818,6 +879,7 @@ function ProjectMenu({
onDuplicateClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
+ onExportJSONClick: () => void;
}) {
const handleMenuClick = ({
event,
@@ -860,6 +922,11 @@ function ProjectMenu({
onOpenChange(false);
};
+ const handleExportJSON = () => {
+ onExportJSONClick();
+ onOpenChange(false);
+ };
+
const isGrid = variant === "grid";
return (
@@ -902,6 +969,10 @@ function ProjectMenu({
Duplicate
+
+
+ Export as JSON
+
Info
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index 460e8ed6e..a45c29c06 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
-import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
+import { CommandIcon, Download01Icon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image";
@@ -127,6 +127,13 @@ function ProjectDropdown() {
Exit project
+ editor.project.exportProjectAsJSON()}
+ icon={}
+ >
+ Export as JSON
+
+
setOpenDialog("shortcuts")}
icon={}
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 6324b9887..28648b39a 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -29,6 +29,29 @@ import { DEFAULTS } from "@/lib/timeline/defaults";
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils";
import type { MediaAsset } from "@/lib/media/types";
+import type { SerializedProject } from "@/services/storage/types";
+
+/** Schema version for the JSON export format. Increment when the format changes. */
+const EXPORT_SCHEMA_VERSION = 1;
+
+/** Manifest entry describing a media asset referenced by the project. */
+export interface ExportedMediaManifestEntry {
+ mediaId: string;
+ filename: string;
+ type: string;
+ size: number;
+ width?: number;
+ height?: number;
+ duration?: number;
+}
+
+/** Top-level shape of an exported OpenCut project JSON file. */
+export interface ExportedProjectJSON {
+ schema_version: number;
+ exported_at: string;
+ project: SerializedProject;
+ media: ExportedMediaManifestEntry[];
+}
export interface MigrationState {
isMigrating: boolean;
@@ -640,6 +663,280 @@ export class ProjectManager {
this.notify();
}
+ /**
+ * Exports the active project as a JSON file and triggers a browser download.
+ *
+ * The exported file includes the full serialized project data and a media
+ * manifest listing every media asset referenced by the project (filename,
+ * type, dimensions, duration). Media file blobs are **not** included -- the
+ * manifest allows users to re-link media after import.
+ */
+ async exportProjectAsJSON(): Promise {
+ if (!this.active) {
+ toast.error("No active project to export");
+ return;
+ }
+
+ try {
+ const scenes = this.editor.scenes.getScenes();
+ const project = {
+ ...this.active,
+ scenes,
+ metadata: {
+ ...this.active.metadata,
+ duration: getProjectDurationFromScenes({ scenes }),
+ updatedAt: new Date(),
+ },
+ };
+
+ const serializedScenes = project.scenes.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ tracks: scene.tracks,
+ bookmarks: scene.bookmarks,
+ createdAt: scene.createdAt.toISOString(),
+ updatedAt: scene.updatedAt.toISOString(),
+ }));
+
+ const serializedProject: SerializedProject = {
+ metadata: {
+ id: project.metadata.id,
+ name: project.metadata.name,
+ thumbnail: project.metadata.thumbnail,
+ duration: project.metadata.duration,
+ createdAt: project.metadata.createdAt.toISOString(),
+ updatedAt: project.metadata.updatedAt.toISOString(),
+ },
+ scenes: serializedScenes,
+ currentSceneId: project.currentSceneId,
+ settings: project.settings,
+ version: project.version,
+ timelineViewState: project.timelineViewState,
+ };
+
+ const mediaAssets = await storageService.loadAllMediaAssets({
+ projectId: project.metadata.id,
+ });
+ const mediaManifest: ExportedMediaManifestEntry[] = mediaAssets.map(
+ (asset) => ({
+ mediaId: asset.id,
+ filename: asset.name,
+ type: asset.type,
+ size: asset.file.size,
+ width: asset.width,
+ height: asset.height,
+ duration: asset.duration,
+ }),
+ );
+
+ const exported: ExportedProjectJSON = {
+ schema_version: EXPORT_SCHEMA_VERSION,
+ exported_at: new Date().toISOString(),
+ project: serializedProject,
+ media: mediaManifest,
+ };
+
+ const json = JSON.stringify(exported, null, 2);
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${project.metadata.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.opencut.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ 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",
+ });
+ }
+ }
+
+ /**
+ * Exports any project (by ID) as a JSON file and triggers a browser download.
+ *
+ * Unlike {@link exportProjectAsJSON}, this does not require the project to be
+ * active. It loads the project data directly from storage.
+ */
+ async exportProjectByIdAsJSON({ id }: { id: string }): Promise {
+ try {
+ const result = await storageService.loadProject({ id });
+ if (!result) {
+ toast.error("Project not found");
+ return;
+ }
+
+ const project = result.project;
+
+ const serializedScenes = project.scenes.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ tracks: scene.tracks,
+ bookmarks: scene.bookmarks,
+ createdAt: scene.createdAt.toISOString(),
+ updatedAt: scene.updatedAt.toISOString(),
+ }));
+
+ const serializedProject: SerializedProject = {
+ metadata: {
+ id: project.metadata.id,
+ name: project.metadata.name,
+ thumbnail: project.metadata.thumbnail,
+ duration: project.metadata.duration,
+ createdAt: project.metadata.createdAt.toISOString(),
+ updatedAt: project.metadata.updatedAt.toISOString(),
+ },
+ scenes: serializedScenes,
+ currentSceneId: project.currentSceneId,
+ settings: project.settings,
+ version: project.version,
+ timelineViewState: project.timelineViewState,
+ };
+
+ const mediaAssets = await storageService.loadAllMediaAssets({
+ projectId: id,
+ });
+ const mediaManifest: ExportedMediaManifestEntry[] = mediaAssets.map(
+ (asset) => ({
+ mediaId: asset.id,
+ filename: asset.name,
+ type: asset.type,
+ size: asset.file.size,
+ width: asset.width,
+ height: asset.height,
+ duration: asset.duration,
+ }),
+ );
+
+ const exported: ExportedProjectJSON = {
+ schema_version: EXPORT_SCHEMA_VERSION,
+ exported_at: new Date().toISOString(),
+ project: serializedProject,
+ media: mediaManifest,
+ };
+
+ const json = JSON.stringify(exported, null, 2);
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${project.metadata.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.opencut.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ 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",
+ });
+ }
+ }
+
+ /**
+ * Imports a project from a JSON string previously created by {@link exportProjectAsJSON}.
+ *
+ * A new project is created with a fresh ID and timestamps. The timeline
+ * structure (scenes, tracks, elements) is fully restored. Media files are
+ * **not** included in the export, so elements that reference media assets
+ * will need their media re-imported by the user.
+ *
+ * @returns The new project ID, or `null` if the import failed.
+ */
+ async importProjectFromJSON({
+ json,
+ }: {
+ json: string;
+ }): Promise {
+ try {
+ const parsed = JSON.parse(json) as ExportedProjectJSON;
+
+ if (
+ !parsed.project ||
+ !parsed.project.metadata ||
+ !parsed.project.scenes ||
+ parsed.project.scenes.length === 0
+ ) {
+ toast.error("Invalid project file", {
+ description: "The file does not contain a valid OpenCut project.",
+ });
+ return null;
+ }
+
+ if (parsed.schema_version !== EXPORT_SCHEMA_VERSION) {
+ toast.error("Incompatible project file", {
+ description: `Unsupported schema version ${parsed.schema_version}, expected ${EXPORT_SCHEMA_VERSION}.`,
+ });
+ return null;
+ }
+
+ const imported = parsed.project;
+
+ const newProjectId = generateUUID();
+ const now = new Date();
+
+ const scenes = imported.scenes.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ tracks: scene.tracks,
+ bookmarks: scene.bookmarks ?? [],
+ createdAt: now,
+ updatedAt: now,
+ }));
+
+ const newProject: TProject = {
+ metadata: {
+ id: newProjectId,
+ name: imported.metadata.name,
+ duration:
+ imported.metadata.duration ??
+ getProjectDurationFromScenes({ scenes }),
+ createdAt: now,
+ updatedAt: now,
+ },
+ scenes,
+ currentSceneId: imported.currentSceneId || scenes[0]?.id || "",
+ settings: imported.settings,
+ version: CURRENT_PROJECT_VERSION,
+ timelineViewState: imported.timelineViewState,
+ };
+
+ await storageService.saveProject({ project: newProject });
+ this.updateMetadata(newProject);
+
+ const mediaCount = parsed.media?.length ?? 0;
+ if (mediaCount > 0) {
+ toast.success("Project imported", {
+ description: `${mediaCount} media file(s) need to be re-imported.`,
+ });
+ } else {
+ toast.success("Project imported successfully");
+ }
+
+ return newProjectId;
+ } 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);