@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/blendUsage
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 helpersAPI
| Export | Description |
|---|---|
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. |
BlendDescriptor | One blend step: mode, space, opacity, fillOpacity, dissolveSeed. |
RgbaF | A premultiplied-alpha RGBA color in working (linear) space. |
Rgb | A 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 primitives | Per-channel formulas: normal, darken, multiply, colorBurn, linearBurn, lighten, screen, colorDodge, linearDodge, overlay, softLight, hardLight, vividLight, linearLight, pinLight, hardMix, difference, exclusion, subtract, divide. |
| whole-pixel modes | darkerColor, lighterColor — luminance comparison over the whole triplet. |
| non-separable HSL helpers | hue, 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.
blendPixelfunctionblendPixel(desc: BlendDescriptor, backdrop: RgbaF, source: RgbaF): RgbaFCPU 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)`.
blendPixelAtfunctionblendPixelAt(desc: BlendDescriptor, backdrop: RgbaF, source: RgbaF, x: number, y: number): RgbaFCPU 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.
clipColorfunctionclipColor(c: Rgb): RgbClipColor(C): pull any out-of-[0,1] channel back toward the luminance so the result stays in gamut while preserving Lum. Verbatim W3C algorithm.
colorfunctioncolor(cb: Rgb, cs: Rgb): RgbColor: SetLum(Cs, Lum(Cb)).
colorBurnfunctioncolorBurn(cb: number, cs: number): numberColorBurn (W3C piecewise, division-guarded): Cb==1 → 1 ; Cs==0 → 0 ; else 1 − min(1, (1−Cb)/Cs).
colorDodgefunctioncolorDodge(cb: number, cs: number): numberColorDodge (W3C piecewise, division-guarded): Cb==0 → 0 ; Cs==1 → 1 ; else min(1, Cb/(1−Cs)).
darkenfunctiondarken(cb: number, cs: number): numberDarken: B = min(Cb, Cs).
darkerColorfunctiondarkerColor(cb: Rgb, cs: Rgb): RgbDarkerColor: keep whichever whole color has the lower luminance.
differencefunctiondifference(cb: number, cs: number): numberDifference: B = |Cb − Cs|.
dissolveCoveragefunctiondissolveCoverage(seed: number, x: number, y: number, alpha: number): booleanDecide 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.
dividefunctiondivide(cb: number, cs: number): numberDivide (Photoshop): B = Cb / Cs, division-guarded. Cs==0 → 1 (Photoshop maps divide-by-zero to white) unless Cb==0 → 0.
exclusionfunctionexclusion(cb: number, cs: number): numberExclusion: B = Cb + Cs − 2·Cb·Cs.
hardLightfunctionhardLight(cb: number, cs: number): numberHardLight: Multiply/Screen on a doubled source, split at Cs=0.5.
hardMixfunctionhardMix(cb: number, cs: number): numberHardMix (Photoshop): threshold of VividLight at 0.5, which reduces to (Cb + Cs ≥ 1) ? 1 : 0.
huefunctionhue(cb: Rgb, cs: Rgb): RgbHue: SetLum(SetSat(Cs, Sat(Cb)), Lum(Cb)).
isNonSeparablefunctionisNonSeparable(mode: "normal" | "dissolve" | "darken" | "multiply" | "colorBurn" | "linearBurn" | "darkerColor" | "lighten" | "screen" | "colorDodge" | "linearDodge" | "lighterColor" | "overlay" | ... 13 more ... | "luminosity"): booleanThe 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.
isSpecial8functionisSpecial8(mode: "normal" | "dissolve" | "darken" | "multiply" | "colorBurn" | "linearBurn" | "darkerColor" | "lighten" | "screen" | "colorDodge" | "linearDodge" | "lighterColor" | "overlay" | ... 13 more ... | "luminosity"): booleanThe "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.
lightenfunctionlighten(cb: number, cs: number): numberLighten: B = max(Cb, Cs).
lighterColorfunctionlighterColor(cb: Rgb, cs: Rgb): RgbLighterColor: keep whichever whole color has the higher luminance.
linearBurnfunctionlinearBurn(cb: number, cs: number): numberLinearBurn (Photoshop): B = Cb + Cs − 1.
linearDodgefunctionlinearDodge(cb: number, cs: number): numberLinearDodge / Add (Photoshop): B = Cb + Cs.
linearLightfunctionlinearLight(cb: number, cs: number): numberLinearLight (Photoshop): both halves of the LinearBurn/LinearDodge split collapse to the same expression B = Cb + 2·Cs − 1. Neutral at Cs=0.5.
lumfunctionlum(c: Rgb): numberLum(C) = 0.3·R + 0.59·G + 0.11·B (W3C / D5 weights).
luminosityfunctionluminosity(cb: Rgb, cs: Rgb): RgbLuminosity: SetLum(Cb, Lum(Cs)).
multiplyfunctionmultiply(cb: number, cs: number): numberMultiply: B = Cb × Cs.
normalfunctionnormal(_cb: number, cs: number): numberNormal: source replaces backdrop.
overlayfunctionoverlay(cb: number, cs: number): numberOverlay: HardLight with backdrop and source swapped.
pinLightfunctionpinLight(cb: number, cs: number): numberPinLight (Photoshop) composed as Darken (lower half) / Lighten (upper half) of a 2×-remapped source. Neutral at Cs=0.5.
satfunctionsat(c: Rgb): numberSat(C) = max(R,G,B) − min(R,G,B).
saturationfunctionsaturation(cb: Rgb, cs: Rgb): RgbSaturation: SetLum(SetSat(Cb, Sat(Cs)), Lum(Cb)).
screenfunctionscreen(cb: number, cs: number): numberScreen: B = Cb + Cs − Cb·Cs.
setLumfunctionsetLum(c: Rgb, l: number): RgbSetLum(C, l): shift every channel so Lum becomes `l`, then ClipColor.
setSatfunctionsetSat(c: Rgb, s: number): RgbSetSat(C, s): rescale the channel spread so Sat becomes `s`, preserving the relative ordering of the three channels. Verbatim W3C min/mid/max algorithm.
softLightfunctionsoftLight(cb: number, cs: number): numberSoftLight (W3C piecewise with the D(Cb) helper). Transcribed verbatim.
subtractfunctionsubtract(cb: number, cs: number): numberSubtract (Photoshop): B = Cb − Cs.
vividLightfunctionvividLight(cb: number, cs: number): numberVividLight (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).
BlendDescriptorinterfaceinterface BlendDescriptorBackend-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`.
Rgbinterfaceinterface RgbA straight-RGB triplet (no alpha).
RgbaFinterfaceinterface RgbaFA premultiplied-alpha RGBA color in working (linear) space.
@faceless-photolib/geometry
Pure 2D affine + homography math for layer transforms: row-major mat3 algebra, W3C unmatrix decompose/recompose into TRS, 4-point DLT homography, and resampling kernel weights (Y-down convention).
@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.