Add canvas JSON import export
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCanvasProjectById } from "@/lib/server/repositories/canvas-project-repository";
|
||||
import { createCanvasProjectExport } from "@/lib/server/services/canvas-project-transfer";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const project = getCanvasProjectById(id);
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Canvas project not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json({
|
||||
item: createCanvasProjectExport({
|
||||
title: project.title,
|
||||
data: project.data,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "导出画布失败" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
getCanvasProjectById,
|
||||
updateCanvasProject,
|
||||
} from "@/lib/server/repositories/canvas-project-repository";
|
||||
import { isCanvasProjectData } from "@/lib/canvas/types";
|
||||
import {
|
||||
type CanvasProjectData,
|
||||
normalizeCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -33,19 +36,26 @@ export async function PATCH(
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const patch: {
|
||||
title?: string;
|
||||
data?: unknown;
|
||||
data?: CanvasProjectData;
|
||||
} = {};
|
||||
|
||||
if (typeof payload.title === "string") {
|
||||
patch.title = payload.title;
|
||||
}
|
||||
if (isCanvasProjectData(payload.data)) {
|
||||
patch.data = payload.data;
|
||||
if ("data" in payload) {
|
||||
const data = normalizeCanvasProjectData(payload.data);
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ error: "画布 JSON 格式无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
patch.data = data;
|
||||
}
|
||||
|
||||
const project = updateCanvasProject(id, {
|
||||
title: patch.title,
|
||||
data: isCanvasProjectData(patch.data) ? patch.data : undefined,
|
||||
data: patch.data,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { importCanvasProjectExport } from "@/lib/server/services/canvas-project-transfer";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const project = importCanvasProjectExport(payload);
|
||||
return NextResponse.json({ item: project }, { status: 201 });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "导入画布失败" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
createCanvasProject,
|
||||
listCanvasProjects,
|
||||
} from "@/lib/server/repositories/canvas-project-repository";
|
||||
import { defaultCanvasProjectData, isCanvasProjectData } from "@/lib/canvas/types";
|
||||
import {
|
||||
defaultCanvasProjectData,
|
||||
normalizeCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -17,9 +20,7 @@ export async function POST(request: Request) {
|
||||
typeof payload.title === "string" && payload.title.trim()
|
||||
? payload.title
|
||||
: "未命名画布";
|
||||
const data = isCanvasProjectData(payload.data)
|
||||
? payload.data
|
||||
: defaultCanvasProjectData;
|
||||
const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData;
|
||||
const project = createCanvasProject({ title, data });
|
||||
|
||||
return NextResponse.json({ item: project }, { status: 201 });
|
||||
|
||||
@@ -98,6 +98,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
const [job, setJob] = useState<ImageJobPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLibrary, setShowLibrary] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [importJson, setImportJson] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
@@ -471,6 +474,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
...current.data.connections,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: inferConnectionType(current.data.nodes, fromNodeId, toNodeId),
|
||||
fromNodeId,
|
||||
toNodeId,
|
||||
},
|
||||
@@ -494,6 +498,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
const node = createCanvasNode(type, position);
|
||||
const connection: CanvasConnection = {
|
||||
id: crypto.randomUUID(),
|
||||
type: inferConnectionType(nodes, activeConnection.fromNodeId, node.id),
|
||||
fromNodeId: activeConnection.fromNodeId,
|
||||
toNodeId: node.id,
|
||||
};
|
||||
@@ -543,6 +548,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
...current.data.connections,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: inferConnectionType(
|
||||
current.data.nodes,
|
||||
materialConnection.fromNodeId,
|
||||
nextNode.id,
|
||||
),
|
||||
fromNodeId: materialConnection.fromNodeId,
|
||||
toNodeId: nextNode.id,
|
||||
},
|
||||
@@ -585,6 +595,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
...getImageNodeDimensions(naturalSize.width, naturalSize.height),
|
||||
metadata: {
|
||||
...baseNode.metadata,
|
||||
filePath: payload.item.filePath,
|
||||
naturalWidth: naturalSize.width,
|
||||
naturalHeight: naturalSize.height,
|
||||
},
|
||||
@@ -599,6 +610,45 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
uploadInputRef.current?.click();
|
||||
}
|
||||
|
||||
async function copyCanvasJson() {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/projects/${projectId}/export`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.item) {
|
||||
throw new Error(payload.error || "复制 JSON 失败");
|
||||
}
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload.item, null, 2));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "复制 JSON 失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function importCanvasJson() {
|
||||
if (!importJson.trim() || isImporting) return;
|
||||
setError(null);
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const parsed = JSON.parse(importJson);
|
||||
const response = await fetch("/api/canvas/projects/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.item?.id) {
|
||||
throw new Error(payload.error || "导入画布失败");
|
||||
}
|
||||
router.push(`/canvas/${payload.item.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "导入画布失败");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceNodeImage(nodeId: string, file: File) {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -856,6 +906,8 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })}
|
||||
onToggleLibrary={() => setShowLibrary((current) => !current)}
|
||||
onToggleSelectionMode={() => setSelectionMode((current) => !current)}
|
||||
onCopyJson={() => void copyCanvasJson()}
|
||||
onImportJson={() => setShowImportDialog(true)}
|
||||
onUndo={undoCanvasChange}
|
||||
onUploadMaterial={openUploadMaterial}
|
||||
onZoomIn={() => updateViewport({ ...viewport, k: clampScale(viewport.k * 1.18) })}
|
||||
@@ -987,6 +1039,17 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
onReplaceNodeImage={(nodeId, file) => void replaceNodeImage(nodeId, file)}
|
||||
onUploadImage={(file) => void uploadImage(file)}
|
||||
/>
|
||||
<CanvasJsonImportDialog
|
||||
isImporting={isImporting}
|
||||
open={showImportDialog}
|
||||
value={importJson}
|
||||
onChange={setImportJson}
|
||||
onClose={() => {
|
||||
if (isImporting) return;
|
||||
setShowImportDialog(false);
|
||||
}}
|
||||
onImport={() => void importCanvasJson()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1097,6 +1160,67 @@ function SelectionRect({ selectionBox }: { selectionBox: SelectionBox }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasJsonImportDialog({
|
||||
open,
|
||||
value,
|
||||
isImporting,
|
||||
onChange,
|
||||
onClose,
|
||||
onImport,
|
||||
}: {
|
||||
open: boolean;
|
||||
value: string;
|
||||
isImporting: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onImport: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-[120] flex items-center justify-center bg-zinc-950/28 p-5 backdrop-blur-sm"
|
||||
data-canvas-ui
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-950">导入画布 JSON</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
粘贴由复制 JSON 生成的完整画布数据,导入后会创建一个新画布。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full px-3 py-1.5 text-sm text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
disabled={isImporting}
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-4 h-80 w-full resize-none rounded-xl border border-zinc-200 bg-zinc-50 p-3 font-mono text-xs leading-5 text-zinc-800 outline-none transition focus:border-zinc-400 focus:bg-white"
|
||||
placeholder='{"kind":"imagegen.canvas.project","version":1,...}'
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={isImporting} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isImporting || !value.trim()} onClick={onImport}>
|
||||
{isImporting ? "导入中..." : "导入为新画布"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function imageUrlToFile(url: string, filename: string) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
@@ -1152,6 +1276,19 @@ function getRelatedNodeIds(
|
||||
return related;
|
||||
}
|
||||
|
||||
function inferConnectionType(
|
||||
nodes: CanvasNodeType[],
|
||||
fromNodeId: string,
|
||||
toNodeId: string,
|
||||
): CanvasConnection["type"] {
|
||||
const from = nodes.find((node) => node.id === fromNodeId);
|
||||
const to = nodes.find((node) => node.id === toNodeId);
|
||||
if (to?.type === "config") return "config";
|
||||
if (to?.type === "prompt") return from?.type === "image" ? "reference" : "prompt";
|
||||
if (to?.type === "image") return from?.type === "config" ? "config" : "generated";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Eraser,
|
||||
FolderOpen,
|
||||
Grid2X2,
|
||||
@@ -39,6 +41,8 @@ type CanvasToolbarProps = {
|
||||
onRedo: () => void;
|
||||
onGenerate: () => void;
|
||||
onUploadMaterial: () => void;
|
||||
onCopyJson: () => void;
|
||||
onImportJson: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
onClearCanvas: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
@@ -66,6 +70,8 @@ export function CanvasToolbar({
|
||||
onRedo,
|
||||
onGenerate,
|
||||
onUploadMaterial,
|
||||
onCopyJson,
|
||||
onImportJson,
|
||||
onDeleteSelection,
|
||||
onClearCanvas,
|
||||
onBackgroundModeChange,
|
||||
@@ -226,6 +232,17 @@ export function CanvasToolbar({
|
||||
label="素材库"
|
||||
onClick={onToggleLibrary}
|
||||
/>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
icon={<Copy className="size-4" />}
|
||||
label="复制JSON"
|
||||
onClick={onCopyJson}
|
||||
/>
|
||||
<DockButton
|
||||
icon={<ClipboardPaste className="size-4" />}
|
||||
label="导入JSON"
|
||||
onClick={onImportJson}
|
||||
/>
|
||||
{canDeleteSelection ? (
|
||||
<>
|
||||
<DockDivider />
|
||||
|
||||
+235
-5
@@ -13,6 +13,8 @@ export type CanvasPosition = {
|
||||
|
||||
export type CanvasNodeType = "prompt" | "config" | "image";
|
||||
|
||||
export const CANVAS_PROJECT_DATA_VERSION = 1;
|
||||
|
||||
export type CanvasPromptNodeMetadata = {
|
||||
prompt: string;
|
||||
};
|
||||
@@ -58,11 +60,13 @@ export type CanvasNode = {
|
||||
|
||||
export type CanvasConnection = {
|
||||
id: string;
|
||||
type?: "reference" | "prompt" | "config" | "generated";
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type CanvasProjectData = {
|
||||
version: typeof CANVAS_PROJECT_DATA_VERSION;
|
||||
viewport: CanvasViewport;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
nodes: CanvasNode[];
|
||||
@@ -86,6 +90,7 @@ export type CanvasProjectListItem = {
|
||||
};
|
||||
|
||||
export const defaultCanvasProjectData: CanvasProjectData = {
|
||||
version: CANVAS_PROJECT_DATA_VERSION,
|
||||
viewport: { x: 0, y: 0, k: 1 },
|
||||
backgroundMode: "lines",
|
||||
nodes: [],
|
||||
@@ -93,15 +98,240 @@ export const defaultCanvasProjectData: CanvasProjectData = {
|
||||
};
|
||||
|
||||
export function isCanvasProjectData(value: unknown): value is CanvasProjectData {
|
||||
return normalizeCanvasProjectData(value) !== null;
|
||||
}
|
||||
|
||||
export function normalizeCanvasProjectData(
|
||||
value: unknown,
|
||||
): CanvasProjectData | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const viewport = normalizeViewport(candidate.viewport);
|
||||
if (!viewport || !Array.isArray(candidate.nodes) || !Array.isArray(candidate.connections)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = candidate.nodes
|
||||
.map(normalizeCanvasNode)
|
||||
.filter((node): node is CanvasNode => Boolean(node));
|
||||
if (nodes.length !== candidate.nodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const connections = candidate.connections
|
||||
.map((connection) => normalizeCanvasConnection(connection, nodeIds))
|
||||
.filter((connection): connection is CanvasConnection => Boolean(connection));
|
||||
if (connections.length !== candidate.connections.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: CANVAS_PROJECT_DATA_VERSION,
|
||||
viewport,
|
||||
backgroundMode: normalizeBackgroundMode(candidate.backgroundMode),
|
||||
nodes,
|
||||
connections,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeViewport(value: unknown): CanvasViewport | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.x !== "number" ||
|
||||
typeof candidate.y !== "number" ||
|
||||
typeof candidate.k !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { x: candidate.x, y: candidate.y, k: candidate.k };
|
||||
}
|
||||
|
||||
function normalizeBackgroundMode(value: unknown): CanvasBackgroundMode {
|
||||
return value === "dots" || value === "blank" || value === "lines"
|
||||
? value
|
||||
: "lines";
|
||||
}
|
||||
|
||||
function normalizeCanvasNode(value: unknown): CanvasNode | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.id !== "string" ||
|
||||
typeof candidate.title !== "string" ||
|
||||
typeof candidate.width !== "number" ||
|
||||
typeof candidate.height !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = normalizePosition(candidate.position);
|
||||
if (!position) return null;
|
||||
|
||||
if (candidate.type === "prompt") {
|
||||
const metadata = normalizePromptMetadata(candidate.metadata);
|
||||
if (!metadata) return null;
|
||||
return {
|
||||
id: candidate.id,
|
||||
type: "prompt",
|
||||
title: candidate.title,
|
||||
position,
|
||||
width: candidate.width,
|
||||
height: candidate.height,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
if (candidate.type === "config") {
|
||||
const metadata = normalizeConfigMetadata(candidate.metadata);
|
||||
if (!metadata) return null;
|
||||
return {
|
||||
id: candidate.id,
|
||||
type: "config",
|
||||
title: candidate.title,
|
||||
position,
|
||||
width: candidate.width,
|
||||
height: candidate.height,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
if (candidate.type === "image") {
|
||||
const metadata = normalizeImageMetadata(candidate.metadata);
|
||||
if (!metadata) return null;
|
||||
return {
|
||||
id: candidate.id,
|
||||
type: "image",
|
||||
title: candidate.title,
|
||||
position,
|
||||
width: candidate.width,
|
||||
height: candidate.height,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePosition(value: unknown): CanvasPosition | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.x !== "number" || typeof candidate.y !== "number") {
|
||||
return null;
|
||||
}
|
||||
return { x: candidate.x, y: candidate.y };
|
||||
}
|
||||
|
||||
function normalizePromptMetadata(value: unknown): CanvasPromptNodeMetadata | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.prompt !== "string") return null;
|
||||
return { prompt: candidate.prompt };
|
||||
}
|
||||
|
||||
function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.model !== "string" ||
|
||||
typeof candidate.size !== "string" ||
|
||||
typeof candidate.quality !== "string" ||
|
||||
typeof candidate.outputFormat !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
model: candidate.model,
|
||||
size: candidate.size,
|
||||
quality: candidate.quality,
|
||||
outputFormat: candidate.outputFormat,
|
||||
preserveIdentity:
|
||||
typeof candidate.preserveIdentity === "boolean"
|
||||
? candidate.preserveIdentity
|
||||
: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImageMetadata(value: unknown): CanvasImageNodeMetadata | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.imageUrl !== "string" ||
|
||||
(candidate.mode !== "reference" && candidate.mode !== "result")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(typeof candidate.historyId === "string" ? { historyId: candidate.historyId } : {}),
|
||||
imageUrl: candidate.imageUrl,
|
||||
...(typeof candidate.filePath === "string" ? { filePath: candidate.filePath } : {}),
|
||||
mode: candidate.mode,
|
||||
...(typeof candidate.prompt === "string" ? { prompt: candidate.prompt } : {}),
|
||||
...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
|
||||
...(typeof candidate.size === "string" ? { size: candidate.size } : {}),
|
||||
...(typeof candidate.quality === "string" ? { quality: candidate.quality } : {}),
|
||||
...(typeof candidate.outputFormat === "string"
|
||||
? { outputFormat: candidate.outputFormat }
|
||||
: {}),
|
||||
...(isImageStatus(candidate.status) ? { status: candidate.status } : {}),
|
||||
...(typeof candidate.errorMessage === "string"
|
||||
? { errorMessage: candidate.errorMessage }
|
||||
: {}),
|
||||
...(typeof candidate.naturalWidth === "number"
|
||||
? { naturalWidth: candidate.naturalWidth }
|
||||
: {}),
|
||||
...(typeof candidate.naturalHeight === "number"
|
||||
? { naturalHeight: candidate.naturalHeight }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCanvasConnection(
|
||||
value: unknown,
|
||||
nodeIds: Set<string>,
|
||||
): CanvasConnection | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.id !== "string" ||
|
||||
typeof candidate.fromNodeId !== "string" ||
|
||||
typeof candidate.toNodeId !== "string" ||
|
||||
!nodeIds.has(candidate.fromNodeId) ||
|
||||
!nodeIds.has(candidate.toNodeId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
...(isConnectionType(candidate.type) ? { type: candidate.type } : {}),
|
||||
fromNodeId: candidate.fromNodeId,
|
||||
toNodeId: candidate.toNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
function isImageStatus(
|
||||
value: unknown,
|
||||
): value is NonNullable<CanvasImageNodeMetadata["status"]> {
|
||||
return (
|
||||
typeof candidate.viewport === "object" &&
|
||||
candidate.viewport !== null &&
|
||||
Array.isArray(candidate.nodes) &&
|
||||
Array.isArray(candidate.connections)
|
||||
value === "idle" ||
|
||||
value === "loading" ||
|
||||
value === "success" ||
|
||||
value === "error"
|
||||
);
|
||||
}
|
||||
|
||||
function isConnectionType(value: unknown): value is NonNullable<CanvasConnection["type"]> {
|
||||
return (
|
||||
value === "reference" ||
|
||||
value === "prompt" ||
|
||||
value === "config" ||
|
||||
value === "generated"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type CanvasProjectData,
|
||||
type CanvasProjectListItem,
|
||||
type CanvasProjectRecord,
|
||||
isCanvasProjectData,
|
||||
normalizeCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
import { getDb } from "@/lib/server/db";
|
||||
|
||||
@@ -65,7 +65,7 @@ export function createCanvasProject(input?: {
|
||||
const now = new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
const title = input?.title?.trim() || "未命名画布";
|
||||
const data = input?.data ?? defaultCanvasProjectData;
|
||||
const data = normalizeCanvasProjectData(input?.data) ?? defaultCanvasProjectData;
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
@@ -88,7 +88,7 @@ export function updateCanvasProject(
|
||||
if (!current) return null;
|
||||
|
||||
const title = patch.title?.trim() || current.title;
|
||||
const data = patch.data ?? current.data;
|
||||
const data = normalizeCanvasProjectData(patch.data) ?? current.data;
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
getDb()
|
||||
@@ -112,7 +112,7 @@ export function deleteCanvasProject(id: string) {
|
||||
function parseCanvasProjectData(raw: string): CanvasProjectData {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isCanvasProjectData(parsed) ? parsed : defaultCanvasProjectData;
|
||||
return normalizeCanvasProjectData(parsed) ?? defaultCanvasProjectData;
|
||||
} catch {
|
||||
return defaultCanvasProjectData;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, basename } from "node:path";
|
||||
import type { CanvasImageNodeMetadata, CanvasProjectData } from "@/lib/canvas/types";
|
||||
import { normalizeCanvasProjectData } from "@/lib/canvas/types";
|
||||
import { createCanvasProject } from "@/lib/server/repositories/canvas-project-repository";
|
||||
import {
|
||||
getGeneratedImagePathFromUrl,
|
||||
persistImageBuffer,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
const CANVAS_EXPORT_KIND = "imagegen.canvas.project";
|
||||
const CANVAS_EXPORT_VERSION = 1;
|
||||
|
||||
type CanvasProjectExportAsset = {
|
||||
nodeId: string;
|
||||
imageUrl: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export type CanvasProjectExportV1 = {
|
||||
kind: typeof CANVAS_EXPORT_KIND;
|
||||
version: typeof CANVAS_EXPORT_VERSION;
|
||||
exportedAt: string;
|
||||
title: string;
|
||||
data: CanvasProjectData;
|
||||
assets: CanvasProjectExportAsset[];
|
||||
};
|
||||
|
||||
export function createCanvasProjectExport(input: {
|
||||
title: string;
|
||||
data: CanvasProjectData;
|
||||
}): CanvasProjectExportV1 {
|
||||
return {
|
||||
kind: CANVAS_EXPORT_KIND,
|
||||
version: CANVAS_EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
title: input.title,
|
||||
data: input.data,
|
||||
assets: collectExportAssets(input.data),
|
||||
};
|
||||
}
|
||||
|
||||
export function importCanvasProjectExport(value: unknown) {
|
||||
const payload = normalizeCanvasProjectExport(value);
|
||||
if (!payload) {
|
||||
throw new Error("导入 JSON 不是有效的画布导出格式");
|
||||
}
|
||||
|
||||
const assetsByNodeId = new Map(payload.assets.map((asset) => [asset.nodeId, asset]));
|
||||
const data: CanvasProjectData = {
|
||||
...payload.data,
|
||||
nodes: payload.data.nodes.map((node) => {
|
||||
if (node.type !== "image") return node;
|
||||
const asset = assetsByNodeId.get(node.id);
|
||||
if (!asset) return node;
|
||||
|
||||
const persisted = persistDataUrl(asset.dataUrl, asset.fileName);
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...(node.metadata as CanvasImageNodeMetadata),
|
||||
imageUrl: persisted.imageUrl,
|
||||
filePath: persisted.filePath,
|
||||
} satisfies CanvasImageNodeMetadata,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const title = payload.title.trim()
|
||||
? `${payload.title.trim()} Copy`
|
||||
: "导入画布";
|
||||
|
||||
return createCanvasProject({ title, data });
|
||||
}
|
||||
|
||||
function collectExportAssets(data: CanvasProjectData) {
|
||||
const assets: CanvasProjectExportAsset[] = [];
|
||||
|
||||
for (const node of data.nodes) {
|
||||
if (node.type !== "image") continue;
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
if (!metadata.imageUrl) continue;
|
||||
|
||||
const filePath = metadata.filePath ?? getGeneratedImagePathFromUrl(metadata.imageUrl);
|
||||
if (!filePath || !existsSync(filePath)) {
|
||||
throw new Error(`图片文件不存在:${node.title || node.id}`);
|
||||
}
|
||||
|
||||
const buffer = readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
assets.push({
|
||||
nodeId: node.id,
|
||||
imageUrl: metadata.imageUrl,
|
||||
fileName: basename(filePath),
|
||||
mimeType,
|
||||
dataUrl: `data:${mimeType};base64,${buffer.toString("base64")}`,
|
||||
});
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
function normalizeCanvasProjectExport(value: unknown): CanvasProjectExportV1 | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
candidate.kind !== CANVAS_EXPORT_KIND ||
|
||||
candidate.version !== CANVAS_EXPORT_VERSION ||
|
||||
typeof candidate.title !== "string" ||
|
||||
!Array.isArray(candidate.assets)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = normalizeCanvasProjectData(candidate.data);
|
||||
if (!data) return null;
|
||||
|
||||
const assets = candidate.assets
|
||||
.map(normalizeExportAsset)
|
||||
.filter((asset): asset is CanvasProjectExportAsset => Boolean(asset));
|
||||
if (assets.length !== candidate.assets.length) return null;
|
||||
|
||||
return {
|
||||
kind: CANVAS_EXPORT_KIND,
|
||||
version: CANVAS_EXPORT_VERSION,
|
||||
exportedAt:
|
||||
typeof candidate.exportedAt === "string"
|
||||
? candidate.exportedAt
|
||||
: new Date().toISOString(),
|
||||
title: candidate.title,
|
||||
data,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExportAsset(value: unknown): CanvasProjectExportAsset | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.nodeId !== "string" ||
|
||||
typeof candidate.imageUrl !== "string" ||
|
||||
typeof candidate.fileName !== "string" ||
|
||||
typeof candidate.mimeType !== "string" ||
|
||||
typeof candidate.dataUrl !== "string" ||
|
||||
!candidate.dataUrl.startsWith("data:")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: candidate.nodeId,
|
||||
imageUrl: candidate.imageUrl,
|
||||
fileName: candidate.fileName,
|
||||
mimeType: candidate.mimeType,
|
||||
dataUrl: candidate.dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function persistDataUrl(dataUrl: string, fileName: string) {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) {
|
||||
throw new Error("图片 dataUrl 格式无效");
|
||||
}
|
||||
|
||||
const mimeType = match[1];
|
||||
const extension = getExtensionFromMimeType(mimeType) ?? getExtension(fileName);
|
||||
return persistImageBuffer(crypto.randomUUID(), extension, Buffer.from(match[2], "base64"));
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string) {
|
||||
const extension = getExtension(filePath);
|
||||
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
|
||||
if (extension === "webp") return "image/webp";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
function getExtensionFromMimeType(mimeType: string) {
|
||||
if (mimeType === "image/jpeg") return "jpg";
|
||||
if (mimeType === "image/png") return "png";
|
||||
if (mimeType === "image/webp") return "webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
function getExtension(filePath: string) {
|
||||
return extname(filePath).replace(".", "").toLowerCase() || "png";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
const imageDir = join(
|
||||
/* turbopackIgnore: true */ process.cwd(),
|
||||
@@ -49,8 +49,32 @@ export function persistUploadedImageFile(
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
export function persistImageBuffer(
|
||||
id: string,
|
||||
extension: string,
|
||||
buffer: Buffer,
|
||||
): PersistedImageFile {
|
||||
const normalizedExtension = normalizeExtension(extension);
|
||||
const fileName = `${id}.${normalizedExtension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, buffer);
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
export function getGeneratedImagePathFromUrl(imageUrl: string) {
|
||||
if (!imageUrl.startsWith("/generated/")) return null;
|
||||
return join(getImageDir(), basename(imageUrl));
|
||||
}
|
||||
|
||||
function getExtensionFromFilename(filename: string) {
|
||||
const parts = filename.split(".");
|
||||
const extension = parts.at(-1)?.toLowerCase();
|
||||
return extension && extension.length <= 5 ? extension : "png";
|
||||
return normalizeExtension(extension);
|
||||
}
|
||||
|
||||
function normalizeExtension(extension: string | undefined) {
|
||||
if (!extension || extension.length > 5) return "png";
|
||||
return extension === "jpeg" ? "jpg" : extension;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user