refactor(image-pipeline): add backend router seam

This commit is contained in:
Matthias
2026-04-04 14:28:17 +02:00
parent 1d2654fec1
commit a6bec59866
5 changed files with 289 additions and 11 deletions

View File

@@ -0,0 +1,83 @@
import { applyPipelineStep, applyPipelineSteps } from "@/lib/image-pipeline/render-core";
import {
CPU_BACKEND_ID,
type BackendHint,
type BackendRouter,
type FullBackendRequest,
type ImagePipelineBackend,
type PreviewBackendRequest,
} from "@/lib/image-pipeline/backend/backend-types";
const cpuBackend: ImagePipelineBackend = {
id: CPU_BACKEND_ID,
runPreviewStep(request) {
applyPipelineStep(
request.pixels,
request.step,
request.width,
request.height,
request.executionOptions,
);
},
runFullPipeline(request) {
applyPipelineSteps(
request.pixels,
request.steps,
request.width,
request.height,
request.executionOptions,
);
},
};
function normalizeBackendHint(value: BackendHint): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
export function createBackendRouter(options?: {
backends?: readonly ImagePipelineBackend[];
defaultBackendId?: string;
}): BackendRouter {
const configuredBackends = options?.backends?.length ? [...options.backends] : [cpuBackend];
const byId = new Map(configuredBackends.map((backend) => [backend.id.toLowerCase(), backend]));
const defaultBackend =
byId.get(options?.defaultBackendId?.toLowerCase() ?? "") ??
byId.get(CPU_BACKEND_ID) ??
configuredBackends[0] ??
cpuBackend;
return {
resolveBackend(backendHint) {
const normalizedHint = normalizeBackendHint(backendHint);
if (!normalizedHint) {
return defaultBackend;
}
return byId.get(normalizedHint) ?? defaultBackend;
},
runPreviewStep(request) {
const backend = this.resolveBackend(request.backendHint);
backend.runPreviewStep(request);
},
runFullPipeline(request) {
const backend = this.resolveBackend(request.backendHint);
backend.runFullPipeline(request);
},
};
}
const defaultRouter = createBackendRouter();
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {
defaultRouter.runPreviewStep(request);
}
export function runFullPipelineWithBackendRouter(request: FullBackendRequest): void {
defaultRouter.runFullPipeline(request);
}

View File

@@ -0,0 +1,42 @@
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
export const CPU_BACKEND_ID = "cpu" as const;
export type BackendHint = string | undefined;
export type BackendExecutionOptions = {
shouldAbort?: () => boolean;
};
export type PreviewBackendRequest = {
pixels: Uint8ClampedArray;
step: PipelineStep<string, unknown>;
width: number;
height: number;
backendHint?: BackendHint;
executionOptions?: BackendExecutionOptions;
};
export type FullBackendRequest = {
pixels: Uint8ClampedArray;
steps: readonly PipelineStep[];
width: number;
height: number;
backendHint?: BackendHint;
executionOptions?: BackendExecutionOptions;
};
export type BackendStepRequest = Omit<PreviewBackendRequest, "backendHint">;
export type BackendPipelineRequest = Omit<FullBackendRequest, "backendHint">;
export type ImagePipelineBackend = {
id: string;
runPreviewStep: (request: BackendStepRequest) => void;
runFullPipeline: (request: BackendPipelineRequest) => void;
};
export type BackendRouter = {
resolveBackend: (backendHint?: BackendHint) => ImagePipelineBackend;
runPreviewStep: (request: PreviewBackendRequest) => void;
runFullPipeline: (request: FullBackendRequest) => void;
};

View File

