fix(image-pipeline): harden worker preview path
This commit is contained in:
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
|
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||||
import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
|
import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
|
||||||
import {
|
import {
|
||||||
|
getLastBackendDiagnostics,
|
||||||
isPipelineAbortError,
|
isPipelineAbortError,
|
||||||
renderPreviewWithWorkerFallback,
|
renderPreviewWithWorkerFallback,
|
||||||
type PreviewRenderResult,
|
type PreviewRenderResult,
|
||||||
@@ -155,6 +156,19 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
|||||||
renderError instanceof Error
|
renderError instanceof Error
|
||||||
? renderError.message
|
? renderError.message
|
||||||
: "Preview rendering failed";
|
: "Preview rendering failed";
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.error("[usePipelinePreview] render failed", {
|
||||||
|
message,
|
||||||
|
sourceUrl,
|
||||||
|
pipelineHash,
|
||||||
|
previewWidth,
|
||||||
|
includeHistogram: options.includeHistogram,
|
||||||
|
diagnostics: getLastBackendDiagnostics(),
|
||||||
|
error: renderError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setError(message);
|
setError(message);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ function parseBooleanFlag(value: unknown): boolean | undefined {
|
|||||||
function readFlagFromRuntimeStore(
|
function readFlagFromRuntimeStore(
|
||||||
key: (typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS)[keyof typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS],
|
key: (typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS)[keyof typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS],
|
||||||
): unknown {
|
): unknown {
|
||||||
const runtimeStore =
|
const runtimeStore = (globalThis as typeof globalThis & {
|
||||||
globalThis.__LEMONSPACE_FEATURE_FLAGS__ ??
|
__LEMONSPACE_FEATURE_FLAGS__?: RuntimeFeatureFlagStore;
|
||||||
(typeof window !== "undefined" ? window.__LEMONSPACE_FEATURE_FLAGS__ : undefined);
|
}).__LEMONSPACE_FEATURE_FLAGS__;
|
||||||
|
|
||||||
if (runtimeStore && key in runtimeStore) {
|
if (runtimeStore && key in runtimeStore) {
|
||||||
return runtimeStore[key];
|
return runtimeStore[key];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,141 @@ import {
|
|||||||
normalizeLightAdjustData,
|
normalizeLightAdjustData,
|
||||||
} from "@/lib/image-pipeline/adjustment-types";
|
} from "@/lib/image-pipeline/adjustment-types";
|
||||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||||
import colorAdjustFragmentShaderSource from "@/lib/image-pipeline/backend/webgl/shaders/color-adjust.frag.glsl?raw";
|
|
||||||
import curvesFragmentShaderSource from "@/lib/image-pipeline/backend/webgl/shaders/curves.frag.glsl?raw";
|
const CURVES_FRAGMENT_SHADER_SOURCE = `
|
||||||
import detailAdjustFragmentShaderSource from "@/lib/image-pipeline/backend/webgl/shaders/detail-adjust.frag.glsl?raw";
|
precision mediump float;
|
||||||
import lightAdjustFragmentShaderSource from "@/lib/image-pipeline/backend/webgl/shaders/light-adjust.frag.glsl?raw";
|
|
||||||
|
varying vec2 vUv;
|
||||||
|
uniform sampler2D uSource;
|
||||||
|
uniform float uGamma;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture2D(uSource, vUv);
|
||||||
|
color.rgb = pow(max(color.rgb, vec3(0.0)), vec3(max(uGamma, 0.001)));
|
||||||
|
gl_FragColor = color;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const COLOR_ADJUST_FRAGMENT_SHADER_SOURCE = `
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
varying vec2 vUv;
|
||||||
|
uniform sampler2D uSource;
|
||||||
|
uniform vec3 uColorShift;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture2D(uSource, vUv);
|
||||||
|
color.rgb = clamp(color.rgb + uColorShift, 0.0, 1.0);
|
||||||
|
gl_FragColor = color;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
const VERTEX_SHADER_SOURCE = `
|
||||||
attribute vec2 aPosition;
|
attribute vec2 aPosition;
|
||||||
@@ -406,10 +537,10 @@ export function createWebglPreviewBackend(): ImagePipelineBackend {
|
|||||||
const gl = createGlContext();
|
const gl = createGlContext();
|
||||||
context = {
|
context = {
|
||||||
gl,
|
gl,
|
||||||
curvesProgram: compileProgram(gl, curvesFragmentShaderSource),
|
curvesProgram: compileProgram(gl, CURVES_FRAGMENT_SHADER_SOURCE),
|
||||||
colorAdjustProgram: compileProgram(gl, colorAdjustFragmentShaderSource),
|
colorAdjustProgram: compileProgram(gl, COLOR_ADJUST_FRAGMENT_SHADER_SOURCE),
|
||||||
lightAdjustProgram: compileProgram(gl, lightAdjustFragmentShaderSource),
|
lightAdjustProgram: compileProgram(gl, LIGHT_ADJUST_FRAGMENT_SHADER_SOURCE),
|
||||||
detailAdjustProgram: compileProgram(gl, detailAdjustFragmentShaderSource),
|
detailAdjustProgram: compileProgram(gl, DETAIL_ADJUST_FRAGMENT_SHADER_SOURCE),
|
||||||
quadBuffer: createQuadBuffer(gl),
|
quadBuffer: createQuadBuffer(gl),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPay
|
|||||||
[pixels],
|
[pixels],
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (typeof console !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||||
|
console.error("[image-pipeline.worker] preview request failed", {
|
||||||
|
requestId,
|
||||||
|
sourceUrl: payload.sourceUrl,
|
||||||
|
previewWidth: payload.previewWidth,
|
||||||
|
includeHistogram: payload.includeHistogram,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
postMessageSafe({
|
postMessageSafe({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
requestId,
|
requestId,
|
||||||
@@ -139,6 +149,16 @@ async function handleFullRequest(requestId: number, payload: RenderFullOptions):
|
|||||||
payload: result,
|
payload: result,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (typeof console !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||||
|
console.error("[image-pipeline.worker] preview request failed", {
|
||||||
|
requestId,
|
||||||
|
sourceUrl: payload.sourceUrl,
|
||||||
|
previewWidth: payload.previewWidth,
|
||||||
|
includeHistogram: payload.includeHistogram,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
postMessageSafe({
|
postMessageSafe({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ function shouldFallbackToMainThread(error: unknown): error is WorkerUnavailableE
|
|||||||
return error instanceof WorkerUnavailableError;
|
return error instanceof WorkerUnavailableError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logWorkerClientDebug(event: string, details: Record<string, unknown>): void {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[worker-client] ${event}`, details);
|
||||||
|
}
|
||||||
|
|
||||||
function updateLastBackendDiagnostics(metadata: BackendDiagnosticsMetadata | undefined): void {
|
function updateLastBackendDiagnostics(metadata: BackendDiagnosticsMetadata | undefined): void {
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
return;
|
return;
|
||||||
@@ -178,6 +186,13 @@ function getWorker(): Worker {
|
|||||||
|
|
||||||
if (message.kind === "error") {
|
if (message.kind === "error") {
|
||||||
updateLastBackendDiagnostics(message.payload.diagnostics);
|
updateLastBackendDiagnostics(message.payload.diagnostics);
|
||||||
|
logWorkerClientDebug("worker response error", {
|
||||||
|
requestId: message.requestId,
|
||||||
|
pendingKind: pending.kind,
|
||||||
|
errorName: message.payload.name,
|
||||||
|
errorMessage: message.payload.message,
|
||||||
|
diagnostics: message.payload.diagnostics,
|
||||||
|
});
|
||||||
const workerError = new Error(message.payload.message);
|
const workerError = new Error(message.payload.message);
|
||||||
workerError.name = message.payload.name;
|
workerError.name = message.payload.name;
|
||||||
pending.reject(workerError);
|
pending.reject(workerError);
|
||||||
@@ -336,9 +351,23 @@ async function runPreviewRequest(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldFallbackToMainThread(error)) {
|
if (!shouldFallbackToMainThread(error)) {
|
||||||
|
logWorkerClientDebug("preview request failed without fallback", {
|
||||||
|
sourceUrl: options.sourceUrl,
|
||||||
|
previewWidth: options.previewWidth,
|
||||||
|
includeHistogram: options.includeHistogram,
|
||||||
|
diagnostics: getLastBackendDiagnostics(),
|
||||||
|
error,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logWorkerClientDebug("preview request falling back to main-thread", {
|
||||||
|
sourceUrl: options.sourceUrl,
|
||||||
|
previewWidth: options.previewWidth,
|
||||||
|
includeHistogram: options.includeHistogram,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
return await renderPreview(options);
|
return await renderPreview(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user