Browse Source

九宫格修复

feature/071
yun 2 weeks ago
parent
commit
86b6b52d7e
  1. 10
      src/components/nodes/GenerateImageNode.tsx
  2. 54
      src/components/nodes/ImageInputNode.tsx
  3. 25
      src/store/execution/__tests__/derivedImageExecutor.test.ts
  4. 8
      src/store/execution/derivedImageExecutor.ts
  5. 75
      src/utils/__tests__/gridSplitter.test.ts
  6. 69
      src/utils/__tests__/splitGridNodes.test.ts
  7. 137
      src/utils/gridSplitter.ts
  8. 48
      src/utils/splitGridNodes.ts

10
src/components/nodes/GenerateImageNode.tsx

@ -278,30 +278,28 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
}, []); }, []);
const handleConfirmSplitGrid = useCallback(async () => { const handleConfirmSplitGrid = useCallback(async () => {
const sourceImage = nodeData.outputImage || displayImage;
const sourceNode = getNodeById(id); const sourceNode = getNodeById(id);
if (!sourceImage || !sourceNode || !gridSelection || selectedGridCellKeys.size === 0) return; if (!sourceNode || !gridSelection || selectedGridCellKeys.size === 0) return;
setIsSplitGridRunning(true); setIsSplitGridRunning(true);
try { try {
await createSplitGridImageNodes({ const createdNodeIds = createSplitGridImageNodes({
sourceNode, sourceNode,
sourceImage,
rows: gridSelection.rows, rows: gridSelection.rows,
cols: gridSelection.cols, cols: gridSelection.cols,
selectedCellKeys: selectedGridCellKeys, selectedCellKeys: selectedGridCellKeys,
addNode, addNode,
updateNodeData,
onConnect, onConnect,
selectSingleNode, selectSingleNode,
}); });
handleCancelGridSelection(); handleCancelGridSelection();
createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId));
} catch (error) { } catch (error) {
alert(t("workflow.splitGrid.failed", { error: error instanceof Error ? error.message : "Unknown error" })); alert(t("workflow.splitGrid.failed", { error: error instanceof Error ? error.message : "Unknown error" }));
} finally { } finally {
setIsSplitGridRunning(false); 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 handleCarouselPrevious = useCallback(async () => {
const history = nodeData.imageHistory || []; const history = nodeData.imageHistory || [];

54
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 { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar";
import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu";
import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes";
import { createSplitGridImageNodes } from "@/utils/splitGridNodes";
import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay";
import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar";
@ -362,52 +363,15 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
setIsSplitGridRunning(true); setIsSplitGridRunning(true);
try { try {
const currentWidth = typeof sourceNode.width === "number" const createdNodeIds = createSplitGridImageNodes({
? sourceNode.width sourceNode,
: typeof sourceNode.style?.width === "number" rows: gridSelection.rows,
? sourceNode.style.width cols: gridSelection.cols,
: IMAGE_INPUT_DIMENSIONS.width; selectedCellKeys: selectedGridCellKeys,
const start = { addNode,
x: sourceNode.position.x + currentWidth + 220, onConnect,
y: sourceNode.position.y, selectSingleNode,
};
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,
});
}); });
if (createdNodeIds.length > 0) {
selectSingleNode(createdNodeIds[0]);
}
handleCancelGridSelection(); handleCancelGridSelection();
createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId)); createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId));
} catch (error) { } catch (error) {

25
src/store/execution/__tests__/derivedImageExecutor.test.ts

@ -4,7 +4,6 @@ import type { NodeExecutionContext } from "../types";
import type { WorkflowNode } from "@/types"; import type { WorkflowNode } from "@/types";
import { createCroppedImageBlob, loadCropImage } from "@/components/media/imageCropUtils"; import { createCroppedImageBlob, loadCropImage } from "@/components/media/imageCropUtils";
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { detectGridWithDimensions } from "@/utils/gridSplitter";
vi.mock("@/components/media/imageCropUtils", () => ({ vi.mock("@/components/media/imageCropUtils", () => ({
createCroppedImageBlob: vi.fn(), createCroppedImageBlob: vi.fn(),
@ -15,10 +14,6 @@ vi.mock("@/utils/gatewayMediaUpload", () => ({
uploadBlobToGatewayMedia: vi.fn(), uploadBlobToGatewayMedia: vi.fn(),
})); }));
vi.mock("@/utils/gridSplitter", () => ({
detectGridWithDimensions: vi.fn(),
}));
function makeNode(data: Record<string, unknown>): WorkflowNode { function makeNode(data: Record<string, unknown>): WorkflowNode {
return { return {
id: "derived-1", id: "derived-1",
@ -125,23 +120,17 @@ describe("derivedImageExecutor", () => {
it("uses grid task coordinates to find the selected cell", async () => { it("uses grid task coordinates to find the selected cell", async () => {
const blob = new Blob(["grid"], { type: "image/png" }); const blob = new Blob(["grid"], { type: "image/png" });
const drawImage = vi.fn(); 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({ vi.mocked(uploadBlobToGatewayMedia).mockResolvedValue({
url: "https://media.example/grid.png", url: "https://media.example/grid.png",
filename: "grid.png", filename: "grid.png",
contentType: "image/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({ vi.spyOn(document, "createElement").mockReturnValue({
width: 0, width: 0,
height: 0, height: 0,
@ -160,7 +149,7 @@ describe("derivedImageExecutor", () => {
await executeDerivedImage(ctx); 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(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 20, 20, 20, 0, 0, 20, 20);
expect(ctx.updateNodeData).toHaveBeenCalledWith("derived-1", expect.objectContaining({ expect(ctx.updateNodeData).toHaveBeenCalledWith("derived-1", expect.objectContaining({
image: "https://media.example/grid.png", image: "https://media.example/grid.png",

8
src/store/execution/derivedImageExecutor.ts

@ -5,7 +5,7 @@ import type {
} from "@/types"; } from "@/types";
import { createCroppedImageBlob } from "@/components/media/imageCropUtils"; import { createCroppedImageBlob } from "@/components/media/imageCropUtils";
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { detectGridWithDimensions } from "@/utils/gridSplitter"; import { createGridForDimensions } from "@/utils/gridSplitter";
import { loadCropImage } from "@/components/media/imageCropUtils"; import { loadCropImage } from "@/components/media/imageCropUtils";
import { isSmartImageLocalDerivedTaskNode } from "@/utils/smartImageMode"; import { isSmartImageLocalDerivedTaskNode } from "@/utils/smartImageMode";
import type { NodeExecutionContext } from "./types"; import type { NodeExecutionContext } from "./types";
@ -35,14 +35,16 @@ async function createSplitGridCellBlob(
sourceImage: string, sourceImage: string,
task: DerivedImageSplitGridTask task: DerivedImageSplitGridTask
): Promise<{ blob: Blob; dimensions: { width: number; height: number } }> { ): 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 cellIndex = (task.row - 1) * task.cols + (task.col - 1);
const cell = grid.cells[cellIndex]; const cell = grid.cells[cellIndex];
if (!cell) { if (!cell) {
throw new Error("Selected grid cell is outside the image grid."); throw new Error("Selected grid cell is outside the image grid.");
} }
const image = await loadCropImage(sourceImage);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = cell.width; canvas.width = cell.width;
canvas.height = cell.height; canvas.height = cell.height;

75
src/utils/__tests__/gridSplitter.test.ts

@ -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"]);
});
});

69
src/utils/__tests__/splitGridNodes.test.ts

@ -1,37 +1,11 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { WorkflowNode } from "@/types"; import type { WorkflowNode } from "@/types";
import { createSplitGridImageNodes } from "../splitGridNodes"; import { createSplitGridImageNodes } from "../splitGridNodes";
import { splitSelectedWithDimensions } from "../gridSplitter";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "../nodeHandles"; 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", () => { describe("createSplitGridImageNodes", () => {
it("creates connected smart image nodes for selected grid cells", async () => { it("creates connected pending smart image nodes for selected grid cells", () => {
vi.stubGlobal("Image", MockImage);
const addNode = vi.fn((type: string, _position: unknown, _data: unknown) => `${type}-node-${addNode.mock.calls.length}`); 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 onConnect = vi.fn();
const selectSingleNode = vi.fn(); const selectSingleNode = vi.fn();
const sourceNode = { const sourceNode = {
@ -42,36 +16,57 @@ describe("createSplitGridImageNodes", () => {
width: 300, width: 300,
} as WorkflowNode; } as WorkflowNode;
const createdNodeIds = await createSplitGridImageNodes({ const createdNodeIds = createSplitGridImageNodes({
sourceNode, sourceNode,
sourceImage: "https://media.example/source.png",
rows: 2, rows: 2,
cols: 2, cols: 2,
selectedCellKeys: new Set(["1-1", "2-2"]), selectedCellKeys: new Set(["1-1", "2-2"]),
addNode, addNode,
updateNodeData,
onConnect, onConnect,
selectSingleNode, 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(createdNodeIds).toEqual(["smartImage-node-1", "smartImage-node-2"]);
expect(addNode).toHaveBeenCalledWith( expect(addNode).toHaveBeenCalledWith(
"smartImage", "smartImage",
expect.objectContaining({ x: 620, y: 200 }), expect.objectContaining({ x: 620, y: 200 }),
expect.objectContaining({ expect.objectContaining({
image: "https://media.example/split-1.png", image: null,
filename: "split-1-1.png", filename: "split-1-1.png",
label: "1-1", label: "1-1",
status: "loading",
disablePreviewModal: true, disablePreviewModal: true,
derivedImage: { derivedImage: {
sourceNodeId: "source-1", sourceNodeId: "source-1",
operation: "splitGrid", 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",
},
}, },
}) })
); );

137
src/utils/gridSplitter.ts

@ -1,16 +1,13 @@
/** /**
* Grid Splitter Utility * 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, * Uses a regularity-first approach that assumes grids are uniform,
* scoring candidates based on how well the image dimensions divide * scoring candidates based on how well the image dimensions divide
* into cells with common photo aspect ratios. * into cells with common photo aspect ratios.
*/ */
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { isHttpMediaUrl } from "@/utils/mediaResolver";
export interface GridCell { export interface GridCell {
x: number; x: number;
y: number; y: number;
@ -48,27 +45,6 @@ const COMMON_ASPECT_RATIOS = [
2 / 3, // 0.667 - Portrait DSLR 2 / 3, // 0.667 - Portrait DSLR
]; ];
async function withCanvasSafeImageSource<T>(
imageSource: string,
callback: (canvasSafeSource: string) => Promise<T>
): Promise<T> {
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. * Analyzes an image and detects grid boundaries.
*/ */
@ -417,114 +393,3 @@ function scoreGridByEdges(
const avgRatio = totalRatio / divisions; const avgRatio = totalRatio / divisions;
return Math.min((avgRatio - 1) / 2, 1); // 0 at ratio=1, 1 at ratio=3+ 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<string[]> {
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<Blob>((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<string>
): 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 };
});
}

48
src/utils/splitGridNodes.ts

@ -1,18 +1,15 @@
import type { XYPosition } from "@xyflow/react"; import type { XYPosition } from "@xyflow/react";
import type { Connection } 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 { defaultNodeDimensions } from "@/constants/nodeDimensions";
import { splitSelectedWithDimensions } from "@/utils/gridSplitter"; import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes";
import { createDerivedImageNode } from "@/utils/derivedImageNodes";
interface SplitGridNodeCreationOptions { interface SplitGridNodeCreationOptions {
sourceNode: WorkflowNode; sourceNode: WorkflowNode;
sourceImage: string;
rows: number; rows: number;
cols: number; cols: number;
selectedCellKeys: Set<string>; selectedCellKeys: Set<string>;
addNode: (type: "smartImage", position: XYPosition, initialData?: Partial<SmartImageNodeData>) => string; addNode: (type: "smartImage", position: XYPosition, initialData?: Partial<SmartImageNodeData>) => string;
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void;
onConnect: (connection: Connection) => void; onConnect: (connection: Connection) => void;
selectSingleNode?: (nodeId: string) => void; selectSingleNode?: (nodeId: string) => void;
} }
@ -30,26 +27,15 @@ function getSplitNodeStartPosition(sourceNode: WorkflowNode): XYPosition {
}; };
} }
function readImageDimensions(image: string): Promise<{ width: number; height: number } | null> { export function createSplitGridImageNodes({
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({
sourceNode, sourceNode,
sourceImage,
rows, rows,
cols, cols,
selectedCellKeys, selectedCellKeys,
addNode, addNode,
updateNodeData,
onConnect, onConnect,
selectSingleNode, selectSingleNode,
}: SplitGridNodeCreationOptions): Promise<string[]> { }: SplitGridNodeCreationOptions): string[] {
const orderedSelectedCells = Array.from(selectedCellKeys) const orderedSelectedCells = Array.from(selectedCellKeys)
.map((key) => { .map((key) => {
const [row, col] = key.split("-").map(Number); 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)) .filter((cell) => Number.isFinite(cell.row) && Number.isFinite(cell.col))
.sort((a, b) => a.row - b.row || a.col - b.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 start = getSplitNodeStartPosition(sourceNode);
const nodeWidth = defaultNodeDimensions.imageInput.width; const nodeWidth = defaultNodeDimensions.imageInput.width;
const nodeHeight = defaultNodeDimensions.imageInput.height; const nodeHeight = defaultNodeDimensions.imageInput.height;
const gap = 24; const gap = 24;
const columns = Math.max(1, Math.ceil(Math.sqrt(orderedSelectedCells.length)));
const createdNodeIds: string[] = []; const createdNodeIds: string[] = [];
images.forEach((image, index) => { orderedSelectedCells.forEach((selectedCell, index) => {
const selectedCell = orderedSelectedCells[index]; const rowIndex = Math.floor(index / columns);
if (!selectedCell) return; const colIndex = index % columns;
const rowIndex = Math.floor(index / Math.max(1, Math.ceil(Math.sqrt(images.length)))); const nodeId = createPendingDerivedImageNode({
const colIndex = index % Math.max(1, Math.ceil(Math.sqrt(images.length)));
const nodeId = createDerivedImageNode({
sourceNodeId: sourceNode.id, sourceNodeId: sourceNode.id,
position: { position: {
x: start.x + colIndex * (nodeWidth + gap), x: start.x + colIndex * (nodeWidth + gap),
y: start.y + rowIndex * (nodeHeight + gap), y: start.y + rowIndex * (nodeHeight + gap),
}, },
image,
filename: `split-${selectedCell.row}-${selectedCell.col}.png`, filename: `split-${selectedCell.row}-${selectedCell.col}.png`,
dimensions: null,
label: `${selectedCell.row}-${selectedCell.col}`, label: `${selectedCell.row}-${selectedCell.col}`,
operation: "splitGrid", operation: "splitGrid",
task: {
rows,
cols,
row: selectedCell.row,
col: selectedCell.col,
cellKey: selectedCell.key,
},
addNode, addNode,
onConnect, onConnect,
}); });
createdNodeIds.push(nodeId); createdNodeIds.push(nodeId);
void readImageDimensions(image).then((dimensions) => {
if (!dimensions) return;
updateNodeData(nodeId, { dimensions });
});
}); });
if (createdNodeIds.length > 0) { if (createdNodeIds.length > 0) {

Loading…
Cancel
Save