Skip to content

Commit d52c82d

Browse files
committed
perf(tasks/image): WebGPU acceleration + skip dataUri round-trip + cheap input validation
The reactive image pipeline was spending 5+ seconds on a 7-task chain because each task (a) re-encoded its output as a base64 dataUri to "match input form", (b) ran its inner loop in pure JS on the main thread, and (c) ran the default deep JSON-Schema validator over the multi-megabyte Uint8ClampedArray on every reactive run. Changes: - @workglow/util/media: - Add `texture` source kind to `Image` so chained GPU tasks can pass a `GPUTexture` along without intermediate readback. `Image.fromTexture()`, `Image.isGpuSupported()`, and a browser-only `getTexture()` upload on demand and cache via WeakMap. - New `imageGpu.ts` (lazy device + pipeline cache, upload/download helpers) and `imageGpuOps.ts` (compute shaders for sepia, invert, grayscale, brightness, contrast, posterize, threshold, tint, transparency, pixelate, separable box blur, flip h/v). - Browser `getPixels` materializes texture/bitmap/canvas/videoFrame sources directly instead of throwing, and `getImageData` short-circuits OffscreenCanvas/ImageBitmap inputs. - @workglow/tasks/image: - `produceImageOutput` no longer re-encodes a dataUri output — pixels flow between tasks as `ImageBinary`/`Image`. Display and other terminal consumers encode lazily on demand. - New `imageOpDispatcher.ts` routes each op to a GPU compute pipeline when available, falling back to a tuned CPU implementation that mirrors the prior in-task behaviour (separable blur with running sums, LUT-based posterize/contrast, etc.). - New `imageOps.ts` defines paired GPU+CPU implementations for each operation, used by every task. - New `ImageTaskBase` overrides `validateInput` to swap image-typed fields for a tiny stand-in before delegating, so the validator exercises every other field but skips the pixel-array walk. - All ImageXTask files refactored to thin shims that call the dispatcher.
1 parent 6801a77 commit d52c82d

29 files changed

Lines changed: 1851 additions & 527 deletions

packages/tasks/src/common.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export * from "./task/image/ImageResizeTask";
3737
export * from "./task/image/ImageRotateTask";
3838
export * from "./task/image/ImageSchemas";
3939
export * from "./task/image/ImageSepiaTask";
40+
export * from "./task/image/imageOpDispatcher";
41+
export * from "./task/image/imageOps";
42+
export * from "./task/image/ImageTaskBase";
4043
export * from "./task/image/imageTaskIo";
4144
export * from "./task/image/ImageTextTask";
4245
export * from "./task/image/ImageThresholdTask";

packages/tasks/src/task/image/ImageBlurTask.ts

Lines changed: 8 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
import {
88
CreateWorkflow,
99
IExecuteReactiveContext,
10-
Task,
1110
TaskConfig,
1211
Workflow,
1312
} from "@workglow/task-graph";
1413
import { DataPortSchema } from "@workglow/util/schema";
1514
import { ImageBinaryOrDataUriSchema, ImageFromSchema } from "./ImageSchemas";
16-
import { produceImageOutput } from "./imageTaskIo";
15+
import { ImageTaskBase } from "./ImageTaskBase";
16+
import { runImageOp } from "./imageOpDispatcher";
17+
import { BLUR_OP, ensureImageGpuApi } from "./imageOps";
1718

