feat(image-pipeline): add backend rollout flags

This commit is contained in:
Matthias
2026-04-04 21:33:00 +02:00
parent 8fb5482550
commit fd4f8f4f3b
3 changed files with 416 additions and 4 deletions

View File

@@ -9,6 +9,10 @@ import {
type ImagePipelineBackend,
type PreviewBackendRequest,
} from "@/lib/image-pipeline/backend/backend-types";
import {
getBackendFeatureFlags,
type BackendFeatureFlags,
} from "@/lib/image-pipeline/backend/feature-flags";
type BackendFallbackReason = "unsupported_api" | "flag_disabled" | "runtime_error";
@@ -59,15 +63,50 @@ export function createBackendRouter(options?: {
backends?: readonly ImagePipelineBackend[];
defaultBackendId?: string;
backendAvailability?: Readonly<Record<string, BackendAvailability>>;
featureFlags?: BackendFeatureFlags;
onFallback?: (event: BackendFallbackEvent) => void;
}): BackendRouter {
const configuredBackends = options?.backends?.length ? [...options.backends] : [cpuBackend];
const byId = new Map(configuredBackends.map((backend) => [backend.id.toLowerCase(), backend]));
const defaultBackend =
const configuredDefaultBackend =
byId.get(options?.defaultBackendId?.toLowerCase() ?? "") ??
byId.get(CPU_BACKEND_ID) ??
configuredBackends[0] ??
cpuBackend;
const cpuFallbackBackend = byId.get(CPU_BACKEND_ID) ?? configuredDefaultBackend;
const featureFlags = options?.featureFlags;
function isBackendEnabledByFlags(backendId: string): boolean {
if (!featureFlags) {
return true;
}
const normalizedBackendId = backendId.toLowerCase();
if (featureFlags.forceCpu) {
return normalizedBackendId === CPU_BACKEND_ID;
}
if (normalizedBackendId === "webgl") {
return featureFlags.webglEnabled;
}
if (normalizedBackendId === "wasm") {
return featureFlags.wasmEnabled;
}
return true;
}
function resolveDefaultBackend(): ImagePipelineBackend {
if (!isBackendEnabledByFlags(configuredDefaultBackend.id)) {
return cpuFallbackBackend;
}
return configuredDefaultBackend;
}
const defaultBackend = resolveDefaultBackend();
const normalizedDefaultId = defaultBackend.id.toLowerCase();
function readAvailability(backendId: string): BackendAvailability | undefined {
@@ -102,6 +141,14 @@ export function createBackendRouter(options?: {
}
const availability = readAvailability(normalizedHint);
if (!isBackendEnabledByFlags(normalizedHint)) {
return {
backend: defaultBackend,
fallbackReason: "flag_disabled",
requestedBackend: normalizedHint,
};
}
if (availability?.enabled === false) {
return {
backend: defaultBackend,
@@ -190,12 +237,14 @@ export function createBackendRouter(options?: {
};
}
const defaultRouter = createBackendRouter();
const rolloutRouter = createBackendRouter({
featureFlags: getBackendFeatureFlags(),
});
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {
defaultRouter.runPreviewStep(request);
rolloutRouter.runPreviewStep(request);
}
export function runFullPipelineWithBackendRouter(request: FullBackendRequest): void {
defaultRouter.runFullPipeline(request);
rolloutRouter.runFullPipeline(request);
}

View File

@@ -0,0 +1,100 @@
export const IMAGE_PIPELINE_BACKEND_FLAG_KEYS = {
forceCpu: "imagePipeline.backend.forceCpu",
webglEnabled: "imagePipeline.backend.webgl.enabled",
wasmEnabled: "imagePipeline.backend.wasm.enabled",
} as const;
export type BackendFeatureFlags = {
forceCpu: boolean;
webglEnabled: boolean;
wasmEnabled: boolean;
};
export type BackendFeatureFlagReader = (
key: (typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS)[keyof typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS],
) => unknown;
const DEFAULT_BACKEND_FEATURE_FLAGS: BackendFeatureFlags = {
forceCpu: false,
webglEnabled: false,
wasmEnabled: false,
};
type RuntimeFeatureFlagStore = {
[IMAGE_PIPELINE_BACKEND_FLAG_KEYS.forceCpu]?: unknown;
[IMAGE_PIPELINE_BACKEND_FLAG_KEYS.webglEnabled]?: unknown;
[IMAGE_PIPELINE_BACKEND_FLAG_KEYS.wasmEnabled]?: unknown;
};
declare global {
interface Window {
__LEMONSPACE_FEATURE_FLAGS__?: RuntimeFeatureFlagStore;
}
var __LEMONSPACE_FEATURE_FLAGS__: RuntimeFeatureFlagStore | undefined;
}
function parseBooleanFlag(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
return undefined;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "off") {
return false;
}
}
return undefined;
}
function readFlagFromRuntimeStore(
key: (typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS)[keyof typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS],
): unknown {
const runtimeStore =
globalThis.__LEMONSPACE_FEATURE_FLAGS__ ??
(typeof window !== "undefined" ? window.__LEMONSPACE_FEATURE_FLAGS__ : undefined);
if (runtimeStore && key in runtimeStore) {
return runtimeStore[key];
}
try {
if (typeof localStorage !== "undefined") {
return localStorage.getItem(key);
}
} catch {
return undefined;
}
return undefined;
}
export function getBackendFeatureFlags(readFlag?: BackendFeatureFlagReader): BackendFeatureFlags {
const reader = readFlag ?? readFlagFromRuntimeStore;
return {
forceCpu:
parseBooleanFlag(reader(IMAGE_PIPELINE_BACKEND_FLAG_KEYS.forceCpu)) ??
DEFAULT_BACKEND_FEATURE_FLAGS.forceCpu,
webglEnabled:
parseBooleanFlag(reader(IMAGE_PIPELINE_BACKEND_FLAG_KEYS.webglEnabled)) ??
DEFAULT_BACKEND_FEATURE_FLAGS.webglEnabled,
wasmEnabled:
parseBooleanFlag(reader(IMAGE_PIPELINE_BACKEND_FLAG_KEYS.wasmEnabled)) ??
DEFAULT_BACKEND_FEATURE_FLAGS.wasmEnabled,
};
}

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types";
import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
import {
type BackendFeatureFlags,
getBackendFeatureFlags,
} from "@/lib/image-pipeline/backend/feature-flags";
function createStep() {
return {
nodeId: "n1",
type: "color-adjust",
params: {
hsl: {
hue: 0,
saturation: 0,
luminance: 0,
},
temperature: 0,
tint: 0,
vibrance: 0,
},
} as const;
}
function createBackends() {
const cpuPreview = vi.fn();
const cpuFull = vi.fn();
const webglPreview = vi.fn();
const webglFull = vi.fn();
const wasmPreview = vi.fn();
const wasmFull = vi.fn();
const backends: readonly ImagePipelineBackend[] = [
{
id: "cpu",
runPreviewStep: cpuPreview,
runFullPipeline: cpuFull,
},
{
id: "webgl",
runPreviewStep: webglPreview,
runFullPipeline: webglFull,
},
{
id: "wasm",
runPreviewStep: wasmPreview,
runFullPipeline: wasmFull,
},
];
return {
backends,
cpuPreview,
cpuFull,
webglPreview,
webglFull,
wasmPreview,
wasmFull,
};
}
function createRouterFlags(overrides: Partial<BackendFeatureFlags>): BackendFeatureFlags {
return {
...getBackendFeatureFlags(),
...overrides,
};
}
describe("backend feature flags", () => {
it("forceCpu overrides all backend choices", () => {
const reasons: string[] = [];
const backend = createBackends();
const router = createBackendRouter({
backends: backend.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: createRouterFlags({
forceCpu: true,
webglEnabled: true,
wasmEnabled: true,
}),
onFallback: (event) => {
reasons.push(event.reason);
},
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
router.runFullPipeline({
pixels: new Uint8ClampedArray(4),
steps: [createStep()],
width: 1,
height: 1,
backendHint: "wasm",
});
expect(backend.cpuPreview).toHaveBeenCalledTimes(1);
expect(backend.cpuFull).toHaveBeenCalledTimes(1);
expect(backend.webglPreview).not.toHaveBeenCalled();
expect(backend.webglFull).not.toHaveBeenCalled();
expect(backend.wasmPreview).not.toHaveBeenCalled();
expect(backend.wasmFull).not.toHaveBeenCalled();
expect(reasons).toEqual(["flag_disabled", "flag_disabled"]);
});
it("webgl and wasm can be independently enabled or disabled", () => {
const reasonA: string[] = [];
const backendA = createBackends();
const routerA = createBackendRouter({
backends: backendA.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: createRouterFlags({
forceCpu: false,
webglEnabled: true,
wasmEnabled: false,
}),
onFallback: (event) => {
reasonA.push(event.reason);
},
});
routerA.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
routerA.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
expect(backendA.webglPreview).toHaveBeenCalledTimes(1);
expect(backendA.wasmPreview).not.toHaveBeenCalled();
expect(backendA.cpuPreview).toHaveBeenCalledTimes(1);
expect(reasonA).toEqual(["flag_disabled"]);
const reasonB: string[] = [];
const backendB = createBackends();
const routerB = createBackendRouter({
backends: backendB.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: createRouterFlags({
forceCpu: false,
webglEnabled: false,
wasmEnabled: true,
}),
onFallback: (event) => {
reasonB.push(event.reason);
},
});
routerB.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
routerB.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
expect(backendB.webglPreview).not.toHaveBeenCalled();
expect(backendB.wasmPreview).toHaveBeenCalledTimes(1);
expect(backendB.cpuPreview).toHaveBeenCalledTimes(1);
expect(reasonB).toEqual(["flag_disabled"]);
});
it("defaults preserve cpu behavior when no explicit flags are set", () => {
const reasons: string[] = [];
const backend = createBackends();
const router = createBackendRouter({
backends: backend.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: getBackendFeatureFlags(),
onFallback: (event) => {
reasons.push(event.reason);
},
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
});
expect(backend.cpuPreview).toHaveBeenCalledTimes(3);
expect(backend.webglPreview).not.toHaveBeenCalled();
expect(backend.wasmPreview).not.toHaveBeenCalled();
expect(reasons).toEqual(["flag_disabled", "flag_disabled"]);
});
});