Merge 2f68b3e906 into b1d0b10354
This commit is contained in:
commit
a59370d55e
|
|
@ -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";
|
||||
|
|
@ -184,6 +187,7 @@ function ProjectsHeader() {
|
|||
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<SearchBar className="hidden md:block" />
|
||||
<ImportProjectButton />
|
||||
<NewProjectButton />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -527,6 +531,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 (
|
||||
<>
|
||||
<input {...fileInputProps} />
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="flex px-5 md:px-6"
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<HugeiconsIcon icon={Upload01Icon} className="size-4" />
|
||||
<span className="text-sm font-medium hidden md:block">Import</span>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
project,
|
||||
allProjectIds,
|
||||
|
|
@ -558,6 +604,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);
|
||||
|
|
@ -677,6 +726,7 @@ function ProjectItem({
|
|||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
onExportJSONClick={handleExportJSON}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -718,6 +768,7 @@ function ProjectItem({
|
|||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
onExportJSONClick={handleExportJSON}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -731,6 +782,7 @@ function ProjectItem({
|
|||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
onExportJSONClick={handleExportJSON}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
|
|
@ -765,11 +817,13 @@ function ProjectContextMenuContent({
|
|||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
onExportJSONClick,
|
||||
}: {
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
onExportJSONClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
|
|
@ -785,6 +839,12 @@ function ProjectContextMenuContent({
|
|||
>
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Download01Icon} />}
|
||||
onClick={onExportJSONClick}
|
||||
>
|
||||
Export as JSON
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
|
||||
onClick={onInfoClick}
|
||||
|
|
@ -811,6 +871,7 @@ function ProjectMenu({
|
|||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
onExportJSONClick,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -819,6 +880,7 @@ function ProjectMenu({
|
|||
onDuplicateClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
onExportJSONClick: () => void;
|
||||
}) {
|
||||
const handleMenuClick = ({
|
||||
event,
|
||||
|
|
@ -861,6 +923,11 @@ function ProjectMenu({
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleExportJSON = () => {
|
||||
onExportJSONClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isGrid = variant === "grid";
|
||||
|
||||
return (
|
||||
|
|
@ -903,6 +970,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
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
|
|||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
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";
|
||||
|
|
@ -130,6 +130,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} />}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -638,6 +661,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<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 = 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<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 ||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue