Merge 55d4e52d86 into 238750c025
This commit is contained in:
commit
0a01bae7e6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue