|
|
|
@@ -17,6 +17,7 @@ import { UserStatusActions } from "@/components/layout/user-status-actions";
|
|
|
|
|
import { useAssetStore } from "@/stores/use-asset-store";
|
|
|
|
|
import { useThemeStore } from "@/stores/use-theme-store";
|
|
|
|
|
import { cropDataUrl } from "../utils/canvas-image-data";
|
|
|
|
|
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
|
|
|
|
|
import { App, Button, Dropdown, Modal } from "antd";
|
|
|
|
|
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
|
|
|
|
|
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
|
|
|
|
@@ -67,7 +68,8 @@ type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
|
|
|
|
backgroundMode: CanvasBackgroundMode;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const UPLOADED_IMAGE_MAX_SIDE = 640;
|
|
|
|
|
const VIDEO_NODE_MAX_WIDTH = 420;
|
|
|
|
|
const VIDEO_NODE_MAX_HEIGHT = 420;
|
|
|
|
|
const NODE_STATUS_LOADING = "loading" as const;
|
|
|
|
|
const NODE_STATUS_SUCCESS = "success" as const;
|
|
|
|
|
const NODE_STATUS_ERROR = "error" as const;
|
|
|
|
@@ -1056,7 +1058,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
|
|
|
|
|
const createImageFileNode = useCallback(async (file: File, position: Position) => {
|
|
|
|
|
const image = await uploadImage(file);
|
|
|
|
|
const size = fitImageNodeSize(image.width, image.height);
|
|
|
|
|
const size = fitNodeSize(image.width, image.height);
|
|
|
|
|
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
const newNode: CanvasNodeData = {
|
|
|
|
|
id,
|
|
|
|
@@ -1076,7 +1078,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
|
|
|
|
|
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
|
|
|
|
|
const video = await uploadMediaFile(file, "video");
|
|
|
|
|
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
|
|
|
|
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
|
|
|
|
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
setNodes((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
@@ -1084,9 +1086,9 @@ function InfiniteCanvasPage() {
|
|
|
|
|
id,
|
|
|
|
|
type: CanvasNodeType.Video,
|
|
|
|
|
title: file.name,
|
|
|
|
|
position: { x: position.x - spec.width / 2, y: position.y - spec.height / 2 },
|
|
|
|
|
width: spec.width,
|
|
|
|
|
height: spec.height,
|
|
|
|
|
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
|
|
|
|
|
width: size.width,
|
|
|
|
|
height: size.height,
|
|
|
|
|
metadata: videoMetadata(video),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
@@ -1304,7 +1306,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => {
|
|
|
|
|
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(patch || {}) } } : node)));
|
|
|
|
|
setNodes((prev) => prev.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
|
|
|
|
@@ -1415,7 +1417,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
(items) => items[0],
|
|
|
|
|
);
|
|
|
|
|
const uploaded = await uploadImage(image.dataUrl);
|
|
|
|
|
const size = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
|
|
|
|
|
const size = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
|
|
|
|
|
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorDetails = error instanceof Error ? error.message : "生成失败";
|
|
|
|
@@ -1445,7 +1447,8 @@ function InfiniteCanvasPage() {
|
|
|
|
|
if (target?.nodeId) {
|
|
|
|
|
if (file.type.startsWith("video/")) {
|
|
|
|
|
const video = await uploadMediaFile(file, "video");
|
|
|
|
|
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, width: NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, height: NODE_DEFAULT_SIZE[CanvasNodeType.Video].height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node)));
|
|
|
|
|
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
|
|
|
|
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node)));
|
|
|
|
|
setSelectedNodeIds(new Set([target.nodeId]));
|
|
|
|
|
setSelectedConnectionId(null);
|
|
|
|
|
setDialogNodeId(target.nodeId);
|
|
|
|
@@ -1454,7 +1457,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const image = await uploadImage(file);
|
|
|
|
|
const size = fitImageNodeSize(image.width, image.height);
|
|
|
|
|
const size = fitNodeSize(image.width, image.height);
|
|
|
|
|
setNodes((prev) =>
|
|
|
|
|
prev.map((node) =>
|
|
|
|
|
node.id === target.nodeId
|
|
|
|
@@ -1672,7 +1675,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages).then((items) => items[0])
|
|
|
|
|
: await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt).then((items) => items[0]);
|
|
|
|
|
const uploaded = await uploadImage(image.dataUrl);
|
|
|
|
|
const imageSize = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
|
|
|
|
|
const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
|
|
|
|
|
setNodes((prev) => {
|
|
|
|
|
const root = prev.find((node) => node.id === rootId);
|
|
|
|
|
return prev.map((node) => {
|
|
|
|
@@ -1724,7 +1727,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "video") {
|
|
|
|
|
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
|
|
|
|
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
|
|
|
|
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
|
|
|
|
|
const videoId = isEmptyVideoNode ? nodeId : nanoid();
|
|
|
|
|
const parent = sourceNode?.position || { x: 0, y: 0 };
|
|
|
|
@@ -1741,7 +1744,8 @@ function InfiniteCanvasPage() {
|
|
|
|
|
setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]));
|
|
|
|
|
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
|
|
|
|
|
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages), "video");
|
|
|
|
|
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) } } : node)));
|
|
|
|
|
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
|
|
|
|
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) } } : node)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1859,14 +1863,15 @@ function InfiniteCanvasPage() {
|
|
|
|
|
}
|
|
|
|
|
if (node.type === CanvasNodeType.Video) {
|
|
|
|
|
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, prompt, retryReferenceImages || []), "video");
|
|
|
|
|
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
|
|
|
|
|
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
|
|
|
|
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
|
|
|
|
|
const uploadedImage = await uploadImage(image.dataUrl);
|
|
|
|
|
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
|
|
|
|
const imageSize = fitImageNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
|
|
|
|
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
|
|
|
|
const generationMetadata = savedImageMetadata?.generationType
|
|
|
|
|
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
|
|
|
|
|
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryReferenceImages || []);
|
|
|
|
@@ -1935,7 +1940,7 @@ function InfiniteCanvasPage() {
|
|
|
|
|
async (image: CanvasAssistantImage) => {
|
|
|
|
|
const storedImage = image.storageKey ? { url: image.dataUrl, storageKey: image.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(image.dataUrl);
|
|
|
|
|
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
|
|
|
|
|
const config = fitImageNodeSize(meta.width, meta.height);
|
|
|
|
|
const config = fitNodeSize(meta.width, meta.height);
|
|
|
|
|
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
|
|
|
|
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
const node: CanvasNodeData = {
|
|
|
|
@@ -1979,7 +1984,8 @@ function InfiniteCanvasPage() {
|
|
|
|
|
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
|
|
|
|
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
|
|
|
|
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
setNodes((prev) => [...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - spec.width / 2, y: center.y - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS } }]);
|
|
|
|
|
const nextSize = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
|
|
|
|
setNodes((prev) => [...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - nextSize.width / 2, y: center.y - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height } }]);
|
|
|
|
|
setSelectedNodeIds(new Set([id]));
|
|
|
|
|
} else {
|
|
|
|
|
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
|
|
|
|
@@ -2483,7 +2489,7 @@ function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
|
|
|
|
|
return { content: video.url, storageKey: video.storageKey, status: "success", bytes: video.bytes, mimeType: video.mimeType || "video/mp4" };
|
|
|
|
|
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
|
|
|
|
@@ -2549,21 +2555,17 @@ async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fitImageNodeSize(width: number, height: number, maxWidth = UPLOADED_IMAGE_MAX_SIDE, maxHeight = UPLOADED_IMAGE_MAX_SIDE) {
|
|
|
|
|
const safeWidth = Math.max(1, width);
|
|
|
|
|
const safeHeight = Math.max(1, height);
|
|
|
|
|
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
width: safeWidth * scale,
|
|
|
|
|
height: safeHeight * scale,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getGenerationCount(count: string) {
|
|
|
|
|
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
|
|
|
|
|
const next = { ...node, metadata: { ...node.metadata, ...(patch || {}) } };
|
|
|
|
|
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
|
|
|
|
const size = typeof patch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(patch.size, spec.width, spec.height) : null;
|
|
|
|
|
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
|
|
|
|
|
const first = nodes.find((node) => node.id === firstNodeId);
|
|
|
|
|
const second = nodes.find((node) => node.id === secondNodeId);
|
|
|
|
|