fix(image-pipeline): wire webgl preview shader execution

This commit is contained in:
Matthias
2026-04-04 22:04:24 +02:00
parent 423eb76581
commit 80f12739f9
6 changed files with 444 additions and 117 deletions

View File

@@ -3,7 +3,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types";
function createCurvesStep(): PipelineStep {
return {
@@ -65,24 +64,6 @@ function createUnsupportedStep(): PipelineStep {
};
}
function createCpuAndWebglBackends(args: {
webglPreview?: ImagePipelineBackend["runPreviewStep"];
cpuPreview?: ImagePipelineBackend["runPreviewStep"];
}): readonly ImagePipelineBackend[] {
return [
{
id: "cpu",
runPreviewStep: args.cpuPreview ?? vi.fn(),
runFullPipeline: vi.fn(),
},
{
id: "webgl",
runPreviewStep: args.webglPreview ?? vi.fn(),
runFullPipeline: vi.fn(),
},
];
}
describe("webgl backend poc", () => {
beforeEach(() => {
vi.resetModules();
@@ -95,8 +76,102 @@ describe("webgl backend poc", () => {
vi.unmock("@/lib/image-pipeline/backend/webgl/webgl-backend");
vi.unmock("@/lib/image-pipeline/backend/backend-router");
vi.unmock("@/lib/image-pipeline/source-loader");
vi.unmock("@/lib/image-pipeline/render-core");
});
function createFakeWebglContext(options?: {
compileSuccess?: boolean;
linkSuccess?: boolean;
readbackPixels?: Uint8Array;
}): WebGLRenderingContext {
const compileSuccess = options?.compileSuccess ?? true;
const linkSuccess = options?.linkSuccess ?? true;
const readbackPixels = options?.readbackPixels ?? new Uint8Array([0, 0, 0, 255]);
return {
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
ARRAY_BUFFER: 0x8892,
STATIC_DRAW: 0x88e4,
TRIANGLE_STRIP: 0x0005,
FLOAT: 0x1406,
TEXTURE_2D: 0x0de1,
RGBA: 0x1908,
UNSIGNED_BYTE: 0x1401,
TEXTURE0: 0x84c0,
TEXTURE_MIN_FILTER: 0x2801,
TEXTURE_MAG_FILTER: 0x2800,
TEXTURE_WRAP_S: 0x2802,
TEXTURE_WRAP_T: 0x2803,
CLAMP_TO_EDGE: 0x812f,
NEAREST: 0x2600,
FRAMEBUFFER: 0x8d40,
COLOR_ATTACHMENT0: 0x8ce0,
FRAMEBUFFER_COMPLETE: 0x8cd5,
createShader: vi.fn(() => ({ shader: true })),
shaderSource: vi.fn(),
compileShader: vi.fn(),
getShaderParameter: vi.fn((_shader: unknown, pname: number) => {
if (pname === 0x8b81) {
return compileSuccess;
}
return true;
}),
getShaderInfoLog: vi.fn(() => "compile error"),
deleteShader: vi.fn(),
createProgram: vi.fn(() => ({ program: true })),
attachShader: vi.fn(),
linkProgram: vi.fn(),
getProgramParameter: vi.fn((_program: unknown, pname: number) => {
if (pname === 0x8b82) {
return linkSuccess;
}
return true;
}),
getProgramInfoLog: vi.fn(() => "link error"),
deleteProgram: vi.fn(),
useProgram: vi.fn(),
createBuffer: vi.fn(() => ({ buffer: true })),
bindBuffer: vi.fn(),
bufferData: vi.fn(),
getAttribLocation: vi.fn(() => 0),
enableVertexAttribArray: vi.fn(),
vertexAttribPointer: vi.fn(),
createTexture: vi.fn(() => ({ texture: true })),
bindTexture: vi.fn(),
texParameteri: vi.fn(),
texImage2D: vi.fn(),
activeTexture: vi.fn(),
getUniformLocation: vi.fn(() => ({ uniform: true })),
uniform1i: vi.fn(),
uniform1f: vi.fn(),
uniform3f: vi.fn(),
createFramebuffer: vi.fn(() => ({ framebuffer: true })),
bindFramebuffer: vi.fn(),
framebufferTexture2D: vi.fn(),
checkFramebufferStatus: vi.fn(() => 0x8cd5),
deleteFramebuffer: vi.fn(),
viewport: vi.fn(),
drawArrays: vi.fn(),
deleteTexture: vi.fn(),
readPixels: vi.fn(
(
_x: number,
_y: number,
_width: number,
_height: number,
_format: number,
_type: number,
pixels: Uint8Array,
) => {
pixels.set(readbackPixels);
},
),
} as unknown as WebGLRenderingContext;
}
it("selects webgl for preview when webgl is available and enabled", async () => {
const webglPreview = vi.fn();
@@ -224,8 +299,49 @@ describe("webgl backend poc", () => {
}
});
it("runs a supported preview step through gpu shader path with readback", async () => {
const cpuPreview = vi.fn();
vi.doMock("@/lib/image-pipeline/render-core", async () => {
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/render-core")>(
"@/lib/image-pipeline/render-core",
);
return {
...actual,
applyPipelineStep: cpuPreview,
};
});
const fakeGl = createFakeWebglContext({
readbackPixels: new Uint8Array([10, 20, 30, 255]),
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
if (contextId === "webgl") {
return fakeGl;
}
return null;
});
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
const pixels = new Uint8ClampedArray([200, 100, 50, 255]);
const backend = createWebglPreviewBackend();
backend.runPreviewStep({
pixels,
step: createCurvesStep(),
width: 1,
height: 1,
});
expect(Array.from(pixels)).toEqual([10, 20, 30, 255]);
expect(cpuPreview).not.toHaveBeenCalled();
expect(fakeGl.readPixels).toHaveBeenCalledTimes(1);
});
it("downgrades compile/link failures to cpu with runtime_error reason", async () => {
const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router");
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
const cpuPreview = vi.fn();
const fallbackEvents: Array<{
reason: string;
@@ -233,13 +349,25 @@ describe("webgl backend poc", () => {
fallbackBackend: string;
}> = [];
const fakeGl = createFakeWebglContext({
compileSuccess: false,
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
if (contextId === "webgl") {
return fakeGl;
}
return null;
});
const router = createBackendRouter({
backends: createCpuAndWebglBackends({
cpuPreview,
webglPreview: () => {
throw new Error("WebGL shader compile failed");
backends: [
{
id: "cpu",
runPreviewStep: cpuPreview,
runFullPipeline: vi.fn(),
},
}),
createWebglPreviewBackend(),
],
defaultBackendId: "webgl",
backendAvailability: {
webgl: {
@@ -277,4 +405,49 @@ describe("webgl backend poc", () => {
},
]);
});
it("re-evaluates rollout flags and capabilities at runtime", async () => {
const runtimeState = {
flags: {
forceCpu: false,
webglEnabled: false,
wasmEnabled: false,
},
capabilities: {
webgl: false,
wasmSimd: false,
offscreenCanvas: false,
},
};
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/backend/feature-flags")>(
"@/lib/image-pipeline/backend/feature-flags",
);
return {
...actual,
getBackendFeatureFlags: () => runtimeState.flags,
};
});
vi.doMock("@/lib/image-pipeline/backend/capabilities", async () => {
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/backend/capabilities")>(
"@/lib/image-pipeline/backend/capabilities",
);
return {
...actual,
detectBackendCapabilities: () => runtimeState.capabilities,
};
});
const { getPreviewBackendHintForSteps } = await import("@/lib/image-pipeline/backend/backend-router");
const steps = [createCurvesStep()] as const;
expect(getPreviewBackendHintForSteps(steps)).toBe("cpu");
runtimeState.flags.webglEnabled = true;
runtimeState.capabilities.webgl = true;
expect(getPreviewBackendHintForSteps(steps)).toBe("webgl");
});
});