From d5741c98feb54cf3235e15e8ce16d94b7324bac5 Mon Sep 17 00:00:00 2001 From: cn-derekdu Date: Tue, 22 Sep 2026 16:31:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=9B=BE=E7=89=87=E8=A3=81?= =?UTF-8?q?=E5=89=AA=20-=20=E4=BF=AE=E5=A4=8D=E4=BA=91=E7=AB=AF=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=B7=A8=E5=9F=9F=E5=AF=BC=E8=87=B4=E7=9A=84=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E8=A3=81=E5=89=AA=E6=97=A0=E5=8F=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 裁剪属于像素级操作,需要先把图片画到 canvas 再导出。云端图片地址通常不带 CORS 头,canvas 会被标记为跨域,toDataURL 直接抛 SecurityError;而 loadImage 只挂了 onload、没有 onerror,加载失败时 Promise 永久挂起。两者叠加后,用户 侧的表现都是「点击确认裁剪没有任何反应」,控制台之外看不到原因。 - loadImage 补充 onerror,避免加载失败时 Promise 永不落地 - 仅对非 data:/blob: 的地址设置 crossOrigin=anonymous,本地资源设了反而 可能取不到像素 - 新增 resolveCroppableImageSource():优先用本地媒体缓存 Blob 构造同源 objectURL,取不到再回退原始地址,并在 finally 中 revoke - cropImageNode 补充 try/catch 与失败提示,让错误可见而不是静默失败 无外部行为变化,仅修复失败路径与错误反馈。 --- web/src/lib/canvas/canvas-image-data.ts | 17 ++- .../pages/canvas/use-canvas-media-tools.ts | 132 +++++++++++------- 2 files changed, 99 insertions(+), 50 deletions(-) diff --git a/web/src/lib/canvas/canvas-image-data.ts b/web/src/lib/canvas/canvas-image-data.ts index c9fd8e4f9..d8a63aaee 100644 --- a/web/src/lib/canvas/canvas-image-data.ts +++ b/web/src/lib/canvas/canvas-image-data.ts @@ -159,9 +159,24 @@ function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, source } function loadImage(dataUrl: string) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const image = new Image(); + // 只有跨源 HTTP(S) 地址才需要声明匿名跨域;同源的 /api 资源不能 + // 强制走 CORS,否则缺少 ACAO 响应头时反而会加载失败。 + if (isCrossOriginHttpUrl(dataUrl)) image.crossOrigin = "anonymous"; image.onload = () => resolve(image); + // 缺少 onerror 时加载失败会让 Promise 永久挂起,调用方 await 后表现为「点击无反应」。 + image.onerror = () => reject(new Error("图片加载失败,无法处理该图片")); image.src = dataUrl; }); } + +function isCrossOriginHttpUrl(value: string) { + if (typeof window === "undefined") return false; + try { + const url = new URL(value, window.location.href); + return (url.protocol === "http:" || url.protocol === "https:") && url.origin !== window.location.origin; + } catch { + return false; + } +} diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index ac67b96fc..cdffa2664 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -49,7 +49,7 @@ import { navigateToSettings } from "@/lib/settings-navigation"; import { storeGeneratedVideo } from "@/services/api/video"; import { getTool } from "@/services/api/tools"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; -import { uploadImage } from "@/services/image-storage"; +import { getImageBlob, uploadImage } from "@/services/image-storage"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import type { GenerationTask } from "@/services/api/task-center"; @@ -219,18 +219,29 @@ export function useCanvasMediaTools({ const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { if (!node.metadata?.content) return; - const cropped = await cropDataUrl(node.metadata.content, crop); - const image = await uploadImage(cropped); - const size = fitNodeSize(image.width, image.height, node.width, node.height); - const childId = nanoid(); - const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 裁剪`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } }; - setNodes((current) => [...current, child]); - setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); - setSelectedNodeIds(new Set([childId])); - setDialogNodeId(childId); - setCropNodeId(null); - await persistMediaNodes([child]); - }, [persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); + // 云端图片地址通常不带 CORS 头,直接画到 canvas 会被判定为跨域而无法导出。 + // 优先用本地缓存里的 Blob 构造同源地址,裁剪才能读取像素。 + let releaseSource = () => {}; + try { + const source = await resolveCroppableImageSource(node); + releaseSource = source.release; + const cropped = await cropDataUrl(source.url, crop); + const image = await uploadImage(cropped); + const size = fitNodeSize(image.width, image.height, node.width, node.height); + const childId = nanoid(); + const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 裁剪`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } }; + setNodes((current) => [...current, child]); + setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); + setSelectedNodeIds(new Set([childId])); + setDialogNodeId(childId); + setCropNodeId(null); + await persistMediaNodes([child]); + } catch (error) { + message.error(error instanceof Error ? `裁剪失败:${error.message}` : "裁剪失败,请重试"); + } finally { + releaseSource(); + } + }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); const saveAnnotatedImageNode = useCallback(async (node: CanvasNodeData, dataUrl: string) => { const image = await uploadImage(dataUrl); @@ -628,31 +639,35 @@ export function useCanvasMediaTools({ const splitImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageSplitParams) => { if (!node.metadata?.content || !isValidGridSplit(params)) return; - const pieces = await splitDataUrl(node.metadata.content, params); - const sizedPieces = await Promise.all(pieces.map(async (piece) => { - const image = await uploadImage(piece.dataUrl); - return { piece, image, size: fitNodeSize(image.width, image.height) }; - })); - const positions = layoutGridSplitCells( - { x: node.position.x + node.width + 96, y: node.position.y }, - sizedPieces.map(({ piece, size }) => ({ row: piece.row, column: piece.column, width: size.width, height: size.height })), - ); - const childNodes = sizedPieces.map(({ piece, image, size }, index) => ({ - id: nanoid(), - type: CanvasNodeType.Image, - title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`, - position: positions[index] || { x: node.position.x + node.width + 96, y: node.position.y }, - width: size.width, - height: size.height, - metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, manualSize: true }, - } satisfies CanvasNodeData)); - setNodes((current) => [...current, ...childNodes]); - setConnections((current) => [...current, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]); - setSelectedNodeIds(new Set(childNodes.map((child) => child.id))); - setSelectedConnectionId(null); - setDialogNodeId(null); - await persistMediaNodes(childNodes); - message.success(`已切分为 ${childNodes.length} 个子节点`); + try { + const pieces = await splitDataUrl(node.metadata.content, params); + const sizedPieces = await Promise.all(pieces.map(async (piece) => { + const image = await uploadImage(piece.dataUrl); + return { piece, image, size: fitNodeSize(image.width, image.height) }; + })); + const positions = layoutGridSplitCells( + { x: node.position.x + node.width + 96, y: node.position.y }, + sizedPieces.map(({ piece, size }) => ({ row: piece.row, column: piece.column, width: size.width, height: size.height })), + ); + const childNodes = sizedPieces.map(({ piece, image, size }, index) => ({ + id: nanoid(), + type: CanvasNodeType.Image, + title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`, + position: positions[index] || { x: node.position.x + node.width + 96, y: node.position.y }, + width: size.width, + height: size.height, + metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, manualSize: true }, + } satisfies CanvasNodeData)); + setNodes((current) => [...current, ...childNodes]); + setConnections((current) => [...current, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]); + setSelectedNodeIds(new Set(childNodes.map((child) => child.id))); + setSelectedConnectionId(null); + setDialogNodeId(null); + await persistMediaNodes(childNodes); + message.success(`已切分为 ${childNodes.length} 个子节点`); + } catch (error) { + message.error(error instanceof Error ? `切分失败:${error.message}` : "图片切分失败,请重试"); + } }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { @@ -1046,17 +1061,21 @@ export function useCanvasMediaTools({ const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { if (!node.metadata?.content) return; setUpscaleNodeId(null); - const upscaled = await upscaleDataUrl(node.metadata.content, params); - const image = await uploadImage(upscaled); - const size = fitNodeSize(image.width, image.height); - const childId = nanoid(); - const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 放大`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } }; - setNodes((current) => [...current, child]); - setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); - setSelectedNodeIds(new Set([childId])); - setDialogNodeId(childId); - await persistMediaNodes([child]); - }, [persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); + try { + const upscaled = await upscaleDataUrl(node.metadata.content, params); + const image = await uploadImage(upscaled); + const size = fitNodeSize(image.width, image.height); + const childId = nanoid(); + const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: `${node.title || "图片"} · 放大`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt } }; + setNodes((current) => [...current, child]); + setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); + setSelectedNodeIds(new Set([childId])); + setDialogNodeId(childId); + await persistMediaNodes([child]); + } catch (error) { + message.error(error instanceof Error ? `放大失败:${error.message}` : "图片放大失败,请重试"); + } + }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); const generateAngleNode = useCallback(async (node: CanvasNodeData, params: CanvasImageAngleParams) => { if (!node.metadata?.content) return; @@ -1280,3 +1299,18 @@ export function useCanvasMediaTools({ upscaleNodeId, }; } + +// 裁剪、切分等像素级操作要求图片同源可读:云端地址若不带 CORS 头, +// canvas 会被标记为跨域,toDataURL 直接抛 SecurityError。 +// 这里优先用本地缓存 Blob 构造同源 objectURL,取不到时再回退原始地址。 +async function resolveCroppableImageSource(node: CanvasNodeData): Promise<{ url: string; release: () => void }> { + const content = node.metadata?.content ?? ""; + if (content.startsWith("data:") || content.startsWith("blob:")) return { url: content, release: () => {} }; + const storageKey = node.metadata?.storageKey; + if (!storageKey) return { url: content, release: () => {} }; + const readBlob = storageKey.startsWith("image:") || storageKey.startsWith("generation-image:") ? getImageBlob : getMediaBlob; + const blob = await readBlob(storageKey).catch(() => null); + if (!blob) return { url: content, release: () => {} }; + const url = URL.createObjectURL(blob); + return { url, release: () => URL.revokeObjectURL(url) }; +}