Skip to content

Repository files navigation

jsColorEngine

The fastest ICC colour engine in JavaScript — and accurate to within 1 LSB of LittleCMS. 100 % native JS, zero dependencies, optional WASM for the hot path.

Fastest: measured single-threaded against lcms-wasm — the only comparable full ICC implementation available to JavaScript — where it runs 3.2–3.6× faster on every LUT workflow. Accurate: 100 % of samples within 1 LSB of the LittleCMS oracle on all four tested workflows, the large majority bit-identical. Both claims, with conditions and the harness that produced them: docs/LcmsComparison.md.

Live benchmark and demo of samples here https://www.o2creative.co.nz/jscolorengine/samples/

  • Fully-featured CMS. RGB, CMYK, Lab, XYZ, 3CLR/4CLR and N-channel (5CLR–15CLR) device spaces; DeviceLink profiles; ICC v2 and v4 loading (LUT-based and matrix-shaper); built-in virtual profiles; all four rendering intents; black-point compensation; trilinear and tetrahedral interpolation; multi-step transforms; custom pipeline stages; ΔE76 / ΔE2000; spectral and illuminant maths. See Features at a glance.

  • Accurate. Image-path LUT within 1 LSB of LittleCMS on 100 % of samples. LUT-free (buildLut: false) walks the full f64 pipeline — no LUT quantisation — for prepress and measurement. See Accuracy.

  • Fast. ~80–120 MPx/s on one core, up to ~787 MPx/s on a pool of 8 workers (transformImages(), byte-identical), ~330 MPx/s on the matrix-shaper path (sRGB, AdobeRGB, ProPhoto…) on one core.1 Against lcms-wasm3.1–3.6× on every LUT workflow, one core. Conditions · pool.

  • Pixel cache. On by default (pixelCache: 'auto') for WASM RGB–6CLR. A clean photograph is a ~10 % boost; noisy photographs are the worst case (~4 %); solids up to 3.94×. Leave on for general images; turn off (0) on grainy images for a small speed boost.

  • Small. The UMD is ~115 KB gzip (~435 KB minified) in one file, WASM inlined — no extra fetch, sync init. The optional worker pool is another ~100 KB gzip. Smaller than most JPEGs; not megabytes.

  • Portable — no GPU required. Node.js, browsers, Electron, web workers, React Native. LUT image and video work in JS-land usually reaches for WebGL / WebGPU shaders — fast and proven, but a non-starter on a headless prepress server, a container, or a CI step. WASM SIMD is the portable acceleration path: same kernel, same ceiling, anywhere a WebAssembly engine runs. No native bindings, no compile step, no platform-specific binaries.

  • Ship a LUT, not the profiles. Bake a small JSON at deploy time; Transform.fromJSON(json) reconstructs it at runtime with no ICC and no pipeline build. Chain and content fingerprint travel with the file. See Portable LUTs · LutBuilder · docs/deepdive/Luts.md.

  • Three ways, one Transform. transform(colorObj) for one colour (µs/call, always LUT-free). array(typedArray) for images. transformImages() for a worker pool. See Three ways, one Transform.

1. Measured on an AMD Ryzen 7 7700X (8 cores / 16 threads), 31 GB RAM, Node v24.16.0, Windows 10 x64 — one core except where a worker count is given. Throughput depends heavily on content, so the ratios above travel better than the absolute numbers do. Every figure is regenerated by one command and rendered into docs/BenchResults.md, which also carries the conditions and says when a table is older than the code.


Table of contents


Benchmark it yourself

You do not have to take any of these figures on trust. The in-browser bench at samples/bench/ — live at the sample demos — runs every lutMode against lcms-wasm on your hardware, in your browser. Zero upload, zero telemetry.

Headline content is photo with 5 % noise added. A pinch of grain stabilises the numbers — clean photos and solids swing with cache locality from one machine (and one image) to the next; a little noise puts every run on the same plateau. The Speed vs Noise tab on that page is the demo. The chooser also has solid, photo, photo with 15 % noise added, noise, and legacy (the old 105-colour LCG — do not quote).

Node / methodology: docs/LcmsComparison.md · docs/BenchResults.md.

npm run browser   # build the UMD bundle (once)
npm run serve     # samples + browser bench on :8080 — see /samples/ and /samples/bench/

Seven tabs: Full comparison, Accuracy sweep, JIT warmup, pixel-count sweep, Pool demo, Speed vs Noise, and the in-page methodology. A blurred tab pauses so a background sample cannot poison the run. "Copy markdown" serialises your results for an issue.

See docs/Bench.md for the guide and the submission template if your numbers disagree with ours.

Absolute MPx/s move with the machine; run it on yours. User-submitted results — and methodology critiques — are welcome.

A lot of the core concepts are lifted from LittleCMS. This is not a port — the implementation is independent, written for how a JIT compiler sees numeric typed-array loops. If you want to see how that changes the code, the deep dive has the V8 assembly walkthroughs.


Why compare to LittleCMS for accuracy and speed?

LittleCMS is the reference open-source ICC engine — comparing against it keeps us honest on both axes. Everything is measured single-threaded, one core vs one core, same hardware, same profiles, same input bytes:

  • Faster than lcms-wasm3.1–3.6× on every LUT workflow, photographic content, one core. Don't assume "WASM = faster": the pure-JS kernels already beat it before any WASM is involved.
  • Accurate against the same reference — image-path LUT within 1 LSB on 100 % of samples; LUT-free f64 pipeline ≤ 0.06 ΔE76 across 130 reference files. See Accuracy.

Native C, flats vs photographs, and where LittleCMS still wins (one-pixel memo, fused matrix-shaper RGB→RGB) live on the comparison page — not here.

