fix(canvas): 图片裁剪 - 修复云端图片跨域导致的确认裁剪无反应
裁剪属于像素级操作,需要先把图片画到 canvas 再导出。云端图片地址通常不带 CORS 头,canvas 会被标记为跨域,toDataURL 直接抛 SecurityError;而 loadImage 只挂了 onload、没有 onerror,加载失败时 Promise 永久挂起。两者叠加后,用户 侧的表现都是「点击确认裁剪没有任何反应」,控制台之外看不到原因。 - loadImage 补充 onerror,避免加载失败时 Promise 永不落地 - 仅对非 data:/blob: 的地址设置 crossOrigin=anonymous,本地资源设了反而 可能取不到像素 - 新增 resolveCroppableImageSource():优先用本地媒体缓存 Blob 构造同源 objectURL,取不到再回退原始地址,并在 finally 中 revoke - cropImageNode 补充 try/catch 与失败提示,让错误可见而不是静默失败 无外部行为变化,仅修复失败路径与错误反馈。
This commit is contained in:
@@ -159,9 +159,24 @@ function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, source
|
||||
}
|
||||
|
||||
function loadImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
return new Promise<HTMLImageElement>((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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user