Add canvas JSON import export

This commit is contained in:
2026-06-23 00:54:34 +08:00
parent 36e6bedf1a
commit 3b8ce48c56
10 changed files with 679 additions and 20 deletions
@@ -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 },
);
}
}
+15 -5
View File
@@ -4,7 +4,10 @@ import {
getCanvasProjectById, getCanvasProjectById,
updateCanvasProject, updateCanvasProject,
} from "@/lib/server/repositories/canvas-project-repository"; } 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"; export const runtime = "nodejs";
@@ -33,19 +36,26 @@ export async function PATCH(
const payload = await request.json().catch(() => ({})); const payload = await request.json().catch(() => ({}));
const patch: { const patch: {
title?: string; title?: string;
data?: unknown; data?: CanvasProjectData;
} = {}; } = {};
if (typeof payload.title === "string") { if (typeof payload.title === "string") {
patch.title = payload.title; patch.title = payload.title;
} }
if (isCanvasProjectData(payload.data)) { if ("data" in payload) {
patch.data = payload.data; const data = normalizeCanvasProjectData(payload.data);
if (!data) {
return NextResponse.json(
{ error: "画布 JSON 格式无效" },
{ status: 400 },
);
}
patch.data = data;
} }
const project = updateCanvasProject(id, { const project = updateCanvasProject(id, {
title: patch.title, title: patch.title,
data: isCanvasProjectData(patch.data) ? patch.data : undefined, data: patch.data,
}); });
if (!project) { 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 },
);
}
}
+5 -4
View File
@@ -3,7 +3,10 @@ import {
createCanvasProject, createCanvasProject,
listCanvasProjects, listCanvasProjects,
} from "@/lib/server/repositories/canvas-project-repository"; } 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"; export const runtime = "nodejs";
@@ -17,9 +20,7 @@ export async function POST(request: Request) {
typeof payload.title === "string" && payload.title.trim() typeof payload.title === "string" && payload.title.trim()
? payload.title ? payload.title
: "未命名画布"; : "未命名画布";
const data = isCanvasProjectData(payload.data) const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData;
? payload.data
: defaultCanvasProjectData;
const project = createCanvasProject({ title, data }); const project = createCanvasProject({ title, data });
return NextResponse.json({ item: project }, { status: 201 }); 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 [job, setJob] = useState<ImageJobPayload | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showLibrary, setShowLibrary] = useState(false); 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 [selectionMode, setSelectionMode] = useState(false);
const [historyAvailability, setHistoryAvailability] = useState({ const [historyAvailability, setHistoryAvailability] = useState({
canUndo: false, canUndo: false,
@@ -471,6 +474,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
...current.data.connections, ...current.data.connections,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: inferConnectionType(current.data.nodes, fromNodeId, toNodeId),
fromNodeId, fromNodeId,
toNodeId, toNodeId,
}, },
@@ -494,6 +498,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
const node = createCanvasNode(type, position); const node = createCanvasNode(type, position);
const connection: CanvasConnection = { const connection: CanvasConnection = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: inferConnectionType(nodes, activeConnection.fromNodeId, node.id),
fromNodeId: activeConnection.fromNodeId, fromNodeId: activeConnection.fromNodeId,
toNodeId: node.id, toNodeId: node.id,
}; };
@@ -543,6 +548,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
...current.data.connections, ...current.data.connections,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: inferConnectionType(
current.data.nodes,
materialConnection.fromNodeId,
nextNode.id,
),
fromNodeId: materialConnection.fromNodeId, fromNodeId: materialConnection.fromNodeId,
toNodeId: nextNode.id, toNodeId: nextNode.id,
}, },
@@ -585,6 +595,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
...getImageNodeDimensions(naturalSize.width, naturalSize.height), ...getImageNodeDimensions(naturalSize.width, naturalSize.height),
metadata: { metadata: {
...baseNode.metadata, ...baseNode.metadata,
filePath: payload.item.filePath,
naturalWidth: naturalSize.width, naturalWidth: naturalSize.width,
naturalHeight: naturalSize.height, naturalHeight: naturalSize.height,
}, },
@@ -599,6 +610,45 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
uploadInputRef.current?.click(); 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) { async function replaceNodeImage(nodeId: string, file: File) {
setError(null); setError(null);
try { try {
@@ -856,6 +906,8 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })} onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })}
onToggleLibrary={() => setShowLibrary((current) => !current)} onToggleLibrary={() => setShowLibrary((current) => !current)}
onToggleSelectionMode={() => setSelectionMode((current) => !current)} onToggleSelectionMode={() => setSelectionMode((current) => !current)}
onCopyJson={() => void copyCanvasJson()}
onImportJson={() => setShowImportDialog(true)}
onUndo={undoCanvasChange} onUndo={undoCanvasChange}
onUploadMaterial={openUploadMaterial} onUploadMaterial={openUploadMaterial}
onZoomIn={() => updateViewport({ ...viewport, k: clampScale(viewport.k * 1.18) })} 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)} onReplaceNodeImage={(nodeId, file) => void replaceNodeImage(nodeId, file)}
onUploadImage={(file) => void uploadImage(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> </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) { async function imageUrlToFile(url: string, filename: string) {
const response = await fetch(url, { cache: "no-store" }); const response = await fetch(url, { cache: "no-store" });
if (!response.ok) { if (!response.ok) {
@@ -1152,6 +1276,19 @@ function getRelatedNodeIds(
return related; 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) { function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms)); return new Promise((resolve) => window.setTimeout(resolve, ms));
} }
+17
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
ClipboardPaste,
Copy,
Eraser, Eraser,
FolderOpen, FolderOpen,
Grid2X2, Grid2X2,
@@ -39,6 +41,8 @@ type CanvasToolbarProps = {
onRedo: () => void; onRedo: () => void;
onGenerate: () => void; onGenerate: () => void;
onUploadMaterial: () => void; onUploadMaterial: () => void;
onCopyJson: () => void;
onImportJson: () => void;
onDeleteSelection: () => void; onDeleteSelection: () => void;
onClearCanvas: () => void; onClearCanvas: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void; onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
@@ -66,6 +70,8 @@ export function CanvasToolbar({
onRedo, onRedo,
onGenerate, onGenerate,
onUploadMaterial, onUploadMaterial,
onCopyJson,
onImportJson,
onDeleteSelection, onDeleteSelection,
onClearCanvas, onClearCanvas,
onBackgroundModeChange, onBackgroundModeChange,
@@ -226,6 +232,17 @@ export function CanvasToolbar({
label="素材库" label="素材库"
onClick={onToggleLibrary} onClick={onToggleLibrary}
/> />
<DockDivider />
<DockButton
icon={<Copy className="size-4" />}
label="复制JSON"
onClick={onCopyJson}
/>
<DockButton
icon={<ClipboardPaste className="size-4" />}
label="导入JSON"
onClick={onImportJson}
/>
{canDeleteSelection ? ( {canDeleteSelection ? (
<> <>
<DockDivider /> <DockDivider />
+235 -5
View File
@@ -13,6 +13,8 @@ export type CanvasPosition = {
export type CanvasNodeType = "prompt" | "config" | "image"; export type CanvasNodeType = "prompt" | "config" | "image";
export const CANVAS_PROJECT_DATA_VERSION = 1;
export type CanvasPromptNodeMetadata = { export type CanvasPromptNodeMetadata = {
prompt: string; prompt: string;
}; };
@@ -58,11 +60,13 @@ export type CanvasNode = {
export type CanvasConnection = { export type CanvasConnection = {
id: string; id: string;
type?: "reference" | "prompt" | "config" | "generated";
fromNodeId: string; fromNodeId: string;
toNodeId: string; toNodeId: string;
}; };
export type CanvasProjectData = { export type CanvasProjectData = {
version: typeof CANVAS_PROJECT_DATA_VERSION;
viewport: CanvasViewport; viewport: CanvasViewport;
backgroundMode: CanvasBackgroundMode; backgroundMode: CanvasBackgroundMode;
nodes: CanvasNode[]; nodes: CanvasNode[];
@@ -86,6 +90,7 @@ export type CanvasProjectListItem = {
}; };
export const defaultCanvasProjectData: CanvasProjectData = { export const defaultCanvasProjectData: CanvasProjectData = {
version: CANVAS_PROJECT_DATA_VERSION,
viewport: { x: 0, y: 0, k: 1 }, viewport: { x: 0, y: 0, k: 1 },
backgroundMode: "lines", backgroundMode: "lines",
nodes: [], nodes: [],
@@ -93,15 +98,240 @@ export const defaultCanvasProjectData: CanvasProjectData = {
}; };
export function isCanvasProjectData(value: unknown): value is 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") { if (!value || typeof value !== "object") {
return false; return null;
} }
const candidate = value as Record<string, unknown>; 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 ( return (
typeof candidate.viewport === "object" && value === "idle" ||
candidate.viewport !== null && value === "loading" ||
Array.isArray(candidate.nodes) && value === "success" ||
Array.isArray(candidate.connections) 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 CanvasProjectData,
type CanvasProjectListItem, type CanvasProjectListItem,
type CanvasProjectRecord, type CanvasProjectRecord,
isCanvasProjectData, normalizeCanvasProjectData,
} from "@/lib/canvas/types"; } from "@/lib/canvas/types";
import { getDb } from "@/lib/server/db"; import { getDb } from "@/lib/server/db";
@@ -65,7 +65,7 @@ export function createCanvasProject(input?: {
const now = new Date().toISOString(); const now = new Date().toISOString();
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const title = input?.title?.trim() || "未命名画布"; const title = input?.title?.trim() || "未命名画布";
const data = input?.data ?? defaultCanvasProjectData; const data = normalizeCanvasProjectData(input?.data) ?? defaultCanvasProjectData;
getDb() getDb()
.prepare( .prepare(
@@ -88,7 +88,7 @@ export function updateCanvasProject(
if (!current) return null; if (!current) return null;
const title = patch.title?.trim() || current.title; 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(); const updatedAt = new Date().toISOString();
getDb() getDb()
@@ -112,7 +112,7 @@ export function deleteCanvasProject(id: string) {
function parseCanvasProjectData(raw: string): CanvasProjectData { function parseCanvasProjectData(raw: string): CanvasProjectData {
try { try {
const parsed = JSON.parse(raw) as unknown; const parsed = JSON.parse(raw) as unknown;
return isCanvasProjectData(parsed) ? parsed : defaultCanvasProjectData; return normalizeCanvasProjectData(parsed) ?? defaultCanvasProjectData;
} catch { } catch {
return defaultCanvasProjectData; 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";
}
+26 -2
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { basename, join } from "node:path";
const imageDir = join( const imageDir = join(
/* turbopackIgnore: true */ process.cwd(), /* turbopackIgnore: true */ process.cwd(),
@@ -49,8 +49,32 @@ export function persistUploadedImageFile(
return { fileName, filePath, imageUrl }; 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) { function getExtensionFromFilename(filename: string) {
const parts = filename.split("."); const parts = filename.split(".");
const extension = parts.at(-1)?.toLowerCase(); 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;
} }