1819
const inputSchema = {
1920
type: "object",
@@ -48,7 +49,7 @@ export class ImageBlurTask<
4849
Input extends ImageBlurTaskInput = ImageBlurTaskInput,
4950
Output extends ImageBlurTaskOutput = ImageBlurTaskOutput,
5051
Config extends TaskConfig = TaskConfig,
51-
> extends Task<Input, Output, Config> {
52+
> extends ImageTaskBase<Input, Output, Config> {
5253
static override readonly type = "ImageBlurTask";
5354
static override readonly category = "Image";
5455
public static override title = "Blur Image";
@@ -67,58 +68,10 @@ export class ImageBlurTask<
6768
_output: Output,
6869
_context: IExecuteReactiveContext
6970
): Promise<Output> {
70-
const { radius = 1 } = input;
71-
const image = await produceImageOutput(input.image, (img) => {
72-
const { data: src, width, height, channels } = img;
73-
const kernelSize = radius * 2 + 1;
74-
75-
// Horizontal pass
76-
const tmp = new Uint8ClampedArray(src.length);
77-
for (let y = 0; y < height; y++) {
78-
for (let c = 0; c < channels; c++) {
79-
let sum = 0;
80-
// Initialize running sum for first pixel
81-
for (let k = -radius; k <= radius; k++) {
82-
const x = Math.max(0, Math.min(k, width - 1));
83-
sum += src[(y * width + x) * channels + c];
84-
}
85-
tmp[y * width * channels + c] = (sum / kernelSize + 0.5) | 0;
86-
87-
// Slide the window across the row
88-
for (let x = 1; x < width; x++) {
89-
const addX = Math.min(x + radius, width - 1);
90-
const removeX = Math.max(x - radius - 1, 0);
91-
sum +=
92-
src[(y * width + addX) * channels + c] - src[(y * width + removeX) * channels + c];
93-
tmp[(y * width + x) * channels + c] = (sum / kernelSize + 0.5) | 0;
94-
}
95-
}
96-
}
97-
98-
// Vertical pass
99-
const dst = new Uint8ClampedArray(src.length);
100-
for (let x = 0; x < width; x++) {
101-
for (let c = 0; c < channels; c++) {
102-
let sum = 0;
103-
for (let k = -radius; k <= radius; k++) {
104-
const y = Math.max(0, Math.min(k, height - 1));
105-
sum += tmp[(y * width + x) * channels + c];
106-
}
107-
dst[x * channels + c] = (sum / kernelSize + 0.5) | 0;
108-
109-
for (let y = 1; y < height; y++) {
110-
const addY = Math.min(y + radius, height - 1);
111-
const removeY = Math.max(y - radius - 1, 0);
112-
sum +=
113-
tmp[(addY * width + x) * channels + c] - tmp[(removeY * width + x) * channels + c];
114-
dst[(y * width + x) * channels + c] = (sum / kernelSize + 0.5) | 0;
115-
}
116-
}
117-
}
118-
119-
return { data: dst, width, height, channels };
120-
});
121-
return { image } as Output;
71+
await ensureImageGpuApi();
72+
const radius = input.radius ?? 1;
73+
const image = await runImageOp(input.image, BLUR_OP, { radius });
74+
return { image: image as unknown as Output["image"] } as Output;
12275
}
12376
}
12477

packages/tasks/src/task/image/ImageBorderTask.ts

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
import {
88
CreateWorkflow,
99
IExecuteReactiveContext,
10-
Task,
1110
TaskConfig,
1211
Workflow,
1312
} from "@workglow/task-graph";
1413
import { resolveColor } from "@workglow/util/media";
1514
import { DataPortSchema } from "@workglow/util/schema";
1615
import { ColorValueSchema, ImageBinaryOrDataUriSchema, ImageFromSchema } from "./ImageSchemas";
17-
import { produceImageOutput } from "./imageTaskIo";
16+
import { ImageTaskBase } from "./ImageTaskBase";
17+
import { runImageResizeOp } from "./imageOpDispatcher";
18+
import { BORDER_OP } from "./imageOps";
1819

1920
const inputSchema = {
2021
type: "object",
@@ -49,7 +50,7 @@ export class ImageBorderTask<
4950
Input extends ImageBorderTaskInput = ImageBorderTaskInput,
5051
Output extends ImageBorderTaskOutput = ImageBorderTaskOutput,
5152
Config extends TaskConfig = TaskConfig,
52-
> extends Task<Input, Output, Config> {
53+
> extends ImageTaskBase<Input, Output, Config> {
5354
static override readonly type = "ImageBorderTask";
5455
static override readonly category = "Image";
5556
public static override title = "Add Border";
@@ -68,43 +69,16 @@ export class ImageBorderTask<
6869
_output: Output,
6970
_context: IExecuteReactiveContext
7071
): Promise<Output> {
71-
const { borderWidth: bw = 1 } = input;
72-
const color = resolveColor(input.color);
73-
const image = await produceImageOutput(input.image, (img) => {
74-
const { data: src, width: srcW, height: srcH, channels: srcCh } = img;
75-
const outCh = 4;
76-
const dstW = srcW + bw * 2;
77-
const dstH = srcH + bw * 2;
78-
const dst = new Uint8ClampedArray(dstW * dstH * outCh);
79-
80-
const r = color.r;
81-
const g = color.g;
82-
const b = color.b;
83-
const a = color.a;
84-
85-
// Fill entire image with border color
86-
for (let i = 0; i < dst.length; i += outCh) {
87-
dst[i] = r;
88-
dst[i + 1] = g;
89-
dst[i + 2] = b;
90-
dst[i + 3] = a;
91-
}
92-
93-
// Copy source image into center
94-
for (let y = 0; y < srcH; y++) {
95-
for (let x = 0; x < srcW; x++) {
96-
const srcIdx = (y * srcW + x) * srcCh;
97-
const dstIdx = ((y + bw) * dstW + (x + bw)) * outCh;
98-
dst[dstIdx] = src[srcIdx];
99-
dst[dstIdx + 1] = srcCh >= 3 ? src[srcIdx + 1] : src[srcIdx];
100-
dst[dstIdx + 2] = srcCh >= 3 ? src[srcIdx + 2] : src[srcIdx];
101-
dst[dstIdx + 3] = srcCh === 4 ? src[srcIdx + 3] : 255;
102-
}
103-
}
104-
105-
return { data: dst, width: dstW, height: dstH, channels: outCh };
72+
const { r, g, b, a } = resolveColor(input.color);
73+
const borderWidth = input.borderWidth ?? 1;
74+
const image = await runImageResizeOp(input.image, BORDER_OP, {
75+
borderWidth,
76+
r,
77+
g,
78+
b,
79+
a,
10680
});
107-
return { image } as Output;
81+
return { image: image as unknown as Output["image"] } as Output;
10882
}
10983
}
11084

packages/tasks/src/task/image/ImageBrightnessTask.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
import {
88
CreateWorkflow,
99
IExecuteReactiveContext,
10-
Task,
1110
TaskConfig,
1211
Workflow,
1312
} from "@workglow/task-graph";
1413
import { DataPortSchema } from "@workglow/util/schema";
1514
import { ImageBinaryOrDataUriSchema, ImageFromSchema } from "./ImageSchemas";
16-
import { produceImageOutput } from "./imageTaskIo";
15+
import { ImageTaskBase } from "./ImageTaskBase";
16+
import { runImageOp } from "./imageOpDispatcher";
17+
import { BRIGHTNESS_OP, ensureImageGpuApi } from "./imageOps";
1718

1819
const inputSchema = {
1920
type: "object",
@@ -48,7 +49,7 @@ export class ImageBrightnessTask<
4849
Input extends ImageBrightnessTaskInput = ImageBrightnessTaskInput,
4950
Output extends ImageBrightnessTaskOutput = ImageBrightnessTaskOutput,
5051
Config extends TaskConfig = TaskConfig,
51-
> extends Task<Input, Output, Config> {
52+
> extends ImageTaskBase<Input, Output, Config> {
5253
static override readonly type = "ImageBrightnessTask";
5354
static override readonly category = "Image";
5455
public static override title = "Adjust Brightness";
@@ -67,27 +68,10 @@ export class ImageBrightnessTask<
6768
_output: Output,
6869
_context: IExecuteReactiveContext
6970
): Promise<Output> {
71+
await ensureImageGpuApi();
7072
const amount = input.amount ?? 0;
71-
const image = await produceImageOutput(input.image, (img) => {
72-
const { data: src, width, height, channels } = img;
73-
const dst = new Uint8ClampedArray(src.length);
74-
75-
if (channels === 4) {
76-
for (let i = 0; i < src.length; i += 4) {
77-
dst[i] = src[i]! + amount;
78-
dst[i + 1] = src[i + 1]! + amount;
79-
dst[i + 2] = src[i + 2]! + amount;
80-
dst[i + 3] = src[i + 3]!; // preserve alpha
81-
}
82-
} else {
83-
for (let i = 0; i < src.length; i++) {
84-
dst[i] = src[i]! + amount;
85-
}
86-
}
87-
88-
return { data: dst, width, height, channels };
89-
});
90-
return { image } as Output;
73+
const image = await runImageOp(input.image, BRIGHTNESS_OP, { amount });
74+
return { image: image as unknown as Output["image"] } as Output;
9175
}
9276
}
9377

packages/tasks/src/task/image/ImageContrastTask.ts

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
import {
88
CreateWorkflow,
99
IExecuteReactiveContext,
10-
Task,
1110
TaskConfig,
1211
Workflow,
1312
} from "@workglow/task-graph";
1413
import { DataPortSchema } from "@workglow/util/schema";
1514
import { ImageBinaryOrDataUriSchema, ImageFromSchema } from "./ImageSchemas";
16-
import { produceImageOutput } from "./imageTaskIo";
15+
import { ImageTaskBase } from "./ImageTaskBase";
16+
import { runImageOp } from "./imageOpDispatcher";
17+
import { CONTRAST_OP, ensureImageGpuApi } from "./imageOps";
1718

1819
const inputSchema = {
1920
type: "object",
@@ -48,7 +49,7 @@ export class ImageContrastTask<
4849
Input extends ImageContrastTaskInput = ImageContrastTaskInput,
4950
Output extends ImageContrastTaskOutput = ImageContrastTaskOutput,
5051
Config extends TaskConfig = TaskConfig,
51-
> extends Task<Input, Output, Config> {
52+
> extends ImageTaskBase<Input, Output, Config> {
5253
static override readonly type = "ImageContrastTask";
5354
static override readonly category = "Image";
5455
public static override title = "Adjust Contrast";
@@ -67,35 +68,10 @@ export class ImageContrastTask<
6768
_output: Output,
6869
_context: IExecuteReactiveContext
6970
): Promise<Output> {
71+
await ensureImageGpuApi();
7072
const amount = input.amount ?? 0;
71-
const image = await produceImageOutput(input.image, (img) => {
72-
const { data: src, width, height, channels } = img;
73-
74-
// Precompute 256-entry lookup table
75-
const factor = (259 * (amount + 255)) / (255 * (259 - amount));
76-
const lut = new Uint8ClampedArray(256);
77-
for (let i = 0; i < 256; i++) {
78-
lut[i] = factor * (i - 128) + 128;
79-
}
80-
81-
const dst = new Uint8ClampedArray(src.length);
82-
83-
if (channels === 4) {
84-
for (let i = 0; i < src.length; i += 4) {
85-
dst[i] = lut[src[i]!]!;
86-
dst[i + 1] = lut[src[i + 1]!]!;
87-
dst[i + 2] = lut[src[i + 2]!]!;
88-
dst[i + 3] = src[i + 3]!; // preserve alpha
89-
}
90-
} else {
91-
for (let i = 0; i < src.length; i++) {
92-
dst[i] = lut[src[i]!]!;
93-
}
94-
}
95-
96-
return { data: dst, width, height, channels };
97-
});
98-
return { image } as Output;
73+
const image = await runImageOp(input.image, CONTRAST_OP, { amount });
74+
return { image: image as unknown as Output["image"] } as Output;
9975
}
10076
}
10177

packages/tasks/src/task/image/ImageCropTask.ts

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
import {
88
CreateWorkflow,
99
IExecuteReactiveContext,
10-
Task,
1110
TaskConfig,
1211
Workflow,
1312
} from "@workglow/task-graph";
1413
import { DataPortSchema } from "@workglow/util/schema";
1514
import { ImageBinaryOrDataUriSchema, ImageFromSchema } from "./ImageSchemas";
16-
import { produceImageOutput } from "./imageTaskIo";
15+
import { ImageTaskBase } from "./ImageTaskBase";
16+
import { runImageResizeOp } from "./imageOpDispatcher";
17+
import { CROP_OP } from "./imageOps";
1718

1819
const inputSchema = {
1920
type: "object",
@@ -44,7 +45,7 @@ export class ImageCropTask<
4445
Input extends ImageCropTaskInput = ImageCropTaskInput,
4546
Output extends ImageCropTaskOutput = ImageCropTaskOutput,
4647
Config extends TaskConfig = TaskConfig,
47-
> extends Task<Input, Output, Config> {
48+
> extends ImageTaskBase<Input, Output, Config> {
4849
static override readonly type = "ImageCropTask";
4950
static override readonly category = "Image";
5051
public static override title = "Crop Image";
@@ -63,35 +64,13 @@ export class ImageCropTask<
6364
_output: Output,
6465
_context: IExecuteReactiveContext
6566
): Promise<Output> {
66-
const { x: rawX, y: rawY, width: rawW, height: rawH } = input;
67-
const image = await produceImageOutput(input.image, (img) => {
68-
const { data: src, width: srcW, height: srcH, channels } = img;
69-
70-
if (srcW < 1 || srcH < 1) {
71-
throw new RangeError("Cannot crop an empty image");
72-
}
73-
74-
if (rawX < 0 || rawX >= srcW || rawY < 0 || rawY >= srcH) {
75-
throw new RangeError("Crop origin is outside the source image bounds");
76-
}
77-
78-
const x = rawX;
79-
const y = rawY;
80-
const w = Math.min(rawW, srcW - x);
81-
const h = Math.min(rawH, srcH - y);
82-
83-
const dst = new Uint8ClampedArray(w * h * channels);
84-
const rowBytes = w * channels;
85-
86-
for (let row = 0; row < h; row++) {
87-
const srcOffset = ((y + row) * srcW + x) * channels;
88-
const dstOffset = row * rowBytes;
89-
dst.set(src.subarray(srcOffset, srcOffset + rowBytes), dstOffset);
90-
}
91-
92-
return { data: dst, width: w, height: h, channels };
67+
const image = await runImageResizeOp(input.image, CROP_OP, {
68+
x: input.x,
69+
y: input.y,
70+
width: input.width,
71+
height: input.height,
9372
});
94-
return { image } as Output;
73+
return { image: image as unknown as Output["image"] } as Output;
9574
}
9675
}
9776

0 commit comments

Comments
 (0)