From f60c9df0292711dd7dc8275e75faf57ae127ab22 Mon Sep 17 00:00:00 2001 From: TianYun Date: Sat, 6 Jun 2026 10:08:35 +0800 Subject: [PATCH] =?UTF-8?q?base64=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ConnectionDropMenu.tsx | 1 + src/components/GenerateNodeMenu.tsx | 52 +++++++------ src/components/WorkflowCanvas.tsx | 4 +- .../__tests__/ConnectionDropMenu.test.tsx | 16 ++++ src/utils/__tests__/gridSplitter.test.ts | 75 +++++++++++++++++++ src/utils/gridSplitter.ts | 38 ++++++++-- 6 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 src/utils/__tests__/gridSplitter.test.ts diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index df74b71e..6f496b0a 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -736,6 +736,7 @@ const COMMON_QUICK_ADD_PRIORITIES: Record> = { imageInput: ["multiAngleImage", "splitGridImmediate"], annotation: ["multiAngleImage", "splitGridImmediate"], + smartImage: ["multiAngleImage", "splitGridImmediate"], nanoBanana: ["multiAngleImage", "splitGridImmediate"], prompt: ["array", "promptConstructor"], promptConstructor: ["llmGenerate", "array"], diff --git a/src/components/GenerateNodeMenu.tsx b/src/components/GenerateNodeMenu.tsx index 956fa12d..c3c0afda 100644 --- a/src/components/GenerateNodeMenu.tsx +++ b/src/components/GenerateNodeMenu.tsx @@ -183,12 +183,15 @@ export function GenerateNodeMenu({ className={`${className ?? "relative min-w-[140px] outline-none"} select-none`} style={position ? { left: position.x, top: position.y } : undefined} > -
+
{GENERATE_NODE_MENU_ITEMS.map((item, index) => ( {otherOpen && ( -
- {OTHER_NODE_MENU_CATEGORIES.map((category, categoryIndex) => ( -
-
0 ? " border-t border-neutral-700" : ""}`}> - {t(category.labelKey)} +
+
+ {OTHER_NODE_MENU_CATEGORIES.map((category, categoryIndex) => ( +
+
0 ? " border-t border-neutral-700" : ""}`}> + {t(category.labelKey)} +
+ {category.nodes.map((item) => ( + + ))}
- {category.nodes.map((item) => ( - - ))} -
- ))} + ))} +
)}
diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index f2cf6589..9b28b6b1 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1394,7 +1394,7 @@ export function WorkflowCanvas() { setIsSplitting(false); } }, - [getNodeById, addNode, updateNodeData, addToGlobalHistory, t] + [getNodeById, edges, addNode, updateNodeData, addToGlobalHistory, t] ); // Helper to get image from a node @@ -1416,7 +1416,7 @@ export function WorkflowCanvas() { default: return null; } - }, [getNodeById]); + }, [getNodeById, edges]); // Handle workflow generation from chat conversation const handleBuildWorkflow = useCallback(async (description: string) => { diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx index d12e4b17..2aaa6c56 100644 --- a/src/components/__tests__/ConnectionDropMenu.test.tsx +++ b/src/components/__tests__/ConnectionDropMenu.test.tsx @@ -193,6 +193,22 @@ describe("ConnectionDropMenu", () => { expect(screen.queryByText("Output Gallery")).not.toBeInTheDocument(); }); + it("keeps image-related actions for smart image quick-add", () => { + render( + + ); + + expect(screen.getByText("Image Multi-Angle")).toBeInTheDocument(); + expect(screen.getByText("Split Grid Now")).toBeInTheDocument(); + expect(screen.queryByText("Split Grid Node")).not.toBeInTheDocument(); + }); + it("keeps common quick-add options when there is no related source mapping", () => { render( ({ + uploadBlobToGatewayMedia: vi.fn(async () => ({ url: "https://media.example/split.png" })), +})); + +class MockImage { + width = 100; + height = 100; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } +} + +function mockCanvas() { + return { + width: 0, + height: 0, + getContext: vi.fn(() => ({ + drawImage: vi.fn(), + })), + toBlob: vi.fn((callback: (blob: Blob | null) => void) => { + callback(new Blob(["split"], { type: "image/png" })); + }), + } as unknown as HTMLCanvasElement; +} + +describe("gridSplitter", () => { + const originalCreateElement = document.createElement.bind(document); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("Image", MockImage); + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + blob: async () => new Blob(["source"], { type: "image/png" }), + }))); + vi.stubGlobal("URL", { + ...URL, + createObjectURL: vi.fn(() => "blob:canvas-safe-source"), + revokeObjectURL: vi.fn(), + }); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName === "canvas") return mockCanvas(); + return originalCreateElement(tagName); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("loads remote images through the media proxy before canvas splitting", async () => { + const result = await splitWithDimensions("https://remote.example/grid.png", 1, 1); + + expect(fetch).toHaveBeenCalledWith( + `/api/proxy-media?url=${encodeURIComponent("https://remote.example/grid.png")}` + ); + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:canvas-safe-source"); + expect(uploadBlobToGatewayMedia).toHaveBeenCalledWith( + expect.any(Blob), + expect.stringMatching(/^grid-cell-\d+-1\.png$/), + "image/png", + "image" + ); + expect(result.images).toEqual(["https://media.example/split.png"]); + }); +}); diff --git a/src/utils/gridSplitter.ts b/src/utils/gridSplitter.ts index 47edd38b..6050e889 100644 --- a/src/utils/gridSplitter.ts +++ b/src/utils/gridSplitter.ts @@ -9,6 +9,7 @@ */ import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; +import { isHttpMediaUrl } from "@/utils/mediaResolver"; export interface GridCell { x: number; @@ -47,6 +48,27 @@ const COMMON_ASPECT_RATIOS = [ 2 / 3, // 0.667 - Portrait DSLR ]; +async function withCanvasSafeImageSource( + imageSource: string, + callback: (canvasSafeSource: string) => Promise +): Promise { + if (!isHttpMediaUrl(imageSource)) { + return callback(imageSource); + } + + const response = await fetch(`/api/proxy-media?url=${encodeURIComponent(imageSource)}`); + if (!response.ok) { + throw new Error(`Failed to load image through proxy: ${response.status}`); + } + + const objectUrl = URL.createObjectURL(await response.blob()); + try { + return await callback(objectUrl); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + /** * Analyzes an image and detects grid boundaries. */ @@ -474,9 +496,11 @@ export async function detectAndSplitGrid(imageDataUrl: string): Promise<{ grid: GridDetectionResult; images: string[]; }> { - const grid = await detectGrid(imageDataUrl); - const images = await splitImage(imageDataUrl, grid); - return { grid, images }; + return withCanvasSafeImageSource(imageDataUrl, async (canvasSafeSource) => { + const grid = await detectGrid(canvasSafeSource); + const images = await splitImage(canvasSafeSource, grid); + return { grid, images }; + }); } /** @@ -490,7 +514,9 @@ export async function splitWithDimensions( grid: GridDetectionResult; images: string[]; }> { - const grid = await detectGridWithDimensions(imageDataUrl, rows, cols); - const images = await splitImage(imageDataUrl, grid); - return { grid, images }; + return withCanvasSafeImageSource(imageDataUrl, async (canvasSafeSource) => { + const grid = await detectGridWithDimensions(canvasSafeSource, rows, cols); + const images = await splitImage(canvasSafeSource, grid); + return { grid, images }; + }); }