refactor(image-pipeline): add backend router seam
This commit is contained in:
83
lib/image-pipeline/backend/backend-router.ts
Normal file
83
lib/image-pipeline/backend/backend-router.ts
Normal 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);
|
||||||
|
}
|
||||||
42
lib/image-pipeline/backend/backend-types.ts
Normal file
42
lib/image-pipeline/backend/backend-types.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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 { resolveRenderSize } from "@/lib/image-pipeline/render-size";
|
||||||
import {
|
import {
|
||||||
RENDER_FORMAT_TO_MIME,
|
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);
|
context.drawImage(bitmap, 0, 0, resolvedSize.width, resolvedSize.height);
|
||||||
|
|
||||||
const imageData = context.getImageData(0, 0, resolvedSize.width, resolvedSize.height);
|
const imageData = context.getImageData(0, 0, resolvedSize.width, resolvedSize.height);
|
||||||
applyPipelineSteps(
|
runFullPipelineWithBackendRouter({
|
||||||
imageData.data,
|
pixels: imageData.data,
|
||||||
options.steps,
|
steps: options.steps,
|
||||||
resolvedSize.width,
|
width: resolvedSize.width,
|
||||||
resolvedSize.height,
|
height: resolvedSize.height,
|
||||||
{
|
executionOptions: {
|
||||||
shouldAbort: () => Boolean(signal?.aborted),
|
shouldAbort: () => Boolean(signal?.aborted),
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new DOMException("The operation was aborted.", "AbortError");
|
throw new DOMException("The operation was aborted.", "AbortError");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
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 { 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";
|
import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader";
|
||||||
|
|
||||||
export type PreviewRenderResult = {
|
export type PreviewRenderResult = {
|
||||||
@@ -77,8 +77,14 @@ export async function renderPreview(options: {
|
|||||||
const imageData = context.getImageData(0, 0, width, height);
|
const imageData = context.getImageData(0, 0, width, height);
|
||||||
|
|
||||||
for (let index = 0; index < options.steps.length; index += 1) {
|
for (let index = 0; index < options.steps.length; index += 1) {
|
||||||
applyPipelineStep(imageData.data, options.steps[index]!, width, height, {
|
runPreviewStepWithBackendRouter({
|
||||||
|
pixels: imageData.data,
|
||||||
|
step: options.steps[index]!,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
executionOptions: {
|
||||||
shouldAbort: () => Boolean(options.signal?.aborted),
|
shouldAbort: () => Boolean(options.signal?.aborted),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await yieldToMainOrWorkerLoop();
|
await yieldToMainOrWorkerLoop();
|
||||||
|
|
||||||
|
|||||||
147
tests/image-pipeline/backend-router.test.ts
Normal file
147
tests/image-pipeline/backend-router.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user