feat: add JSON project export/import (closes #719)

This commit is contained in:
Rene Zander 2026-04-13 08:55:47 +00:00
parent cbd1b82123
commit bf2caa8193
3 changed files with 374 additions and 2 deletions

View File

@ -4,7 +4,7 @@ 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 { 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,
Download01Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { OcVideoIcon } from "@/components/icons";
import { Label } from "@/components/ui/label";
@ -183,6 +185,7 @@ function ProjectsHeader() {
<div className="flex items-center gap-3 md:gap-4">
<SearchBar className="hidden md:block" />
<ImportProjectButton />
<NewProjectButton />
</div>
</div>
@ -526,6 +529,56 @@ function NewProjectButton() {
);
}
function ImportProjectButton() {
const editor = useEditor();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.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",
});
}
// Reset the input so the same file can be re-selected
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept=".json,.opencut.json"
className="hidden"
onChange={handleImport}
/>
<Button
size="lg"
variant="outline"
className="flex px-5 md:px-6"
onClick={() => fileInputRef.current?.click()}
>
<HugeiconsIcon icon={Upload01Icon} className="size-4" />
<span className="text-sm font-medium hidden md:block">Import</span>
</Button>
</>
);
}
function ProjectItem({
project,
allProjectIds,
@ -557,6 +610,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 +732,7 @@ function ProjectItem({
onDuplicateClick={handleDuplicate}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
onExportJSONClick={handleExportJSON}
/>
)}
</div>
@ -717,6 +774,7 @@ function ProjectItem({
onDuplicateClick={handleDuplicate}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
onExportJSONClick={handleExportJSON}
/>
)}
</>
@ -730,6 +788,7 @@ function ProjectItem({
onDuplicateClick={handleDuplicate}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
onExportJSONClick={handleExportJSON}
/>
</ContextMenu>
@ -764,11 +823,13 @@ function ProjectContextMenuContent({
onDuplicateClick,
onDeleteClick,
onInfoClick,
onExportJSONClick,
}: {
onRenameClick: () => void;
onDuplicateClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
onExportJSONClick: () => void;
}) {
return (
<ContextMenuContent>
@ -784,6 +845,12 @@ function ProjectContextMenuContent({
>
Duplicate
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={Download01Icon} />}
onClick={onExportJSONClick}
>
Export as JSON
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
onClick={onInfoClick}
@ -810,6 +877,7 @@ function ProjectMenu({
onDuplicateClick,
onDeleteClick,
onInfoClick,
onExportJSONClick,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@ -818,6 +886,7 @@ function ProjectMenu({
onDuplicateClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
onExportJSONClick: () => void;
}) {
const handleMenuClick = ({
event,
@ -860,6 +929,11 @@ function ProjectMenu({
onOpenChange(false);
};
const handleExportJSON = () => {
onExportJSONClick();
onOpenChange(false);
};
const isGrid = variant === "grid";
return (
@ -902,6 +976,10 @@ function ProjectMenu({
<HugeiconsIcon icon={Copy01Icon} />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportJSON}>
<HugeiconsIcon icon={Download01Icon} />
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={handleInfoClick}>
<HugeiconsIcon icon={InformationCircleIcon} />
Info

View File

@ -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
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => editor.project.exportProjectAsJSON()}
icon={<HugeiconsIcon icon={Download01Icon} />}
>
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setOpenDialog("shortcuts")}
icon={<HugeiconsIcon icon={CommandIcon} />}

View File

@ -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,270 @@ 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<void> {
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 = this.editor.media.getAssets();
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<void> {
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<string | null> {
try {
const parsed = JSON.parse(json) as ExportedProjectJSON;
if (
!parsed.project ||
!parsed.project.metadata ||
!parsed.project.scenes
) {
toast.error("Invalid project file", {
description: "The file does not contain a valid OpenCut project.",
});
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);