The full comparison — methodology, tables, caveats, and the upstream discussion with LittleCMS's author — lives on the LittleCMS comparison page. Reproduce all of it with node bench/reproduce.js.


Three ways, one Transform

Three call shapes on the same Transform. Picking the right one matters more than any other choice you'll make.

Use case API Speed Accuracy When to use
Single colour / colour picker transform.transform(colorObj) µs per call, slow per pixel Full 64-bit precision, all stages run UI colour pickers, swatch libraries, Lab/RGB/CMYK display, ΔE calculations, prepress maths
Image / array processing transform.array(typedArray, ...) ~80–120 MPx/s LUT photographs, ~330 MPx/s matrix-shaper (sRGB / AdobeRGB / ProPhoto), one core Slightly less accurate (LUT is finite resolution) Soft-proofing, image conversion, video, anything pixel-bulk
Worker pool transform.transformImages(...) up to ~787 MPx/s LUT / ~1,080 MPx/s matrix-shaper on 8 workers Byte-identical to the sequential path Whole folders, servers, RIPs — see docs/pool.md

All three live on the same Transform — you pick which by calling transform(), array(), or transformImages(), and by passing {buildLut: true} to the constructor when you want the image path.

The library is deliberately split this way so you don't pay accuracy costs for image work, and you don't pay speed-optimisation tax (unrolled loops, skipped bounds checks, typed arrays only) for one-off conversions.

Architectural detail and the "don't do this" anti-pattern warning live in deep dive / Architecture.


Portable LUTs / LutBuilder

Bake a colour transform to a self-describing JSON file at deploy time — no ICC profiles, no lcms, no pipeline build cost at the consumer.

// Producer (build time — has profiles)
const t = new Transform({ dataFormat: 'int8', buildLut: true });
t.create(cmykProfile, '*srgb', eIntent.relative);
fs.writeFileSync('lut.json', JSON.stringify(t));

// Consumer (runtime — profiles not needed)
const t = Transform.fromJSON(fs.readFileSync('lut.json'), { dataFormat: 'int8' });
t.array(cmykPixels);

Key features:

Build sources Engine ICC pipeline, custom callback, or lcms-wasm bridge
lcms emulation Sample LittleCMS into the grid once — jsCE kernels at runtime, lcms colour math baked in (jsCE ↔ lcms agree to < 0.1 ΔP per channel)
Auditable Every LUT is content-signed ("FNV1A:xxxxxxxx" over chain + grid + pixel data); Transform.fromJSON(json, { verify: true }) throws on tamper
Size ~650 KB JSON for a 4D CMYK LUT (17-pt); parses + dispatches in ~6 ms
Speed ~6× faster per frame than the f64 live pipeline on typical images; < 1 ΔP mean error
Editable editLut() for per-cell mutations (TAC limits, ink substitution), clone() for variants, toJSON() to re-export

TIFF visual editing — capture any CMS as a reusable LUT

Export an identity LUT as a TIFF image, edit it in Photoshop (or any ICC-aware editor), reimport. The editor's colour engine becomes your LUT — captured at the grid's resolution, dispatched at WASM-SIMD speed.

--create  →  open in Photoshop  →  apply conversion/grade  →  --import  →  LUT JSON

Captured so far: sRGB → SWOP CMYK (Adobe CMM) at N=33 gives mean ΔP 0.76 vs Photoshop ground truth — sub-LSB at 8-bit output. Any conversion Photoshop (or GIMP, Affinity, ColorSync) can perform can be captured: profile conversions, TAC-limited device links, creative grades, grayscale tone curves.

# Create a TIFF identity, open in Photoshop, convert to CMYK, save, reimport:
node samples/LutBuilder/lut-tiff-cli.js --create --channels 3 --size 33 --out srgb.tiff
node samples/LutBuilder/lut-tiff-cli.js --import --in edited_cmyk.tiff --out my_lut.json
node samples/LutBuilder/lut-tiff-cli.js --validate --original srgb.tiff --edited edited_cmyk.tiff --lut my_lut.json
# → Grade: EXCELLENT (mean ΔP 0.756 / threshold 1)

Metadata survives Photoshop. Grid parameters are stored in both a private TIFF tag (tag 32768) and XMP (jsce:LutMeta) — Photoshop strips the private tag but always preserves unknown XMP namespaces. The embedded ICC profile (tag 34675, written by Photoshop) is extracted on import and placed in the LUT chain as the output descriptor. The text strip in the image is a human-readable last-resort fallback.

Validate and compare. builder.analyze() and LutBuilder.comparePixels() produce ΔP reports (mean, max, RMSE, p95/p99, per-channel, grade) and optional amplified delta TIFF images for visual diagnosis. Use --compare to benchmark jsCE vs lcms vs Photoshop conversions of the same source.

The LutBuilder guide covers the full lifecycle, CLI reference, and error handling. Format spec, emulation architecture, and the reasoning behind the design are in docs/deepdive/Luts.md. The samples/lut-cmyk-to-rgb.html demo shows the build-once / ship-anywhere workflow end-to-end with measured numbers.


Install

npm i jscolorengine

Node

const { Profile, Transform, eIntent, color } = require('jscolorengine');

(async () => {
    // ACCURACY PATH — single colour, full precision.
    // Build a Lab→sRGB pipeline once, then convert as many colours as you like.
    const lab2rgb = new Transform();
    lab2rgb.create('*lab', '*sRGB', eIntent.relative);

    const rgb = lab2rgb.transform(color.Lab(70, 30, 30));
    console.log(rgb);   // { R: 233, G: 149, B: 118, type: 5 }
})();

Browser — UMD bundle

