fix(image-pipeline): diagnose and stabilize webgl preview path

This commit is contained in:
2026-04-05 11:28:42 +02:00
parent 186a5b9f92
commit 451ab0b986
11 changed files with 401 additions and 25 deletions

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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 {