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 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 || [];

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 { 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) {

25
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<string, unknown>): 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",

8
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;

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

137
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<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.
*/
@ -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<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 { 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<string>;
addNode: (type: "smartImage", position: XYPosition, initialData?: Partial<SmartImageNodeData>) => string;
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => 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<string[]> {
}: 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) {

Loading…
Cancel
Save