From a6bec59866f5c064376c0a96dd7bdc47e9f18be4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 14:28:17 +0200 Subject: [PATCH] refactor(image-pipeline): add backend router seam --- lib/image-pipeline/backend/backend-router.ts | 83 +++++++++++ lib/image-pipeline/backend/backend-types.ts | 42 ++++++ lib/image-pipeline/bridge.ts | 16 +- lib/image-pipeline/preview-renderer.ts | 12 +- tests/image-pipeline/backend-router.test.ts | 147 +++++++++++++++++++ 5 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 lib/image-pipeline/backend/backend-router.ts create mode 100644 lib/image-pipeline/backend/backend-types.ts create mode 100644 tests/image-pipeline/backend-router.test.ts diff --git a/lib/image-pipeline/backend/backend-router.ts b/lib/image-pipeline/backend/backend-router.ts new file mode 100644 index 0000000..f4c00a8 --- /dev/null +++ b/lib/image-pipeline/backend/backend-router.ts @@ -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); +} diff --git a/lib/image-pipeline/backend/backend-types.ts b/lib/image-pipeline/backend/backend-types.ts new file mode 100644 index 0000000..72874de --- /dev/null +++ b/lib/image-pipeline/backend/backend-types.ts @@ -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; + 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; +export type BackendPipelineRequest = Omit; + +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; +}; diff --git a/lib/image-pipeline/bridge.ts b/lib/image-pipeline/bridge.ts index a9cf9a0..bafd006 100644 --- a/lib/image-pipeline/bridge.ts +++ b/lib/image-pipeline/bridge.ts @@ -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 Boolean(signal?.aborted), }, - ); + }); if (signal?.aborted) { throw new DOMException("The operation was aborted.", "AbortError"); diff --git a/lib/image-pipeline/preview-renderer.ts b/lib/image-pipeline/preview-renderer.ts index 7910386..7756c09 100644 --- a/lib/image-pipeline/preview-renderer.ts +++ b/lib/image-pipeline/preview-renderer.ts @@ -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(); diff --git a/tests/image-pipeline/backend-router.test.ts b/tests/image-pipeline/backend-router.test.ts new file mode 100644 index 0000000..1f2c5e0 --- /dev/null +++ b/tests/image-pipeline/backend-router.test.ts @@ -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]); + }); +});