import type { BackendPipelineRequest, BackendStepRequest, ImagePipelineBackend, } from "@/lib/image-pipeline/backend/backend-types"; import { normalizeColorAdjustData, normalizeCurvesData, normalizeDetailAdjustData, normalizeLightAdjustData, type CurvePoint, } from "@/lib/image-pipeline/adjustment-types"; import type { PipelineStep } from "@/lib/image-pipeline/contracts"; const CURVES_FRAGMENT_SHADER_SOURCE = ` precision mediump float; varying vec2 vUv; uniform sampler2D uSource; uniform sampler2D uRgbLut; uniform sampler2D uRedLut; uniform sampler2D uGreenLut; uniform sampler2D uBlueLut; uniform float uBlackPoint; uniform float uWhitePoint; uniform float uInvGamma; uniform float uChannelMode; float sampleLut(sampler2D lut, float value) { return texture2D(lut, vec2(clamp(value, 0.0, 1.0), 0.5)).r; } void main() { vec4 color = texture2D(uSource, vUv); float levelRange = max(uWhitePoint - uBlackPoint, 1.0); vec3 leveled = clamp((color.rgb * 255.0 - vec3(uBlackPoint)) / levelRange, 0.0, 1.0); vec3 mapped = pow(max(leveled, vec3(0.0)), vec3(max(uInvGamma, 0.001))); vec3 rgbCurve = vec3( sampleLut(uRgbLut, mapped.r), sampleLut(uRgbLut, mapped.g), sampleLut(uRgbLut, mapped.b) ); vec3 result = rgbCurve; if (uChannelMode < 0.5) { result = vec3( sampleLut(uRedLut, rgbCurve.r), sampleLut(uGreenLut, rgbCurve.g), sampleLut(uBlueLut, rgbCurve.b) ); } else if (uChannelMode < 1.5) { result.r = sampleLut(uRedLut, rgbCurve.r); } else if (uChannelMode < 2.5) { result.g = sampleLut(uGreenLut, rgbCurve.g); } else { result.b = sampleLut(uBlueLut, rgbCurve.b); } gl_FragColor = vec4(result, color.a); } `; const COLOR_ADJUST_FRAGMENT_SHADER_SOURCE = ` precision mediump float; varying vec2 vUv; uniform sampler2D uSource; uniform float uHueShift; uniform float uSaturationFactor; uniform float uLuminanceShift; uniform float uTemperatureShift; uniform float uTintShift; uniform float uVibranceBoost; vec3 rgbToHsl(vec3 color) { float maxChannel = max(max(color.r, color.g), color.b); float minChannel = min(min(color.r, color.g), color.b); float delta = maxChannel - minChannel; float lightness = (maxChannel + minChannel) * 0.5; if (delta == 0.0) { return vec3(0.0, 0.0, lightness); } float saturation = delta / (1.0 - abs(2.0 * lightness - 1.0)); float hue; if (maxChannel == color.r) { hue = mod((color.g - color.b) / delta, 6.0); } else if (maxChannel == color.g) { hue = (color.b - color.r) / delta + 2.0; } else { hue = (color.r - color.g) / delta + 4.0; } hue *= 60.0; if (hue < 0.0) { hue += 360.0; } return vec3(hue, saturation, lightness); } vec3 hslToRgb(float hue, float saturation, float lightness) { float chroma = (1.0 - abs(2.0 * lightness - 1.0)) * saturation; float x = chroma * (1.0 - abs(mod(hue / 60.0, 2.0) - 1.0)); float m = lightness - chroma * 0.5; vec3 rgbPrime; if (hue < 60.0) { rgbPrime = vec3(chroma, x, 0.0); } else if (hue < 120.0) { rgbPrime = vec3(x, chroma, 0.0); } else if (hue < 180.0) { rgbPrime = vec3(0.0, chroma, x); } else if (hue < 240.0) { rgbPrime = vec3(0.0, x, chroma); } else if (hue < 300.0) { rgbPrime = vec3(x, 0.0, chroma); } else { rgbPrime = vec3(chroma, 0.0, x); } return clamp(rgbPrime + vec3(m), 0.0, 1.0); } void main() { vec4 color = texture2D(uSource, vUv); vec3 hsl = rgbToHsl(color.rgb); float shiftedHue = mod(hsl.x + uHueShift + 360.0, 360.0); float shiftedSaturation = clamp(hsl.y * uSaturationFactor, 0.0, 1.0); float shiftedLuminance = clamp(hsl.z + uLuminanceShift, 0.0, 1.0); float saturationDelta = (1.0 - hsl.y) * uVibranceBoost; vec3 vivid = hslToRgb( shiftedHue, clamp(shiftedSaturation + saturationDelta, 0.0, 1.0), shiftedLuminance ); vec3 shiftedBytes = vivid * 255.0; shiftedBytes.r += uTemperatureShift; shiftedBytes.g += uTintShift; shiftedBytes.b -= uTemperatureShift + uTintShift * 0.3; gl_FragColor = vec4(clamp(shiftedBytes / 255.0, 0.0, 1.0), color.a); } `; const LIGHT_ADJUST_FRAGMENT_SHADER_SOURCE = ` precision mediump float; varying vec2 vUv; uniform sampler2D uSource; uniform float uExposureFactor; uniform float uContrastFactor; uniform float uBrightnessShift; uniform float uHighlights; uniform float uShadows; uniform float uWhites; uniform float uBlacks; uniform float uVignetteAmount; uniform float uVignetteSize; uniform float uVignetteRoundness; float toByte(float value) { return clamp(floor(value + 0.5), 0.0, 255.0); } void main() { vec4 color = texture2D(uSource, vUv); vec3 rgb = color.rgb * 255.0; rgb *= uExposureFactor; rgb = (rgb - 128.0) * uContrastFactor + 128.0 + uBrightnessShift; float luma = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); float highlightsBoost = (luma / 255.0) * uHighlights * 40.0; float shadowsBoost = ((255.0 - luma) / 255.0) * uShadows * 40.0; float whitesBoost = (luma / 255.0) * uWhites * 35.0; float blacksBoost = ((255.0 - luma) / 255.0) * uBlacks * 35.0; float totalBoost = highlightsBoost + shadowsBoost + whitesBoost + blacksBoost; rgb = vec3( toByte(rgb.r + totalBoost), toByte(rgb.g + totalBoost), toByte(rgb.b + totalBoost) ); if (uVignetteAmount > 0.0) { vec2 centeredUv = (vUv - vec2(0.5)) / vec2(0.5); float radialDistance = length(centeredUv); float softEdge = pow(1.0 - clamp(radialDistance, 0.0, 1.0), 1.0 + uVignetteRoundness); float strength = 1.0 - uVignetteAmount * (1.0 - softEdge) * (1.5 - uVignetteSize); rgb = vec3( toByte(rgb.r * strength), toByte(rgb.g * strength), toByte(rgb.b * strength) ); } gl_FragColor = vec4(clamp(rgb / 255.0, 0.0, 1.0), color.a); } `; const DETAIL_ADJUST_FRAGMENT_SHADER_SOURCE = ` precision mediump float; varying vec2 vUv; uniform sampler2D uSource; uniform float uSharpenBoost; uniform float uClarityBoost; uniform float uDenoiseLuma; uniform float uDenoiseColor; uniform float uGrainAmount; uniform float uGrainScale; uniform float uImageWidth; float pseudoNoise(float seed) { float x = sin(seed * 12.9898) * 43758.5453; return fract(x); } void main() { vec4 color = texture2D(uSource, vUv); vec3 rgb = color.rgb * 255.0; float luma = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); rgb.r = rgb.r + (rgb.r - luma) * uSharpenBoost * 0.6; rgb.g = rgb.g + (rgb.g - luma) * uSharpenBoost * 0.6; rgb.b = rgb.b + (rgb.b - luma) * uSharpenBoost * 0.6; float midtoneFactor = 1.0 - abs(luma / 255.0 - 0.5) * 2.0; float clarityScale = 1.0 + uClarityBoost * midtoneFactor * 0.7; rgb = (rgb - 128.0) * clarityScale + 128.0; if (uDenoiseLuma > 0.0 || uDenoiseColor > 0.0) { rgb = rgb * (1.0 - uDenoiseLuma * 0.2) + vec3(luma) * uDenoiseLuma * 0.2; float average = (rgb.r + rgb.g + rgb.b) / 3.0; rgb = rgb * (1.0 - uDenoiseColor * 0.2) + vec3(average) * uDenoiseColor * 0.2; } if (uGrainAmount > 0.0) { float pixelX = floor(gl_FragCoord.x); float pixelY = floor(gl_FragCoord.y); float pixelIndex = ((pixelY * max(1.0, uImageWidth)) + pixelX) * 4.0; float grainSeed = (pixelIndex + 1.0) / max(0.5, uGrainScale); float grain = (pseudoNoise(grainSeed) - 0.5) * uGrainAmount * 40.0; rgb += vec3(grain); } gl_FragColor = vec4(clamp(rgb / 255.0, 0.0, 1.0), color.a); } `; const VERTEX_SHADER_SOURCE = ` attribute vec2 aPosition; varying vec2 vUv; void main() { vUv = (aPosition + 1.0) * 0.5; gl_Position = vec4(aPosition, 0.0, 1.0); } `; type SupportedPreviewStepType = "curves" | "color-adjust" | "light-adjust" | "detail-adjust"; type WebglBackendContext = { gl: WebGLRenderingContext; curvesProgram: WebGLProgram; colorAdjustProgram: WebGLProgram; lightAdjustProgram: WebGLProgram; detailAdjustProgram: WebGLProgram; quadBuffer: WebGLBuffer; }; const SUPPORTED_PREVIEW_STEP_TYPES = new Set([ "curves", "color-adjust", "light-adjust", "detail-adjust", ]); function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function toByte(value: number): number { return clamp(Math.round(value), 0, 255); } function buildCurveLut(points: CurvePoint[]): Uint8Array { const lut = new Uint8Array(256); const normalized = [...points].sort((left, right) => left.x - right.x); for (let input = 0; input < 256; input += 1) { const first = normalized[0] ?? { x: 0, y: 0 }; const last = normalized[normalized.length - 1] ?? { x: 255, y: 255 }; if (input <= first.x) { lut[input] = toByte(first.y); continue; } if (input >= last.x) { lut[input] = toByte(last.y); continue; } for (let index = 1; index < normalized.length; index += 1) { const left = normalized[index - 1]!; const right = normalized[index]!; if (input < left.x || input > right.x) { continue; } const span = Math.max(1, right.x - left.x); const progress = (input - left.x) / span; lut[input] = toByte(left.y + (right.y - left.y) * progress); break; } } return lut; } function createLutTexture( gl: WebGLRenderingContext, lut: Uint8Array, textureUnit: number, ): WebGLTexture { const texture = gl.createTexture(); if (!texture) { throw new Error("WebGL LUT texture allocation failed."); } const rgba = new Uint8Array(256 * 4); for (let index = 0; index < 256; index += 1) { const value = lut[index] ?? 0; const offset = index * 4; rgba[offset] = value; rgba[offset + 1] = value; rgba[offset + 2] = value; rgba[offset + 3] = 255; } gl.activeTexture(gl.TEXTURE0 + textureUnit); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba); return texture; } function assertSupportedStep(step: PipelineStep): void { if (SUPPORTED_PREVIEW_STEP_TYPES.has(step.type as SupportedPreviewStepType)) { return; } throw new Error(`WebGL backend does not support step type '${step.type}'.`); } function createGlContext(): WebGLRenderingContext { if (typeof document !== "undefined") { const canvas = document.createElement("canvas"); const contextOptions: WebGLContextAttributes = { alpha: true, antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: true, }; const context = canvas.getContext("webgl2", contextOptions) ?? canvas.getContext("webgl", contextOptions); if (context) { return context; } } if (typeof OffscreenCanvas !== "undefined") { const canvas = new OffscreenCanvas(1, 1); const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); if (context) { return context; } } throw new Error("WebGL context is unavailable."); } function compileShader( gl: WebGLRenderingContext, source: string, shaderType: number, ): WebGLShader { const shader = gl.createShader(shaderType); if (!shader) { throw new Error("WebGL shader allocation failed."); } gl.shaderSource(shader, source); gl.compileShader(shader); if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { return shader; } const info = gl.getShaderInfoLog(shader) ?? "Unknown shader compile error."; gl.deleteShader(shader); throw new Error(`WebGL shader compile failed: ${info}`); } function compileProgram( gl: WebGLRenderingContext, fragmentShaderSource: string, ): WebGLProgram { const vertexShader = compileShader(gl, VERTEX_SHADER_SOURCE, gl.VERTEX_SHADER); const fragmentShader = compileShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) { gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); throw new Error("WebGL program allocation failed."); } gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); if (gl.getProgramParameter(program, gl.LINK_STATUS)) { return program; } const info = gl.getProgramInfoLog(program) ?? "Unknown program link error."; gl.deleteProgram(program); throw new Error(`WebGL program link failed: ${info}`); } function createQuadBuffer(gl: WebGLRenderingContext): WebGLBuffer { const quadBuffer = gl.createBuffer(); if (!quadBuffer) { throw new Error("WebGL quad buffer allocation failed."); } gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); return quadBuffer; } function applyStepUniforms( gl: WebGLRenderingContext, shaderProgram: WebGLProgram, request: BackendStepRequest, ): WebGLTexture[] { const disposableTextures: WebGLTexture[] = []; if (request.step.type === "curves") { const curves = normalizeCurvesData(request.step.params); const blackPointLocation = gl.getUniformLocation(shaderProgram, "uBlackPoint"); if (blackPointLocation) { gl.uniform1f(blackPointLocation, curves.levels.blackPoint); } const whitePointLocation = gl.getUniformLocation(shaderProgram, "uWhitePoint"); if (whitePointLocation) { gl.uniform1f(whitePointLocation, curves.levels.whitePoint); } const invGammaLocation = gl.getUniformLocation(shaderProgram, "uInvGamma"); if (invGammaLocation) { gl.uniform1f(invGammaLocation, 1 / Math.max(curves.levels.gamma, 0.001)); } const channelModeLocation = gl.getUniformLocation(shaderProgram, "uChannelMode"); if (channelModeLocation) { const channelMode = curves.channelMode === "red" ? 1 : curves.channelMode === "green" ? 2 : curves.channelMode === "blue" ? 3 : 0; gl.uniform1f(channelModeLocation, channelMode); } const lutBindings = [ { uniform: "uRgbLut", unit: 1, lut: buildCurveLut(curves.points.rgb) }, { uniform: "uRedLut", unit: 2, lut: buildCurveLut(curves.points.red) }, { uniform: "uGreenLut", unit: 3, lut: buildCurveLut(curves.points.green) }, { uniform: "uBlueLut", unit: 4, lut: buildCurveLut(curves.points.blue) }, ] as const; for (const binding of lutBindings) { const texture = createLutTexture(gl, binding.lut, binding.unit); disposableTextures.push(texture); const location = gl.getUniformLocation(shaderProgram, binding.uniform); if (location) { gl.uniform1i(location, binding.unit); } } gl.activeTexture(gl.TEXTURE0); return disposableTextures; } if (request.step.type === "color-adjust") { const color = normalizeColorAdjustData(request.step.params); const hueShiftLocation = gl.getUniformLocation(shaderProgram, "uHueShift"); if (hueShiftLocation) { gl.uniform1f(hueShiftLocation, color.hsl.hue); } const saturationFactorLocation = gl.getUniformLocation(shaderProgram, "uSaturationFactor"); if (saturationFactorLocation) { gl.uniform1f(saturationFactorLocation, 1 + color.hsl.saturation / 100); } const luminanceShiftLocation = gl.getUniformLocation(shaderProgram, "uLuminanceShift"); if (luminanceShiftLocation) { gl.uniform1f(luminanceShiftLocation, color.hsl.luminance / 100); } const temperatureShiftLocation = gl.getUniformLocation(shaderProgram, "uTemperatureShift"); if (temperatureShiftLocation) { gl.uniform1f(temperatureShiftLocation, color.temperature * 0.6); } const tintShiftLocation = gl.getUniformLocation(shaderProgram, "uTintShift"); if (tintShiftLocation) { gl.uniform1f(tintShiftLocation, color.tint * 0.4); } const vibranceBoostLocation = gl.getUniformLocation(shaderProgram, "uVibranceBoost"); if (vibranceBoostLocation) { gl.uniform1f(vibranceBoostLocation, color.vibrance / 100); } return disposableTextures; } if (request.step.type === "light-adjust") { const light = normalizeLightAdjustData(request.step.params); const exposureFactorLocation = gl.getUniformLocation(shaderProgram, "uExposureFactor"); if (exposureFactorLocation) { gl.uniform1f(exposureFactorLocation, Math.pow(2, light.exposure / 2)); } const contrastFactorLocation = gl.getUniformLocation(shaderProgram, "uContrastFactor"); if (contrastFactorLocation) { gl.uniform1f(contrastFactorLocation, 1 + light.contrast / 100); } const brightnessShiftLocation = gl.getUniformLocation(shaderProgram, "uBrightnessShift"); if (brightnessShiftLocation) { gl.uniform1f(brightnessShiftLocation, light.brightness * 1.8); } const highlightsLocation = gl.getUniformLocation(shaderProgram, "uHighlights"); if (highlightsLocation) { gl.uniform1f(highlightsLocation, light.highlights / 100); } const shadowsLocation = gl.getUniformLocation(shaderProgram, "uShadows"); if (shadowsLocation) { gl.uniform1f(shadowsLocation, light.shadows / 100); } const whitesLocation = gl.getUniformLocation(shaderProgram, "uWhites"); if (whitesLocation) { gl.uniform1f(whitesLocation, light.whites / 100); } const blacksLocation = gl.getUniformLocation(shaderProgram, "uBlacks"); if (blacksLocation) { gl.uniform1f(blacksLocation, light.blacks / 100); } const vignetteAmountLocation = gl.getUniformLocation(shaderProgram, "uVignetteAmount"); if (vignetteAmountLocation) { gl.uniform1f(vignetteAmountLocation, light.vignette.amount); } const vignetteSizeLocation = gl.getUniformLocation(shaderProgram, "uVignetteSize"); if (vignetteSizeLocation) { gl.uniform1f(vignetteSizeLocation, light.vignette.size); } const vignetteRoundnessLocation = gl.getUniformLocation(shaderProgram, "uVignetteRoundness"); if (vignetteRoundnessLocation) { gl.uniform1f(vignetteRoundnessLocation, light.vignette.roundness); } return disposableTextures; } if (request.step.type === "detail-adjust") { const detail = normalizeDetailAdjustData(request.step.params); const sharpenBoostLocation = gl.getUniformLocation(shaderProgram, "uSharpenBoost"); if (sharpenBoostLocation) { gl.uniform1f(sharpenBoostLocation, detail.sharpen.amount / 500); } const clarityBoostLocation = gl.getUniformLocation(shaderProgram, "uClarityBoost"); if (clarityBoostLocation) { gl.uniform1f(clarityBoostLocation, detail.clarity / 100); } const denoiseLumaLocation = gl.getUniformLocation(shaderProgram, "uDenoiseLuma"); if (denoiseLumaLocation) { gl.uniform1f(denoiseLumaLocation, detail.denoise.luminance / 100); } const denoiseColorLocation = gl.getUniformLocation(shaderProgram, "uDenoiseColor"); if (denoiseColorLocation) { gl.uniform1f(denoiseColorLocation, detail.denoise.color / 100); } const grainAmountLocation = gl.getUniformLocation(shaderProgram, "uGrainAmount"); if (grainAmountLocation) { gl.uniform1f(grainAmountLocation, detail.grain.amount / 100); } const grainScaleLocation = gl.getUniformLocation(shaderProgram, "uGrainScale"); if (grainScaleLocation) { gl.uniform1f(grainScaleLocation, Math.max(0.5, detail.grain.size)); } const imageWidthLocation = gl.getUniformLocation(shaderProgram, "uImageWidth"); if (imageWidthLocation) { gl.uniform1f(imageWidthLocation, request.width); } } return disposableTextures; } function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest): void { const { gl } = context; const startedAtMs = performance.now(); const shaderProgram = request.step.type === "curves" ? context.curvesProgram : request.step.type === "color-adjust" ? context.colorAdjustProgram : request.step.type === "light-adjust" ? context.lightAdjustProgram : context.detailAdjustProgram; gl.useProgram(shaderProgram); gl.bindBuffer(gl.ARRAY_BUFFER, context.quadBuffer); const positionLocation = gl.getAttribLocation(shaderProgram, "aPosition"); if (positionLocation >= 0) { gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); } const sourceTexture = gl.createTexture(); if (!sourceTexture) { throw new Error("WebGL source texture allocation failed."); } gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, sourceTexture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, request.width, request.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, request.pixels, ); const outputTexture = gl.createTexture(); if (!outputTexture) { gl.deleteTexture(sourceTexture); throw new Error("WebGL output texture allocation failed."); } gl.bindTexture(gl.TEXTURE_2D, outputTexture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, request.width, request.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null, ); const framebuffer = gl.createFramebuffer(); if (!framebuffer) { gl.deleteTexture(sourceTexture); gl.deleteTexture(outputTexture); throw new Error("WebGL framebuffer allocation failed."); } gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, outputTexture, 0); if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { gl.deleteFramebuffer(framebuffer); gl.deleteTexture(sourceTexture); gl.deleteTexture(outputTexture); throw new Error("WebGL framebuffer is incomplete."); } gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, sourceTexture); const sourceLocation = gl.getUniformLocation(shaderProgram, "uSource"); if (sourceLocation) { gl.uniform1i(sourceLocation, 0); } const disposableTextures = applyStepUniforms(gl, shaderProgram, request); gl.viewport(0, 0, request.width, request.height); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); const readback = new Uint8Array(request.pixels.length); const readbackStartedAtMs = performance.now(); gl.readPixels(0, 0, request.width, request.height, gl.RGBA, gl.UNSIGNED_BYTE, readback); const readbackDurationMs = performance.now() - readbackStartedAtMs; request.pixels.set(readback); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.deleteFramebuffer(framebuffer); gl.deleteTexture(sourceTexture); gl.deleteTexture(outputTexture); for (const texture of disposableTextures) { gl.deleteTexture(texture); } } export function isWebglPreviewStepSupported(step: PipelineStep): boolean { return SUPPORTED_PREVIEW_STEP_TYPES.has(step.type as SupportedPreviewStepType); } export function isWebglPreviewPipelineSupported(steps: readonly PipelineStep[]): boolean { return steps.every((step) => isWebglPreviewStepSupported(step)); } export function createWebglPreviewBackend(): ImagePipelineBackend { let context: WebglBackendContext | null = null; function ensureInitialized(): WebglBackendContext { if (context) { return context; } const gl = createGlContext(); context = { gl, curvesProgram: compileProgram(gl, CURVES_FRAGMENT_SHADER_SOURCE), colorAdjustProgram: compileProgram(gl, COLOR_ADJUST_FRAGMENT_SHADER_SOURCE), lightAdjustProgram: compileProgram(gl, LIGHT_ADJUST_FRAGMENT_SHADER_SOURCE), detailAdjustProgram: compileProgram(gl, DETAIL_ADJUST_FRAGMENT_SHADER_SOURCE), quadBuffer: createQuadBuffer(gl), }; return context; } return { id: "webgl", runPreviewStep(request: BackendStepRequest): void { assertSupportedStep(request.step); runStepOnGpu(ensureInitialized(), request); }, runFullPipeline(request: BackendPipelineRequest): void { if (!isWebglPreviewPipelineSupported(request.steps)) { throw new Error("WebGL backend does not support all pipeline steps."); } const initializedContext = ensureInitialized(); for (const step of request.steps) { runStepOnGpu(initializedContext, { pixels: request.pixels, step, width: request.width, height: request.height, executionOptions: request.executionOptions, }); } }, }; }