The prebuilt UMD bundle at browser/jsColorEngineWeb.js (~115 KB gzip, WASM inlined) exposes everything on a global jsColorEngine. The worker browser/jsColorEngineWorker.js (~100 KB gzip) is only needed if you call enablePool({ workerUrl }):

<script src="jsColorEngineWeb.js"></script>
<script>
    const lab2rgb = new jsColorEngine.Transform();
    lab2rgb.create('*lab', '*sRGB', jsColorEngine.eIntent.relative);

    const rgb = lab2rgb.transform(jsColorEngine.color.Lab(70, 30, 30));
    console.log(rgb);
</script>

Bundlers (Webpack, Vite, Next, Angular, …)

The package's main field points at the raw CommonJS source (src/main.js), so any modern bundler can tree-shake and re-bundle it normally:

import { Profile, Transform, eIntent, color } from 'jscolorengine';

The browser field stubs out the Node-only modules (fs, path, util, child_process) for browser builds. No extra bundler config should be needed.

Environments

The engine ships with backends for three environments and picks the right one automatically:

  • Node.jsfs.readFileSync for local files, http.get for URLs
  • BrowserXMLHttpRequest for URLs, base64 / Uint8Array for in-memory
  • Adobe CEP (Photoshop / Illustrator panels) — window.cep.fs for local reads

On old installs (1.0.0 – 1.0.3)

If jsColorEngine.color is undefined, you're on pre-1.0.4. Either upgrade (npm i jscolorengine@latest) or use the original export name jsColorEngine.convert.Lab(…)color and convert are the same module. See #4, #5.

The 1.0.5 release also fixed a ReferenceError: self is not defined that required falling back to jsColorengine/build/… in some SSR setups — #2, #3.


Upgrading — pinning an earlier release's defaults

Defaults occasionally change in ways that move output. If you need byte-for-byte reproducibility across an upgrade, pin the version you were on rather than hunting for the options that moved:

const { Transform } = require('jscolorengine');
Transform.compatibility('1.5');    // 1.5.0 defaults; call before creating anything

An explicit option always beats the pin — it changes what you get by default and nothing you asked for. Transform.compatibility(null) clears it, Transform.compatibility() reads it back, and an unknown version throws rather than silently leaving you unpinned.

What moved in 1.5.5: wasmMatrixShaper defaults to 'auto', putting the matrix-shaper kernel on the no-LUT int8/int16 RGB→RGB path. It is within 1 LSB of the stage pipeline it replaced and measurably closer to the exact maths than the CLUT alternative — but it is not byte-identical to 1.5.0.


Quick start

Single colour — Lab to CMYK (accuracy path)

const { Profile, Transform, eIntent, color } = require('jscolorengine');

(async () => {
    // Wrap profile loading in try/catch — profiles can be corrupt or missing.
    const cmykProfile = new Profile();
    try {
        await cmykProfile.loadPromise('./profiles/GRACoL2006_Coated1v2.icc');
    } catch (err) {
        console.error('Failed to load profile:', err.message);
        return;
    }
    // Always check .loaded — false if the file existed but wasn't valid ICC.
    if (!cmykProfile.loaded) {
        console.error('Profile did not load correctly:', cmykProfile.lastError);
        return;
    }

    // No buildLut — this is the accuracy path.
    const lab2cmyk = new Transform();
    try {
        lab2cmyk.create('*lab', cmykProfile, eIntent.perceptual);
    } catch (err) {
        console.error('Transform create failed:', err.message);
        return;
    }

    const cmyk = lab2cmyk.transform(color.Lab(80.1, -22.3, 35.1));
    console.log(`CMYK: ${cmyk.C}, ${cmyk.M}, ${cmyk.Y}, ${cmyk.K}`);
})();

Image bytes — RGB to CMYK (hot path)

const { Profile, Transform, eIntent } = require('jscolorengine');

(async () => {
    const cmykProfile = new Profile();
    try {
        await cmykProfile.loadPromise('./profiles/GRACoL2006_Coated1v2.icc');
    } catch (err) {
        console.error('Profile load failed:', err.message);
        return;
    }
    if (!cmykProfile.loaded) {
        console.error('Profile invalid or unsupported:', cmykProfile.lastError);
        return;
    }

    // SPEED PATH — pre-bake a LUT, pick int8 IO, enable BPC.
    // lutMode defaults to 'auto' which resolves to the fastest
    // WASM SIMD kernel available on the host (with automatic
    // demotion to scalar WASM / JS int on older runtimes).
    const rgb2cmyk = new Transform({
        buildLut:   true,
        dataFormat: 'int8',
        BPC:        true
    });
    try {
        rgb2cmyk.create('*sRGB', cmykProfile, eIntent.relative);
    } catch (err) {
        console.error('Transform create failed:', err.message);
        return;
    }

    // imageData.data is [R, G, B, A, R, G, B, A, ...].
    // 2nd / 3rd args say "input has alpha, output does not" — alpha dropped.
    const cmykBytes = rgb2cmyk.array(imageData.data, true, false);
    // cmykBytes is now [C, M, Y, K, C, M, Y, K, ...].
})();

Soft-proof an RGB image through CMYK back to RGB

The classic prepress preview — simulate what an RGB image will look like printed on a CMYK device by routing pixels through both profiles in one pre-built transform.

const { Profile, Transform, eIntent } = require('jscolorengine');

