diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 1094d4ab..87f3be54 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -278,30 +278,28 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV }, []); const handleConfirmSplitGrid = useCallback(async () => { - const sourceImage = nodeData.outputImage || displayImage; const sourceNode = getNodeById(id); - if (!sourceImage || !sourceNode || !gridSelection || selectedGridCellKeys.size === 0) return; + if (!sourceNode || !gridSelection || selectedGridCellKeys.size === 0) return; setIsSplitGridRunning(true); try { - await createSplitGridImageNodes({ + const createdNodeIds = createSplitGridImageNodes({ sourceNode, - sourceImage, rows: gridSelection.rows, cols: gridSelection.cols, selectedCellKeys: selectedGridCellKeys, addNode, - updateNodeData, onConnect, selectSingleNode, }); handleCancelGridSelection(); + createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId)); } catch (error) { alert(t("workflow.splitGrid.failed", { error: error instanceof Error ? error.message : "Unknown error" })); } finally { setIsSplitGridRunning(false); } - }, [addNode, displayImage, getNodeById, gridSelection, handleCancelGridSelection, id, nodeData.outputImage, onConnect, selectSingleNode, selectedGridCellKeys, t, updateNodeData]); + }, [addNode, getNodeById, gridSelection, handleCancelGridSelection, id, onConnect, regenerateNode, selectSingleNode, selectedGridCellKeys, t]); const handleCarouselPrevious = useCallback(async () => { const history = nodeData.imageHistory || []; diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 009ca7c6..a1d53a69 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -26,6 +26,7 @@ import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPaylo import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; +import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; @@ -362,52 +363,15 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal setIsSplitGridRunning(true); try { - const currentWidth = typeof sourceNode.width === "number" - ? sourceNode.width - : typeof sourceNode.style?.width === "number" - ? sourceNode.style.width - : IMAGE_INPUT_DIMENSIONS.width; - const start = { - x: sourceNode.position.x + currentWidth + 220, - y: sourceNode.position.y, - }; - const nodeWidth = IMAGE_INPUT_DIMENSIONS.width; - const nodeHeight = IMAGE_INPUT_DIMENSIONS.height; - const gap = 24; - const orderedSelectedCells = Array.from(selectedGridCellKeys) - .map((key) => { - const [row, col] = key.split("-").map(Number); - return { key, row, col }; - }) - .filter((cell) => Number.isFinite(cell.row) && Number.isFinite(cell.col)) - .sort((a, b) => a.row - b.row || a.col - b.col); - const columns = Math.max(1, Math.ceil(Math.sqrt(orderedSelectedCells.length))); - const createdNodeIds = orderedSelectedCells.map((cell, index) => { - const rowIndex = Math.floor(index / columns); - const colIndex = index % columns; - return createPendingDerivedImageNode({ - sourceNodeId: id, - position: { - x: start.x + colIndex * (nodeWidth + gap), - y: start.y + rowIndex * (nodeHeight + gap), - }, - filename: `split-${cell.row}-${cell.col}.png`, - label: `${cell.row}-${cell.col}`, - operation: "splitGrid", - task: { - rows: gridSelection.rows, - cols: gridSelection.cols, - row: cell.row, - col: cell.col, - cellKey: cell.key, - }, - addNode, - onConnect, - }); + const createdNodeIds = createSplitGridImageNodes({ + sourceNode, + rows: gridSelection.rows, + cols: gridSelection.cols, + selectedCellKeys: selectedGridCellKeys, + addNode, + onConnect, + selectSingleNode, }); - if (createdNodeIds.length > 0) { - selectSingleNode(createdNodeIds[0]); - } handleCancelGridSelection(); createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId)); } catch (error) { diff --git a/src/store/execution/__tests__/derivedImageExecutor.test.ts b/src/store/execution/__tests__/derivedImageExecutor.test.ts index fb5e82e0..e67203d9 100644 --- a/src/store/execution/__tests__/derivedImageExecutor.test.ts +++ b/src/store/execution/__tests__/derivedImageExecutor.test.ts @@ -4,7 +4,6 @@ import type { NodeExecutionContext } from "../types"; import type { WorkflowNode } from "@/types"; import { createCroppedImageBlob, loadCropImage } from "@/components/media/imageCropUtils"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; -import { detectGridWithDimensions } from "@/utils/gridSplitter"; vi.mock("@/components/media/imageCropUtils", () => ({ createCroppedImageBlob: vi.fn(), @@ -15,10 +14,6 @@ vi.mock("@/utils/gatewayMediaUpload", () => ({ uploadBlobToGatewayMedia: vi.fn(), })); -vi.mock("@/utils/gridSplitter", () => ({ - detectGridWithDimensions: vi.fn(), -})); - function makeNode(data: Record): WorkflowNode { return { id: "derived-1", @@ -125,23 +120,17 @@ describe("derivedImageExecutor", () => { it("uses grid task coordinates to find the selected cell", async () => { const blob = new Blob(["grid"], { type: "image/png" }); const drawImage = vi.fn(); - vi.mocked(detectGridWithDimensions).mockResolvedValue({ - rows: 2, - cols: 2, - confidence: 1, - cells: [ - { x: 0, y: 0, width: 20, height: 20 }, - { x: 20, y: 0, width: 20, height: 20 }, - { x: 0, y: 20, width: 20, height: 20 }, - { x: 20, y: 20, width: 20, height: 20 }, - ], - }); vi.mocked(uploadBlobToGatewayMedia).mockResolvedValue({ url: "https://media.example/grid.png", filename: "grid.png", contentType: "image/png", }); - vi.mocked(loadCropImage).mockResolvedValue({} as HTMLImageElement); + vi.mocked(loadCropImage).mockResolvedValue({ + naturalWidth: 40, + naturalHeight: 40, + width: 40, + height: 40, + } as HTMLImageElement); vi.spyOn(document, "createElement").mockReturnValue({ width: 0, height: 0, @@ -160,7 +149,7 @@ describe("derivedImageExecutor", () => { await executeDerivedImage(ctx); - expect(detectGridWithDimensions).toHaveBeenCalledWith("source.png", 2, 2); + expect(loadCropImage).toHaveBeenCalledWith("source.png"); expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 20, 20, 20, 0, 0, 20, 20); expect(ctx.updateNodeData).toHaveBeenCalledWith("derived-1", expect.objectContaining({ image: "https://media.example/grid.png", diff --git a/src/store/execution/derivedImageExecutor.ts b/src/store/execution/derivedImageExecutor.ts index 1af21dfc..46a0b808 100644 --- a/src/store/execution/derivedImageExecutor.ts +++ b/src/store/execution/derivedImageExecutor.ts @@ -5,7 +5,7 @@ import type { } from "@/types"; import { createCroppedImageBlob } from "@/components/media/imageCropUtils"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; -import { detectGridWithDimensions } from "@/utils/gridSplitter"; +import { createGridForDimensions } from "@/utils/gridSplitter"; import { loadCropImage } from "@/components/media/imageCropUtils"; import { isSmartImageLocalDerivedTaskNode } from "@/utils/smartImageMode"; import type { NodeExecutionContext } from "./types"; @@ -35,14 +35,16 @@ async function createSplitGridCellBlob( sourceImage: string, task: DerivedImageSplitGridTask ): Promise<{ blob: Blob; dimensions: { width: number; height: number } }> { - const grid = await detectGridWithDimensions(sourceImage, task.rows, task.cols); + const image = await loadCropImage(sourceImage); + const imageWidth = image.naturalWidth || image.width; + const imageHeight = image.naturalHeight || image.height; + const grid = createGridForDimensions(imageWidth, imageHeight, task.rows, task.cols); const cellIndex = (task.row - 1) * task.cols + (task.col - 1); const cell = grid.cells[cellIndex]; if (!cell) { throw new Error("Selected grid cell is outside the image grid."); } - const image = await loadCropImage(sourceImage); const canvas = document.createElement("canvas"); canvas.width = cell.width; canvas.height = cell.height; diff --git a/src/utils/__tests__/gridSplitter.test.ts b/src/utils/__tests__/gridSplitter.test.ts deleted file mode 100644 index 03da9b28..00000000 --- a/src/utils/__tests__/gridSplitter.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { splitWithDimensions } from "../gridSplitter"; -import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; - -vi.mock("@/utils/gatewayMediaUpload", () => ({ - 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/__tests__/splitGridNodes.test.ts b/src/utils/__tests__/splitGridNodes.test.ts index eaa3b11a..f251581f 100644 --- a/src/utils/__tests__/splitGridNodes.test.ts +++ b/src/utils/__tests__/splitGridNodes.test.ts @@ -1,37 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { WorkflowNode } from "@/types"; import { createSplitGridImageNodes } from "../splitGridNodes"; -import { splitSelectedWithDimensions } from "../gridSplitter"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "../nodeHandles"; -vi.mock("../gridSplitter", () => ({ - splitSelectedWithDimensions: vi.fn(async () => ({ - grid: { - rows: 2, - cols: 2, - cells: [], - confidence: 1, - }, - images: ["https://media.example/split-1.png", "https://media.example/split-2.png"], - })), -})); - -class MockImage { - width = 120; - height = 80; - onload: (() => void) | null = null; - - set src(_value: string) { - queueMicrotask(() => this.onload?.()); - } -} - describe("createSplitGridImageNodes", () => { - it("creates connected smart image nodes for selected grid cells", async () => { - vi.stubGlobal("Image", MockImage); - + it("creates connected pending smart image nodes for selected grid cells", () => { const addNode = vi.fn((type: string, _position: unknown, _data: unknown) => `${type}-node-${addNode.mock.calls.length}`); - const updateNodeData = vi.fn(); const onConnect = vi.fn(); const selectSingleNode = vi.fn(); const sourceNode = { @@ -42,36 +16,57 @@ describe("createSplitGridImageNodes", () => { width: 300, } as WorkflowNode; - const createdNodeIds = await createSplitGridImageNodes({ + const createdNodeIds = createSplitGridImageNodes({ sourceNode, - sourceImage: "https://media.example/source.png", rows: 2, cols: 2, selectedCellKeys: new Set(["1-1", "2-2"]), addNode, - updateNodeData, onConnect, selectSingleNode, }); - expect(splitSelectedWithDimensions).toHaveBeenCalledWith( - "https://media.example/source.png", - 2, - 2, - new Set(["1-1", "2-2"]) - ); expect(createdNodeIds).toEqual(["smartImage-node-1", "smartImage-node-2"]); expect(addNode).toHaveBeenCalledWith( "smartImage", expect.objectContaining({ x: 620, y: 200 }), expect.objectContaining({ - image: "https://media.example/split-1.png", + image: null, filename: "split-1-1.png", label: "1-1", + status: "loading", disablePreviewModal: true, derivedImage: { sourceNodeId: "source-1", operation: "splitGrid", + task: { + rows: 2, + cols: 2, + row: 1, + col: 1, + cellKey: "1-1", + }, + }, + }) + ); + expect(addNode).toHaveBeenCalledWith( + "smartImage", + expect.objectContaining({ x: 620 + 324, y: 200 }), + expect.objectContaining({ + image: null, + filename: "split-2-2.png", + label: "2-2", + status: "loading", + derivedImage: { + sourceNodeId: "source-1", + operation: "splitGrid", + task: { + rows: 2, + cols: 2, + row: 2, + col: 2, + cellKey: "2-2", + }, }, }) ); diff --git a/src/utils/gridSplitter.ts b/src/utils/gridSplitter.ts index 83523fc6..11861c1a 100644 --- a/src/utils/gridSplitter.ts +++ b/src/utils/gridSplitter.ts @@ -1,16 +1,13 @@ /** * Grid Splitter Utility * - * Detects and splits contact sheet grids into component images. + * Detects contact sheet grid dimensions and cell bounds. * * Uses a regularity-first approach that assumes grids are uniform, * scoring candidates based on how well the image dimensions divide * into cells with common photo aspect ratios. */ -import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; -import { isHttpMediaUrl } from "@/utils/mediaResolver"; - export interface GridCell { x: number; y: number; @@ -48,27 +45,6 @@ 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. */ @@ -417,114 +393,3 @@ function scoreGridByEdges( const avgRatio = totalRatio / divisions; return Math.min((avgRatio - 1) / 2, 1); // 0 at ratio=1, 1 at ratio=3+ } - -/** - * Splits an image into grid cells and returns them as separate data URLs - */ -export async function splitImage( - imageDataUrl: string, - grid: GridDetectionResult -): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - - img.onload = async () => { - try { - const results: string[] = []; - - for (const cell of grid.cells) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } - - canvas.width = cell.width; - canvas.height = cell.height; - - ctx.drawImage( - img, - cell.x, - cell.y, - cell.width, - cell.height, - 0, - 0, - cell.width, - cell.height - ); - - const blob = await new Promise((resolve, rejectBlob) => { - canvas.toBlob((result) => { - if (result) { - resolve(result); - return; - } - rejectBlob(new Error("Grid cell export failed")); - }, "image/png"); - }); - const uploaded = await uploadBlobToGatewayMedia( - blob, - `grid-cell-${Date.now()}-${results.length + 1}.png`, - "image/png", - "image" - ); - results.push(uploaded.url); - } - - resolve(results); - } catch (error) { - reject(error); - } - }; - - img.onerror = () => { - reject(new Error("Failed to load image")); - }; - - img.src = imageDataUrl; - }); -} - -/** - * Split with user-specified dimensions (most reliable method) - */ -export async function splitWithDimensions( - imageDataUrl: string, - rows: number, - cols: number -): Promise<{ - grid: GridDetectionResult; - images: string[]; -}> { - return withCanvasSafeImageSource(imageDataUrl, async (canvasSafeSource) => { - const grid = await detectGridWithDimensions(canvasSafeSource, rows, cols); - const images = await splitImage(canvasSafeSource, grid); - return { grid, images }; - }); -} - -export async function splitSelectedWithDimensions( - imageDataUrl: string, - rows: number, - cols: number, - selectedCellKeys: Set -): Promise<{ - grid: GridDetectionResult; - images: string[]; -}> { - return withCanvasSafeImageSource(imageDataUrl, async (canvasSafeSource) => { - const grid = await detectGridWithDimensions(canvasSafeSource, rows, cols); - const selectedCells = grid.cells.filter((_, index) => { - const row = Math.floor(index / cols) + 1; - const col = index % cols + 1; - return selectedCellKeys.has(`${row}-${col}`); - }); - const selectedGrid = { ...grid, cells: selectedCells }; - const images = await splitImage(canvasSafeSource, selectedGrid); - return { grid: selectedGrid, images }; - }); -} diff --git a/src/utils/splitGridNodes.ts b/src/utils/splitGridNodes.ts index be86486e..79edd7df 100644 --- a/src/utils/splitGridNodes.ts +++ b/src/utils/splitGridNodes.ts @@ -1,18 +1,15 @@ import type { XYPosition } from "@xyflow/react"; import type { Connection } from "@xyflow/react"; -import type { SmartImageNodeData, WorkflowNode, WorkflowNodeData } from "@/types"; +import type { SmartImageNodeData, WorkflowNode } from "@/types"; import { defaultNodeDimensions } from "@/constants/nodeDimensions"; -import { splitSelectedWithDimensions } from "@/utils/gridSplitter"; -import { createDerivedImageNode } from "@/utils/derivedImageNodes"; +import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; interface SplitGridNodeCreationOptions { sourceNode: WorkflowNode; - sourceImage: string; rows: number; cols: number; selectedCellKeys: Set; addNode: (type: "smartImage", position: XYPosition, initialData?: Partial) => string; - updateNodeData: (nodeId: string, data: Partial) => void; onConnect: (connection: Connection) => void; selectSingleNode?: (nodeId: string) => void; } @@ -30,26 +27,15 @@ function getSplitNodeStartPosition(sourceNode: WorkflowNode): XYPosition { }; } -function readImageDimensions(image: string): Promise<{ width: number; height: number } | null> { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve({ width: img.width, height: img.height }); - img.onerror = () => resolve(null); - img.src = image; - }); -} - -export async function createSplitGridImageNodes({ +export function createSplitGridImageNodes({ sourceNode, - sourceImage, rows, cols, selectedCellKeys, addNode, - updateNodeData, onConnect, selectSingleNode, -}: SplitGridNodeCreationOptions): Promise { +}: SplitGridNodeCreationOptions): string[] { const orderedSelectedCells = Array.from(selectedCellKeys) .map((key) => { const [row, col] = key.split("-").map(Number); @@ -57,38 +43,36 @@ export async function createSplitGridImageNodes({ }) .filter((cell) => Number.isFinite(cell.row) && Number.isFinite(cell.col)) .sort((a, b) => a.row - b.row || a.col - b.col); - const { images } = await splitSelectedWithDimensions(sourceImage, rows, cols, new Set(orderedSelectedCells.map((cell) => cell.key))); const start = getSplitNodeStartPosition(sourceNode); const nodeWidth = defaultNodeDimensions.imageInput.width; const nodeHeight = defaultNodeDimensions.imageInput.height; const gap = 24; + const columns = Math.max(1, Math.ceil(Math.sqrt(orderedSelectedCells.length))); const createdNodeIds: string[] = []; - images.forEach((image, index) => { - const selectedCell = orderedSelectedCells[index]; - if (!selectedCell) return; - const rowIndex = Math.floor(index / Math.max(1, Math.ceil(Math.sqrt(images.length)))); - const colIndex = index % Math.max(1, Math.ceil(Math.sqrt(images.length))); - const nodeId = createDerivedImageNode({ + orderedSelectedCells.forEach((selectedCell, index) => { + const rowIndex = Math.floor(index / columns); + const colIndex = index % columns; + const nodeId = createPendingDerivedImageNode({ sourceNodeId: sourceNode.id, position: { x: start.x + colIndex * (nodeWidth + gap), y: start.y + rowIndex * (nodeHeight + gap), }, - image, filename: `split-${selectedCell.row}-${selectedCell.col}.png`, - dimensions: null, label: `${selectedCell.row}-${selectedCell.col}`, operation: "splitGrid", + task: { + rows, + cols, + row: selectedCell.row, + col: selectedCell.col, + cellKey: selectedCell.key, + }, addNode, onConnect, }); createdNodeIds.push(nodeId); - - void readImageDimensions(image).then((dimensions) => { - if (!dimensions) return; - updateNodeData(nodeId, { dimensions }); - }); }); if (createdNodeIds.length > 0) {