mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 08:54:23 +08:00
refactor: remove "use client" directive from multiple files and update project structure to align with Vite and React Router
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export type CanvasAgentOp =
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType }
|
||||
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
| { type: "select_nodes"; ids: string[] }
|
||||
| { type: "run_generation"; nodeId: string; mode?: "text" | "image" | "video" | "audio"; prompt?: string };
|
||||
|
||||
export type CanvasAgentSnapshot = {
|
||||
projectId: string;
|
||||
title: string;
|
||||
nodes: CanvasNodeData[];
|
||||
connections: CanvasConnection[];
|
||||
selectedNodeIds: string[];
|
||||
viewport: ViewportTransform;
|
||||
};
|
||||
|
||||
export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
|
||||
const counts = (Array.isArray(ops) ? ops : []).reduce<Record<string, number>>((acc, op) => {
|
||||
if (!op?.type) return acc;
|
||||
acc[op.type] = (acc[op.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.entries(counts)
|
||||
.map(([type, count]) => `${opLabel(type)} ${count}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasAgentOp[]) {
|
||||
let nodes = snapshot.nodes;
|
||||
let connections = snapshot.connections;
|
||||
let selectedNodeIds = snapshot.selectedNodeIds;
|
||||
let viewport = snapshot.viewport;
|
||||
|
||||
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
||||
if (!op?.type) return;
|
||||
if (op.type === "add_node") {
|
||||
const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text;
|
||||
const spec = getNodeSpec(nodeType);
|
||||
const node: CanvasNodeData = {
|
||||
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
||||
type: nodeType,
|
||||
title: op.title || spec.title,
|
||||
position: op.position || { x: op.x ?? index * 36, y: op.y ?? index * 36 },
|
||||
width: op.width || spec.width,
|
||||
height: op.height || spec.height,
|
||||
metadata: { ...spec.metadata, ...op.metadata },
|
||||
};
|
||||
nodes = [...nodes, node];
|
||||
selectedNodeIds = [node.id];
|
||||
}
|
||||
if (op.type === "update_node") {
|
||||
if (!op.id) return;
|
||||
nodes = nodes.map((node) => (node.id === op.id ? { ...node, ...op.patch, metadata: { ...node.metadata, ...op.patch?.metadata, ...op.metadata } } : node));
|
||||
}
|
||||
if (op.type === "delete_node") {
|
||||
const ids = new Set(op.ids || (op.id ? [op.id] : op.nodeType ? nodes.filter((node) => node.type === op.nodeType).map((node) => node.id) : []));
|
||||
nodes = nodes.filter((node) => !ids.has(node.id));
|
||||
connections = connections.filter((conn) => !ids.has(conn.fromNodeId) && !ids.has(conn.toNodeId));
|
||||
selectedNodeIds = selectedNodeIds.filter((id) => !ids.has(id));
|
||||
}
|
||||
if (op.type === "delete_connections") {
|
||||
const ids = new Set(op.ids || (op.id ? [op.id] : []));
|
||||
connections = op.all ? [] : connections.filter((conn) => !ids.has(conn.id));
|
||||
}
|
||||
if (op.type === "connect_nodes") {
|
||||
if (!op.fromNodeId || !op.toNodeId) return;
|
||||
const exists = connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId);
|
||||
const hasNodes = nodes.some((node) => node.id === op.fromNodeId) && nodes.some((node) => node.id === op.toNodeId);
|
||||
if (!exists && hasNodes) connections = [...connections, { id: op.id || nanoid(), fromNodeId: op.fromNodeId, toNodeId: op.toNodeId }];
|
||||
}
|
||||
if (op.type === "set_viewport" && op.viewport) viewport = op.viewport;
|
||||
if (op.type === "select_nodes") selectedNodeIds = (op.ids || []).filter((id) => nodes.some((node) => node.id === id));
|
||||
});
|
||||
|
||||
return { ...snapshot, nodes, connections, selectedNodeIds, viewport };
|
||||
}
|
||||
|
||||
function opLabel(type: string) {
|
||||
if (type === "add_node") return "新增节点";
|
||||
if (type === "update_node") return "更新节点";
|
||||
if (type === "delete_node") return "删除节点";
|
||||
if (type === "delete_connections") return "删除连线";
|
||||
if (type === "connect_nodes") return "连接";
|
||||
if (type === "set_viewport") return "调整视图";
|
||||
if (type === "select_nodes") return "选择节点";
|
||||
if (type === "run_generation") return "触发生成";
|
||||
return type;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { createZip } from "@/lib/zip";
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
|
||||
const zipFiles: { name: string; data: BlobPart }[] = [];
|
||||
const exportedProjects = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const files: CanvasExportAsset[] = [];
|
||||
await Promise.all(
|
||||
collectStorageKeys(project).map(async (storageKey) => {
|
||||
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
|
||||
if (!blob) return;
|
||||
const path = `projects/${project.id}/files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`;
|
||||
files.push({ storageKey, path, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
|
||||
zipFiles.push({ name: path, data: blob });
|
||||
}),
|
||||
);
|
||||
return { project, files };
|
||||
}),
|
||||
);
|
||||
|
||||
const data: CanvasExportFile = { app: "infinite-canvas", version: 3, exportedAt: new Date().toISOString(), projects: exportedProjects };
|
||||
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||
saveAs(zip, `${safeFileName(fileName)}.zip`);
|
||||
}
|
||||
|
||||
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
|
||||
if (!value || typeof value !== "object") return [...keys];
|
||||
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
|
||||
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
|
||||
function fileExtension(mimeType: string, storageKey: string) {
|
||||
if (mimeType.includes("png")) return "png";
|
||||
if (mimeType.includes("jpeg")) return "jpg";
|
||||
if (mimeType.includes("webp")) return "webp";
|
||||
if (mimeType.includes("gif")) return "gif";
|
||||
if (mimeType.includes("mp4")) return "mp4";
|
||||
if (mimeType.includes("webm")) return "webm";
|
||||
return storageKey.startsWith("image:") ? "png" : "bin";
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
export type ImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ImageAngleTransform = {
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
};
|
||||
|
||||
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
|
||||
|
||||
export const MAX_UPSCALE_LONG_EDGE = 4096;
|
||||
|
||||
export type ImageUpscaleParams = {
|
||||
targetLongEdge: number;
|
||||
algorithm: ImageUpscaleAlgorithm;
|
||||
};
|
||||
|
||||
export type ImageSplitParams = {
|
||||
rows: number;
|
||||
columns: number;
|
||||
};
|
||||
|
||||
export type ImageSplitPiece = {
|
||||
row: number;
|
||||
column: number;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||||
const image = await loadImage(dataUrl);
|
||||
if (crop) {
|
||||
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
|
||||
}
|
||||
const size = Math.min(image.width, image.height);
|
||||
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
||||
const sy = Math.max(0, Math.floor((image.height - size) / 2));
|
||||
return drawCrop(image, sx, sy, size, size);
|
||||
}
|
||||
|
||||
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
|
||||
const image = await loadImage(dataUrl);
|
||||
const rows = Math.max(1, Math.floor(params.rows));
|
||||
const columns = Math.max(1, Math.floor(params.columns));
|
||||
const pieces: ImageSplitPiece[] = [];
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
const sy = Math.floor((row * image.height) / rows);
|
||||
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const sx = Math.floor((column * image.width) / columns);
|
||||
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
|
||||
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
|
||||
}
|
||||
}
|
||||
|
||||
return pieces;
|
||||
}
|
||||
|
||||
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
|
||||
const image = await loadImage(dataUrl);
|
||||
const canvas = document.createElement("canvas");
|
||||
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
|
||||
canvas.width = image.width + padding * 2;
|
||||
canvas.height = image.height + padding * 2;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return dataUrl;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const horizontal = params.horizontalAngle / 60;
|
||||
const pitch = params.pitchAngle / 45;
|
||||
const distanceScale = 1.12 - params.cameraDistance * 0.035;
|
||||
const wideScale = params.wideAngle ? 0.88 : 1;
|
||||
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
|
||||
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
|
||||
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const skewX = horizontal * image.width * 0.18;
|
||||
const skewY = pitch * image.height * 0.12;
|
||||
const x = cx - width / 2 + horizontal * padding * 0.5;
|
||||
const y = cy - height / 2 + pitch * padding * 0.45;
|
||||
|
||||
context.save();
|
||||
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
|
||||
context.drawImage(image, x + skewX, y + skewY, width, height);
|
||||
context.restore();
|
||||
|
||||
if (params.wideAngle) {
|
||||
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
|
||||
const image = await loadImage(dataUrl);
|
||||
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
|
||||
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
|
||||
}
|
||||
|
||||
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
|
||||
const longEdge = Math.max(1, width, height);
|
||||
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
|
||||
const scale = target / longEdge;
|
||||
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
||||
}
|
||||
|
||||
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, sw);
|
||||
canvas.height = Math.max(1, sh);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return image.src;
|
||||
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
|
||||
let source: CanvasImageSource = image;
|
||||
let sourceWidth = image.width;
|
||||
let sourceHeight = image.height;
|
||||
|
||||
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
|
||||
const nextWidth = sourceWidth * 2;
|
||||
const nextHeight = sourceHeight * 2;
|
||||
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
|
||||
source = next;
|
||||
sourceWidth = nextWidth;
|
||||
sourceHeight = nextHeight;
|
||||
}
|
||||
|
||||
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
|
||||
}
|
||||
|
||||
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||||
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
|
||||
}
|
||||
|
||||
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return canvas;
|
||||
context.imageSmoothingEnabled = algorithm !== "nearest";
|
||||
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
|
||||
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function loadImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640) {
|
||||
const w = Math.max(1, width);
|
||||
const h = Math.max(1, height);
|
||||
const scale = Math.min(1, maxWidth / w, maxHeight / h);
|
||||
return { width: w * scale, height: h * scale };
|
||||
}
|
||||
|
||||
export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) {
|
||||
const match = size?.match(/^(\d+)(?:x|:)(\d+)/);
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
const ratio = width / Math.max(1, height);
|
||||
if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight };
|
||||
return ratio >= baseWidth / baseHeight ? { width: baseWidth, height: baseWidth / ratio } : { width: baseHeight * ratio, height: baseHeight };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
export type CanvasResourceReference = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
kind: CanvasResourceKind;
|
||||
label: string;
|
||||
title: string;
|
||||
previewUrl?: string;
|
||||
text?: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
|
||||
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
|
||||
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
|
||||
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
|
||||
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
|
||||
}
|
||||
|
||||
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
|
||||
}
|
||||
|
||||
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
|
||||
if (configInputs.length) return configInputs;
|
||||
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
|
||||
if (ownInputs.length) return ownInputs;
|
||||
const node = nodes.find((item) => item.id === nodeId);
|
||||
return node && isResourceNode(node) ? [node] : [];
|
||||
}
|
||||
|
||||
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
|
||||
if (configInputs.length) return configInputs;
|
||||
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
|
||||
if (ownInputs.length) return ownInputs;
|
||||
return [];
|
||||
}
|
||||
|
||||
function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
return connections
|
||||
.filter((connection) => connection.toNodeId === nodeId)
|
||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && isResourceNode(node)));
|
||||
}
|
||||
|
||||
function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const configConnection = connections.find((connection) => connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config);
|
||||
if (!configConnection) return [];
|
||||
return getContextResourceNodes(configConnection.toNodeId, nodes, connections).filter((node) => node.id !== nodeId);
|
||||
}
|
||||
|
||||
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
||||
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0 };
|
||||
return nodes.flatMap((node): CanvasResourceReference[] => {
|
||||
const kind = resourceKind(node);
|
||||
if (!kind) return [];
|
||||
const index = counts[kind]++;
|
||||
const label = labelForKind(kind, index);
|
||||
return [
|
||||
{
|
||||
id: node.id,
|
||||
nodeId: node.id,
|
||||
kind,
|
||||
label,
|
||||
title: node.title || label,
|
||||
previewUrl: node.metadata?.content,
|
||||
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
|
||||
active,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function labelForKind(kind: CanvasResourceKind, index: number) {
|
||||
if (kind === "image") return imageReferenceLabel(index);
|
||||
if (kind === "video") return seedanceReferenceLabel("video", index);
|
||||
if (kind === "audio") return seedanceReferenceLabel("audio", index);
|
||||
return `文本${index + 1}`;
|
||||
}
|
||||
|
||||
function isResourceNode(node: CanvasNodeData) {
|
||||
return Boolean(resourceKind(node));
|
||||
}
|
||||
|
||||
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user