npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

jscolorengine

v1.6.1

Published

Fast ICC colour engine for JavaScript. WASM-SIMD LUT kernels (~80–120 MPx/s on photographs) and a fused matrix-shaper kernel (~330 MPx/s) on one core, plus a worker pool for batches of images. Accurate to within 1 LSB of LittleCMS, and 3.1–3.6x faster tha

Readme

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.