Browse Source

base64处理

feature/group
TianYun 1 month ago
parent
commit
f60c9df029
  1. 1
      src/components/ConnectionDropMenu.tsx
  2. 12
      src/components/GenerateNodeMenu.tsx
  3. 4
      src/components/WorkflowCanvas.tsx
  4. 16
      src/components/__tests__/ConnectionDropMenu.test.tsx
  5. 75
      src/utils/__tests__/gridSplitter.test.ts
  6. 34
      src/utils/gridSplitter.ts

1
src/components/ConnectionDropMenu.tsx

@ -736,6 +736,7 @@ const COMMON_QUICK_ADD_PRIORITIES: Record<ConnectionHandleType, QuickAddOptionTy
const RELATED_QUICK_ADD_PRIORITIES: Partial<Record<NodeType, QuickAddOptionType[]>> = {
imageInput: ["multiAngleImage", "splitGridImmediate"],
annotation: ["multiAngleImage", "splitGridImmediate"],
smartImage: ["multiAngleImage", "splitGridImmediate"],
nanoBanana: ["multiAngleImage", "splitGridImmediate"],
prompt: ["array", "promptConstructor"],
promptConstructor: ["llmGenerate", "array"],

12
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}
>
<div className="min-w-[140px] overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl">
<div className="min-w-[140px] overflow-visible rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl">
{GENERATE_NODE_MENU_ITEMS.map((item, index) => (
<button
key={item.type}
onClick={() => onSelect(item.type)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseEnter={() => {
setSelectedIndex(index);
setOtherOpen(false);
}}
draggable={draggable}
onDragStart={(event) => onDragStart?.(event, item.type)}
className={`w-full select-none px-3 py-2 text-left text-[11px] font-medium transition-colors flex items-center gap-2 ${
@ -206,7 +209,6 @@ export function GenerateNodeMenu({
setSelectedIndex(otherIndex);
setOtherOpen(true);
}}
onMouseLeave={() => setOtherOpen(false)}
>
<button
onClick={() => setOtherOpen((current) => !current)}
@ -228,7 +230,8 @@ export function GenerateNodeMenu({
</button>
{otherOpen && (
<div className="absolute left-full top-0 ml-2 max-h-[420px] min-w-[180px] overflow-y-auto rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl">
<div className="absolute left-full top-0 min-w-[188px] pl-2">
<div className="max-h-[420px] min-w-[180px] overflow-y-auto rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl">
{OTHER_NODE_MENU_CATEGORIES.map((category, categoryIndex) => (
<div key={category.labelKey}>
<div className={`px-3 py-1 text-[10px] uppercase tracking-wide text-neutral-500${categoryIndex > 0 ? " border-t border-neutral-700" : ""}`}>
@ -251,6 +254,7 @@ export function GenerateNodeMenu({
</div>
))}
</div>
</div>
)}
</div>
</div>

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

16
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(
<ConnectionDropMenu
{...defaultProps}
handleType="image"
connectionType="source"
mode="quickAdd"
sourceNodeType="smartImage"
/>
);
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(
<ConnectionDropMenu

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

@ -0,0 +1,75 @@
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"]);
});
});

34
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<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.
*/
@ -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 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 withCanvasSafeImageSource(imageDataUrl, async (canvasSafeSource) => {
const grid = await detectGridWithDimensions(canvasSafeSource, rows, cols);
const images = await splitImage(canvasSafeSource, grid);
return { grid, images };
});
}

Loading…
Cancel
Save