feat: add generation cancel job functionality and improve job handling
- Introduced `generationCancelJob` command ID in `commands/ids.ts`. - Added `GenerationCancelJobPayload` type in `commands/payloads.ts`. - Enhanced job status to include "cancelled" in `editor/state.ts`. - Updated `runGenerationJob` to accept an `AbortSignal` and handle cancellation. - Implemented cancellation logic in `runGenerate` and related functions. - Added tests for job cancellation in `operations/generation/workflow.test.ts`. - Improved layer rendering logic to prevent stack overflow in `editor/document-indexes.ts`. - Added raster size assertions in `platform/browser/rasterLimits.ts` for image processing limits. - Enhanced image file handling to check for size limits in `platform/browser/imageFiles.ts`. - Updated UI components to reflect job cancellation state in `view/GenerationJobStatus.tsx` and `view/bottom-controls/GenerateActionControls.tsx`.
This commit is contained in:
@@ -8,8 +8,16 @@ export type BrowserImageFile = {
|
||||
|
||||
export async function decodeBrowserImageFile(file: File): Promise<BrowserImageFile | undefined> {
|
||||
if (!file.type.startsWith("image/")) return undefined;
|
||||
if (file.size > 128 * 1024 * 1024) throw new Error("Image file is too large. The import limit is 128 MB.");
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
let intrinsicSize: { w: number; h: number };
|
||||
try {
|
||||
intrinsicSize = await loadImageSize(objectUrl);
|
||||
assertCanvasRasterSize(intrinsicSize.w, intrinsicSize.h, "Imported image");
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
const source = await fileToDataUrl(file);
|
||||
const intrinsicSize = await loadImageSize(source);
|
||||
return { name: file.name, mimeType: file.type, source, intrinsicSize, release: () => undefined };
|
||||
}
|
||||
|
||||
@@ -30,3 +38,4 @@ function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
import { assertCanvasRasterSize } from "./rasterLimits";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { cropMaskValuesToRgba, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
|
||||
import { blurMaskValues, cropMaskValuesToRgba, dilateMaskValues, erodeMaskValues, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
|
||||
|
||||
describe("mask raster utilities", () => {
|
||||
test("exports the normalized drawn mask without filling the whole crop", () => {
|
||||
@@ -28,6 +28,13 @@ describe("mask raster utilities", () => {
|
||||
h: 64,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses separable mask kernels without changing square-kernel semantics", () => {
|
||||
const values = new Uint8ClampedArray([0, 0, 0, 0, 255, 0, 0, 0, 0]);
|
||||
expect([...dilateMaskValues(values, 3, 3, 1)]).toEqual(new Array(9).fill(255));
|
||||
expect([...erodeMaskValues(new Uint8ClampedArray(new Array(9).fill(255)), 3, 3, 1)]).toEqual(new Array(9).fill(255));
|
||||
expect([...blurMaskValues(values, 3, 3, 1)]).toEqual(new Array(9).fill(28));
|
||||
});
|
||||
});
|
||||
|
||||
function activeRedPixels(rgba: Uint8ClampedArray) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Rect } from "@core/geometry";
|
||||
import { assertCanvasRasterSize, assertProcessingRasterSize } from "./rasterLimits";
|
||||
|
||||
export type MaskFill = "white" | "black" | "clear";
|
||||
|
||||
@@ -32,6 +33,7 @@ export type NormalizedMaskOptions = {
|
||||
};
|
||||
|
||||
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
|
||||
assertProcessingRasterSize(width, height, "Mask");
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = require2dContext(canvas);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -48,6 +50,7 @@ export async function createSolidMaskSource(width: number, height: number, fill:
|
||||
}
|
||||
|
||||
export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise<string> {
|
||||
assertProcessingRasterSize(width, height, "Mask");
|
||||
if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill);
|
||||
|
||||
const mask = await loadMaskValues(source, width, height);
|
||||
@@ -56,6 +59,7 @@ export async function applyMaskRasterOperation(source: string, width: number, he
|
||||
}
|
||||
|
||||
export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> {
|
||||
assertProcessingRasterSize(width, height, "Mask");
|
||||
const mask = await loadMaskValues(source, width, height);
|
||||
const revealed = analyzeValues(mask.values, mask.width, mask.height, false);
|
||||
const hiddenValues = invertMaskValues(mask.values);
|
||||
@@ -74,6 +78,7 @@ export async function analyzeMaskSource(source: string, width: number, height: n
|
||||
}
|
||||
|
||||
export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> {
|
||||
assertProcessingRasterSize(width, height, "Mask");
|
||||
const mask = await loadMaskValues(source, width, height);
|
||||
let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values);
|
||||
|
||||
@@ -94,6 +99,7 @@ export async function createNormalizedMaskSource(source: string, width: number,
|
||||
|
||||
export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> {
|
||||
const image = await loadImage(source);
|
||||
assertCanvasRasterSize(width ?? image.naturalWidth, height ?? image.naturalHeight);
|
||||
const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight);
|
||||
const context = require2dContext(canvas);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -108,6 +114,7 @@ export async function imageSourceToPngDataUrl(source: string): Promise<string> {
|
||||
}
|
||||
|
||||
export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
|
||||
assertProcessingRasterSize(outputWidth, outputHeight, "Crop");
|
||||
const canvas = createCanvas(outputWidth, outputHeight);
|
||||
const context = require2dContext(canvas);
|
||||
context.clearRect(0, 0, outputWidth, outputHeight);
|
||||
@@ -116,6 +123,7 @@ export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWi
|
||||
}
|
||||
|
||||
export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
|
||||
assertProcessingRasterSize(outputWidth, outputHeight, "Mask crop");
|
||||
const canvas = createCanvas(outputWidth, outputHeight);
|
||||
const context = require2dContext(canvas);
|
||||
const imageData = context.createImageData(outputWidth, outputHeight);
|
||||
@@ -203,52 +211,53 @@ export function invertMaskValues(values: Uint8ClampedArray): Uint8ClampedArray {
|
||||
export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
||||
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
||||
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
||||
const next = new Uint8ClampedArray(values.length);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let value = 255;
|
||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
|
||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.min(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
|
||||
}
|
||||
next[y * width + x] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
return separableExtrema(values, width, height, safeRadius, Math.min, 255);
|
||||
}
|
||||
|
||||
export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
||||
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
||||
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
||||
const next = new Uint8ClampedArray(values.length);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let value = 0;
|
||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
|
||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.max(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
|
||||
}
|
||||
next[y * width + x] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
return separableExtrema(values, width, height, safeRadius, Math.max, 0);
|
||||
}
|
||||
|
||||
export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
||||
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
||||
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
||||
const horizontal = new Float64Array(values.length);
|
||||
const next = new Uint8ClampedArray(values.length);
|
||||
const span = safeRadius * 2 + 1;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
let total = 0;
|
||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) total += values[y * width + clampInt(ox, 0, width - 1)] ?? 0;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
|
||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) {
|
||||
total += values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
next[y * width + x] = Math.round(total / count);
|
||||
horizontal[y * width + x] = total / span;
|
||||
total += (values[y * width + clampInt(x + safeRadius + 1, 0, width - 1)] ?? 0) - (values[y * width + clampInt(x - safeRadius, 0, width - 1)] ?? 0);
|
||||
}
|
||||
}
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let total = 0;
|
||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) total += horizontal[clampInt(oy, 0, height - 1) * width + x] ?? 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
next[y * width + x] = Math.round(total / span);
|
||||
total += (horizontal[clampInt(y + safeRadius + 1, 0, height - 1) * width + x] ?? 0) - (horizontal[clampInt(y - safeRadius, 0, height - 1) * width + x] ?? 0);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function separableExtrema(values: Uint8ClampedArray, width: number, height: number, radius: number, combine: (a: number, b: number) => number, initial: number) {
|
||||
const horizontal = new Uint8ClampedArray(values.length);
|
||||
const next = new Uint8ClampedArray(values.length);
|
||||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||
let value = initial;
|
||||
for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, values[y * width + clampInt(x + offset, 0, width - 1)] ?? 0);
|
||||
horizontal[y * width + x] = value;
|
||||
}
|
||||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||
let value = initial;
|
||||
for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, horizontal[clampInt(y + offset, 0, height - 1) * width + x] ?? 0);
|
||||
next[y * width + x] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
20
platform/browser/rasterLimits.ts
Normal file
20
platform/browser/rasterLimits.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const maxCanvasDimension = 16_384;
|
||||
const maxCanvasPixels = 64 * 1024 * 1024;
|
||||
const maxProcessingPixels = 24 * 1024 * 1024;
|
||||
|
||||
export function assertCanvasRasterSize(width: number, height: number, label = "Image") {
|
||||
assertRasterSize(width, height, maxCanvasPixels, label);
|
||||
}
|
||||
|
||||
export function assertProcessingRasterSize(width: number, height: number, label = "Image") {
|
||||
assertRasterSize(width, height, maxProcessingPixels, label);
|
||||
}
|
||||
|
||||
function assertRasterSize(width: number, height: number, maxPixels: number, label: string) {
|
||||
const w = Math.round(width);
|
||||
const h = Math.round(height);
|
||||
if (!Number.isSafeInteger(w) || !Number.isSafeInteger(h) || w <= 0 || h <= 0) throw new Error(`${label} dimensions are invalid.`);
|
||||
if (w > maxCanvasDimension || h > maxCanvasDimension || w * h > maxPixels) {
|
||||
throw new Error(`${label} is too large (${w} x ${h}). Maximum processing size is ${maxCanvasDimension} px per side and ${Math.floor(maxPixels / 1_000_000)} megapixels.`);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ export async function fetchGenerationOptions(): Promise<unknown> {
|
||||
return response.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function requestGeneration(body: unknown): Promise<{ source: string; mimeType: string }> {
|
||||
const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
export async function requestGeneration(body: unknown, signal?: AbortSignal): Promise<{ source: string; mimeType: string }> {
|
||||
const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal });
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<{ source: string; mimeType: string }>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user