import { createWebglPreviewBackend } from "@/lib/image-pipeline/backend/webgl/webgl-backend"; import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import { applyPipelineStep } from "@/lib/image-pipeline/render-core"; import { vi } from "vitest"; type ParityPipelineKey = | "curvesOnly" | "colorAdjustOnly" | "curvesPlusColorAdjust" | "lightAdjustOnly" | "detailAdjustOnly" | "curvesColorLightDetailChain" | "curvesChannelPressure" | "colorAdjustPressure" | "curvesColorPressureChain"; type HistogramBundle = { red: number[]; green: number[]; blue: number[]; rgb: number[]; }; export type ParityMetrics = { maxChannelDelta: number; histogramSimilarity: number; spatialRmse: number; }; type ParityPipeline = { key: ParityPipelineKey; steps: PipelineStep[]; }; type ParityTolerance = { maxChannelDelta: number; histogramSimilarity: number; spatialRmse: number; }; const FIXTURE_WIDTH = 8; const FIXTURE_HEIGHT = 8; let contextSpy: { mockRestore: () => void } | null = null; export const parityTolerances: Record = { // Tightened against measured jsdom+mock outputs (2026-04-04 baseline): // - maxChannelDelta / spatialRmse gates use ~+2 delta and ~+1.3 RMSE headroom. // - histogramSimilarity gates use ~0.005-0.01 headroom below measured values. // This intentionally validates backend-contract parity in deterministic jsdom mocks, // not driver-level GPU conformance. curvesOnly: { maxChannelDelta: 33, histogramSimilarity: 0.16, spatialRmse: 24.7, }, colorAdjustOnly: { maxChannelDelta: 42, histogramSimilarity: 0.15, spatialRmse: 21.6, }, curvesPlusColorAdjust: { maxChannelDelta: 71, histogramSimilarity: 0.16, spatialRmse: 43.5, }, lightAdjustOnly: { maxChannelDelta: 52, histogramSimilarity: 0.5, spatialRmse: 20.0, }, detailAdjustOnly: { maxChannelDelta: 2, histogramSimilarity: 0.99, spatialRmse: 1.2, }, curvesColorLightDetailChain: { maxChannelDelta: 130, histogramSimilarity: 0.17, spatialRmse: 57.5, }, curvesChannelPressure: { maxChannelDelta: 99, histogramSimilarity: 0.08, spatialRmse: 52.5, }, colorAdjustPressure: { maxChannelDelta: 64, histogramSimilarity: 0.5, spatialRmse: 24, }, curvesColorPressureChain: { maxChannelDelta: 96, histogramSimilarity: 0.35, spatialRmse: 36, }, }; function createCurvesStep(): PipelineStep { return { nodeId: "curves-parity", type: "curves", params: { channelMode: "master", levels: { blackPoint: 0, whitePoint: 255, gamma: 1.18, }, 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-adjust-parity", type: "color-adjust", params: { hsl: { hue: 0, saturation: 0, luminance: 9, }, temperature: 6, tint: -4, vibrance: 0, }, }; } function createCurvesChannelPressureStep(): PipelineStep { return { nodeId: "curves-channel-pressure-parity", type: "curves", params: { channelMode: "red", levels: { blackPoint: 20, whitePoint: 230, gamma: 0.72, }, points: { rgb: [ { x: 0, y: 0 }, { x: 80, y: 68 }, { x: 190, y: 224 }, { x: 255, y: 255 }, ], red: [ { x: 0, y: 0 }, { x: 60, y: 36 }, { x: 180, y: 228 }, { x: 255, y: 255 }, ], green: [ { x: 0, y: 0 }, { x: 124, y: 104 }, { x: 255, y: 255 }, ], blue: [ { x: 0, y: 0 }, { x: 120, y: 146 }, { x: 255, y: 255 }, ], }, }, }; } function createColorAdjustPressureStep(): PipelineStep { return { nodeId: "color-adjust-pressure-parity", type: "color-adjust", params: { hsl: { hue: 48, saturation: 64, luminance: 18, }, temperature: 24, tint: -28, vibrance: 52, }, }; } function createCurvesColorPressureChainSteps(): PipelineStep[] { return [ createCurvesChannelPressureStep(), { nodeId: "curves-master-pressure-parity", type: "curves", params: { channelMode: "master", levels: { blackPoint: 10, whitePoint: 246, gamma: 1.36, }, points: { rgb: [ { x: 0, y: 0 }, { x: 62, y: 40 }, { x: 172, y: 214 }, { 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 }, ], }, }, }, createColorAdjustPressureStep(), ]; } function createLightAdjustStep(): PipelineStep { return { nodeId: "light-adjust-parity", type: "light-adjust", params: { brightness: 14, contrast: 22, exposure: 0.8, highlights: -16, shadows: 24, whites: 8, blacks: -10, vignette: { amount: 0.25, size: 0.72, roundness: 0.85, }, }, }; } function createDetailAdjustStep(): PipelineStep { return { nodeId: "detail-adjust-parity", type: "detail-adjust", params: { sharpen: { amount: 210, radius: 1.4, threshold: 6, }, clarity: 32, denoise: { luminance: 18, color: 14, }, grain: { amount: 12, size: 1.3, }, }, }; } function createCurvesColorLightDetailChainSteps(): PipelineStep[] { return [createCurvesStep(), createColorAdjustStep(), createLightAdjustStep(), createDetailAdjustStep()]; } export function createParityPipelines(): Record { const curvesStep = createCurvesStep(); const colorAdjustStep = createColorAdjustStep(); const lightAdjustStep = createLightAdjustStep(); const detailAdjustStep = createDetailAdjustStep(); const curvesChannelPressureStep = createCurvesChannelPressureStep(); const colorAdjustPressureStep = createColorAdjustPressureStep(); return { curvesOnly: { key: "curvesOnly", steps: [curvesStep], }, colorAdjustOnly: { key: "colorAdjustOnly", steps: [colorAdjustStep], }, curvesPlusColorAdjust: { key: "curvesPlusColorAdjust", steps: [curvesStep, colorAdjustStep], }, lightAdjustOnly: { key: "lightAdjustOnly", steps: [lightAdjustStep], }, detailAdjustOnly: { key: "detailAdjustOnly", steps: [detailAdjustStep], }, curvesColorLightDetailChain: { key: "curvesColorLightDetailChain", steps: createCurvesColorLightDetailChainSteps(), }, curvesChannelPressure: { key: "curvesChannelPressure", steps: [curvesChannelPressureStep], }, colorAdjustPressure: { key: "colorAdjustPressure", steps: [colorAdjustPressureStep], }, curvesColorPressureChain: { key: "curvesColorPressureChain", steps: createCurvesColorPressureChainSteps(), }, }; } function createFixturePixels(): Uint8ClampedArray { const pixels = new Uint8ClampedArray(FIXTURE_WIDTH * FIXTURE_HEIGHT * 4); for (let y = 0; y < FIXTURE_HEIGHT; y += 1) { for (let x = 0; x < FIXTURE_WIDTH; x += 1) { const offset = (y * FIXTURE_WIDTH + x) * 4; pixels[offset] = (x * 31 + y * 17) % 256; pixels[offset + 1] = (x * 13 + y * 47) % 256; pixels[offset + 2] = (x * 57 + y * 19) % 256; pixels[offset + 3] = 255; } } return pixels; } function clonePixels(pixels: Uint8ClampedArray): Uint8ClampedArray { return new Uint8ClampedArray(pixels); } type ShaderKind = | "curves" | "color-adjust" | "light-adjust" | "detail-adjust" | "vertex" | "unknown"; type FakeShader = { type: number; source: string; kind: ShaderKind; }; type FakeProgram = { attachedShaders: FakeShader[]; kind: ShaderKind; uniforms: Map; }; type FakeTexture = { width: number; height: number; data: Uint8Array; }; type FakeTextureImageSource = { width: number; height: number; data: Uint8Array; }; type FakeFramebuffer = { attachment: FakeTexture | null; }; function createEmptyTexture(width: number, height: number): FakeTexture { return { width, height, data: new Uint8Array(width * height * 4), }; } function inferShaderKind(source: string): ShaderKind { if (source.includes("uInvGamma") || source.includes("uRgbLut")) { return "curves"; } if (source.includes("uHueShift") || source.includes("uVibranceBoost")) { return "color-adjust"; } if (source.includes("uExposureFactor")) { return "light-adjust"; } if (source.includes("uSharpenBoost")) { return "detail-adjust"; } if (source.includes("aPosition")) { return "vertex"; } return "unknown"; } function toNormalized(value: number): number { return Math.max(0, Math.min(1, value / 255)); } function toByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value * 255))); } function sampleLutTexture(texture: FakeTexture | null, value: number): number { if (!texture) { return value; } const index = Math.max(0, Math.min(255, Math.round(value * 255))); return texture.data[index * 4] / 255; } function runCurvesShader( input: Uint8Array, uniforms: Map, textures: { rgb: FakeTexture | null; red: FakeTexture | null; green: FakeTexture | null; blue: FakeTexture | null; }, ): Uint8Array { const output = new Uint8Array(input.length); const blackPoint = Number(uniforms.get("uBlackPoint") ?? 0); const whitePoint = Number(uniforms.get("uWhitePoint") ?? 255); const invGamma = Number(uniforms.get("uInvGamma") ?? 1); const channelMode = Number(uniforms.get("uChannelMode") ?? 0); const levelRange = Math.max(1, whitePoint - blackPoint); for (let index = 0; index < input.length; index += 4) { const mappedRed = Math.pow( Math.max(Math.min(((input[index] - blackPoint) / levelRange), 1), 0), Math.max(invGamma, 0.001), ); const mappedGreen = Math.pow( Math.max(Math.min(((input[index + 1] - blackPoint) / levelRange), 1), 0), Math.max(invGamma, 0.001), ); const mappedBlue = Math.pow( Math.max(Math.min(((input[index + 2] - blackPoint) / levelRange), 1), 0), Math.max(invGamma, 0.001), ); const rgbRed = sampleLutTexture(textures.rgb, mappedRed); const rgbGreen = sampleLutTexture(textures.rgb, mappedGreen); const rgbBlue = sampleLutTexture(textures.rgb, mappedBlue); let red = rgbRed; let green = rgbGreen; let blue = rgbBlue; if (channelMode < 0.5) { red = sampleLutTexture(textures.red, rgbRed); green = sampleLutTexture(textures.green, rgbGreen); blue = sampleLutTexture(textures.blue, rgbBlue); } else if (channelMode < 1.5) { red = sampleLutTexture(textures.red, rgbRed); } else if (channelMode < 2.5) { green = sampleLutTexture(textures.green, rgbGreen); } else { blue = sampleLutTexture(textures.blue, rgbBlue); } output[index] = toByte(red); output[index + 1] = toByte(green); output[index + 2] = toByte(blue); output[index + 3] = input[index + 3] ?? 255; } return output; } function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const delta = max - min; const l = (max + min) / 2; if (delta === 0) { return { h: 0, s: 0, l }; } const s = delta / (1 - Math.abs(2 * l - 1)); let h = 0; if (max === rn) { h = ((gn - bn) / delta) % 6; } else if (max === gn) { h = (bn - rn) / delta + 2; } else { h = (rn - gn) / delta + 4; } h *= 60; if (h < 0) { h += 360; } return { h, s, l }; } function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; let rp = 0; let gp = 0; let bp = 0; if (h < 60) { rp = c; gp = x; } else if (h < 120) { rp = x; gp = c; } else if (h < 180) { gp = c; bp = x; } else if (h < 240) { gp = x; bp = c; } else if (h < 300) { rp = x; bp = c; } else { rp = c; bp = x; } return { r: Math.max(0, Math.min(255, Math.round((rp + m) * 255))), g: Math.max(0, Math.min(255, Math.round((gp + m) * 255))), b: Math.max(0, Math.min(255, Math.round((bp + m) * 255))), }; } function runColorAdjustShader( input: Uint8Array, uniforms: Map, ): Uint8Array { const output = new Uint8Array(input.length); const hueShift = Number(uniforms.get("uHueShift") ?? 0); const saturationFactor = Number(uniforms.get("uSaturationFactor") ?? 1); const luminanceShift = Number(uniforms.get("uLuminanceShift") ?? 0); const temperatureShift = Number(uniforms.get("uTemperatureShift") ?? 0); const tintShift = Number(uniforms.get("uTintShift") ?? 0); const vibranceBoost = Number(uniforms.get("uVibranceBoost") ?? 0); for (let index = 0; index < input.length; index += 4) { const hsl = rgbToHsl(input[index] ?? 0, input[index + 1] ?? 0, input[index + 2] ?? 0); const shiftedHue = (hsl.h + hueShift + 360) % 360; const shiftedSaturation = Math.max(0, Math.min(1, hsl.s * saturationFactor)); const shiftedLuminance = Math.max(0, Math.min(1, hsl.l + luminanceShift)); const saturationDelta = (1 - hsl.s) * vibranceBoost; const vivid = hslToRgb( shiftedHue, Math.max(0, Math.min(1, shiftedSaturation + saturationDelta)), shiftedLuminance, ); const red = Math.max(0, Math.min(1, (vivid.r + temperatureShift) / 255)); const green = Math.max(0, Math.min(1, (vivid.g + tintShift) / 255)); const blue = Math.max(0, Math.min(1, (vivid.b - temperatureShift - tintShift * 0.3) / 255)); output[index] = toByte(red); output[index + 1] = toByte(green); output[index + 2] = toByte(blue); output[index + 3] = input[index + 3] ?? 255; } return output; } function pseudoNoise(seed: number): number { const x = Math.sin(seed * 12.9898) * 43758.5453; return x - Math.floor(x); } function runLightAdjustShader( input: Uint8Array, width: number, height: number, uniforms: Map, ): Uint8Array { const output = new Uint8Array(input.length); const centerX = width / 2; const centerY = height / 2; const exposureFactor = Number(uniforms.get("uExposureFactor") ?? 1); const contrastFactor = Number(uniforms.get("uContrastFactor") ?? 1); const brightnessShift = Number(uniforms.get("uBrightnessShift") ?? 0); const highlights = Number(uniforms.get("uHighlights") ?? 0); const shadows = Number(uniforms.get("uShadows") ?? 0); const whites = Number(uniforms.get("uWhites") ?? 0); const blacks = Number(uniforms.get("uBlacks") ?? 0); const vignetteAmount = Number(uniforms.get("uVignetteAmount") ?? 0); const vignetteSize = Number(uniforms.get("uVignetteSize") ?? 0.5); const vignetteRoundness = Number(uniforms.get("uVignetteRoundness") ?? 1); for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const index = (y * width + x) * 4; let red = input[index] ?? 0; let green = input[index + 1] ?? 0; let blue = input[index + 2] ?? 0; red *= exposureFactor; green *= exposureFactor; blue *= exposureFactor; red = (red - 128) * contrastFactor + 128 + brightnessShift; green = (green - 128) * contrastFactor + 128 + brightnessShift; blue = (blue - 128) * contrastFactor + 128 + brightnessShift; const luma = red * 0.2126 + green * 0.7152 + blue * 0.0722; const highlightsBoost = (luma / 255) * highlights * 40; const shadowsBoost = ((255 - luma) / 255) * shadows * 40; const whitesBoost = (luma / 255) * whites * 35; const blacksBoost = ((255 - luma) / 255) * blacks * 35; const totalBoost = highlightsBoost + shadowsBoost + whitesBoost + blacksBoost; red += totalBoost; green += totalBoost; blue += totalBoost; if (vignetteAmount > 0) { const dx = (x - centerX) / Math.max(1, centerX); const dy = (y - centerY) / Math.max(1, centerY); const radialDistance = Math.sqrt(dx * dx + dy * dy); const softEdge = Math.pow(1 - Math.max(0, Math.min(1, radialDistance)), 1 + vignetteRoundness); const strength = 1 - vignetteAmount * (1 - softEdge) * (1.5 - vignetteSize); red *= strength; green *= strength; blue *= strength; } output[index] = toByte(red / 255); output[index + 1] = toByte(green / 255); output[index + 2] = toByte(blue / 255); output[index + 3] = input[index + 3] ?? 255; } } return output; } function runDetailAdjustShader( input: Uint8Array, width: number, uniforms: Map, ): Uint8Array { const output = new Uint8Array(input.length); const sharpenBoost = Number(uniforms.get("uSharpenBoost") ?? 0); const clarityBoost = Number(uniforms.get("uClarityBoost") ?? 0); const denoiseLuma = Number(uniforms.get("uDenoiseLuma") ?? 0); const denoiseColor = Number(uniforms.get("uDenoiseColor") ?? 0); const grainAmount = Number(uniforms.get("uGrainAmount") ?? 0); const grainScale = Math.max(0.5, Number(uniforms.get("uGrainScale") ?? 1)); const imageWidth = Math.max(1, Number(uniforms.get("uImageWidth") ?? width)); for (let index = 0; index < input.length; index += 4) { let red = input[index] ?? 0; let green = input[index + 1] ?? 0; let blue = input[index + 2] ?? 0; const luma = red * 0.2126 + green * 0.7152 + blue * 0.0722; red = red + (red - luma) * sharpenBoost * 0.6; green = green + (green - luma) * sharpenBoost * 0.6; blue = blue + (blue - luma) * sharpenBoost * 0.6; const midtoneFactor = 1 - Math.abs(luma / 255 - 0.5) * 2; const clarityScale = 1 + clarityBoost * midtoneFactor * 0.7; red = (red - 128) * clarityScale + 128; green = (green - 128) * clarityScale + 128; blue = (blue - 128) * clarityScale + 128; if (denoiseLuma > 0 || denoiseColor > 0) { red = red * (1 - denoiseLuma * 0.2) + luma * denoiseLuma * 0.2; green = green * (1 - denoiseLuma * 0.2) + luma * denoiseLuma * 0.2; blue = blue * (1 - denoiseLuma * 0.2) + luma * denoiseLuma * 0.2; const average = (red + green + blue) / 3; red = red * (1 - denoiseColor * 0.2) + average * denoiseColor * 0.2; green = green * (1 - denoiseColor * 0.2) + average * denoiseColor * 0.2; blue = blue * (1 - denoiseColor * 0.2) + average * denoiseColor * 0.2; } if (grainAmount > 0) { const pixel = index / 4; const x = pixel % imageWidth; const y = Math.floor(pixel / imageWidth); const pixelIndex = (y * imageWidth + x) * 4; const grain = (pseudoNoise((pixelIndex + 1) / grainScale) - 0.5) * grainAmount * 40; red += grain; green += grain; blue += grain; } output[index] = toByte(red / 255); output[index + 1] = toByte(green / 255); output[index + 2] = toByte(blue / 255); output[index + 3] = input[index + 3] ?? 255; } return output; } function createParityWebglContext(): WebGLRenderingContext { const glConstants = { 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, } as const; let currentProgram: FakeProgram | null = null; let currentTexture: FakeTexture | null = null; let currentFramebuffer: FakeFramebuffer | null = null; let activeTextureUnit = 0; const boundTextures = new Map(); let drawWidth = 1; let drawHeight = 1; const gl = { ...glConstants, createShader(shaderType: number): FakeShader { return { type: shaderType, source: "", kind: "unknown", }; }, shaderSource(shader: FakeShader, source: string) { shader.source = source; shader.kind = inferShaderKind(source); }, compileShader() {}, getShaderParameter() { return true; }, getShaderInfoLog() { return null; }, deleteShader() {}, createProgram(): FakeProgram { return { attachedShaders: [], kind: "unknown", uniforms: new Map(), }; }, attachShader(program: FakeProgram, shader: FakeShader) { program.attachedShaders.push(shader); }, linkProgram(program: FakeProgram) { const fragmentShader = program.attachedShaders.find((shader) => shader.type === glConstants.FRAGMENT_SHADER); program.kind = fragmentShader?.kind ?? "unknown"; }, getProgramParameter() { return true; }, getProgramInfoLog() { return null; }, deleteProgram() {}, useProgram(program: FakeProgram) { currentProgram = program; }, createBuffer() { return {}; }, bindBuffer() {}, bufferData() {}, getAttribLocation() { return 0; }, enableVertexAttribArray() {}, vertexAttribPointer() {}, createTexture(): FakeTexture { return createEmptyTexture(1, 1); }, bindTexture(_target: number, texture: FakeTexture | null) { currentTexture = texture; boundTextures.set(activeTextureUnit, texture); }, texParameteri() {}, texImage2D( _target: number, _level: number, _internalformat: number, width: number, height: number, _border: number, _format: number, _type: number, pixels: ArrayBufferView | FakeTextureImageSource | null, ) { if (!currentTexture) { return; } if (pixels) { currentTexture.width = width; currentTexture.height = height; if ("buffer" in pixels) { const byteOffset = pixels.byteOffset ?? 0; const byteLength = pixels.byteLength ?? pixels.buffer.byteLength; currentTexture.data = new Uint8Array(pixels.buffer.slice(byteOffset, byteOffset + byteLength)); } else { currentTexture.data = new Uint8Array(pixels.data); } return; } currentTexture.width = width; currentTexture.height = height; currentTexture.data = new Uint8Array(width * height * 4); }, activeTexture(textureUnit: number) { activeTextureUnit = textureUnit - glConstants.TEXTURE0; }, getUniformLocation(program: FakeProgram, name: string) { return { program, name, }; }, uniform1i(location: { program: FakeProgram; name: string }, value: number) { location.program.uniforms.set(location.name, value); }, uniform1f(location: { program: FakeProgram; name: string }, value: number) { location.program.uniforms.set(location.name, value); }, uniform3f(location: { program: FakeProgram; name: string }, x: number, y: number, z: number) { location.program.uniforms.set(location.name, [x, y, z]); }, createFramebuffer(): FakeFramebuffer { return { attachment: null, }; }, bindFramebuffer(_target: number, framebuffer: FakeFramebuffer | null) { currentFramebuffer = framebuffer; }, framebufferTexture2D( _target: number, _attachment: number, _textarget: number, texture: FakeTexture | null, ) { if (!currentFramebuffer) { return; } currentFramebuffer.attachment = texture; }, checkFramebufferStatus() { return glConstants.FRAMEBUFFER_COMPLETE; }, deleteFramebuffer() {}, viewport(_x: number, _y: number, width: number, height: number) { drawWidth = width; drawHeight = height; }, drawArrays() { const sourceTexture = boundTextures.get(0) ?? null; if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) { throw new Error("Parity WebGL mock is missing required render state."); } if (currentProgram.kind === "curves") { const rgbUnit = Number(currentProgram.uniforms.get("uRgbLut") ?? 1); const redUnit = Number(currentProgram.uniforms.get("uRedLut") ?? 2); const greenUnit = Number(currentProgram.uniforms.get("uGreenLut") ?? 3); const blueUnit = Number(currentProgram.uniforms.get("uBlueLut") ?? 4); currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, currentProgram.uniforms, { rgb: boundTextures.get(rgbUnit) ?? null, red: boundTextures.get(redUnit) ?? null, green: boundTextures.get(greenUnit) ?? null, blue: boundTextures.get(blueUnit) ?? null, }); return; } if (currentProgram.kind === "color-adjust") { currentFramebuffer.attachment.data = runColorAdjustShader( sourceTexture.data, currentProgram.uniforms, ); return; } if (currentProgram.kind === "light-adjust") { currentFramebuffer.attachment.data = runLightAdjustShader( sourceTexture.data, drawWidth, drawHeight, currentProgram.uniforms, ); return; } if (currentProgram.kind === "detail-adjust") { currentFramebuffer.attachment.data = runDetailAdjustShader( sourceTexture.data, drawWidth, currentProgram.uniforms, ); return; } throw new Error(`Unsupported parity shader kind '${currentProgram.kind}'.`); }, deleteTexture() {}, readPixels( _x: number, _y: number, _width: number, _height: number, _format: number, _type: number, output: Uint8Array, ) { if (!currentFramebuffer?.attachment) { throw new Error("Parity WebGL mock has no framebuffer attachment to read from."); } output.set(currentFramebuffer.attachment.data.subarray(0, output.length)); }, }; return gl as unknown as WebGLRenderingContext; } export function installParityWebglContextMock(): void { if (contextSpy) { return; } contextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => { if (contextId === "webgl" || contextId === "webgl2") { return createParityWebglContext(); } return null; }); } export function restoreParityWebglContextMock(): void { if (!contextSpy) { return; } contextSpy.mockRestore(); contextSpy = null; } function buildHistogram(pixels: Uint8ClampedArray): HistogramBundle { const histogram: HistogramBundle = { red: Array.from({ length: 256 }, () => 0), green: Array.from({ length: 256 }, () => 0), blue: Array.from({ length: 256 }, () => 0), rgb: Array.from({ length: 256 }, () => 0), }; for (let index = 0; index < pixels.length; index += 4) { const red = pixels[index] ?? 0; const green = pixels[index + 1] ?? 0; const blue = pixels[index + 2] ?? 0; const luma = Math.round(red * 0.299 + green * 0.587 + blue * 0.114); histogram.red[red] += 1; histogram.green[green] += 1; histogram.blue[blue] += 1; histogram.rgb[Math.max(0, Math.min(255, luma))] += 1; } return histogram; } function channelIntersectionSimilarity(lhs: number[], rhs: number[], denominator: number): number { let overlap = 0; for (let index = 0; index < lhs.length; index += 1) { overlap += Math.min(lhs[index] ?? 0, rhs[index] ?? 0); } return overlap / Math.max(1, denominator); } function calculateHistogramSimilarity(lhs: Uint8ClampedArray, rhs: Uint8ClampedArray): number { const lhsHistogram = buildHistogram(lhs); const rhsHistogram = buildHistogram(rhs); const totalPixels = lhs.length / 4; const channels = [ channelIntersectionSimilarity(lhsHistogram.red, rhsHistogram.red, totalPixels), channelIntersectionSimilarity(lhsHistogram.green, rhsHistogram.green, totalPixels), channelIntersectionSimilarity(lhsHistogram.blue, rhsHistogram.blue, totalPixels), channelIntersectionSimilarity(lhsHistogram.rgb, rhsHistogram.rgb, totalPixels), ]; const sum = channels.reduce((acc, value) => acc + value, 0); return sum / channels.length; } function calculateMaxChannelDelta(lhs: Uint8ClampedArray, rhs: Uint8ClampedArray): number { let maxDelta = 0; for (let index = 0; index < lhs.length; index += 4) { const redDelta = Math.abs((lhs[index] ?? 0) - (rhs[index] ?? 0)); const greenDelta = Math.abs((lhs[index + 1] ?? 0) - (rhs[index + 1] ?? 0)); const blueDelta = Math.abs((lhs[index + 2] ?? 0) - (rhs[index + 2] ?? 0)); maxDelta = Math.max(maxDelta, redDelta, greenDelta, blueDelta); } return maxDelta; } function calculateSpatialRmse(lhs: Uint8ClampedArray, rhs: Uint8ClampedArray): number { let squaredErrorSum = 0; let sampleCount = 0; for (let index = 0; index < lhs.length; index += 4) { const redDelta = (lhs[index] ?? 0) - (rhs[index] ?? 0); const greenDelta = (lhs[index + 1] ?? 0) - (rhs[index + 1] ?? 0); const blueDelta = (lhs[index + 2] ?? 0) - (rhs[index + 2] ?? 0); squaredErrorSum += redDelta * redDelta + greenDelta * greenDelta + blueDelta * blueDelta; sampleCount += 3; } return Math.sqrt(squaredErrorSum / Math.max(1, sampleCount)); } export function evaluateCpuWebglParity(pipeline: ParityPipeline): ParityMetrics { const source = createFixturePixels(); const cpuPixels = clonePixels(source); const webglPixels = clonePixels(source); for (const step of pipeline.steps) { applyPipelineStep(cpuPixels, step, FIXTURE_WIDTH, FIXTURE_HEIGHT); } const webglBackend = createWebglPreviewBackend(); for (const step of pipeline.steps) { webglBackend.runPreviewStep({ pixels: webglPixels, step, width: FIXTURE_WIDTH, height: FIXTURE_HEIGHT, }); } return { maxChannelDelta: calculateMaxChannelDelta(cpuPixels, webglPixels), histogramSimilarity: calculateHistogramSimilarity(cpuPixels, webglPixels), spatialRmse: calculateSpatialRmse(cpuPixels, webglPixels), }; } export function enforceCpuWebglParityGates(): Record { const pipelines = createParityPipelines(); const metricsByPipeline = {} as Record; installParityWebglContextMock(); try { for (const pipeline of Object.values(pipelines)) { const metrics = evaluateCpuWebglParity(pipeline); const tolerance = parityTolerances[pipeline.key]; metricsByPipeline[pipeline.key] = metrics; if (metrics.maxChannelDelta > tolerance.maxChannelDelta) { throw new Error( `${pipeline.key} parity max delta ${metrics.maxChannelDelta} exceeded tolerance ${tolerance.maxChannelDelta}`, ); } if (metrics.histogramSimilarity < tolerance.histogramSimilarity) { throw new Error( `${pipeline.key} histogram similarity ${metrics.histogramSimilarity.toFixed(4)} below tolerance ${tolerance.histogramSimilarity.toFixed(4)}`, ); } if (metrics.spatialRmse > tolerance.spatialRmse) { throw new Error( `${pipeline.key} spatial RMSE ${metrics.spatialRmse.toFixed(4)} exceeded tolerance ${tolerance.spatialRmse.toFixed(4)}`, ); } } } finally { restoreParityWebglContextMock(); } return metricsByPipeline; }