mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +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);
|
||||
}
|
||||
+282
-402
@@ -9,8 +9,8 @@ import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audi
|
||||
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||
import { DOCS_URL } from "@/constant/env";
|
||||
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 { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||
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 { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
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 { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
||||
@@ -55,7 +75,6 @@ import {
|
||||
type CanvasAssistantImage,
|
||||
type CanvasAssistantSession,
|
||||
type CanvasConnection,
|
||||
type CanvasImageGenerationType,
|
||||
type CanvasNodeData,
|
||||
type CanvasNodeMetadata,
|
||||
type CanvasNodeTypeId,
|
||||
@@ -117,24 +136,6 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
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() {
|
||||
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)];
|
||||
return (
|
||||
<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 }) {
|
||||
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 }}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
@@ -247,7 +267,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<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="关闭">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
@@ -416,13 +438,7 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
setRunningNodeId((current) => (current === runningId ? null : current));
|
||||
if (!affectedNodeIds.size) return;
|
||||
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,
|
||||
),
|
||||
);
|
||||
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)));
|
||||
}, []);
|
||||
|
||||
const confirmStopGeneration = useCallback(
|
||||
@@ -486,7 +502,15 @@ function InfiniteCanvasPage() {
|
||||
if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return;
|
||||
const next = createHistoryEntry();
|
||||
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);
|
||||
historyCommitTimerRef.current = setTimeout(() => {
|
||||
@@ -588,10 +612,13 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const keepNodeToolbar = useCallback((nodeId: string) => {
|
||||
if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return;
|
||||
setToolbarNodeId(nodeId);
|
||||
}, [nodeImageSettingsOpen]);
|
||||
const keepNodeToolbar = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return;
|
||||
setToolbarNodeId(nodeId);
|
||||
},
|
||||
[nodeImageSettingsOpen],
|
||||
);
|
||||
|
||||
const hideNodeToolbar = useCallback(() => {}, []);
|
||||
|
||||
@@ -771,7 +798,10 @@ function InfiniteCanvasPage() {
|
||||
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 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;
|
||||
connectionsRef.current = next.connections;
|
||||
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
|
||||
@@ -787,7 +817,7 @@ function InfiniteCanvasPage() {
|
||||
queueMicrotask(() =>
|
||||
generationOps.forEach((op) => {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
@@ -820,8 +850,7 @@ function InfiniteCanvasPage() {
|
||||
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
||||
const pluginAi = useMemo<CanvasPluginAi>(() => {
|
||||
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 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 }));
|
||||
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
|
||||
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
|
||||
const ensureReady = (config: AiConfig) => {
|
||||
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 } : {}) };
|
||||
ensureReady(config);
|
||||
const references = toReferences(options?.references);
|
||||
const items = references.length
|
||||
? await requestEdit(config, prompt, references, undefined, { signal: options?.signal })
|
||||
: await requestGeneration(config, prompt, { signal: options?.signal });
|
||||
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
|
||||
return { images: items.map((item) => item.dataUrl) };
|
||||
},
|
||||
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);
|
||||
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 };
|
||||
@@ -853,8 +885,7 @@ function InfiniteCanvasPage() {
|
||||
return { text };
|
||||
},
|
||||
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
|
||||
listModels: (capability) =>
|
||||
selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
|
||||
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
|
||||
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
|
||||
};
|
||||
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
|
||||
@@ -864,8 +895,16 @@ function InfiniteCanvasPage() {
|
||||
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
|
||||
getNodes: () => nodesRef.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)),
|
||||
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)),
|
||||
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)),
|
||||
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))),
|
||||
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
|
||||
applyOps: (ops) => applyAgentOps(ops),
|
||||
@@ -1260,14 +1299,17 @@ function InfiniteCanvasPage() {
|
||||
// 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。
|
||||
// capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。
|
||||
const pendingSelectionRef = useRef<Set<string> | null>(null);
|
||||
const handleNodeSelectCapture = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
||||
if (event.button !== 0) return;
|
||||
setContextMenu(null);
|
||||
setHoveredNodeId(null);
|
||||
setSelectedConnectionId(null);
|
||||
const { nextSelected } = selectNodeByEvent(event, nodeId);
|
||||
pendingSelectionRef.current = nextSelected;
|
||||
}, [selectNodeByEvent]);
|
||||
const handleNodeSelectCapture = useCallback(
|
||||
(event: ReactMouseEvent, nodeId: string) => {
|
||||
if (event.button !== 0) return;
|
||||
setContextMenu(null);
|
||||
setHoveredNodeId(null);
|
||||
setSelectedConnectionId(null);
|
||||
const { nextSelected } = selectNodeByEvent(event, nodeId);
|
||||
pendingSelectionRef.current = nextSelected;
|
||||
},
|
||||
[selectNodeByEvent],
|
||||
);
|
||||
|
||||
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
||||
event.stopPropagation();
|
||||
@@ -1766,7 +1808,15 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
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("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
@@ -1805,11 +1855,7 @@ function InfiniteCanvasPage() {
|
||||
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
|
||||
const centerY = node.position.y + node.height / 2;
|
||||
const textNode = {
|
||||
...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 },
|
||||
),
|
||||
...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 }),
|
||||
title: "反推提示词",
|
||||
};
|
||||
const configNode = {
|
||||
@@ -1827,11 +1873,7 @@ function InfiniteCanvasPage() {
|
||||
};
|
||||
|
||||
setNodes((prev) => [...prev, textNode, configNode]);
|
||||
setConnections((prev) => [
|
||||
...prev,
|
||||
{ id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id },
|
||||
{ id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id },
|
||||
]);
|
||||
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id }, { id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id }]);
|
||||
setSelectedNodeIds(new Set([configNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(configNode.id);
|
||||
@@ -2012,9 +2054,13 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId(childId);
|
||||
const controller = startGenerationRequest(childId, node.id, childId);
|
||||
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(
|
||||
(items) => items[0],
|
||||
);
|
||||
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((items) => items[0]);
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
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)));
|
||||
@@ -2049,7 +2095,21 @@ function InfiniteCanvasPage() {
|
||||
if (isAudioFile(file)) {
|
||||
const audio = await uploadMediaFile(file, "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]));
|
||||
setSelectedConnectionId(null);
|
||||
uploadTargetRef.current = null;
|
||||
@@ -2059,7 +2119,21 @@ function InfiniteCanvasPage() {
|
||||
if (file.type.startsWith("video/")) {
|
||||
const video = await uploadMediaFile(file, "video");
|
||||
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]));
|
||||
setSelectedConnectionId(null);
|
||||
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 requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]);
|
||||
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);
|
||||
} catch (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 },
|
||||
width: isEmptyVideoNode ? sourceNode.width : spec.width,
|
||||
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];
|
||||
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 }]);
|
||||
const controller = startGenerationRequest(videoId, nodeId, nodeId, runController);
|
||||
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);
|
||||
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 {
|
||||
finishGenerationRequest(videoId, controller);
|
||||
}
|
||||
@@ -2424,7 +2539,11 @@ function InfiniteCanvasPage() {
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
|
||||
};
|
||||
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 }]);
|
||||
const controller = startGenerationRequest(audioId, nodeId, nodeId, runController);
|
||||
try {
|
||||
@@ -2467,12 +2586,19 @@ function InfiniteCanvasPage() {
|
||||
const answers = await Promise.all(
|
||||
textTargetIds.map((targetNodeId) => {
|
||||
let localStreamed = "";
|
||||
return requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }), (text) => {
|
||||
localStreamed = text;
|
||||
streamed = text;
|
||||
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)));
|
||||
}, { signal: controller.signal }).then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })).finally(() => finishGenerationRequest(targetNodeId, controller));
|
||||
return requestImageQuestion(
|
||||
generationConfig,
|
||||
buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }),
|
||||
(text) => {
|
||||
localStreamed = text;
|
||||
streamed = text;
|
||||
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)));
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed }))
|
||||
.finally(() => finishGenerationRequest(targetNodeId, controller));
|
||||
}),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -2553,17 +2679,44 @@ function InfiniteCanvasPage() {
|
||||
if (node.type === CanvasNodeType.Text) {
|
||||
if (!context) return;
|
||||
let streamed = "";
|
||||
const answer = await requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...context, prompt }), (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)));
|
||||
}, { signal: controller.signal });
|
||||
const answer = await requestImageQuestion(
|
||||
generationConfig,
|
||||
buildNodeResponseMessages({ ...context, prompt }),
|
||||
(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)));
|
||||
},
|
||||
{ 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)));
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Audio) {
|
||||
@@ -2572,12 +2725,22 @@ function InfiniteCanvasPage() {
|
||||
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 imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
||||
const generationMetadata = savedImageMetadata?.generationType
|
||||
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, ...(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);
|
||||
setNodes((prev) =>
|
||||
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 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);
|
||||
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]));
|
||||
} else {
|
||||
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
|
||||
@@ -2725,7 +2899,12 @@ function InfiniteCanvasPage() {
|
||||
getNodeDefinition(panelNode.type)?.Panel ? (
|
||||
renderPluginPanel(panelNode)
|
||||
) : 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
|
||||
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}
|
||||
|
||||
{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}
|
||||
|
||||
{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)}>
|
||||
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
||||
@@ -3016,13 +3199,7 @@ function InfiniteCanvasPage() {
|
||||
width="auto"
|
||||
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}
|
||||
>
|
||||
{previewNode?.metadata?.content ? (
|
||||
<img
|
||||
src={previewNode.metadata.content}
|
||||
alt={previewNode.title || "图片"}
|
||||
style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }}
|
||||
/>
|
||||
) : null}
|
||||
{previewNode?.metadata?.content ? <img src={previewNode.metadata.content} alt={previewNode.title || "图片"} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -3161,11 +3338,7 @@ function CanvasTopBar({
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
<UserStatusActions
|
||||
variant="canvas"
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
/>
|
||||
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} />
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
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 dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
||||
return (
|
||||
<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 面板"
|
||||
>
|
||||
<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 面板">
|
||||
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
||||
<span className="max-w-[140px] truncate">{label}</span>
|
||||
</button>
|
||||
@@ -3247,290 +3414,3 @@ function Shortcut({ keys, value }: { keys: string[]; value: string }) {
|
||||
</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