This commit is contained in:
René Zander 2026-05-20 06:49:41 +00:00 committed by GitHub
commit 0a01bae7e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 241 additions and 2 deletions

View File

@ -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 "@/project/components/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";
@ -184,6 +186,7 @@ function ProjectsHeader() {
<div className="flex items-center gap-3 md:gap-4">
<SearchBar className="hidden md:block" />
<ImportProjectButton />
<NewProjectButton />
</div>
</div>
@ -527,6 +530,45 @@ function NewProjectButton() {
);
}
function ImportProjectButton() {
const editor = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
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 (
<>
<input
ref={fileInputRef}
type="file"
accept=".opencut"
onChange={handleImport}
className="hidden"
/>
<Button
size="lg"
variant="outline"
className="flex px-5 md:px-6"
onClick={() => fileInputRef.current?.click()}
aria-label="Import project"
>
<HugeiconsIcon icon={Upload04Icon} className="size-4" />
<span className="text-sm font-medium hidden md:block">Import</span>
</Button>
</>
);
}
function ProjectItem({
project,
allProjectIds,
@ -556,6 +598,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 () => {
@ -675,6 +720,7 @@ function ProjectItem({
variant="list"
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
@ -716,6 +762,7 @@ function ProjectItem({
onOpenChange={setIsDropdownOpen}
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
@ -729,6 +776,7 @@ function ProjectItem({
<ProjectContextMenuContent
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
@ -763,11 +811,13 @@ function ProjectItem({
function ProjectContextMenuContent({
onRenameClick,
onDuplicateClick,
onExportClick,
onDeleteClick,
onInfoClick,
}: {
onRenameClick: () => void;
onDuplicateClick: () => void;
onExportClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
}) {
@ -785,6 +835,12 @@ function ProjectContextMenuContent({
>
Duplicate
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={Download04Icon} />}
onClick={onExportClick}
>
Export
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
onClick={onInfoClick}
@ -809,6 +865,7 @@ function ProjectMenu({
variant = "grid",
onRenameClick,
onDuplicateClick,
onExportClick,
onDeleteClick,
onInfoClick,
}: {
@ -817,6 +874,7 @@ function ProjectMenu({
variant?: "grid" | "list";
onRenameClick: () => void;
onDuplicateClick: () => void;
onExportClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
}) {
@ -851,6 +909,11 @@ function ProjectMenu({
onOpenChange(false);
};
const handleExport = () => {
onExportClick();
onOpenChange(false);
};
const handleDeleteClick = () => {
onDeleteClick();
onOpenChange(false);
@ -903,6 +966,10 @@ function ProjectMenu({
<HugeiconsIcon icon={Copy01Icon} />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>
<HugeiconsIcon icon={Download04Icon} />
Export
</DropdownMenuItem>
<DropdownMenuItem onClick={handleInfoClick}>
<HugeiconsIcon icon={InformationCircleIcon} />
Info

View File

@ -9,6 +9,7 @@ import type {
} from "@/project/types";
import type { ExportOptions, ExportResult, ExportState } from "@/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 "@/commands/project";
@ -30,6 +31,8 @@ import { getElementFontFamilies } from "@/timeline/element-utils";
import { getRaisedProjectFpsForImportedMedia } from "@/fps/utils";
import type { MediaAsset } from "@/media/types";
const SUPPORTED_FORMAT_VERSION = 1;
export interface MigrationState {
isMigrating: boolean;
fromVersion: number | null;
@ -642,6 +645,151 @@ export class ProjectManager {
this.notify();
}
async exportProjectToFile({ id }: { id: string }): Promise<void> {
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<string | null> {
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<string, unknown>;
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<string, unknown>
| 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);

View File

@ -566,6 +566,30 @@ class StorageService {
return "indexedDB" in window;
}
async exportProjectToJSON({
id,
}: {
id: string;
}): Promise<SerializedProject | null> {
await this.ensureMigrations();
const serializedProject = await this.projectsAdapter.get(id);
return serializedProject ?? null;
}
async importProjectFromJSON({
serializedProject,
}: {
serializedProject: SerializedProject;
}): Promise<void> {
// 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();
}