(async () => {
    const cmykProfile = new Profile();
    try {
        await cmykProfile.loadPromise('./profiles/GRACoL2006_Coated1v2.icc');
    } catch (err) {
        console.error('Profile load failed:', err.message);
        return;
    }
    if (!cmykProfile.loaded) {
        console.error('Profile invalid or unsupported:', cmykProfile.lastError);
        return;
    }

    // BPC is per-stage: enable on the perceptual leg, disable on the
    // relative leg — a common soft-proofing recipe. lutMode defaults
    // to 'auto' → best available SIMD/WASM/JS kernel.
    const proof = new Transform({
        buildLut:   true,
        dataFormat: 'int8',
        BPC:        [true, false]
    });
    try {
        proof.createMultiStage([
            '*sRGB',     eIntent.perceptual,
            cmykProfile, eIntent.relative,
            '*sRGB'
        ]);
    } catch (err) {
        console.error('Transform create failed:', err.message);
        return;
    }

    const rgbIn  = new Uint8ClampedArray([255, 0, 0,  0, 255, 0,  0, 0, 255]);
    const rgbOut = proof.array(rgbIn, false, false);

    console.log('soft-proofed sRGB:', Array.from(rgbOut));
})();

More examples (canvas round-trip, custom pipeline stages) are in docs/Examples.md.


Parallel worker pool

Convert a batch of images across worker threads — 6.2× peak, 787 MPx/s, ~1,080 MPx/s with the matrix-shaper kernel, byte-identical to single-threaded in every one of the 72 cells measured.

await Transform.enablePool({ workers: 4 });        // once, at startup

await t.transformImages(files.map(f => ({ data: f.pixels, id: f.name })), {
    onImage: (i, data, info) => save(info.id, data)  // fires as each finishes
});
  • Fragments, not images. Every image is split and pulled from one queue, so one 60 MP scan among twenty thumbnails does not pin a worker while the rest idle.
  • Feed it while it runs. Submit more before the first batch finishes and it joins the queue — workers never drain and restart between batches.
  • Results as they land, out of order, via onImage — write each one out instead of holding every output until the end.
  • data is the only required field. pixelCount is inferred, id is generated, and alpha can be set per image, so a mixed folder of PNG and JPEG is one call.
  • Cancel, pace and interruptcancel(id) (cancelled images still fire their callbacks), onQueueBelow / onMemoryBelow for backpressure, and interrupt(fn) to borrow the cores back.
  • Idle workers give the memory back after 30 s and rebuild on demand — or set idleTimeoutMs: 0 where latency matters more than footprint.
  • An optimisation, never a capability. No workers — a browser, a hostile CSP, a transform carrying hooks — means the same call runs sequentially with the same bytes and the same callbacks.

It is not free: per-worker LUT copies, efficiency that falls as the kernel gets faster, and a floor below which splitting stops paying. All of it, plus the full API, is in docs/pool.md — costs stated as plainly as the wins.


Features at a glance

ICC profiles

  • v2 and v4 profiles — LUT-based and matrix-shaper
  • Parametric curves (function types 0–4, including sRGB)
  • Lab and XYZ Profile Connection Space
  • Grey / Duo / RGB / CMY / CMYK / 3CLR / 4CLR device spaces
  • N-channel (5CLR–15CLR) press profiles — both directions: n-ink → PCS via a generic N-D simplex interpolator, and PCS/RGB → n-ink including the baked-LUT image path (implementation notes)
  • DeviceLink profiles (pClass: 'link') — device→device with no PCS, t.create(deviceLink), including curves-only linearization links, ink-limit links, and asymmetric conversions (CMYK→RGB, RGB→CMYK) (implementation notes)

Transforms

  • Trilinear and tetrahedral interpolation (tetrahedral is the default)
  • Rendering intents: perceptual, relative, saturation, absolute
  • Black point compensation (global or per-stage)
  • Multi-step transforms: profile → profile → profile → …
  • Custom pipeline stages — drop a function into the chain at PCS (or any other named location) and it bakes into the precomputed LUT
  • Chromatic adaptation for abstract Lab profiles
  • Full debug mode showing values at every stage
  • Baked gamut warnings & maps — embed out-of-gamut detection directly into the LUT at build time (zero per-pixel cost). Four modes: hard-threshold colour replace, continuous ΔE heatmap (white → warning colour), raw ΔE map for analysis, or off. Pluggable ΔE function (deltaE1976 default, swap in deltaE2000 etc.). See Transform docs.

Parallelism and fast paths

  • Parallel worker pool (transformImages) — batches converted across worker threads, fragments pulled from one queue and reassembled by offset, with per-image callbacks, cancellation, backpressure and interrupt(). 6.2× peak, byte-identical to sequential. Falls back to sequential automatically whenever it cannot run. Details and costs
  • Pixel cache (pixelCache: 'auto') — last-pixel memo on WASM RGB–6CLR, on by default. A clean photograph is a ~10 % boost; noisy photographs are the worst case (~4 %; int-wasm-simd, Chrome 151: 100→96 / 71→69); solids up to 3.94× (CMYK→RGB 104→407). Matrix-shaper declines. Leave on for general images; turn off (pixelCache: 0) on grainy images for a small speed boost. Node off-vs-auto: pixelCache.inKernel.*. PixelCache.md
  • Matrix-shaper WASM kernel (wasmMatrixShaper) — RGB→RGB matrix-shaper pairs run as a curve, a 3×3 and another curve rather than through a CLUT: 331 MPx/s at int8 on photographic content against ~123 for the CLUT, and within 1 LSB of the exact pipeline where that CLUT reaches 25 LSB. int8 and int16, SIMD and a bit-identical scalar build, plus a plain-JS implementation for hosts without WebAssembly and for profiles with per-channel TRCs.
  • Alpha helpers (alpha.unpremultiply / premultiply / flatten) — premultiplied colour cannot be converted directly (T(a·C) ≠ a·T(C), up to 69 LSB out at a = 0.5), and nothing in a buffer says whether it is premultiplied. So the decision stays with the caller and the arithmetic comes from here.

Kernel modes (lutMode)

