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