faceless-photolib

@faceless-photolib/blend

All 27 W3C Compositing & Blending Level 1 blend modes (the Photoshop set) over premultiplied Porter-Duff alpha, as a CPU golden reference for faceless-photolib.

Blend & compositing for faceless-photolib — a headless, color-managed, GPU-accelerated image-editing engine.

Implements all 27 W3C Compositing & Blending Level 1 blend modes — the exact Photoshop set — over premultiplied Porter-Duff source-over. The CPU evaluator here is the golden reference for parity testing; GPU backends lower the same math. Blends can run in linear-light or a Photoshop-parity perceptual (sRGB-gamma) space, with separate Opacity (post-scale) and Fill (blend-input) controls and deterministic, seeded dissolve coverage.

Install

pnpm add @faceless-photolib/blend

Usage

import { blendPixel, blendPixelAt, type BlendDescriptor, type RgbaF } from "@faceless-photolib/blend";

// Describe one blend step. Colors are premultiplied RGBA in working (linear) space.
const desc: BlendDescriptor = {
  node: "blend",
  mode: "multiply",
  space: "linear-light",
  opacity: 1,
  fillOpacity: 1,
  dissolveSeed: 0,
};

const backdrop: RgbaF = { r: 0.8, g: 0.8, b: 0.8, a: 1 };
const source: RgbaF = { r: 0.5, g: 0.2, b: 0.1, a: 1 };

const out = blendPixel(desc, backdrop, source);
// → premultiplied RgbaF composite

// `dissolve` needs the pixel position for its deterministic stochastic coverage:
const dissolved = blendPixelAt({ ...desc, mode: "dissolve", opacity: 0.5 }, backdrop, source, x, y);

The pure per-mode formulas are also exported so backends and conformance harnesses can test against the golden math directly:

import { screen, softLight, colorBurn } from "@faceless-photolib/blend";
import { color, setLum, lum } from "@faceless-photolib/blend"; // non-separable HSL helpers

API

ExportDescription
blendPixel(desc, backdrop, source)CPU reference: composite one premultiplied source over a premultiplied backdrop per the descriptor.
blendPixelAt(desc, backdrop, source, x, y)Like blendPixel, but applies deterministic seeded dissolve coverage at pixel (x, y); identical to blendPixel for every other mode.
BlendDescriptorOne blend step: mode, space, opacity, fillOpacity, dissolveSeed.
RgbaFA premultiplied-alpha RGBA color in working (linear) space.
RgbA straight-RGB triplet (no alpha).
isNonSeparable(mode)Whether mode is one of the four HSL modes (hue / saturation / color / luminosity).
isSpecial8(mode)Whether mode is one of the eight modes whose Fill response differs from Opacity.
dissolveCoverage(seed, x, y, alpha)Deterministic paint/skip decision for dissolve, uniform in (seed, x, y).
separable primitivesPer-channel formulas: normal, darken, multiply, colorBurn, linearBurn, lighten, screen, colorDodge, linearDodge, overlay, softLight, hardLight, vividLight, linearLight, pinLight, hardMix, difference, exclusion, subtract, divide.
whole-pixel modesdarkerColor, lighterColor — luminance comparison over the whole triplet.
non-separable HSL helpershue, saturation, color, luminosity, plus lum, sat, clipColor, setLum, setSat.

mode, space, and the BlendMode set come from @faceless-photolib/schemas. Reducing Fill is observably different from reducing Opacity for the "Special-8" modes (Fill enters the non-linear blend equation; Opacity post-scales the result).

License

MIT

API reference

39 public exports · 39 documented · generated from source.

blendPixelfunction
blendPixel(desc: BlendDescriptor, backdrop: RgbaF, source: RgbaF): RgbaF

CPU reference: blend a premultiplied source over a premultiplied backdrop for one pixel using the given descriptor. The golden source for parity testing. Pipeline (D5): 1. Un-premultiply backdrop and source to straight RGB (α==0 → 0). 2. Apply Fill to the source coverage: `αs_in = fillOpacity · αs`. For the Special-8 modes Fill enters the blend equation here (so it is *not* interchangeable with Opacity); for the others Fill and Opacity fold together and post-scale identically. 3. Map straight RGB into the chosen blend space (linear-light or perceptual). 4. Compute `B(Cb, Cs)` and the W3C blend-in-place `Cs' = (1−αb)·Cs + αb·B`. 5. Map back to working (linear) space. 6. Premultiplied Porter-Duff source-over with Opacity post-scaling the source alpha: `co = cs'·αeff + cb·(1−αeff)`, `αo = αeff + αb·(1−αeff)`.

