fix(image-pipeline): diagnose and stabilize webgl preview path
This commit is contained in:
@@ -65,6 +65,14 @@ function normalizeBackendHint(value: BackendHint): string | null {
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function logBackendRouterDebug(event: string, payload: Record<string, unknown>): void {
|
||||
if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("[image-pipeline backend]", event, payload);
|
||||
}
|
||||
|
||||
export function createBackendRouter(options?: {
|
||||
backends?: readonly ImagePipelineBackend[];
|
||||
defaultBackendId?: string;
|
||||
@@ -123,6 +131,12 @@ export function createBackendRouter(options?: {
|
||||
}
|
||||
|
||||
function emitFallback(event: BackendFallbackEvent): void {
|
||||
logBackendRouterDebug("fallback", {
|
||||
reason: event.reason,
|
||||
requestedBackend: event.requestedBackend,
|
||||
fallbackBackend: event.fallbackBackend,
|
||||
errorMessage: event.error?.message,
|
||||
});
|
||||
options?.onFallback?.(event);
|
||||
}
|
||||
|
||||
@@ -335,23 +349,32 @@ function getRolloutRouterState(): RolloutRouterState {
|
||||
export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequest["step"][]): BackendHint {
|
||||
const rolloutState = getRolloutRouterState();
|
||||
|
||||
let backendHint: BackendHint;
|
||||
|
||||
if (rolloutState.webglEnabled && rolloutState.webglAvailable) {
|
||||
if (isWebglPreviewPipelineSupported(steps)) {
|
||||
return "webgl";
|
||||
backendHint = "webgl";
|
||||
} else if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {
|
||||
backendHint = "wasm";
|
||||
} else {
|
||||
backendHint = CPU_BACKEND_ID;
|
||||
}
|
||||
|
||||
if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {
|
||||
return "wasm";
|
||||
}
|
||||
|
||||
return CPU_BACKEND_ID;
|
||||
} else if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {
|
||||
backendHint = "wasm";
|
||||
} else {
|
||||
backendHint = CPU_BACKEND_ID;
|
||||
}
|
||||
|
||||
if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {
|
||||
return "wasm";
|
||||
}
|
||||
logBackendRouterDebug("preview-backend-hint", {
|
||||
backendHint,
|
||||
stepTypes: steps.map((step) => step.type),
|
||||
webglAvailable: rolloutState.webglAvailable,
|
||||
webglEnabled: rolloutState.webglEnabled,
|
||||
wasmAvailable: rolloutState.wasmAvailable,
|
||||
wasmEnabled: rolloutState.wasmEnabled,
|
||||
});
|
||||
|
||||
return CPU_BACKEND_ID;
|
||||
return backendHint;
|
||||
}
|
||||
|
||||
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {
|
||||
|
||||
@@ -10,6 +10,8 @@ type CapabilityProbes = {
|
||||
probeOffscreenCanvas: () => boolean;
|
||||
};
|
||||
|
||||
let cachedDefaultCapabilities: BackendCapabilities | null = null;
|
||||
|
||||
export const WASM_SIMD_PROBE_MODULE = new Uint8Array([
|
||||
0x00,
|
||||
0x61,
|
||||
@@ -46,12 +48,27 @@ function probeOffscreenCanvasAvailability(): boolean {
|
||||
return typeof OffscreenCanvas !== "undefined";
|
||||
}
|
||||
|
||||
function releaseProbeWebglContext(
|
||||
context: WebGLRenderingContext | WebGL2RenderingContext | null,
|
||||
): void {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
context.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
// Ignore cleanup failures in capability probes.
|
||||
}
|
||||
}
|
||||
|
||||
function probeWebglAvailability(): boolean {
|
||||
try {
|
||||
if (typeof document !== "undefined") {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
if (context) {
|
||||
releaseProbeWebglContext(context);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +76,7 @@ function probeWebglAvailability(): boolean {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
const offscreenCanvas = new OffscreenCanvas(1, 1);
|
||||
const context = offscreenCanvas.getContext("webgl2") ?? offscreenCanvas.getContext("webgl");
|
||||
releaseProbeWebglContext(context);
|
||||
return Boolean(context);
|
||||
}
|
||||
|
||||
@@ -80,14 +98,28 @@ function probeWasmSimdAvailability(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function resetBackendCapabilitiesCache(): void {
|
||||
cachedDefaultCapabilities = null;
|
||||
}
|
||||
|
||||
export function detectBackendCapabilities(probes?: Partial<CapabilityProbes>): BackendCapabilities {
|
||||
if (!probes && cachedDefaultCapabilities) {
|
||||
return cachedDefaultCapabilities;
|
||||
}
|
||||
|
||||
const probeWebgl = probes?.probeWebgl ?? probeWebglAvailability;
|
||||
const probeWasmSimd = probes?.probeWasmSimd ?? probeWasmSimdAvailability;
|
||||
const probeOffscreenCanvas = probes?.probeOffscreenCanvas ?? probeOffscreenCanvasAvailability;
|
||||
|
||||
return {
|
||||
const capabilities = {
|
||||
webgl: probeWebgl(),
|
||||
wasmSimd: probeWasmSimd(),
|
||||
offscreenCanvas: probeOffscreenCanvas(),
|
||||
};
|
||||
|
||||
if (!probes) {
|
||||
cachedDefaultCapabilities = capabilities;
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
@@ -172,6 +172,14 @@ const SUPPORTED_PREVIEW_STEP_TYPES = new Set<SupportedPreviewStepType>([
|
||||
"detail-adjust",
|
||||
]);
|
||||
|
||||
function logWebglBackendDebug(event: string, payload: Record<string, unknown>): void {
|
||||
if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("[image-pipeline webgl]", event, payload);
|
||||
}
|
||||
|
||||
function assertSupportedStep(step: PipelineStep): void {
|
||||
if (SUPPORTED_PREVIEW_STEP_TYPES.has(step.type as SupportedPreviewStepType)) {
|
||||
return;
|
||||
@@ -415,6 +423,7 @@ function applyStepUniforms(
|
||||
|
||||
function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest): void {
|
||||
const { gl } = context;
|
||||
const startedAtMs = performance.now();
|
||||
const shaderProgram =
|
||||
request.step.type === "curves"
|
||||
? context.curvesProgram
|
||||
@@ -509,13 +518,23 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest)
|
||||
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);
|
||||
|
||||
logWebglBackendDebug("step-complete", {
|
||||
stepType: request.step.type,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
totalDurationMs: performance.now() - startedAtMs,
|
||||
readbackDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function isWebglPreviewStepSupported(step: PipelineStep): boolean {
|
||||
|
||||
@@ -3,12 +3,21 @@ import { renderPreview } from "@/lib/image-pipeline/preview-renderer";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import type { HistogramData } from "@/lib/image-pipeline/histogram";
|
||||
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
|
||||
import {
|
||||
IMAGE_PIPELINE_BACKEND_FLAG_KEYS,
|
||||
type BackendFeatureFlags,
|
||||
} from "@/lib/image-pipeline/backend/feature-flags";
|
||||
|
||||
type PreviewWorkerPayload = {
|
||||
sourceUrl: string;
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
featureFlags?: BackendFeatureFlags;
|
||||
};
|
||||
|
||||
type FullWorkerPayload = RenderFullOptions & {
|
||||
featureFlags?: BackendFeatureFlags;
|
||||
};
|
||||
|
||||
type WorkerRequestMessage =
|
||||
@@ -20,7 +29,7 @@ type WorkerRequestMessage =
|
||||
| {
|
||||
kind: "full";
|
||||
requestId: number;
|
||||
payload: RenderFullOptions;
|
||||
payload: FullWorkerPayload;
|
||||
}
|
||||
| {
|
||||
kind: "cancel";
|
||||
@@ -62,6 +71,16 @@ type WorkerScope = {
|
||||
const workerScope = self as unknown as WorkerScope;
|
||||
const runningControllers = new Map<number, AbortController>();
|
||||
|
||||
function applyWorkerFeatureFlags(featureFlags: BackendFeatureFlags | undefined): void {
|
||||
(globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_FEATURE_FLAGS__?: Record<string, unknown>;
|
||||
}).__LEMONSPACE_FEATURE_FLAGS__ = {
|
||||
[IMAGE_PIPELINE_BACKEND_FLAG_KEYS.forceCpu]: featureFlags?.forceCpu ?? false,
|
||||
[IMAGE_PIPELINE_BACKEND_FLAG_KEYS.webglEnabled]: featureFlags?.webglEnabled ?? false,
|
||||
[IMAGE_PIPELINE_BACKEND_FLAG_KEYS.wasmEnabled]: featureFlags?.wasmEnabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function postMessageSafe(message: WorkerResponseMessage, transfer?: Transferable[]): void {
|
||||
if (transfer) {
|
||||
workerScope.postMessage(message, transfer);
|
||||
@@ -90,6 +109,7 @@ async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPay
|
||||
runningControllers.set(requestId, controller);
|
||||
|
||||
try {
|
||||
applyWorkerFeatureFlags(payload.featureFlags);
|
||||
const result = await renderPreview({
|
||||
sourceUrl: payload.sourceUrl,
|
||||
steps: payload.steps,
|
||||
@@ -133,13 +153,16 @@ async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPay
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFullRequest(requestId: number, payload: RenderFullOptions): Promise<void> {
|
||||
async function handleFullRequest(requestId: number, payload: FullWorkerPayload): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
runningControllers.set(requestId, controller);
|
||||
|
||||
try {
|
||||
applyWorkerFeatureFlags(payload.featureFlags);
|
||||
const result = await renderFull({
|
||||
...payload,
|
||||
sourceUrl: payload.sourceUrl,
|
||||
steps: payload.steps,
|
||||
render: payload.render,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -150,11 +173,10 @@ async function handleFullRequest(requestId: number, payload: RenderFullOptions):
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (typeof console !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
console.error("[image-pipeline.worker] preview request failed", {
|
||||
console.error("[image-pipeline.worker] full request failed", {
|
||||
requestId,
|
||||
sourceUrl: payload.sourceUrl,
|
||||
previewWidth: payload.previewWidth,
|
||||
includeHistogram: payload.includeHistogram,
|
||||
render: payload.render,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import type { HistogramData } from "@/lib/image-pipeline/histogram";
|
||||
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
|
||||
import {
|
||||
getBackendFeatureFlags,
|
||||
type BackendFeatureFlags,
|
||||
} from "@/lib/image-pipeline/backend/feature-flags";
|
||||
|
||||
export type { PreviewRenderResult };
|
||||
|
||||
@@ -20,6 +24,11 @@ type PreviewWorkerPayload = {
|
||||
steps: readonly PipelineStep[];
|
||||
previewWidth: number;
|
||||
includeHistogram?: boolean;
|
||||
featureFlags?: BackendFeatureFlags;
|
||||
};
|
||||
|
||||
type FullWorkerPayload = RenderFullOptions & {
|
||||
featureFlags?: BackendFeatureFlags;
|
||||
};
|
||||
|
||||
type WorkerRequestMessage =
|
||||
@@ -31,7 +40,7 @@ type WorkerRequestMessage =
|
||||
| {
|
||||
kind: "full";
|
||||
requestId: number;
|
||||
payload: RenderFullOptions;
|
||||
payload: FullWorkerPayload;
|
||||
}
|
||||
| {
|
||||
kind: "cancel";
|
||||
@@ -239,7 +248,7 @@ function getWorker(): Worker {
|
||||
|
||||
function runWorkerRequest<TResponse extends PreviewRenderResult | RenderFullResult>(args: {
|
||||
kind: "preview" | "full";
|
||||
payload: PreviewWorkerPayload | RenderFullOptions;
|
||||
payload: PreviewWorkerPayload | FullWorkerPayload;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<TResponse> {
|
||||
if (args.signal?.aborted) {
|
||||
@@ -327,6 +336,10 @@ function getPreviewRequestKey(options: {
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function getWorkerFeatureFlagsSnapshot(): BackendFeatureFlags {
|
||||
return getBackendFeatureFlags();
|
||||
}
|
||||
|
||||
async function runPreviewRequest(options: {
|
||||
sourceUrl: string;
|
||||
steps: readonly PipelineStep[];
|
||||
@@ -342,6 +355,7 @@ async function runPreviewRequest(options: {
|
||||
steps: options.steps,
|
||||
previewWidth: options.previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
featureFlags: getWorkerFeatureFlagsSnapshot(),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
@@ -477,7 +491,10 @@ export async function renderFullWithWorkerFallback(
|
||||
try {
|
||||
return await runWorkerRequest<RenderFullResult>({
|
||||
kind: "full",
|
||||
payload: options,
|
||||
payload: {
|
||||
...options,
|
||||
featureFlags: getWorkerFeatureFlagsSnapshot(),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user