Eight values, plus 'auto' (the default) which picks the best kernel for your (dataFormat, buildLut) combination automatically. Pin a specific mode when you want determinism, or rely on 'auto' and let create() resolve to the fastest kernel the host can run.

8-bit I/O — dataFormat: 'int8' (Uint8 / Uint8Clamped buffers)

Mode Kernel Throughput vs 'int' When
'float' f64 CLUT, JS baseline pin for bit-stable f64 LUT interp across releases
'int' u16 CLUT, JS int32 baseline pin when you want JS-only, no WASM
'int-wasm-scalar' u16 CLUT, WASM 1.22–1.45× pin for WASM without SIMD (rare — benchmarking)
'int-wasm-simd' u16 CLUT, WASM v128 2.04–3.50× what 'auto' picks for int8+LUT; pin to fail loudly on non-SIMD hosts

16-bit I/O — dataFormat: 'int16' (Uint16 buffers, full [0..65535] range, Q0.13 fractional weights — shipped in v1.3)

Mode Kernel Throughput vs 'int16' When
'int16' u16 CLUT @ 65535, JS int32 baseline pin when you want JS-only, no WASM
'int16-wasm-scalar' u16 CLUT, WASM ~1.3–1.4× (3D) pin for WASM without SIMD
'int16-wasm-simd' u16 CLUT, WASM v128 ~2.0–2.6× what 'auto' picks for int16+LUT

The three u16 kernels are bit-exact against each other across the full (mode × inCh × outCh) matrix. Browser-bench headline: int16-wasm-simd lands 3.9–4.9× over lcms-wasm 16-bit on every workflow (158 MPx/s RGB→RGB, 149 RGB→CMYK, 90 CMYK→RGB, 86 CMYK→CMYK on Chrome 147 / x86_64).

Demotion is automatic in both ladders:

  • 8-bit: 'int-wasm-simd''int-wasm-scalar''int'
  • 16-bit: 'int16-wasm-simd''int16-wasm-scalar''int16'

You can set the SIMD mode globally and older hosts just fall through; 'auto' does the same thing by default for int8+LUT and int16+LUT transforms and resolves to 'float' for anything else (which is what the engine would have used anyway — lutMode is ignored for non-int dataFormats). Inspect xform.lutMode after construction to see what will actually run.

Details: deep dive / LUT modes · deep dive / WASM kernels · v1.3 16-bit kernel ladder in Roadmap.

Colour conversion helpers (no profiles needed)

color.* (exported as both color.* and convert.*) provides direct maths between common spaces — useful when you don't need a full pipeline:

  • XYZ2xyYxyY2XYZ · XYZ2LabLab2XYZ · Lab2LCHLCH2Lab
  • Lab2Lab (chromatic adaptation across whitepoints)
  • RGB2LabLab2RGB · XYZ2RGBRGB2XYZ (virtual RGB matrices)
  • Lab2sRGBsRGB2Lab (hard-coded sRGB, fast path for UI)
  • RGB2Hex
  • ΔE: deltaE2000, deltaE94, deltaE76, deltaECMC

Built-in virtual profiles

*Lab / *LabD50, *LabD65, *sRGB, *AdobeRGB, *AppleRGB, *ColorMatchRGB, *ProPhotoRGB. Names are case-insensitive; the leading * tells the loader "build this in memory, don't fetch a file".

Spectral & measurement

For anyone working with a spectrophotometer (i1Pro, ColorMunki, etc.):

  • Standard illuminants: A, C, D50, D55, D65, CIE F-series
  • Standard observers: CIE 1931 2°, CIE 1964 10°
  • Convert spectral reflectance / transmittance → CIE XYZ under a chosen illuminant + observer
  • wavelength2RGB — single-wavelength → displayable sRGB

Virtual vs ICC profiles — which should you use?

You can describe a colour space two ways:

  1. Virtual — a built-in name like '*sRGB', '*AdobeRGB', '*ProPhotoRGB', '*Lab'. Built in memory from primaries + gamma. No file I/O, no decode.
  2. ICC file — a real .icc / .icm profile, loaded from disk, URL, base64, or already-in-memory Uint8Array.

For the common working spaces — sRGB, Adobe RGB, Apple RGB, ColorMatch RGB, ProPhoto RGB — virtual is the right default. Most RGB ICC profiles in the wild are matrix + TRC (no LUT), and the maths is identical to what the virtual constructor builds. Once loaded, the engine can't tell them apart — they hit the same inlined kernel. The only difference is startup cost.

// These two profiles are functionally identical.
// Prefer the virtual one — same maths, no I/O, no decode time.
const fast = new Profile('*sRGB');                   // ~0 ms

// If you must load from disk, always wrap + check:
const slow = new Profile();
try {
    await slow.loadPromise('./profiles/sRGB_v4_ICC_preference.icc');
} catch (err) {
    console.error('Profile load failed:', err.message);
}
if (!slow.loaded) {
    console.error('Profile invalid:', slow.lastError);
}

Use a real ICC profile when you actually need one:

  • It's a CMYK or 3CLR/4CLR device profile (LUT-based — no virtual equivalent).
  • It's a printer or scanner profile with measurement-derived AtoB / BtoA LUTs.
  • It's a calibrated monitor profile (primaries/TRC won't match virtual sRGB).
  • You need to faithfully reproduce another CMM's interpretation of a specific embedded profile (e.g. matching Photoshop's output exactly).

As an internal optimisation, when the engine decodes an RGB ICC profile that has no AtoB / BtoA LUT, it auto-promotes it to the same fast path that virtual profiles use. So even a loaded sRGB.icc only pays decode cost — runtime is identical.


Accuracy