blendPixelAtfunction
blendPixelAt(desc: BlendDescriptor, backdrop: RgbaF, source: RgbaF, x: number, y: number): RgbaF

CPU reference: blend one premultiplied pixel at integer coordinates (x, y), applying the deterministic seeded Dissolve coverage for the `dissolve` mode (blend-compositing spec; D5). Every other mode ignores (x, y) and is identical to {@link blendPixel}; only `dissolve` needs the position to decide coverage. Dissolve does NOT blend colors: each pixel is either fully painted with the source (composited `normal` at the source's own straight alpha, with the paint *decision* consuming opacity·fill·αs as its probability) or left as the backdrop. The decision is `dissolveCoverage(seed, x, y, αEff)`, deterministic in (seed, x, y), so a re-render reproduces byte-identical coverage.

clipColorfunction
clipColor(c: Rgb): Rgb

ClipColor(C): pull any out-of-[0,1] channel back toward the luminance so the result stays in gamut while preserving Lum. Verbatim W3C algorithm.

colorfunction
color(cb: Rgb, cs: Rgb): Rgb

Color: SetLum(Cs, Lum(Cb)).

colorBurnfunction
colorBurn(cb: number, cs: number): number

ColorBurn (W3C piecewise, division-guarded): Cb==1 → 1 ; Cs==0 → 0 ; else 1 − min(1, (1−Cb)/Cs).

colorDodgefunction
colorDodge(cb: number, cs: number): number

ColorDodge (W3C piecewise, division-guarded): Cb==0 → 0 ; Cs==1 → 1 ; else min(1, Cb/(1−Cs)).

darkenfunction
darken(cb: number, cs: number): number

Darken: B = min(Cb, Cs).

darkerColorfunction
darkerColor(cb: Rgb, cs: Rgb): Rgb

DarkerColor: keep whichever whole color has the lower luminance.

differencefunction
difference(cb: number, cs: number): number

Difference: B = |Cb − Cs|.

dissolveCoveragefunction
dissolveCoverage(seed: number, x: number, y: number, alpha: number): boolean

Decide whether the Dissolve source paints pixel (x, y). `alpha` is the source's effective coverage in [0,1] (opacity × fill × source alpha). The decision is deterministic in (seed, x, y): alpha ≤ 0 never paints, alpha ≥ 1 always paints, and the boundary uses the hashed uniform value.

dividefunction
divide(cb: number, cs: number): number

Divide (Photoshop): B = Cb / Cs, division-guarded. Cs==0 → 1 (Photoshop maps divide-by-zero to white) unless Cb==0 → 0.

exclusionfunction
exclusion(cb: number, cs: number): number

Exclusion: B = Cb + Cs − 2·Cb·Cs.

hardLightfunction
hardLight(cb: number, cs: number): number

HardLight: Multiply/Screen on a doubled source, split at Cs=0.5.

hardMixfunction
hardMix(cb: number, cs: number): number

HardMix (Photoshop): threshold of VividLight at 0.5, which reduces to (Cb + Cs ≥ 1) ? 1 : 0.

huefunction
hue(cb: Rgb, cs: Rgb): Rgb

Hue: SetLum(SetSat(Cs, Sat(Cb)), Lum(Cb)).

isNonSeparablefunction
isNonSeparable(mode: "normal" | "dissolve" | "darken" | "multiply" | "colorBurn" | "linearBurn" | "darkerColor" | "lighten" | "screen" | "colorDodge" | "linearDodge" | "lighterColor" | "overlay" | ... 13 more ... | "luminosity"): boolean

The four non-separable HSL modes. They are computed on the whole straight-RGB triplet via the W3C SetLum/SetSat/ClipColor helpers rather than channel-wise.

isSpecial8function
isSpecial8(mode: "normal" | "dissolve" | "darken" | "multiply" | "colorBurn" | "linearBurn" | "darkerColor" | "lighten" | "screen" | "colorDodge" | "linearDodge" | "lighterColor" | "overlay" | ... 13 more ... | "luminosity"): boolean

The "Special 8" modes whose response to Fill differs from their response to Opacity. Fill is consumed at the blend input (scaling the source coverage inside the — non-linear — blend equation) while Opacity post-scales the blended result; for these eight that distinction is observable. Membership is the commonly-cited Photoshop set {ColorBurn, LinearBurn, ColorDodge, LinearDodge, VividLight, LinearLight, HardMix, Difference}. D5 notes the Fill calibration is reverse-engineered (no Adobe spec); `blendPixel` therefore emits a `warnDegraded` beacon when a Special-8 mode is rendered with reduced Fill until calibrated against the conformance corpus.

lightenfunction
lighten(cb: number, cs: number): number

Lighten: B = max(Cb, Cs).

lighterColorfunction
lighterColor(cb: Rgb, cs: Rgb): Rgb

LighterColor: keep whichever whole color has the higher luminance.

linearBurnfunction
linearBurn(cb: number, cs: number): number

LinearBurn (Photoshop): B = Cb + Cs − 1.

linearDodgefunction
linearDodge(cb: number, cs: number): number

LinearDodge / Add (Photoshop): B = Cb + Cs.

linearLightfunction
linearLight(cb: number, cs: number): number

LinearLight (Photoshop): both halves of the LinearBurn/LinearDodge split collapse to the same expression B = Cb + 2·Cs − 1. Neutral at Cs=0.5.

lumfunction
lum(c: Rgb): number

Lum(C) = 0.3·R + 0.59·G + 0.11·B (W3C / D5 weights).

luminosityfunction
luminosity(cb: Rgb, cs: Rgb): Rgb

Luminosity: SetLum(Cb, Lum(Cs)).

multiplyfunction
multiply(cb: number, cs: number): number

Multiply: B = Cb × Cs.

normalfunction
normal(_cb: number, cs: number): number

Normal: source replaces backdrop.

overlayfunction
overlay(cb: number, cs: number): number

Overlay: HardLight with backdrop and source swapped.

pinLightfunction
pinLight(cb: number, cs: number): number

PinLight (Photoshop) composed as Darken (lower half) / Lighten (upper half) of a 2×-remapped source. Neutral at Cs=0.5.

satfunction
sat(c: Rgb): number

Sat(C) = max(R,G,B) − min(R,G,B).

saturationfunction
saturation(cb: Rgb, cs: Rgb): Rgb

Saturation: SetLum(SetSat(Cb, Sat(Cs)), Lum(Cb)).

screenfunction
screen(cb: number, cs: number): number

Screen: B = Cb + Cs − Cb·Cs.

setLumfunction
setLum(c: Rgb, l: number): Rgb

SetLum(C, l): shift every channel so Lum becomes `l`, then ClipColor.

setSatfunction
setSat(c: Rgb, s: number): Rgb

SetSat(C, s): rescale the channel spread so Sat becomes `s`, preserving the relative ordering of the three channels. Verbatim W3C min/mid/max algorithm.

softLightfunction
softLight(cb: number, cs: number): number

SoftLight (W3C piecewise with the D(Cb) helper). Transcribed verbatim.

subtractfunction
subtract(cb: number, cs: number): number

Subtract (Photoshop): B = Cb − Cs.

vividLightfunction
vividLight(cb: number, cs: number): number

VividLight (Photoshop) composed as half-strength ColorBurn (lower half) / ColorDodge (upper half) of a 2×-remapped source. Inherits the W3C division guards. Neutral at Cs=0.5 (ColorBurn(Cb,1)=Cb, ColorDodge(Cb,0)=Cb).

BlendDescriptorinterface
interface BlendDescriptor

Backend-agnostic descriptor for a single blend step (Shader IR node). A WGSL codegen lowers it; the CPU reference evaluates it. Fill is consumed at the blend input, Opacity post-scales the result (Special-8 semantics). `dissolveSeed` is threaded through from the document settings so the `dissolve` mode's stochastic per-pixel coverage is deterministic and reproducible (blend-compositing spec; D5). It is a REQUIRED number — 0 is the default seed, never `.optional()`/`null` — so a re-render of the same document and region reproduces byte-identical coverage. It is consumed by `blendPixelAt` (which knows the pixel's (x, y)); the seedless `blendPixel` remains for non-positional callers and composites `dissolve` as `normal`.

Rgbinterface
interface Rgb

A straight-RGB triplet (no alpha).

RgbaFinterface
interface RgbaF

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

On this page