mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
refactor(canvas-project): extract pure helpers into @/lib/canvas modules
Move the ~30 module-scope pure functions out of the 3.5k-line project.tsx into three focused lib modules, with identical names and signatures. No behavior change; project.tsx imports them back. - canvas-node-factory.ts: createCanvasNode, *Metadata builders, referenceUrl, applyNodeConfigPatch. - canvas-node-geometry.ts: nodeBounds, group drop/snap, connection anchors, normalizeConnection, hidden-batch checks. - canvas-generation-helpers.ts: generation config/inputs, reference hydration, retry-source lookup, angle prompt builders, misc pure helpers. project.tsx drops from 3536 to ~3230 lines. Also removes imports that were only used by the moved helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
import { defaultConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||||
|
import { resolveMediaUrl } from "@/services/file-storage";
|
||||||
|
import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory";
|
||||||
|
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
|
||||||
|
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||||
|
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
|
||||||
|
import type { ReferenceImage } from "@/types/image";
|
||||||
|
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||||
|
|
||||||
|
export function imageExtension(dataUrl: string) {
|
||||||
|
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioExtension(mimeType?: string) {
|
||||||
|
if (mimeType?.includes("wav")) return "wav";
|
||||||
|
if (mimeType?.includes("opus")) return "opus";
|
||||||
|
if (mimeType?.includes("aac")) return "aac";
|
||||||
|
if (mimeType?.includes("flac")) return "flac";
|
||||||
|
if (mimeType?.includes("pcm")) return "pcm";
|
||||||
|
return "mp3";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
|
||||||
|
return [
|
||||||
|
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
||||||
|
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
|
||||||
|
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
|
||||||
|
if (metadata.generationType !== "edit") return [];
|
||||||
|
if (!metadata.references?.length) return null;
|
||||||
|
const references = await Promise.all(
|
||||||
|
metadata.references.map(async (url, index) => {
|
||||||
|
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
|
||||||
|
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return references.every(Boolean) ? (references as ReferenceImage[]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
|
||||||
|
return Promise.all(
|
||||||
|
nodes.map(async (node) => {
|
||||||
|
const content = node.metadata?.content;
|
||||||
|
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
|
||||||
|
if (node.type !== CanvasNodeType.Image || !content) return node;
|
||||||
|
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
|
||||||
|
if (!content.startsWith("data:image/")) return node;
|
||||||
|
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
|
||||||
|
const hydrateItem = async <T extends { dataUrl?: string; storageKey?: string }>(item: T) => {
|
||||||
|
if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) };
|
||||||
|
if (item.dataUrl?.startsWith("data:image/")) {
|
||||||
|
const image = await uploadImage(item.dataUrl);
|
||||||
|
return { ...item, dataUrl: image.url, storageKey: image.storageKey };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
return Promise.all(
|
||||||
|
sessions.map(async (session) => ({
|
||||||
|
...session,
|
||||||
|
messages: await Promise.all(
|
||||||
|
session.messages.map(async (message) => ({
|
||||||
|
...message,
|
||||||
|
references: await Promise.all((message.references || []).map(hydrateItem)),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenerationCount(count: string) {
|
||||||
|
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInputSummary(inputs: NodeGenerationInput[]) {
|
||||||
|
return {
|
||||||
|
textCount: inputs.filter((input) => input.type === "text").length,
|
||||||
|
imageCount: inputs.filter((input) => input.type === "image").length,
|
||||||
|
videoCount: inputs.filter((input) => input.type === "video").length,
|
||||||
|
audioCount: inputs.filter((input) => input.type === "audio").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
|
||||||
|
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
|
||||||
|
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||||
|
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||||
|
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
|
||||||
|
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
|
||||||
|
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
|
||||||
|
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||||
|
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
|
||||||
|
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
|
||||||
|
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
|
||||||
|
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
|
||||||
|
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
|
||||||
|
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
|
||||||
|
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGenerationCanceled(error: unknown) {
|
||||||
|
return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||||
|
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (queue.length) {
|
||||||
|
const id = queue.shift()!;
|
||||||
|
if (visited.has(id)) continue;
|
||||||
|
visited.add(id);
|
||||||
|
const node = nodes.find((item) => item.id === id);
|
||||||
|
if (node?.type === CanvasNodeType.Config) return node;
|
||||||
|
connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceNodeReferenceImages(node: CanvasNodeData | null) {
|
||||||
|
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: node.id,
|
||||||
|
name: `${node.title || node.id}.png`,
|
||||||
|
type: node.metadata.mimeType || "image/png",
|
||||||
|
dataUrl: node.metadata.content,
|
||||||
|
storageKey: node.metadata.storageKey,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAudioFile(file: File) {
|
||||||
|
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAngleLabel(params: CanvasImageAngleParams) {
|
||||||
|
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`;
|
||||||
|
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`;
|
||||||
|
return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAnglePrompt(params: CanvasImageAngleParams) {
|
||||||
|
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { getNodeSpec, NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||||
|
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||||
|
import type { AiConfig } from "@/stores/use-config-store";
|
||||||
|
import type { UploadedImage } from "@/services/image-storage";
|
||||||
|
import type { UploadedFile } from "@/services/file-storage";
|
||||||
|
import type { ReferenceImage } from "@/types/image";
|
||||||
|
import { CanvasNodeType, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type Position } from "@/types/canvas";
|
||||||
|
|
||||||
|
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||||
|
const spec = getNodeSpec(type);
|
||||||
|
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title: spec.title,
|
||||||
|
position: {
|
||||||
|
x: position.x - spec.width / 2,
|
||||||
|
y: position.y - spec.height / 2,
|
||||||
|
},
|
||||||
|
width: spec.width,
|
||||||
|
height: spec.height,
|
||||||
|
metadata: { ...spec.metadata, ...metadata },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
|
||||||
|
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
|
||||||
|
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
|
||||||
|
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function referenceUrl(image: ReferenceImage) {
|
||||||
|
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
|
||||||
|
return {
|
||||||
|
generationType: type,
|
||||||
|
model: config.model,
|
||||||
|
size: config.size,
|
||||||
|
quality: config.quality,
|
||||||
|
...(config.background ? { background: config.background } : {}),
|
||||||
|
count,
|
||||||
|
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
|
||||||
|
return {
|
||||||
|
model: config.model,
|
||||||
|
audioVoice: config.audioVoice,
|
||||||
|
audioFormat: config.audioFormat,
|
||||||
|
audioSpeed: config.audioSpeed,
|
||||||
|
audioInstructions: config.audioInstructions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
|
||||||
|
const safePatch = patch || {};
|
||||||
|
const next = { ...node, metadata: { ...node.metadata, ...safePatch } };
|
||||||
|
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||||
|
const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle } from "@/types/canvas";
|
||||||
|
|
||||||
|
export function nodeBounds(nodes: CanvasNodeData[]) {
|
||||||
|
return nodes.reduce(
|
||||||
|
(acc, node) => ({
|
||||||
|
left: Math.min(acc.left, node.position.x),
|
||||||
|
top: Math.min(acc.top, node.position.y),
|
||||||
|
right: Math.max(acc.right, node.position.x + node.width),
|
||||||
|
bottom: Math.max(acc.bottom, node.position.y + node.height),
|
||||||
|
}),
|
||||||
|
{ left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGroupDropTarget(movedIds: Set<string>, nodes: CanvasNodeData[]) {
|
||||||
|
if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null;
|
||||||
|
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
|
||||||
|
if (!movingNodes.length) return null;
|
||||||
|
return (
|
||||||
|
[...nodes].reverse().find((group) => {
|
||||||
|
if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false;
|
||||||
|
return movingNodes.some((node) => {
|
||||||
|
const centerX = node.position.x + node.width / 2;
|
||||||
|
const centerY = node.position.y + node.height / 2;
|
||||||
|
return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height;
|
||||||
|
});
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapNodesIntoGroup(movedIds: Set<string>, nodes: CanvasNodeData[], group: CanvasNodeData) {
|
||||||
|
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
|
||||||
|
if (!movingNodes.length) return nodes;
|
||||||
|
const pad = 24;
|
||||||
|
const bounds = nodeBounds(movingNodes);
|
||||||
|
const left = group.position.x + pad;
|
||||||
|
const top = group.position.y + pad;
|
||||||
|
const right = group.position.x + group.width - pad;
|
||||||
|
const bottom = group.position.y + group.height - pad;
|
||||||
|
const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0;
|
||||||
|
const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0;
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
|
||||||
|
return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) {
|
||||||
|
const centerX = node.position.x + node.width / 2;
|
||||||
|
const centerY = node.position.y + node.height / 2;
|
||||||
|
return (
|
||||||
|
[...nodes]
|
||||||
|
.reverse()
|
||||||
|
.find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id ||
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
||||||
|
return {
|
||||||
|
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||||
|
y: node.position.y + node.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
if (!first || !second || first.id === second.id) return null;
|
||||||
|
if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null;
|
||||||
|
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
|
||||||
|
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
||||||
|
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
|
||||||
|
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
||||||
|
return { fromNodeId: first.id, toNodeId: second.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
|
||||||
|
const rootId = node.metadata?.batchRootId;
|
||||||
|
if (!rootId) return false;
|
||||||
|
const root = nodes.find((item) => item.id === rootId);
|
||||||
|
if (root && collapsingBatchIds?.has(rootId)) return false;
|
||||||
|
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) {
|
||||||
|
const rootId = node.metadata?.batchRootId;
|
||||||
|
if (!rootId) return false;
|
||||||
|
const root = nodes.find((item) => item.id === rootId);
|
||||||
|
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
||||||
|
}
|
||||||
+268
-388
@@ -9,8 +9,8 @@ import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audi
|
|||||||
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||||
import { DOCS_URL } from "@/constant/env";
|
import { DOCS_URL } from "@/constant/env";
|
||||||
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||||
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
|
import { uploadImage } from "@/services/image-storage";
|
||||||
import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
import { uploadMediaFile } from "@/services/file-storage";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||||
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||||
@@ -44,6 +44,26 @@ import { useAgentStore } from "@/stores/use-agent-store";
|
|||||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||||
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||||
|
import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory";
|
||||||
|
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
|
||||||
|
import {
|
||||||
|
audioExtension,
|
||||||
|
buildAngleLabel,
|
||||||
|
buildAnglePrompt,
|
||||||
|
buildGenerationConfig,
|
||||||
|
findRetrySourceNode,
|
||||||
|
generationReferenceUrls,
|
||||||
|
getGenerationCount,
|
||||||
|
getInputSummary,
|
||||||
|
hydrateAssistantImages,
|
||||||
|
hydrateCanvasImages,
|
||||||
|
imageExtension,
|
||||||
|
isAudioFile,
|
||||||
|
isGenerationCanceled,
|
||||||
|
resetInterruptedGeneration,
|
||||||
|
resolveMetadataReferences,
|
||||||
|
sourceNodeReferenceImages,
|
||||||
|
} from "@/lib/canvas/canvas-generation-helpers";
|
||||||
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||||
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||||
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
||||||
@@ -55,7 +75,6 @@ import {
|
|||||||
type CanvasAssistantImage,
|
type CanvasAssistantImage,
|
||||||
type CanvasAssistantSession,
|
type CanvasAssistantSession,
|
||||||
type CanvasConnection,
|
type CanvasConnection,
|
||||||
type CanvasImageGenerationType,
|
|
||||||
type CanvasNodeData,
|
type CanvasNodeData,
|
||||||
type CanvasNodeMetadata,
|
type CanvasNodeMetadata,
|
||||||
type CanvasNodeTypeId,
|
type CanvasNodeTypeId,
|
||||||
@@ -117,24 +136,6 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
|
|||||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||||
|
|
||||||
function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
|
||||||
const spec = getNodeSpec(type);
|
|
||||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
title: spec.title,
|
|
||||||
position: {
|
|
||||||
x: position.x - spec.width / 2,
|
|
||||||
y: position.y - spec.height / 2,
|
|
||||||
},
|
|
||||||
width: spec.width,
|
|
||||||
height: spec.height,
|
|
||||||
metadata: { ...spec.metadata, ...metadata },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CanvasPage() {
|
export default function CanvasPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -182,7 +183,15 @@ function CanvasRefreshShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: PendingConnectionCreate; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; onClose: () => void }) {
|
function ConnectionCreateMenu({
|
||||||
|
pending,
|
||||||
|
onCreate,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
pending: PendingConnectionCreate;
|
||||||
|
onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -213,13 +222,24 @@ function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: Pending
|
|||||||
|
|
||||||
function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
|
function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition" style={{ color: theme.node.text }} onClick={onClick} onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)} onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition"
|
||||||
|
style={{ color: theme.node.text }}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)}
|
||||||
|
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
|
||||||
|
>
|
||||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
|
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
|
||||||
{description ? <span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>{description}</span> : null}
|
{description ? (
|
||||||
|
<span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -247,7 +267,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
|||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between px-1">
|
<div className="mb-2 flex items-center justify-between px-1">
|
||||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>选择节点</span>
|
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||||
|
选择节点
|
||||||
|
</span>
|
||||||
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
|
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -416,13 +438,7 @@ function InfiniteCanvasPage() {
|
|||||||
});
|
});
|
||||||
setRunningNodeId((current) => (current === runningId ? null : current));
|
setRunningNodeId((current) => (current === runningId ? null : current));
|
||||||
if (!affectedNodeIds.size) return;
|
if (!affectedNodeIds.size) return;
|
||||||
setNodes((prev) =>
|
setNodes((prev) => prev.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
|
||||||
prev.map((node) =>
|
|
||||||
affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING
|
|
||||||
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } }
|
|
||||||
: node,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const confirmStopGeneration = useCallback(
|
const confirmStopGeneration = useCallback(
|
||||||
@@ -486,7 +502,15 @@ function InfiniteCanvasPage() {
|
|||||||
if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return;
|
if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return;
|
||||||
const next = createHistoryEntry();
|
const next = createHistoryEntry();
|
||||||
const previous = lastHistoryRef.current;
|
const previous = lastHistoryRef.current;
|
||||||
if (previous?.nodes === next.nodes && previous.connections === next.connections && previous.chatSessions === next.chatSessions && previous.activeChatId === next.activeChatId && previous.backgroundMode === next.backgroundMode && previous.showImageInfo === next.showImageInfo) return;
|
if (
|
||||||
|
previous?.nodes === next.nodes &&
|
||||||
|
previous.connections === next.connections &&
|
||||||
|
previous.chatSessions === next.chatSessions &&
|
||||||
|
previous.activeChatId === next.activeChatId &&
|
||||||
|
previous.backgroundMode === next.backgroundMode &&
|
||||||
|
previous.showImageInfo === next.showImageInfo
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current);
|
if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current);
|
||||||
historyCommitTimerRef.current = setTimeout(() => {
|
historyCommitTimerRef.current = setTimeout(() => {
|
||||||
@@ -588,10 +612,13 @@ function InfiniteCanvasPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const keepNodeToolbar = useCallback((nodeId: string) => {
|
const keepNodeToolbar = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return;
|
if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return;
|
||||||
setToolbarNodeId(nodeId);
|
setToolbarNodeId(nodeId);
|
||||||
}, [nodeImageSettingsOpen]);
|
},
|
||||||
|
[nodeImageSettingsOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const hideNodeToolbar = useCallback(() => {}, []);
|
const hideNodeToolbar = useCallback(() => {}, []);
|
||||||
|
|
||||||
@@ -771,7 +798,10 @@ function InfiniteCanvasPage() {
|
|||||||
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
|
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
|
||||||
const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
|
const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
|
||||||
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
|
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
|
||||||
const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation"));
|
const next = applyCanvasAgentOps(
|
||||||
|
before,
|
||||||
|
safeOps.filter((op) => op.type !== "run_generation"),
|
||||||
|
);
|
||||||
nodesRef.current = next.nodes;
|
nodesRef.current = next.nodes;
|
||||||
connectionsRef.current = next.connections;
|
connectionsRef.current = next.connections;
|
||||||
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
|
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
|
||||||
@@ -787,7 +817,7 @@ function InfiniteCanvasPage() {
|
|||||||
queueMicrotask(() =>
|
queueMicrotask(() =>
|
||||||
generationOps.forEach((op) => {
|
generationOps.forEach((op) => {
|
||||||
const target = nodesRef.current.find((node) => node.id === op.nodeId);
|
const target = nodesRef.current.find((node) => node.id === op.nodeId);
|
||||||
const prompt = op.prompt?.trim() ? op.prompt : target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "";
|
const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
|
||||||
void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt);
|
void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -820,8 +850,7 @@ function InfiniteCanvasPage() {
|
|||||||
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
||||||
const pluginAi = useMemo<CanvasPluginAi>(() => {
|
const pluginAi = useMemo<CanvasPluginAi>(() => {
|
||||||
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
|
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
|
||||||
const toReferences = (refs?: string[]): ReferenceImage[] =>
|
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
|
||||||
(refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
|
|
||||||
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
|
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
|
||||||
const ensureReady = (config: AiConfig) => {
|
const ensureReady = (config: AiConfig) => {
|
||||||
if (!isAiConfigReady(config, config.model)) {
|
if (!isAiConfigReady(config, config.model)) {
|
||||||
@@ -834,13 +863,16 @@ function InfiniteCanvasPage() {
|
|||||||
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
|
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
|
||||||
ensureReady(config);
|
ensureReady(config);
|
||||||
const references = toReferences(options?.references);
|
const references = toReferences(options?.references);
|
||||||
const items = references.length
|
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
|
||||||
? await requestEdit(config, prompt, references, undefined, { signal: options?.signal })
|
|
||||||
: await requestGeneration(config, prompt, { signal: options?.signal });
|
|
||||||
return { images: items.map((item) => item.dataUrl) };
|
return { images: items.map((item) => item.dataUrl) };
|
||||||
},
|
},
|
||||||
generateVideo: async (prompt, options) => {
|
generateVideo: async (prompt, options) => {
|
||||||
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "video"), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}), ...(options?.seconds ? { videoSeconds: options.seconds } : {}) };
|
const config = {
|
||||||
|
...buildGenerationConfig(effectiveConfig, undefined, "video"),
|
||||||
|
...(options?.model ? { model: options.model } : {}),
|
||||||
|
...(options?.size ? { size: options.size } : {}),
|
||||||
|
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
|
||||||
|
};
|
||||||
ensureReady(config);
|
ensureReady(config);
|
||||||
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
|
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
|
||||||
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
|
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
|
||||||
@@ -853,8 +885,7 @@ function InfiniteCanvasPage() {
|
|||||||
return { text };
|
return { text };
|
||||||
},
|
},
|
||||||
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
|
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
|
||||||
listModels: (capability) =>
|
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
|
||||||
selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
|
|
||||||
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
|
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
|
||||||
};
|
};
|
||||||
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
|
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
|
||||||
@@ -864,8 +895,16 @@ function InfiniteCanvasPage() {
|
|||||||
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
|
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
|
||||||
getNodes: () => nodesRef.current,
|
getNodes: () => nodesRef.current,
|
||||||
getConnections: () => connectionsRef.current,
|
getConnections: () => connectionsRef.current,
|
||||||
getUpstream: (nodeId) => connectionsRef.current.filter((conn) => conn.toNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
getUpstream: (nodeId) =>
|
||||||
getDownstream: (nodeId) => connectionsRef.current.filter((conn) => conn.fromNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
connectionsRef.current
|
||||||
|
.filter((conn) => conn.toNodeId === nodeId)
|
||||||
|
.map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId))
|
||||||
|
.filter((node): node is CanvasNodeData => Boolean(node)),
|
||||||
|
getDownstream: (nodeId) =>
|
||||||
|
connectionsRef.current
|
||||||
|
.filter((conn) => conn.fromNodeId === nodeId)
|
||||||
|
.map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId))
|
||||||
|
.filter((node): node is CanvasNodeData => Boolean(node)),
|
||||||
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
|
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
|
||||||
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
|
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
|
||||||
applyOps: (ops) => applyAgentOps(ops),
|
applyOps: (ops) => applyAgentOps(ops),
|
||||||
@@ -1260,14 +1299,17 @@ function InfiniteCanvasPage() {
|
|||||||
// 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。
|
// 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。
|
||||||
// capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。
|
// capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。
|
||||||
const pendingSelectionRef = useRef<Set<string> | null>(null);
|
const pendingSelectionRef = useRef<Set<string> | null>(null);
|
||||||
const handleNodeSelectCapture = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
const handleNodeSelectCapture = useCallback(
|
||||||
|
(event: ReactMouseEvent, nodeId: string) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setHoveredNodeId(null);
|
setHoveredNodeId(null);
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
const { nextSelected } = selectNodeByEvent(event, nodeId);
|
const { nextSelected } = selectNodeByEvent(event, nodeId);
|
||||||
pendingSelectionRef.current = nextSelected;
|
pendingSelectionRef.current = nextSelected;
|
||||||
}, [selectNodeByEvent]);
|
},
|
||||||
|
[selectNodeByEvent],
|
||||||
|
);
|
||||||
|
|
||||||
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1766,7 +1808,15 @@ function InfiniteCanvasPage() {
|
|||||||
}
|
}
|
||||||
if (node.type === CanvasNodeType.Video) {
|
if (node.type === CanvasNodeType.Video) {
|
||||||
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
||||||
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
|
addAsset({
|
||||||
|
kind: "video",
|
||||||
|
title: node.metadata?.prompt?.slice(0, 24) || "画布视频",
|
||||||
|
coverUrl: "",
|
||||||
|
tags: [],
|
||||||
|
source: "Canvas",
|
||||||
|
data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" },
|
||||||
|
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
|
||||||
|
});
|
||||||
message.success("已加入我的资产");
|
message.success("已加入我的资产");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1805,11 +1855,7 @@ function InfiniteCanvasPage() {
|
|||||||
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
|
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
|
||||||
const centerY = node.position.y + node.height / 2;
|
const centerY = node.position.y + node.height / 2;
|
||||||
const textNode = {
|
const textNode = {
|
||||||
...createCanvasNode(
|
...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }),
|
||||||
CanvasNodeType.Text,
|
|
||||||
{ x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY },
|
|
||||||
{ content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 },
|
|
||||||
),
|
|
||||||
title: "反推提示词",
|
title: "反推提示词",
|
||||||
};
|
};
|
||||||
const configNode = {
|
const configNode = {
|
||||||
@@ -1827,11 +1873,7 @@ function InfiniteCanvasPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setNodes((prev) => [...prev, textNode, configNode]);
|
setNodes((prev) => [...prev, textNode, configNode]);
|
||||||
setConnections((prev) => [
|
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id }, { id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id }]);
|
||||||
...prev,
|
|
||||||
{ id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id },
|
|
||||||
{ id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id },
|
|
||||||
]);
|
|
||||||
setSelectedNodeIds(new Set([configNode.id]));
|
setSelectedNodeIds(new Set([configNode.id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
setDialogNodeId(configNode.id);
|
setDialogNodeId(configNode.id);
|
||||||
@@ -2012,9 +2054,13 @@ function InfiniteCanvasPage() {
|
|||||||
setDialogNodeId(childId);
|
setDialogNodeId(childId);
|
||||||
const controller = startGenerationRequest(childId, node.id, childId);
|
const controller = startGenerationRequest(childId, node.id, childId);
|
||||||
try {
|
try {
|
||||||
const image = await requestEdit(generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }], undefined, { signal: controller.signal }).then(
|
const image = await requestEdit(
|
||||||
(items) => items[0],
|
generationConfig,
|
||||||
);
|
prompt,
|
||||||
|
[{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }],
|
||||||
|
undefined,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
).then((items) => items[0]);
|
||||||
const uploaded = await uploadImage(image.dataUrl);
|
const uploaded = await uploadImage(image.dataUrl);
|
||||||
const size = fitNodeSize(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)));
|
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
|
||||||
@@ -2049,7 +2095,21 @@ function InfiniteCanvasPage() {
|
|||||||
if (isAudioFile(file)) {
|
if (isAudioFile(file)) {
|
||||||
const audio = await uploadMediaFile(file, "audio");
|
const audio = await uploadMediaFile(file, "audio");
|
||||||
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
|
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
|
||||||
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Audio, title: file.name, position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined } } : node)));
|
setNodes((prev) =>
|
||||||
|
prev.map((node) =>
|
||||||
|
node.id === target.nodeId
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
type: CanvasNodeType.Audio,
|
||||||
|
title: file.name,
|
||||||
|
position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 },
|
||||||
|
width: spec.width,
|
||||||
|
height: spec.height,
|
||||||
|
metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined },
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
);
|
||||||
setSelectedNodeIds(new Set([target.nodeId]));
|
setSelectedNodeIds(new Set([target.nodeId]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
uploadTargetRef.current = null;
|
uploadTargetRef.current = null;
|
||||||
@@ -2059,7 +2119,21 @@ function InfiniteCanvasPage() {
|
|||||||
if (file.type.startsWith("video/")) {
|
if (file.type.startsWith("video/")) {
|
||||||
const video = await uploadMediaFile(file, "video");
|
const video = await uploadMediaFile(file, "video");
|
||||||
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
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)));
|
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]));
|
setSelectedNodeIds(new Set([target.nodeId]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
setDialogNodeId(target.nodeId);
|
setDialogNodeId(target.nodeId);
|
||||||
@@ -2177,7 +2251,9 @@ function InfiniteCanvasPage() {
|
|||||||
? await requestEdit({ ...generationConfig, count: "1" }, fullPrompt, refs, undefined, { signal: controller.signal }).then((items) => items[0])
|
? await requestEdit({ ...generationConfig, count: "1" }, fullPrompt, refs, undefined, { signal: controller.signal }).then((items) => items[0])
|
||||||
: await requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]);
|
: await requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]);
|
||||||
const uploaded = await uploadImage(image.dataUrl);
|
const uploaded = await uploadImage(image.dataUrl);
|
||||||
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)));
|
setNodes((prev) =>
|
||||||
|
prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)),
|
||||||
|
);
|
||||||
setDialogNodeId(null);
|
setDialogNodeId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isGenerationCanceled(error)) {
|
if (!isGenerationCanceled(error)) {
|
||||||
@@ -2393,16 +2469,55 @@ function InfiniteCanvasPage() {
|
|||||||
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
|
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
|
||||||
width: isEmptyVideoNode ? sourceNode.width : spec.width,
|
width: isEmptyVideoNode ? sourceNode.width : spec.width,
|
||||||
height: isEmptyVideoNode ? sourceNode.height : spec.height,
|
height: isEmptyVideoNode ? sourceNode.height : spec.height,
|
||||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) },
|
metadata: {
|
||||||
|
prompt: effectivePrompt,
|
||||||
|
status: NODE_STATUS_LOADING,
|
||||||
|
model: generationConfig.model,
|
||||||
|
size: generationConfig.size,
|
||||||
|
seconds: generationConfig.videoSeconds,
|
||||||
|
vquality: generationConfig.vquality,
|
||||||
|
generateAudio: generationConfig.videoGenerateAudio,
|
||||||
|
watermark: generationConfig.videoWatermark,
|
||||||
|
references: generationReferenceUrls(generationContext),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
pendingChildIds = [videoId];
|
pendingChildIds = [videoId];
|
||||||
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]));
|
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 }]);
|
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
|
||||||
const controller = startGenerationRequest(videoId, nodeId, nodeId, runController);
|
const controller = startGenerationRequest(videoId, nodeId, nodeId, runController);
|
||||||
try {
|
try {
|
||||||
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios, { signal: controller.signal }));
|
const video = await storeGeneratedVideo(
|
||||||
|
await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios, { signal: controller.signal }),
|
||||||
|
);
|
||||||
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
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, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) } } : node)));
|
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,
|
||||||
|
generateAudio: generationConfig.videoGenerateAudio,
|
||||||
|
watermark: generationConfig.videoWatermark,
|
||||||
|
references: generationReferenceUrls(generationContext),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
finishGenerationRequest(videoId, controller);
|
finishGenerationRequest(videoId, controller);
|
||||||
}
|
}
|
||||||
@@ -2424,7 +2539,11 @@ function InfiniteCanvasPage() {
|
|||||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
|
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
|
||||||
};
|
};
|
||||||
pendingChildIds = [audioId];
|
pendingChildIds = [audioId];
|
||||||
setNodes((prev) => (isEmptyAudioNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode]));
|
setNodes((prev) =>
|
||||||
|
isEmptyAudioNode
|
||||||
|
? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node))
|
||||||
|
: [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode],
|
||||||
|
);
|
||||||
if (!isEmptyAudioNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]);
|
if (!isEmptyAudioNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]);
|
||||||
const controller = startGenerationRequest(audioId, nodeId, nodeId, runController);
|
const controller = startGenerationRequest(audioId, nodeId, nodeId, runController);
|
||||||
try {
|
try {
|
||||||
@@ -2467,12 +2586,19 @@ function InfiniteCanvasPage() {
|
|||||||
const answers = await Promise.all(
|
const answers = await Promise.all(
|
||||||
textTargetIds.map((targetNodeId) => {
|
textTargetIds.map((targetNodeId) => {
|
||||||
let localStreamed = "";
|
let localStreamed = "";
|
||||||
return requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }), (text) => {
|
return requestImageQuestion(
|
||||||
|
generationConfig,
|
||||||
|
buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }),
|
||||||
|
(text) => {
|
||||||
localStreamed = text;
|
localStreamed = text;
|
||||||
streamed = text;
|
streamed = text;
|
||||||
if (isConfigNode) return;
|
if (isConfigNode) return;
|
||||||
setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node)));
|
setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node)));
|
||||||
}, { signal: controller.signal }).then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })).finally(() => finishGenerationRequest(targetNodeId, controller));
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed }))
|
||||||
|
.finally(() => finishGenerationRequest(targetNodeId, controller));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -2553,17 +2679,44 @@ function InfiniteCanvasPage() {
|
|||||||
if (node.type === CanvasNodeType.Text) {
|
if (node.type === CanvasNodeType.Text) {
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
let streamed = "";
|
let streamed = "";
|
||||||
const answer = await requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...context, prompt }), (text) => {
|
const answer = await requestImageQuestion(
|
||||||
|
generationConfig,
|
||||||
|
buildNodeResponseMessages({ ...context, prompt }),
|
||||||
|
(text) => {
|
||||||
streamed = text;
|
streamed = text;
|
||||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item)));
|
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item)));
|
||||||
}, { signal: controller.signal });
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item)));
|
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.type === CanvasNodeType.Video) {
|
if (node.type === CanvasNodeType.Video) {
|
||||||
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || [], context?.referenceAudios || [], { signal: controller.signal }));
|
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || [], context?.referenceAudios || [], { signal: controller.signal }));
|
||||||
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
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, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark } } : item)));
|
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,
|
||||||
|
generateAudio: generationConfig.videoGenerateAudio,
|
||||||
|
watermark: generationConfig.videoWatermark,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.type === CanvasNodeType.Audio) {
|
if (node.type === CanvasNodeType.Audio) {
|
||||||
@@ -2572,12 +2725,22 @@ function InfiniteCanvasPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]);
|
const image = useReferenceImages
|
||||||
|
? await requestEdit(generationConfig, prompt, retryImages, undefined, { signal: controller.signal }).then((items) => items[0])
|
||||||
|
: await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]);
|
||||||
const uploadedImage = await uploadImage(image.dataUrl);
|
const uploadedImage = await uploadImage(image.dataUrl);
|
||||||
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||||
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
||||||
const generationMetadata = savedImageMetadata?.generationType
|
const generationMetadata = savedImageMetadata?.generationType
|
||||||
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, ...(generationConfig.background ? { background: generationConfig.background } : {}), count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
|
? {
|
||||||
|
generationType: savedImageMetadata.generationType,
|
||||||
|
model: generationConfig.model,
|
||||||
|
size: generationConfig.size,
|
||||||
|
quality: generationConfig.quality,
|
||||||
|
...(generationConfig.background ? { background: generationConfig.background } : {}),
|
||||||
|
count: savedImageMetadata.count || 1,
|
||||||
|
references: savedImageMetadata.references,
|
||||||
|
}
|
||||||
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
|
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
@@ -2691,7 +2854,18 @@ function InfiniteCanvasPage() {
|
|||||||
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
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)}`;
|
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
const nextSize = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
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 } }]);
|
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]));
|
setSelectedNodeIds(new Set([id]));
|
||||||
} else {
|
} else {
|
||||||
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
|
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
|
||||||
@@ -2725,7 +2899,12 @@ function InfiniteCanvasPage() {
|
|||||||
getNodeDefinition(panelNode.type)?.Panel ? (
|
getNodeDefinition(panelNode.type)?.Panel ? (
|
||||||
renderPluginPanel(panelNode)
|
renderPluginPanel(panelNode)
|
||||||
) : panelNode.type === CanvasNodeType.Config ? (
|
) : panelNode.type === CanvasNodeType.Config ? (
|
||||||
<CanvasConfigComposer value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""} inputs={configInputsById.get(panelNode.id) || []} onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })} onClose={() => setDialogNodeId(null)} />
|
<CanvasConfigComposer
|
||||||
|
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||||
|
inputs={configInputsById.get(panelNode.id) || []}
|
||||||
|
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
|
||||||
|
onClose={() => setDialogNodeId(null)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CanvasNodePromptPanel
|
<CanvasNodePromptPanel
|
||||||
node={panelNode}
|
node={panelNode}
|
||||||
@@ -2995,11 +3174,15 @@ function InfiniteCanvasPage() {
|
|||||||
|
|
||||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
||||||
|
|
||||||
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open={Boolean(maskEditNode)} onClose={() => setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null}
|
{maskEditNode?.metadata?.content ? (
|
||||||
|
<CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open={Boolean(maskEditNode)} onClose={() => setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open={Boolean(splitNode)} onClose={() => setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null}
|
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open={Boolean(splitNode)} onClose={() => setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null}
|
||||||
|
|
||||||
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null}
|
{upscaleNode?.metadata?.content ? (
|
||||||
|
<CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
|
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
|
||||||
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
||||||
@@ -3016,13 +3199,7 @@ function InfiniteCanvasPage() {
|
|||||||
width="auto"
|
width="auto"
|
||||||
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}
|
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}
|
||||||
>
|
>
|
||||||
{previewNode?.metadata?.content ? (
|
{previewNode?.metadata?.content ? <img src={previewNode.metadata.content} alt={previewNode.title || "图片"} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null}
|
||||||
<img
|
|
||||||
src={previewNode.metadata.content}
|
|
||||||
alt={previewNode.title || "图片"}
|
|
||||||
style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -3161,11 +3338,7 @@ function CanvasTopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||||
<UserStatusActions
|
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} />
|
||||||
variant="canvas"
|
|
||||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
|
||||||
onOpenPlugins={onOpenPlugins}
|
|
||||||
/>
|
|
||||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -3214,13 +3387,7 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
|
|||||||
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
|
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
|
||||||
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
||||||
return (
|
return (
|
||||||
<button
|
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板">
|
||||||
type="button"
|
|
||||||
className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75"
|
|
||||||
style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }}
|
|
||||||
onClick={onClick}
|
|
||||||
title="打开本地 Codex 面板"
|
|
||||||
>
|
|
||||||
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
||||||
<span className="max-w-[140px] truncate">{label}</span>
|
<span className="max-w-[140px] truncate">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -3247,290 +3414,3 @@ function Shortcut({ keys, value }: { keys: string[]; value: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageExtension(dataUrl: string) {
|
|
||||||
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
|
|
||||||
}
|
|
||||||
|
|
||||||
function audioExtension(mimeType?: string) {
|
|
||||||
if (mimeType?.includes("wav")) return "wav";
|
|
||||||
if (mimeType?.includes("opus")) return "opus";
|
|
||||||
if (mimeType?.includes("aac")) return "aac";
|
|
||||||
if (mimeType?.includes("flac")) return "flac";
|
|
||||||
if (mimeType?.includes("pcm")) return "pcm";
|
|
||||||
return "mp3";
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
|
|
||||||
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
|
|
||||||
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
|
|
||||||
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
|
|
||||||
return {
|
|
||||||
generationType: type,
|
|
||||||
model: config.model,
|
|
||||||
size: config.size,
|
|
||||||
quality: config.quality,
|
|
||||||
...(config.background ? { background: config.background } : {}),
|
|
||||||
count,
|
|
||||||
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
|
|
||||||
return {
|
|
||||||
model: config.model,
|
|
||||||
audioVoice: config.audioVoice,
|
|
||||||
audioFormat: config.audioFormat,
|
|
||||||
audioSpeed: config.audioSpeed,
|
|
||||||
audioInstructions: config.audioInstructions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function referenceUrl(image: ReferenceImage) {
|
|
||||||
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
|
|
||||||
return [
|
|
||||||
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
|
||||||
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
|
|
||||||
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
|
|
||||||
if (metadata.generationType !== "edit") return [];
|
|
||||||
if (!metadata.references?.length) return null;
|
|
||||||
const references = await Promise.all(
|
|
||||||
metadata.references.map(async (url, index) => {
|
|
||||||
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
|
|
||||||
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return references.every(Boolean) ? (references as ReferenceImage[]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
|
|
||||||
return Promise.all(
|
|
||||||
nodes.map(async (node) => {
|
|
||||||
const content = node.metadata?.content;
|
|
||||||
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
|
|
||||||
if (node.type !== CanvasNodeType.Image || !content) return node;
|
|
||||||
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
|
|
||||||
if (!content.startsWith("data:image/")) return node;
|
|
||||||
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
|
|
||||||
const hydrateItem = async <T extends { dataUrl?: string; storageKey?: string }>(item: T) => {
|
|
||||||
if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) };
|
|
||||||
if (item.dataUrl?.startsWith("data:image/")) {
|
|
||||||
const image = await uploadImage(item.dataUrl);
|
|
||||||
return { ...item, dataUrl: image.url, storageKey: image.storageKey };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
};
|
|
||||||
return Promise.all(
|
|
||||||
sessions.map(async (session) => ({
|
|
||||||
...session,
|
|
||||||
messages: await Promise.all(
|
|
||||||
session.messages.map(async (message) => ({
|
|
||||||
...message,
|
|
||||||
references: await Promise.all((message.references || []).map(hydrateItem)),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 safePatch = patch || {};
|
|
||||||
const next = { ...node, metadata: { ...node.metadata, ...safePatch } };
|
|
||||||
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
|
||||||
const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.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 findGroupDropTarget(movedIds: Set<string>, nodes: CanvasNodeData[]) {
|
|
||||||
if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null;
|
|
||||||
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
|
|
||||||
if (!movingNodes.length) return null;
|
|
||||||
return (
|
|
||||||
[...nodes]
|
|
||||||
.reverse()
|
|
||||||
.find((group) => {
|
|
||||||
if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false;
|
|
||||||
return movingNodes.some((node) => {
|
|
||||||
const centerX = node.position.x + node.width / 2;
|
|
||||||
const centerY = node.position.y + node.height / 2;
|
|
||||||
return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height;
|
|
||||||
});
|
|
||||||
}) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapNodesIntoGroup(movedIds: Set<string>, nodes: CanvasNodeData[], group: CanvasNodeData) {
|
|
||||||
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
|
|
||||||
if (!movingNodes.length) return nodes;
|
|
||||||
const pad = 24;
|
|
||||||
const bounds = nodeBounds(movingNodes);
|
|
||||||
const left = group.position.x + pad;
|
|
||||||
const top = group.position.y + pad;
|
|
||||||
const right = group.position.x + group.width - pad;
|
|
||||||
const bottom = group.position.y + group.height - pad;
|
|
||||||
const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0;
|
|
||||||
const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0;
|
|
||||||
return nodes.map((node) => {
|
|
||||||
if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
|
|
||||||
return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeBounds(nodes: CanvasNodeData[]) {
|
|
||||||
return nodes.reduce(
|
|
||||||
(acc, node) => ({
|
|
||||||
left: Math.min(acc.left, node.position.x),
|
|
||||||
top: Math.min(acc.top, node.position.y),
|
|
||||||
right: Math.max(acc.right, node.position.x + node.width),
|
|
||||||
bottom: Math.max(acc.bottom, node.position.y + node.height),
|
|
||||||
}),
|
|
||||||
{ left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) {
|
|
||||||
const centerX = node.position.x + node.width / 2;
|
|
||||||
const centerY = node.position.y + node.height / 2;
|
|
||||||
return (
|
|
||||||
[...nodes]
|
|
||||||
.reverse()
|
|
||||||
.find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id || undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
|
||||||
return {
|
|
||||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
|
||||||
y: node.position.y + node.height / 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (!first || !second || first.id === second.id) return null;
|
|
||||||
if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null;
|
|
||||||
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
|
|
||||||
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
|
||||||
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
|
|
||||||
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
|
||||||
return { fromNodeId: first.id, toNodeId: second.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInputSummary(inputs: NodeGenerationInput[]) {
|
|
||||||
return {
|
|
||||||
textCount: inputs.filter((input) => input.type === "text").length,
|
|
||||||
imageCount: inputs.filter((input) => input.type === "image").length,
|
|
||||||
videoCount: inputs.filter((input) => input.type === "video").length,
|
|
||||||
audioCount: inputs.filter((input) => input.type === "audio").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
|
|
||||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
|
||||||
return {
|
|
||||||
...config,
|
|
||||||
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
|
|
||||||
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
|
||||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
|
||||||
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
|
|
||||||
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
|
|
||||||
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
|
|
||||||
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
|
||||||
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
|
|
||||||
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
|
|
||||||
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
|
|
||||||
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
|
|
||||||
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
|
|
||||||
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
|
|
||||||
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGenerationCanceled(error: unknown) {
|
|
||||||
return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError");
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
|
||||||
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
|
|
||||||
const visited = new Set<string>();
|
|
||||||
while (queue.length) {
|
|
||||||
const id = queue.shift()!;
|
|
||||||
if (visited.has(id)) continue;
|
|
||||||
visited.add(id);
|
|
||||||
const node = nodes.find((item) => item.id === id);
|
|
||||||
if (node?.type === CanvasNodeType.Config) return node;
|
|
||||||
connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceNodeReferenceImages(node: CanvasNodeData | null) {
|
|
||||||
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: node.id,
|
|
||||||
name: `${node.title || node.id}.png`,
|
|
||||||
type: node.metadata.mimeType || "image/png",
|
|
||||||
dataUrl: node.metadata.content,
|
|
||||||
storageKey: node.metadata.storageKey,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAudioFile(file: File) {
|
|
||||||
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
|
|
||||||
const rootId = node.metadata?.batchRootId;
|
|
||||||
if (!rootId) return false;
|
|
||||||
const root = nodes.find((item) => item.id === rootId);
|
|
||||||
if (root && collapsingBatchIds?.has(rootId)) return false;
|
|
||||||
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) {
|
|
||||||
const rootId = node.metadata?.batchRootId;
|
|
||||||
if (!rootId) return false;
|
|
||||||
const root = nodes.find((item) => item.id === rootId);
|
|
||||||
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAngleLabel(params: CanvasImageAngleParams) {
|
|
||||||
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`;
|
|
||||||
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`;
|
|
||||||
return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAnglePrompt(params: CanvasImageAngleParams) {
|
|
||||||
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user