TL;DR: the float pipeline matches LittleCMS to ≤ 0.06 ΔE76 on Lab outputs, ≤ 1.24 LSB on 8-bit RGB, ≤ 0.04 % ink on CMYK across 130 reference files (~580 k in-gamut samples) measured against an lcms2 2.16 full-f64 oracle. The image-path LUT quantises that math to within 1 LSB on 100 % of samples (max 1 LSB) vs lcms-wasm's default pipeline. All named reference colours (white, black, primaries, mid-greys, skin tone, paper white, rich black) match exactly or within 1 LSB. Residual drift is well below visible threshold across both paths and the remaining outliers are documented and explained — see docs/deepdive/Accuracy.md for the full methodology, headline numbers, the one structural divergence we found, and the philosophy that keeps jsColorEngine an independent engine rather than an lcms reimplementation.

jsColorEngine has two accuracy paths and a separate validation harness for each:

1. Float pipeline vs lcms native f64 (bench/lcms_compat/)

The "is the underlying math right?" question. Measured against a committed reference oracle of 150 CGATS .it8 files generated from LittleCMS 2.16's full f64 float pipeline (TYPE_*_DBL). 130 files pass, 20 SKIP (lcms-internal XYZ-identity working profiles — v1.5 follow-up), 0 ERROR. Worst-case in-gamut error per output type:

Output type Worst case Unit Verdict
Lab 0.06 ΔE76 16× below the ΔE 1.0 visibility threshold
RGB → RGB 1.24 LSB at u8 invisible at 8-bit display precision
CMYK ink 0.04 % ink well below dot-gain measurement noise
2C spot 2.88e-4 fraction noise floor — basically zero

node bench/lcms_compat/run.js reproduces this in ~1.3 s on a current laptop. Per-pixel triage for any divergence is in bench/lcms_compat/probe-pixel.js. Full writeup, including the one documented outlier (* → ISOcoated_v2_grey1c_bas.ICC Perceptual without BPC — a profile-table-interpretation difference, both readings spec-permissive) is in docs/deepdive/Accuracy.md.

2. Image-path LUT vs lcms-wasm (bench/lcms-comparison/)

The "after the LUT quantises everything, do we still agree?" question. Measured against lcms-wasm (LittleCMS 2.16 compiled to WASM) on a systematic 9^N input grid plus named reference colours, with lcms's default optimisation (flags = 0) as the oracle.

Workflow within 1 LSB max Δ mean Δ
RGB → Lab 100.00 % 1 LSB 0.004 LSB
RGB → CMYK 100.00 % 1 LSB 0.006 LSB
CMYK → RGB 100.00 % 1 LSB 0.002 LSB
CMYK → CMYK 100.00 % 1 LSB 0.008 LSB

node bench/lcms-comparison/accuracy.js reproduces this on your hardware.

