fix(canvas): 图片工具 - 修复裁剪和尺寸调整的跨域导出
This commit is contained in:
@@ -1,3 +1,17 @@
|
||||
export async function prepareRasterSource(source: string, readBlob?: () => Promise<Blob | null>): Promise<{ url: string; release: () => void }> {
|
||||
if (!source.trim()) throw new Error("图片地址为空");
|
||||
if (source.startsWith("data:") || source.startsWith("blob:")) return { url: source, release: () => {} };
|
||||
let blob = await readBlob?.();
|
||||
if (!blob) {
|
||||
// fetch validates CORS across redirects; a display-only image URL must never be drawn as a fallback.
|
||||
const response = await fetch(source, { mode: "cors", credentials: "same-origin" });
|
||||
if (!response.ok) throw new Error(`原图读取失败(${response.status})`);
|
||||
blob = await response.blob();
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { url, release: () => URL.revokeObjectURL(url) };
|
||||
}
|
||||
|
||||
export function loadRasterImage(source: string, signal?: AbortSignal): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const picture = new Image();
|
||||
@@ -25,7 +39,8 @@ export function loadRasterImage(source: string, signal?: AbortSignal): Promise<H
|
||||
const location = globalThis.location;
|
||||
if (location) {
|
||||
const address = new URL(source, location.href);
|
||||
if (["https:", "http:"].includes(address.protocol) && address.origin !== location.origin) picture.crossOrigin = "anonymous";
|
||||
// Same-origin resource URLs can redirect to OSS/CDN, so they also need a CORS image request.
|
||||
if (["https:", "http:"].includes(address.protocol)) picture.crossOrigin = "anonymous";
|
||||
}
|
||||
picture.src = source;
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { PanoramaGenerateConfig } from "@/components/canvas/canvas-panorama
|
||||
import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-frame-dialog";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||
import { prepareRasterSource } from "@/lib/image-raster";
|
||||
import { isValidGridSplit, layoutGridSplitCells } from "@/lib/canvas/canvas-grid-split";
|
||||
import { audioMetadata, buildSourceImageLayerNodeResult, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitProducedModel } from "@/lib/canvas/produced-model";
|
||||
@@ -227,11 +228,9 @@ export function useCanvasMediaTools({
|
||||
|
||||
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
|
||||
if (!node.metadata?.content) return;
|
||||
// 云端图片地址通常不带 CORS 头,直接画到 canvas 会被判定为跨域而无法导出。
|
||||
// 优先用本地缓存里的 Blob 构造同源地址,裁剪才能读取像素。
|
||||
let releaseSource = () => {};
|
||||
try {
|
||||
const source = await resolveCroppableImageSource(node);
|
||||
const source = await resolveProcessableImageSource(node);
|
||||
releaseSource = source.release;
|
||||
const cropped = await cropDataUrl(source.url, crop);
|
||||
const image = await uploadImage(cropped);
|
||||
@@ -647,8 +646,11 @@ export function useCanvasMediaTools({
|
||||
|
||||
const splitImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageSplitParams) => {
|
||||
if (!node.metadata?.content || !isValidGridSplit(params)) return;
|
||||
let releaseSource = () => {};
|
||||
try {
|
||||
const pieces = await splitDataUrl(node.metadata.content, params);
|
||||
const source = await resolveProcessableImageSource(node);
|
||||
releaseSource = source.release;
|
||||
const pieces = await splitDataUrl(source.url, 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) };
|
||||
@@ -675,6 +677,8 @@ export function useCanvasMediaTools({
|
||||
message.success(`已切分为 ${childNodes.length} 个子节点`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? `切分失败:${error.message}` : "图片切分失败,请重试");
|
||||
} finally {
|
||||
releaseSource();
|
||||
}
|
||||
}, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
@@ -1507,8 +1511,11 @@ export function useCanvasMediaTools({
|
||||
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
|
||||
if (!node.metadata?.content) return;
|
||||
setUpscaleNodeId(null);
|
||||
let releaseSource = () => {};
|
||||
try {
|
||||
const upscaled = await upscaleDataUrl(node.metadata.content, params);
|
||||
const source = await resolveProcessableImageSource(node);
|
||||
releaseSource = source.release;
|
||||
const upscaled = await upscaleDataUrl(source.url, params);
|
||||
const image = await uploadImage(upscaled);
|
||||
const size = fitNodeSize(image.width, image.height);
|
||||
const childId = nanoid();
|
||||
@@ -1520,6 +1527,8 @@ export function useCanvasMediaTools({
|
||||
await persistMediaNodes([child]);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? `放大失败:${error.message}` : "图片放大失败,请重试");
|
||||
} finally {
|
||||
releaseSource();
|
||||
}
|
||||
}, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
|
||||
|
||||
@@ -1747,17 +1756,11 @@ export function useCanvasMediaTools({
|
||||
};
|
||||
}
|
||||
|
||||
// 裁剪、切分等像素级操作要求图片同源可读:云端地址若不带 CORS 头,
|
||||
// canvas 会被标记为跨域,toDataURL 直接抛 SecurityError。
|
||||
// 这里优先用本地缓存 Blob 构造同源 objectURL,取不到时再回退原始地址。
|
||||
async function resolveCroppableImageSource(node: CanvasNodeData): Promise<{ url: string; release: () => void }> {
|
||||
// 资源 Blob 读取使用 browser-process 授权,复用缓存;读取失败时保留错误,不退回展示地址。
|
||||
async function resolveProcessableImageSource(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: () => {} };
|
||||
if (!storageKey) return prepareRasterSource(content);
|
||||
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) };
|
||||
return prepareRasterSource(content, () => readBlob(storageKey));
|
||||
}
|
||||
|
||||
142
web/test/image-raster.node.test.ts
Normal file
142
web/test/image-raster.node.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { loadRasterImage, prepareRasterSource } from "../src/lib/image-raster";
|
||||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "../src/lib/canvas/canvas-image-data";
|
||||
|
||||
test("same-origin resource redirects request CORS before assigning src", async () => {
|
||||
const originalImage = globalThis.Image;
|
||||
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
||||
const requests: Array<{ src: string; crossOrigin: string }> = [];
|
||||
try {
|
||||
Object.defineProperty(globalThis, "location", { configurable: true, value: { href: "https://canvas.example/", origin: "https://canvas.example" } });
|
||||
globalThis.Image = class {
|
||||
crossOrigin = "";
|
||||
naturalWidth = 940;
|
||||
naturalHeight = 1674;
|
||||
onload?: () => void;
|
||||
onerror?: () => void;
|
||||
set src(src: string) {
|
||||
requests.push({ src, crossOrigin: this.crossOrigin });
|
||||
queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
for (const src of ["/api/resources/image/file", "https://cdn.example/image.png", "data:image/png;base64,test", "blob:local-image"]) {
|
||||
await loadRasterImage(src);
|
||||
}
|
||||
assert.deepEqual(requests.map((request) => request.crossOrigin), ["anonymous", "anonymous", "", ""]);
|
||||
} finally {
|
||||
globalThis.Image = originalImage;
|
||||
if (originalLocation) Object.defineProperty(globalThis, "location", originalLocation);
|
||||
else Reflect.deleteProperty(globalThis, "location");
|
||||
}
|
||||
});
|
||||
|
||||
test("crop, resize and split export a redirected resource without taint", async () => {
|
||||
const originalImage = globalThis.Image;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
||||
const outputs: Array<{ width: number; height: number }> = [];
|
||||
try {
|
||||
Object.defineProperty(globalThis, "location", { configurable: true, value: { href: "https://canvas.example/", origin: "https://canvas.example" } });
|
||||
globalThis.Image = class {
|
||||
crossOrigin = "";
|
||||
naturalWidth = 940;
|
||||
naturalHeight = 1674;
|
||||
tainted = false;
|
||||
onload?: () => void;
|
||||
onerror?: () => void;
|
||||
set src(_src: string) {
|
||||
// Model a resource endpoint redirecting to a different image origin.
|
||||
this.tainted = this.crossOrigin !== "anonymous";
|
||||
queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
let tainted = false;
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext() { return { drawImage(image: { tainted: boolean }) { tainted = image.tainted; } }; },
|
||||
toDataURL() {
|
||||
if (tainted) throw new DOMException("Tainted canvases may not be exported", "SecurityError");
|
||||
outputs.push({ width: this.width, height: this.height });
|
||||
return "data:image/png;base64,output";
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as Document;
|
||||
const source = "/api/resources/image/file";
|
||||
assert.match(await cropDataUrl(source, { x: 0, y: 0, width: 0.5, height: 0.5 }), /^data:image\/png/);
|
||||
assert.match(await upscaleDataUrl(source, { targetLongEdge: 2048, algorithm: "high" }), /^data:image\/png/);
|
||||
assert.equal((await splitDataUrl(source, { rows: 2, columns: 2 })).length, 4);
|
||||
assert.deepEqual(outputs[0], { width: 470, height: 837 });
|
||||
assert.deepEqual(outputs[1], { width: 1150, height: 2048 });
|
||||
} finally {
|
||||
globalThis.Image = originalImage;
|
||||
globalThis.document = originalDocument;
|
||||
if (originalLocation) Object.defineProperty(globalThis, "location", originalLocation);
|
||||
else Reflect.deleteProperty(globalThis, "location");
|
||||
}
|
||||
});
|
||||
|
||||
test("readable local sources skip remote resource reads", async () => {
|
||||
for (const url of ["data:image/png;base64,local", "blob:local-image"]) {
|
||||
const source = await prepareRasterSource(url, async () => { throw new Error("must not read remote bytes"); });
|
||||
assert.equal(source.url, url);
|
||||
source.release();
|
||||
}
|
||||
});
|
||||
|
||||
test("cached resource bytes use a Blob URL and release it", async () => {
|
||||
const blob = new Blob(["pixels"], { type: "image/png" });
|
||||
const source = await prepareRasterSource("/api/resources/image/file", async () => blob);
|
||||
assert.match(source.url, /^blob:/);
|
||||
assert.equal(await (await fetch(source.url)).text(), "pixels");
|
||||
source.release();
|
||||
await assert.rejects(fetch(source.url));
|
||||
});
|
||||
|
||||
test("resource read failures propagate without falling back to the display URL", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetched = false;
|
||||
const failure = new Error("resource authorization failed");
|
||||
try {
|
||||
globalThis.fetch = async () => { fetched = true; throw new Error("unsafe fallback"); };
|
||||
await assert.rejects(prepareRasterSource("/api/resources/image/file", async () => { throw failure; }), (error) => error === failure);
|
||||
assert.equal(fetched, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("uncached sources fetch with CORS and create a readable Blob URL", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: unknown; options: unknown }> = [];
|
||||
try {
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return new Response(new Blob(["pixels"], { type: "image/png" }));
|
||||
};
|
||||
const source = await prepareRasterSource("/api/resources/image/file", async () => null);
|
||||
assert.deepEqual(requests, [{ url: "/api/resources/image/file", options: { mode: "cors", credentials: "same-origin" } }]);
|
||||
assert.match(source.url, /^blob:/);
|
||||
source.release();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("HTTP and CORS failures stop processing instead of returning an unsafe URL", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
|
||||
await assert.rejects(prepareRasterSource("https://cdn.example/image.png"), /原图读取失败(403)/);
|
||||
const failure = new TypeError("Failed to fetch");
|
||||
globalThis.fetch = async () => { throw failure; };
|
||||
await assert.rejects(prepareRasterSource("https://cdn.example/image.png"), (error) => error === failure);
|
||||
await assert.rejects(prepareRasterSource(" "), /图片地址为空/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user