@@ -1,4 +1,4 @@
import { applyPipelineSteps } from "@/lib/image-pipeline/render-core";
import { runFullPipelineWithBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
import { resolveRenderSize } from "@/lib/image-pipeline/render-size";
import {
RENDER_FORMAT_TO_MIME,
@@ -108,15 +108,15 @@ export async function renderFull(options: RenderFullOptions): Promise<RenderFull
context.drawImage(bitmap, 0, 0, resolvedSize.width, resolvedSize.height);
const imageData = context.getImageData(0, 0, resolvedSize.width, resolvedSize.height);
applyPipelineSteps(
imageData.data,
options.steps,
resolvedSize.width,
resolvedSize.height,
{
runFullPipelineWithBackendRouter({
pixels: imageData.data,
steps: options.steps,
width: resolvedSize.width,
height: resolvedSize.height,
executionOptions: {
shouldAbort: () => Boolean(signal?.aborted),
},
);
});
if (signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");

View File

@@ -1,6 +1,6 @@
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { runPreviewStepWithBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
import { computeHistogram, emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
import { applyPipelineStep } from "@/lib/image-pipeline/render-core";
import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader";
export type PreviewRenderResult = {
@@ -77,8 +77,14 @@ export async function renderPreview(options: {
const imageData = context.getImageData(0, 0, width, height);
for (let index = 0; index < options.steps.length; index += 1) {
applyPipelineStep(imageData.data, options.steps[index]!, width, height, {
shouldAbort: () => Boolean(options.signal?.aborted),
runPreviewStepWithBackendRouter({
pixels: imageData.data,
step: options.steps[index]!,
width,
height,
executionOptions: {
shouldAbort: () => Boolean(options.signal?.aborted),
},
});
await yieldToMainOrWorkerLoop();

View File

@@ -0,0 +1,147 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { applyPipelineStep } from "@/lib/image-pipeline/render-core";
import {
createBackendRouter,
runPreviewStepWithBackendRouter,
} from "@/lib/image-pipeline/backend/backend-router";
import { renderFull } from "@/lib/image-pipeline/bridge";
const sourceLoaderMocks = vi.hoisted(() => ({
loadSourceBitmap: vi.fn(),
}));
vi.mock("@/lib/image-pipeline/source-loader", () => ({
loadSourceBitmap: sourceLoaderMocks.loadSourceBitmap,
}));
function createPreviewPixels(): Uint8ClampedArray {
return new Uint8ClampedArray([
16,
32,
48,
255,
80,
96,
112,
255,
144,
160,
176,
255,
208,
224,
240,
255,
]);
}
function createStep(): PipelineStep {
return {
nodeId: "color-1",
type: "color-adjust",
params: {
hsl: {
hue: 12,
saturation: 18,
luminance: -8,
},
temperature: 6,
tint: -4,
vibrance: 10,
},
};
}
describe("backend router", () => {
beforeEach(() => {
vi.resetAllMocks();
sourceLoaderMocks.loadSourceBitmap.mockResolvedValue({
width: 2,
height: 2,
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({
data: createPreviewPixels(),
})),
putImageData: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(function toBlob(
callback: BlobCallback,
type?: string,
) {
callback(new Blob(["rendered-full-output"], { type: type ?? "image/png" }));
});
});
it("keeps preview step output identical to render-core with cpu backend", () => {
const width = 2;
const height = 2;
const step = createStep();
const expected = createPreviewPixels();
const actual = createPreviewPixels();
applyPipelineStep(expected, step, width, height);
runPreviewStepWithBackendRouter({
pixels: actual,
step,
width,
height,
backendHint: "cpu",
});
expect([...actual]).toEqual([...expected]);
});
it("keeps full render output valid when routed through cpu backend", async () => {
const result = await renderFull({
sourceUrl: "https://cdn.example.com/full.png",
steps: [createStep()],
render: {
resolution: "original",
format: "png",
},
});
expect(result.blob).toBeInstanceOf(Blob);
expect(result.blob.size).toBeGreaterThan(0);
expect(result.mimeType).toBe("image/png");
});
it("falls back to cpu for unknown backend hint", () => {
const width = 2;
const height = 2;
const step = createStep();
const cpuPixels = createPreviewPixels();
const unknownPixels = createPreviewPixels();
const router = createBackendRouter();
router.runPreviewStep({
pixels: cpuPixels,
step,
width,
height,
backendHint: "cpu",
});
router.runPreviewStep({
pixels: unknownPixels,
step,
width,
height,
backendHint: "backend-that-does-not-exist",
});
expect([...unknownPixels]).toEqual([...cpuPixels]);
});
});