Earlier published runs of this harness used cmsFLAGS_HIGHRESPRECALC as the oracle and showed a small out-of-gamut tail (98.5–98.8 % within 1 LSB, max 14 LSB on deep-cyan CMYK → RGB inputs). Marti Maria pointed out (#6) that HIGHRESPRECALC is a legacy lcms 1.x emulation flag, not the reference behaviour — re-run against lcms's default pipeline, the divergences disappear entirely. --highres reproduces the old oracle for comparison.

For ΔE-critical work (colour measurement, calibration QA), use lutMode: 'float' and skip the LUT entirely — see Quick reference.

3. 16-bit kernel ladder (dataFormat: 'int16', v1.3)

For workflows that need extra headroom over the u8 ladder — TIFF processing, intermediate image stages, anything where 1 LSB at u8 isn't quite tight enough — pass dataFormat: 'int16' and the engine routes through the v1.3 u16 kernel ladder. Pure-kernel quantisation noise (jsCE float-LUT vs jsCE int16-LUT) is ≤ 4 LSB u16 max, mean ≤ 0.48 LSB across all four image directions — roughly 65× tighter than the u8 path because Q0.13 weights and the 65535-scaled CLUT keep the rounding budget below the u16 LSB. The JS / WASM scalar / WASM SIMD u16 kernels are bit-exact against each other across the full coverage matrix; the identity gate at bench/int16_identity.js asserts kernels round at the u16 LSB on every release.


Speed

Single-colour transform() is microsecond-scale — fine for UI, prepress calcs, anything converting tens to hundreds of colours at a time.

For image work: build a LUT (new Transform({buildLut: true})) and use array().

Current figures — real photographs, corrected inputs

Ryzen 7700X, one thread, Node 24, 1 M px, GRACoL2006 + AdobeRGB1998, each measurement in its own process. Full conditions and every content class: docs/LcmsComparison.md.

Workflow 'int' (pure JS) 'int-wasm-simd' vs lcms-wasm vs native C
RGB → Lab 53.9 120.3 3.6× 1.8×
RGB → CMYK 48.4 119.3 3.4× 1.9×
CMYK → RGB 44.0 78.6 3.1× 2.0×
CMYK → CMYK 37.2 80.8 3.5× 2.2×
RGB → RGB (soft-proof) 53.8 115.9 3.4× 2.1×
RGB → RGB (matrix) 53.4 117.6 ⁽ᵏ⁾ 1.8× 0.71×

Every figure above is generated, not transcribed — js.content.* in BenchResults, with the content classes, CLUT coverage and lcms-wasm NOCACHE column beside them.

⁽ᵏ⁾ This row no longer describes the default path. Matrix-shaper RGB→RGB was the one workflow where native C led, because lcms runs a fused matrix path where jsCE interpolated a baked CLUT. 1.5.5 ships that fused path (wasmMatrixShaper, on by default wherever there is no LUT to displace): measured under this table's own conditions — 1 M px, sRGB → AdobeRGB1998, same photo corpus — it runs at 328 MPx/s against 119 for the CLUT column beside it, and stays within 1 LSB of the exact pipeline where that CLUT reaches 25 (throughput, accuracy).

The vs native C column comes from the same session as the rest of the row — lcms2 2.18 built by gcc in WSL2, at its best of -O2 and -O3 per workflow, pinned to one core. The matrix row is the exception and is discussed above. Conditions and method: LcmsComparison.

Earlier figures, and what replaced them

The table above replaces one measured on 65 K pixels of the first noise generator — 256 distinct colours, small enough to keep the CLUT in L1, and at that size only ~0.8× coverage of a 4D CMYK grid. Both effects lift the result. It read ~210–216 MPx/s for int8 SIMD on x86_64 and ~258–269 on an Apple M4; on a photo corpus the same x86_64 paths land at 82–122. The old tables are not reproduced here — they measure the generator as much as the engine. The A/B that separated generator from harness is in deepdive/benchmark.md.

Two findings from that round do survive, because both are ratios measured within one harness rather than levels:

  • ARM64 lifts 4D far more than 3D — ~25 % on the 3D paths against ~65 % on 4D CMYK. That is the register-pressure prediction in JIT inspection landing: 4D was GPR-saturated on x86, and ARM64's 31 GPRs free the spill traffic. The Apple Silicon column itself has not been re-measured.
  • The three u16 kernels are bit-exact against each other, and int16 SIMD ran 3.9–4.9× lcms-wasm's 16-bit path across all four workflows.

Image-work mental model. A 1080p frame is ~2.07 MPx and a 4K still is 8.3 MPx, so triple-digit MPx/s is a 4K still in well under 100 ms, single-threaded. Frames per second is a strange unit for a colour library, but it is the honest way to picture the headroom — and it is why there is a live demo of real-time soft-proofing of HD video in the browser.

vs native C LittleCMS

The single-threaded native-C comparison has its own page → docs/LcmsComparison.md — the original "steelman" harness and its tables, the fast-float plugin measurements, and the re-measurement now underway after generous upstream feedback from LittleCMS's author (#6).

The short version: on our original harness, single-threaded pure JavaScript matched or beat aggressively-compiled stock lcms2 on 4 of 5 LUT image workflows — an existence proof that a JIT-compiled JS kernel runs in the same performance class as single-threaded native C, which is the claim we actually care about. Those native numbers are being re-measured with corrected lcms API calls before we quote them further; the lcms-wasm comparisons above are unaffected.

Key takeaways

  • Building a LUT makes image transforms ~11–15× faster than the per-pixel accuracy path. There's no reason not to use one for any workflow that touches more than a few hundred pixels.
  • The pure-JS 'int' hot path already beats lcms-wasm by 1.48–2.12× across these four directions — no WASM required.
  • It's in native-C territory on a single thread — the full native comparison, its caveats, and the re-measurement still to be run are on the LittleCMS comparison page.
  • And it scales past that across cores. transformImages() reaches 6.2× and 787 MPx/s on the CLUT path, ~1,080 MPx/s with the matrix-shaper kernel, byte-identical to sequential — with the costs stated plainly rather than buried: what the pool buys and what it charges.
  • Enabling WASM SIMD triples the 3D throughput over JS 'int' (range 2.94–3.50×), bit-exact. 4D kernels (CMYK input) land 2.04– 2.57× over JS 'int' on x86_64 — limited by per-pixel scalar prologue, not the SIMD body. On hosts without v128, the dispatcher demotes to WASM scalar, then to JS 'int', then to 'float'; code doesn't change.
  • Apple Silicon (M4) runs ~1.4–1.6× faster than x86_64 at every tier — pure JS 'int', WASM scalar, and WASM SIMD — because ARM64's 31 GPRs (vs ~11 allocatable on x86-64) erase the spill traffic that dominates the kernels on x86. The biggest lift lands on the 4D CMYK paths (+65 %), which is exactly the prediction the JIT-inspection deep-dive was filed against — see Performance § 2.6 for the full table.
  • Ratios are stable across like CPUs; absolute numbers aren't. JS engines (V8, SpiderMonkey, JSC) schedule the hot loops differently and their optimisers evolve between releases. Treat the numbers above as a guide, not a contract. Run the in-browser bench (npm run serve) on your own machine.

Full benchmark methodology, the 15 % JIT-deletable-by-WASM analysis, the lcms-wasm comparison table, the "we measured" vs "we predicted" gap, and the roadmap live in docs/deepdive/Performance.md. For why these numbers are possible — asm dumps, op-count tables, .wat design notes — see the deep dive.

If you plan to edit the hot loops, read the PERFORMANCE LESSONS comment block at the top of src/Transform.js first, and the deep dive's JIT inspection page. Several things in those loops are deliberately counter-intuitive — named temps, helper calls, "cleanup" of duplicated expressions — and they measurably slow things down. Always benchmark before and after.


Examples

Three working snippets are in Quick start above: single-colour Lab → CMYK, RGB → CMYK image bytes, and a multi-stage soft-proof chain.

More recipes, including the canvas read-modify-write pattern and custom pipeline stages at PCS, are in docs/Examples.md.

Live demos

Self-contained HTML demos ship in samples/. The site entry is https://www.o2creative.co.nz/jscolorengine/samples/ (project landing); the demo index and setup notes are on samples.html.

  • Live Video Soft Proof — real-time video colour management. Every frame decoded and soft-proofed through a pre-built 3D CLUT — pure JS, no WASM, no workers. 40+ fps on 720p.
  • Soft Proof — sRGB → CMYK soft proof + C/M/Y/K plate previews with floating colour picker (Lab, sRGB, CMYK, ΔE 2000, ΔE 76).
  • jsCE vs lcms-wasm — pixel-by-pixel accuracy comparison with amplified diff slider (up to 128×), CMYK + RGB stats, speed ratio.

Run locally with npm run serve — see docs/Samples.md for setup.


Testing

npm test

Runs the Jest suite: transform pipeline (object IO, device IO, LUT IO, multi-stage), the lower-level decodeICC (parametric curves, sampled curv curves, unsupported LUT sentinels), and the WASM kernel dispatch-counter tests that guard against silent demotion regressions.


Limitations

The engine is deliberately scoped to the cases that matter for everyday colour management. Things outside that scope:

  • The parallel worker pool runs on Node and in the browser. Node uses worker_threads with no extra setup. A browser needs the worker bundle (browser/jsColorEngineWorker.js, built by npm run browser) and a URL:

    await Transform.enablePool({ workerUrl: '/path/to/jsColorEngineWorker.js' });

    Without that URL, transformImages() converts sequentially — same bytes, same callbacks, one thread. Try it on the bench Pool demo tab. → pool.md

  • Named Color profiles (ncl2) are not supported. → Why named colour profiles are not supported

  • N-channel (5CLR–15CLR) input runs on the accuracy pipeline onlybuildLut is declined for n-ink input (a grid^N bake is impractical; the profile's own A2B grid is authoritative) and the per-pixel pipeline is used instead. N-channel as output gets the full baked-LUT image path. → notes

  • MultiProcessElement (mpet) profiles load via the fallback path — the ICC spec mandates the standard AtoB / BtoA LUT tags are always present in a conforming profile, so MPE-bearing profiles work through the standard tags. MPE-only film / scientific workflows are not the target. → Why MPE is not supported

  • Abstract profiles (pClass: 'abst') and ColorSpace profiles (pClass: 'spac') are not currently supported. Both are extremely rare in practice. Abstract profiles perform PCS→PCS transforms (colour effects, viewing condition adjustments) and require a pipeline branch that skips device I/O entirely. ColorSpace profiles are structurally identical to device profiles and would be trivial to enable — raise an issue if you need either.

  • Lab input to the integer LUT kernels — Lab a / b are signed; the 'int' / 'int-wasm-*' kernels assume unsigned u8 / u16. The engine sidesteps this by always routing through device colour (RGB or CMYK) under those modes. For Lab → Lab image work, pin lutMode: 'float' (or set buildLut: false for the f64 pipeline). The default 'auto' resolves to 'float' when no int kernel is applicable, so this is handled correctly out of the box for non-int8 dataFormats.


Documentation

Page What it covers
Benchmark results Generated — every measured table with its conditions, the engine version it was measured on, and an index of which document cites which table
Parallel worker pool transformImages() — batching, per-image callbacks, cancellation, backpressure, deployment, and what the pool costs
Bench Current in-browser harness — baselines + jsCE directions × LUT modes. Older vs-lcms UI: samples/bench/ (docs/Bench.md)
Deep dive How it works, why it's fast — pipeline model, lutMode internals, JIT inspection, WASM kernel design
Performance Historical measurement retrospective — the journey, not current tables
Samples Live demos — video soft-proof, image soft-proof, jsCE vs lcms-wasm comparison (live)
vs LittleCMS The full comparison — lcms-wasm results, the native-C harness and its re-measurement, the specialisation story
Roadmap What's coming next — single source of truth for future plans (compiled pipeline / toModule(), profile oracle QC, kernel emit)
Examples Canvas round-trip, custom pipeline stages, and other recipes beyond Quick start
API — Profile Profile class: loading, virtual profiles, tag access
API — Transform Transform class: constructor options, create, createMultiStage, transform, array
API — Loader Optional batch profile loader
Plugins Registering custom LUT kernels (lutMode: 'custom…') — signature contract, resolution order, isolation
DeviceLink How DeviceLink (pClass: 'link') profiles are supported — element structure, asymmetric links, test fixtures
N-channel How 5CLR–15CLR press profiles are supported — both directions, LUT policy, memory trade-offs
CHANGELOG Release-by-release changes

In-source JSDoc on every public class and method is the authoritative reference for method signatures and parameter types:

Benchmark your own machine

In the browsersamples/benchmark/. Hardware baselines, then the jsColorEngine directions × LUT modes. After npm run browser && npm run serve, open http://localhost:8080/samples/benchmark/bench.html.

On Node — that run writes Benchmark results — generated:

node bench/reproduce.js
node scripts/build_bench_results.js "bobs pc"   # → docs/BenchResults-bobs-pc.md

--quick is the short pass (~15 min). The older five-tab vs-lcms UI is still at samples/bench/ (docs/Bench.md).

If your numbers differ meaningfully from the tables we want to know — open an issue with CPU, OS, Node/browser version, and the raw bench output. Critiques of the methodology are equally welcome.


License

MPL-2.0.

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.

Credits & influences

  • LittleCMS — colour-management architecture, ICC profile handling, CLUT interpolation approach. A genuine debt: much of the thinking in this engine was shaped by studying Marti Maria's 25-year solo maintenance of lcms. No code is derived — this is a clean-room JavaScript implementation with different optimisation constraints (V8 JIT vs C compiler) — but the intellectual lineage is acknowledged here rather than claimed independently. If this project ever produces commercial revenue, a meaningful share is intended to flow back to LittleCMS.
  • Bruce Lindbloom — RGB / XYZ / Lab math, ΔE formulas.
  • BabelColor — RGB working-space primaries reference.

About

jsColorEngine is a color management engine using ICC profiles in 100% JavaScript.

Topics

Resources

Security policy

Stars

37 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages