540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
|
import { enforceCpuWebglParityGates } from "@/tests/image-pipeline/parity/fixtures";
|
|
|
|
function createCurvesStep(): PipelineStep {
|
|
return {
|
|
nodeId: "curves-1",
|
|
type: "curves",
|
|
params: {
|
|
channelMode: "master",
|
|
levels: {
|
|
blackPoint: 0,
|
|
whitePoint: 255,
|
|
gamma: 1,
|
|
},
|
|
points: {
|
|
rgb: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 255, y: 255 },
|
|
],
|
|
red: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 255, y: 255 },
|
|
],
|
|
green: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 255, y: 255 },
|
|
],
|
|
blue: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 255, y: 255 },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createColorAdjustStep(): PipelineStep {
|
|
return {
|
|
nodeId: "color-1",
|
|
type: "color-adjust",
|
|
params: {
|
|
hsl: {
|
|
hue: 0,
|
|
saturation: 0,
|
|
luminance: 0,
|
|
},
|
|
temperature: 0,
|
|
tint: 0,
|
|
vibrance: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createUnsupportedStep(): PipelineStep {
|
|
return {
|
|
nodeId: "light-1",
|
|
type: "unsupported-adjust" as PipelineStep["type"],
|
|
params: {
|
|
exposure: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("webgl backend poc", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unmock("@/lib/image-pipeline/backend/capabilities");
|
|
vi.unmock("@/lib/image-pipeline/backend/feature-flags");
|
|
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 () => {
|
|
// Parity gate in jsdom with mocked WebGL verifies backend contract behavior,
|
|
// not GPU-driver conformance.
|
|
enforceCpuWebglParityGates();
|
|
|
|
const webglPreview = vi.fn();
|
|
|
|
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: () => ({
|
|
forceCpu: false,
|
|
webglEnabled: true,
|
|
wasmEnabled: false,
|
|
}),
|
|
};
|
|
});
|
|
|
|
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: () => ({
|
|
webgl: true,
|
|
wasmSimd: false,
|
|
offscreenCanvas: true,
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.doMock("@/lib/image-pipeline/backend/webgl/webgl-backend", () => ({
|
|
createWebglPreviewBackend: () => ({
|
|
id: "webgl",
|
|
runPreviewStep: webglPreview,
|
|
runFullPipeline: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const { runPreviewStepWithBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router");
|
|
|
|
runPreviewStepWithBackendRouter({
|
|
pixels: new Uint8ClampedArray(4),
|
|
step: createCurvesStep(),
|
|
width: 1,
|
|
height: 1,
|
|
});
|
|
|
|
expect(webglPreview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("uses cpu for every step in a mixed pipeline request", async () => {
|
|
// Keep backend-contract parity gate explicit for mocked jsdom runs.
|
|
enforceCpuWebglParityGates();
|
|
|
|
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: () => ({
|
|
forceCpu: false,
|
|
webglEnabled: true,
|
|
wasmEnabled: false,
|
|
}),
|
|
};
|
|
});
|
|
|
|
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: () => ({
|
|
webgl: true,
|
|
wasmSimd: false,
|
|
offscreenCanvas: true,
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.doMock("@/lib/image-pipeline/backend/webgl/webgl-backend", async () => {
|
|
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/backend/webgl/webgl-backend")>(
|
|
"@/lib/image-pipeline/backend/webgl/webgl-backend",
|
|
);
|
|
return {
|
|
...actual,
|
|
};
|
|
});
|
|
|
|
const backendRouterModule = await import("@/lib/image-pipeline/backend/backend-router");
|
|
const runPreviewStepWithBackendRouter = vi
|
|
.spyOn(backendRouterModule, "runPreviewStepWithBackendRouter")
|
|
.mockImplementation(() => {});
|
|
|
|
vi.doMock("@/lib/image-pipeline/source-loader", () => ({
|
|
loadSourceBitmap: vi.fn().mockResolvedValue({ width: 2, height: 2 }),
|
|
}));
|
|
|
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
|
drawImage: vi.fn(),
|
|
getImageData: vi.fn(() => ({
|
|
data: new Uint8ClampedArray(16),
|
|
})),
|
|
} as unknown as CanvasRenderingContext2D);
|
|
|
|
vi.stubGlobal("requestAnimationFrame", ((callback: FrameRequestCallback) => {
|
|
callback(0);
|
|
return 1;
|
|
}) as typeof requestAnimationFrame);
|
|
|
|
const { renderPreview } = await import("@/lib/image-pipeline/preview-renderer");
|
|
|
|
await renderPreview({
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [createColorAdjustStep(), createUnsupportedStep()],
|
|
previewWidth: 2,
|
|
includeHistogram: false,
|
|
});
|
|
|
|
expect(runPreviewStepWithBackendRouter).toHaveBeenCalledTimes(2);
|
|
for (const call of runPreviewStepWithBackendRouter.mock.calls) {
|
|
expect(call[0]).toMatchObject({
|
|
backendHint: "cpu",
|
|
});
|
|
}
|
|
});
|
|
|
|
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("keeps source texture bound on sampler unit when drawing", async () => {
|
|
const fakeGl = createFakeWebglContext({
|
|
readbackPixels: new Uint8Array([1, 2, 3, 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 backend = createWebglPreviewBackend();
|
|
backend.runPreviewStep({
|
|
pixels: new Uint8ClampedArray([9, 9, 9, 255]),
|
|
step: createCurvesStep(),
|
|
width: 1,
|
|
height: 1,
|
|
});
|
|
|
|
const sourceTexture = (fakeGl.createTexture as any).mock.results[0]?.value;
|
|
const outputTexture = (fakeGl.createTexture as any).mock.results[1]?.value;
|
|
expect(sourceTexture).toBeTruthy();
|
|
expect(outputTexture).toBeTruthy();
|
|
|
|
expect(fakeGl.framebufferTexture2D).toHaveBeenCalledWith(
|
|
fakeGl.FRAMEBUFFER,
|
|
fakeGl.COLOR_ATTACHMENT0,
|
|
fakeGl.TEXTURE_2D,
|
|
outputTexture,
|
|
0,
|
|
);
|
|
|
|
const bindTextureCalls = (fakeGl.bindTexture as any).mock.calls as Array<[number, unknown]>;
|
|
const bindTextureOrder = (fakeGl.bindTexture as any).mock.invocationCallOrder as number[];
|
|
const drawOrder = (fakeGl.drawArrays as any).mock.invocationCallOrder[0] as number;
|
|
const lastBindBeforeDrawIndex = bindTextureOrder
|
|
.map((callOrder, index) => ({ callOrder, index }))
|
|
.filter(({ callOrder, index }) => callOrder < drawOrder && bindTextureCalls[index]?.[0] === fakeGl.TEXTURE_2D)
|
|
.at(-1)?.index;
|
|
|
|
expect(lastBindBeforeDrawIndex).toBeTypeOf("number");
|
|
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toBe(sourceTexture);
|
|
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).not.toBe(outputTexture);
|
|
});
|
|
|
|
it("initializes backend with webgl2-only context availability", async () => {
|
|
const fakeGl = createFakeWebglContext({
|
|
readbackPixels: new Uint8Array([11, 22, 33, 255]),
|
|
});
|
|
const getContextSpy = vi
|
|
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
|
.mockImplementation((contextId) => {
|
|
if (contextId === "webgl2") {
|
|
return fakeGl;
|
|
}
|
|
if (contextId === "webgl") {
|
|
return null;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
|
const backend = createWebglPreviewBackend();
|
|
const pixels = new Uint8ClampedArray([200, 100, 50, 255]);
|
|
|
|
backend.runPreviewStep({
|
|
pixels,
|
|
step: createCurvesStep(),
|
|
width: 1,
|
|
height: 1,
|
|
});
|
|
|
|
expect(Array.from(pixels)).toEqual([11, 22, 33, 255]);
|
|
expect(fakeGl.drawArrays).toHaveBeenCalledTimes(1);
|
|
expect(getContextSpy).toHaveBeenCalledWith("webgl2", expect.any(Object));
|
|
});
|
|
|
|
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;
|
|
requestedBackend: string;
|
|
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: [
|
|
{
|
|
id: "cpu",
|
|
runPreviewStep: cpuPreview,
|
|
runFullPipeline: vi.fn(),
|
|
},
|
|
createWebglPreviewBackend(),
|
|
],
|
|
defaultBackendId: "webgl",
|
|
backendAvailability: {
|
|
webgl: {
|
|
supported: true,
|
|
enabled: true,
|
|
},
|
|
},
|
|
featureFlags: {
|
|
forceCpu: false,
|
|
webglEnabled: true,
|
|
wasmEnabled: false,
|
|
},
|
|
onFallback: (event) => {
|
|
fallbackEvents.push({
|
|
reason: event.reason,
|
|
requestedBackend: event.requestedBackend,
|
|
fallbackBackend: event.fallbackBackend,
|
|
});
|
|
},
|
|
});
|
|
|
|
router.runPreviewStep({
|
|
pixels: new Uint8ClampedArray(4),
|
|
step: createCurvesStep(),
|
|
width: 1,
|
|
height: 1,
|
|
});
|
|
|
|
expect(cpuPreview).toHaveBeenCalledTimes(1);
|
|
expect(fallbackEvents).toEqual([
|
|
{
|
|
reason: "runtime_error",
|
|
requestedBackend: "webgl",
|
|
fallbackBackend: "cpu",
|
|
},
|
|
]);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|