Merge 55d4e52d86 into 238750c025
This commit is contained in:
commit
0a01bae7e6
|
|
@ -3,8 +3,8 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { KeyboardEvent, MouseEvent } from "react";
|
import type { ChangeEvent, KeyboardEvent, MouseEvent } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||||
|
|
@ -45,6 +45,8 @@ import {
|
||||||
Edit03Icon,
|
Edit03Icon,
|
||||||
ArrowDown02Icon,
|
ArrowDown02Icon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
|
Download04Icon,
|
||||||
|
Upload04Icon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { OcVideoIcon } from "@/components/icons";
|
import { OcVideoIcon } from "@/components/icons";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -184,6 +186,7 @@ function ProjectsHeader() {
|
||||||
|
|
||||||
<div className="flex items-center gap-3 md:gap-4">
|
<div className="flex items-center gap-3 md:gap-4">
|
||||||
<SearchBar className="hidden md:block" />
|
<SearchBar className="hidden md:block" />
|
||||||
|
<ImportProjectButton />
|
||||||
<NewProjectButton />
|
<NewProjectButton />
|
||||||
</div>
|
</div>
|
||||||
</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({
|
function ProjectItem({
|
||||||
project,
|
project,
|
||||||
allProjectIds,
|
allProjectIds,
|
||||||
|
|
@ -556,6 +598,9 @@ function ProjectItem({
|
||||||
const handleDuplicate = async () => {
|
const handleDuplicate = async () => {
|
||||||
await duplicateProjects({ editor, ids: [project.id] });
|
await duplicateProjects({ editor, ids: [project.id] });
|
||||||
};
|
};
|
||||||
|
const handleExport = async () => {
|
||||||
|
await editor.project.exportProjectToFile({ id: project.id });
|
||||||
|
};
|
||||||
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
|
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
|
||||||
const handleInfoClick = () => setIsInfoDialogOpen(true);
|
const handleInfoClick = () => setIsInfoDialogOpen(true);
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
|
|
@ -675,6 +720,7 @@ function ProjectItem({
|
||||||
variant="list"
|
variant="list"
|
||||||
onRenameClick={handleRename}
|
onRenameClick={handleRename}
|
||||||
onDuplicateClick={handleDuplicate}
|
onDuplicateClick={handleDuplicate}
|
||||||
|
onExportClick={handleExport}
|
||||||
onDeleteClick={handleDeleteClick}
|
onDeleteClick={handleDeleteClick}
|
||||||
onInfoClick={handleInfoClick}
|
onInfoClick={handleInfoClick}
|
||||||
/>
|
/>
|
||||||
|
|
@ -716,6 +762,7 @@ function ProjectItem({
|
||||||
onOpenChange={setIsDropdownOpen}
|
onOpenChange={setIsDropdownOpen}
|
||||||
onRenameClick={handleRename}
|
onRenameClick={handleRename}
|
||||||
onDuplicateClick={handleDuplicate}
|
onDuplicateClick={handleDuplicate}
|
||||||
|
onExportClick={handleExport}
|
||||||
onDeleteClick={handleDeleteClick}
|
onDeleteClick={handleDeleteClick}
|
||||||
onInfoClick={handleInfoClick}
|
onInfoClick={handleInfoClick}
|
||||||
/>
|
/>
|
||||||
|
|
@ -729,6 +776,7 @@ function ProjectItem({
|
||||||
<ProjectContextMenuContent
|
<ProjectContextMenuContent
|
||||||
onRenameClick={handleRename}
|
onRenameClick={handleRename}
|
||||||
onDuplicateClick={handleDuplicate}
|
onDuplicateClick={handleDuplicate}
|
||||||
|
onExportClick={handleExport}
|
||||||
onDeleteClick={handleDeleteClick}
|
onDeleteClick={handleDeleteClick}
|
||||||
onInfoClick={handleInfoClick}
|
onInfoClick={handleInfoClick}
|
||||||
/>
|
/>
|
||||||
|
|
@ -763,11 +811,13 @@ function ProjectItem({
|
||||||
function ProjectContextMenuContent({
|
function ProjectContextMenuContent({
|
||||||
onRenameClick,
|
onRenameClick,
|
||||||
onDuplicateClick,
|
onDuplicateClick,
|
||||||
|
onExportClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onInfoClick,
|
onInfoClick,
|
||||||
}: {
|
}: {
|
||||||
onRenameClick: () => void;
|
onRenameClick: () => void;
|
||||||
onDuplicateClick: () => void;
|
onDuplicateClick: () => void;
|
||||||
|
onExportClick: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onInfoClick: () => void;
|
onInfoClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -785,6 +835,12 @@ function ProjectContextMenuContent({
|
||||||
>
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={<HugeiconsIcon icon={Download04Icon} />}
|
||||||
|
onClick={onExportClick}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
|
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
|
||||||
onClick={onInfoClick}
|
onClick={onInfoClick}
|
||||||
|
|
@ -809,6 +865,7 @@ function ProjectMenu({
|
||||||
variant = "grid",
|
variant = "grid",
|
||||||
onRenameClick,
|
onRenameClick,
|
||||||
onDuplicateClick,
|
onDuplicateClick,
|
||||||
|
onExportClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onInfoClick,
|
onInfoClick,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -817,6 +874,7 @@ function ProjectMenu({
|
||||||
variant?: "grid" | "list";
|
variant?: "grid" | "list";
|
||||||
onRenameClick: () => void;
|
onRenameClick: () => void;
|
||||||
onDuplicateClick: () => void;
|
onDuplicateClick: () => void;
|
||||||
|
onExportClick: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onInfoClick: () => void;
|
onInfoClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -851,6 +909,11 @@ function ProjectMenu({
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
onExportClick();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
const handleDeleteClick = () => {
|
||||||
onDeleteClick();
|
onDeleteClick();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|
@ -903,6 +966,10 @@ function ProjectMenu({
|
||||||
<HugeiconsIcon icon={Copy01Icon} />
|
<HugeiconsIcon icon={Copy01Icon} />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleExport}>
|
||||||
|
<HugeiconsIcon icon={Download04Icon} />
|
||||||
|
Export
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleInfoClick}>
|
<DropdownMenuItem onClick={handleInfoClick}>
|
||||||
<HugeiconsIcon icon={InformationCircleIcon} />
|
<HugeiconsIcon icon={InformationCircleIcon} />
|
||||||
Info
|
Info
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
} from "@/project/types";
|
} from "@/project/types";
|
||||||
import type { ExportOptions, ExportResult, ExportState } from "@/export";
|
import type { ExportOptions, ExportResult, ExportState } from "@/export";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
|
import type { SerializedProject } from "@/services/storage/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { UpdateProjectSettingsCommand } from "@/commands/project";
|
import { UpdateProjectSettingsCommand } from "@/commands/project";
|
||||||
|
|
@ -30,6 +31,8 @@ import { getElementFontFamilies } from "@/timeline/element-utils";
|
||||||
import { getRaisedProjectFpsForImportedMedia } from "@/fps/utils";
|
import { getRaisedProjectFpsForImportedMedia } from "@/fps/utils";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
|
|
||||||
|
const SUPPORTED_FORMAT_VERSION = 1;
|
||||||
|
|
||||||
export interface MigrationState {
|
export interface MigrationState {
|
||||||
isMigrating: boolean;
|
isMigrating: boolean;
|
||||||
fromVersion: number | null;
|
fromVersion: number | null;
|
||||||
|
|
@ -642,6 +645,151 @@ export class ProjectManager {
|
||||||
this.notify();
|
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 {
|
subscribe(listener: () => void): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
return () => this.listeners.delete(listener);
|
return () => this.listeners.delete(listener);
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,30 @@ class StorageService {
|
||||||
return "indexedDB" in window;
|
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 {
|
isFullySupported(): boolean {
|
||||||
return this.isIndexedDBSupported() && this.isOPFSSupported();
|
return this.isIndexedDBSupported() && this.isOPFSSupported();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue