faceless-photolib

@faceless-photolib/adjustments

Non-destructive image adjustments (curves, levels, exposure, contrast, hue/saturation, white balance, channel mixer) as color-managed shader-IR nodes with a declared working color space and a CPU reference.

Non-destructive adjustments for faceless-photolib — a headless, color-managed, GPU-accelerated image-editing engine.

Each adjustment (curves, levels, exposure, contrast, hue/saturation, white balance, channel mixer) is a backend-agnostic shader-IR node that declares the working color space it operates in. The engine auto-inserts reference↔working-space conversions around it, so the effect math always runs in the space it was authored against. This package owns boundary validation (Zod safeParse — out-of-range params are rejected, never clamped), effect dispatch (a table lookup; an unknown effect is not-implemented, never a silent identity pass-through), and a CPU reference evaluator that is the golden source for the GPU path.

Install

pnpm add @faceless-photolib/adjustments

Usage

import {
  compileAdjustment,
  applyAdjustment,
  supportedEffects,
  type PixelF,
} from "@faceless-photolib/adjustments";
import { AdjustmentParamsSchema, ColorSpaceIdSchema } from "@faceless-photolib/schemas";

// The working space is a registered color-space id (e.g. "acescg", "srgb-linear").
const workingSpace = ColorSpaceIdSchema.parse("srgb-linear");

// `AdjustmentParams` is the envelope { effect, params }; params are validated
// against the effect's own schema. `exposure` takes { stops } (one stop = ×2 light).
const request = AdjustmentParamsSchema.parse({
  effect: "exposure",
  params: { stops: 1 },
});

// Validate + lower into a renderable descriptor. Returns a discriminated `Result`.
const compiled = compileAdjustment(request, workingSpace);
if (compiled.kind !== "ok") {
  throw new Error(`compile failed: ${compiled.kind}`);
}

// CPU reference: apply to a straight (un-premultiplied) RGBA pixel in connection space.
const input: PixelF = { r: 0.18, g: 0.18, b: 0.18, a: 1 };
const output = applyAdjustment(compiled.value, input);
// output ≈ { r: 0.36, g: 0.36, b: 0.36, a: 1 } — one stop brighter.

// Which effects this package recognizes.
console.log(supportedEffects());
// → ["curves", "levels", "exposure", "contrast", "hueSaturation", "whiteBalance", "channelMixer"]

API

ExportDescription
compileAdjustment(params, workingSpace)Validates an AdjustmentParams envelope and declared ColorSpaceId, returning a Result<AdjustmentDescriptor>. Unknown effect → rejected("not-implemented"); out-of-range params → invalid-request with Zod issues; unresolvable space → failure.
applyAdjustment(descriptor, pixel)CPU reference: converts the PixelF into the descriptor's working space, applies the effect math, and converts back to connection space. Fails loud on a non-renderable descriptor.
supportedEffects()Returns the ids of the recognized adjustment effects (curves, levels, exposure, contrast, hueSaturation, whiteBalance, channelMixer).
AdjustmentDescriptorShader-IR node for one adjustment: { node, effect, workingSpace, params }.
PixelFA straight (un-premultiplied) RGBA color, components as number.

Alpha passes through unchanged — these tonal/color adjustments operate on straight RGB only.

License

MIT

API reference

7 public exports · 7 documented · generated from source.

applyAdjustmentfunction
applyAdjustment(desc: AdjustmentDescriptor, pixel: PixelF): PixelF

CPU reference: apply an adjustment to a single working-space pixel (golden source). The incoming pixel is in the connection (reference) space; this routine converts it into the descriptor's declared working space, applies the effect math there, and converts back to the connection space — the auto-inserted reference→workingSpace→reference wrapping the adjustments spec requires, realized against the real color-package conversion descriptors. Failure modes are loud (this returns a `PixelF`, not a `Result`): an unknown effect throws via `warnNotImplemented`, and a descriptor whose params or working space cannot be resolved is a `warnUnexpected` + throw — never a silent identity (a well-formed descriptor from `compileAdjustment` guarantees both resolve, so these guard against hand-built/forward-compat descriptors).

applyEffectfunction
applyEffect(desc: AdjustmentDescriptor, pixel: PixelF): PixelF

CPU reference: apply ONLY the effect math to a pixel that is ALREADY in the descriptor's declared working space — the conversion-free counterpart to {@link applyAdjustment}. The render-graph compiler lowers an adjustment LAYER into a colour-managed sandwich of SEPARATE `colorConversion` passes (connection→workingSpace before, workingSpace→connection after — see render-graph `intoConnection`). The adjustment pass that sits between them therefore receives a buffer already in the working space and must run effect math ONLY; calling the all-in-one {@link applyAdjustment} there would convert a second time (double-convert). This is the CPU twin of the GPU `generateAdjustmentWgsl` contract, which is documented to emit effect math only for exactly this reason. Failure modes are loud (returns a `PixelF`, not a `Result`): an unknown effect throws via `warnNotImplemented`, and a descriptor whose params fail re-validation throws — never a silent identity. Alpha passes through.

compileAdjustmentfunction
compileAdjustment(params: { effect: string; params: Record<string, unknown>; }, workingSpace: string & $brand<"ColorSpaceId">): Result<AdjustmentDescriptor>

Validate + lower an `AdjustmentParams` into a descriptor; unknown effect is rejected + beacon. The params record is validated against the effect's own Zod schema at this boundary (`safeParse`), and an out-of-range payload is `invalid-request` with the Zod issues — the engine never clamps or defaults out-of-range parameters (adjustments spec). The declared working space must resolve in the registry, otherwise the descriptor would be unrenderable; an unknown space is `not-implemented` + beacon.

naturalWorkingSpacefunction
naturalWorkingSpace(effect: string): string & $brand<"ColorSpaceId">

The colour working space an effect is AUTHORED for — the space a client should stamp on an adjustment layer's `colorSpace` so the math behaves as intended. Most effects (`contrast`, `levels`, `curves`, `hueSaturation`, `channelMixer`) are **display-referred**: their math lives on a `[0, 1]` domain with a mid-grey pivot at `0.5` (the Photoshop/Lightroom semantic) and `clamp01`'d HSL. Running them in the scene-linear connection space is wrong — e.g. contrast `pivot 0.5` on a scene-linear shadow (~0.05) yields `(0.05 - 0.5) * amount + 0.5`, a large NEGATIVE value, which the output transform then cross-mixes through the AP1→display matrix into wildly wrong hues. They belong in a display-encoded space (`srgb`), where `0.5` really is mid-grey. `exposure` and `whiteBalance` are scene-linear multiplicative gains (a stop is a light multiplier; von Kries is a channel-domain gain) and correctly operate in the connection space (`acescg`). Dispatch is a set membership test, not an exhaustive `match`, because `effect` is a free-form string at the boundary (mirrors `EFFECT_TABLE`); an unrecognized effect defaults to the display-referred space (the common case) — the effect id itself is still validated loudly by {@link compileAdjustment}.

supportedEffectsfunction
supportedEffects(): readonly string[]

Ids of the adjustment effects this package recognizes (e.g. curves, levels, hueSaturation).

AdjustmentDescriptorinterface
interface AdjustmentDescriptor

Backend-agnostic descriptor for one adjustment (Shader IR node).

PixelFinterface
interface PixelF

A straight (un-premultiplied) RGBA color in working (linear) space.