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

@calrk/clarity

v0.2.0

Published

Canvas image filter library - composable, pipeline-style filters over ImageData

Readme

Clarity

Fifty-nine composable image filters for canvas — blur, edge detection, chromatic aberration, posterising, normal maps, motion detection — running as fragment shaders by default, with a CPU implementation of every one of them behind it.

Every filter takes ImageData and returns ImageData, so they compose by chaining. You never write a shader, manage a context, or touch a framebuffer.

Open the playground →

The Clarity playground: a colour gradient pixelated on the GPU, with the source list, the searchable filter palette, the live pipeline and the generated code around it

Stack filters, point it at an image or your webcam, and watch it run. The chain lives in the URL, so any stack you build is a link you can send:

Why you might want it

  • The GPU path is the default, not an add-on. An N-filter chain is N draw calls with no CPU round-trip between them. Where there's no WebGL2 it falls back silently, per stage rather than all-or-nothing.
  • CSS can't do most of these. filter: blur() already covers blur and saturation, and does it better. Clarity is for the ones the platform has no answer for — chromatic aberration, colour bleed, posterising, normal generation, motion and difference detection, puzzling.
  • Filters describe themselves. Each carries a schema of its properties, so a host app can build controls for filters it has never heard of. Clarity ships no UI code at all.
  • Chains are text. 'Blur,radius=8/Invert' round-trips through a URL, a data attribute or a saved preset.
  • No dependencies, no DOM requirement. It runs in Node against a plain ImageData, which is how its own test suite works.

Install

npm install @calrk/clarity

Usage

A single filter is a function from ImageData to ImageData. If that is all you need, that is all there is:

import { Blur } from '@calrk/clarity';

const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
ctx.putImageData(new Blur({ radius: 8 }).process(frame), 0, 0);

For anything moving, Renderer owns the canvas, the source, the ordered chain and the frame loop — and puts the whole stack on the GPU:

import { Renderer, Blur, EdgeDetector, Invert } from '@calrk/clarity';

const renderer = new Renderer(canvas)
	.source(video)
	.add(new Blur({ radius: 8 }))
	.add(new EdgeDetector({ fast: true }))
	.add(new Invert());

renderer.start();

source() takes an image, a video, a canvas or a webcam stream. start() runs a requestAnimationFrame loop; render() does one frame. move(from, to), insert, remove and clear reorder the chain live, mid-playback.

Each filter takes a typed options bag, exposes enabled to bypass it without removing it from the chain, and describes its own tweakable properties through a schema. The two-input filters (Add, Subtract, Difference, Blend, Mask, Multiply, Displace, Stamper) take process([frameA, frameB]), and Stamper will read a third if you give it one — process([frame, sprite, probabilityMap]).

Options are not properties. A property is one scalar, because it has to survive a chain string, a URL and a generated control. An option is whatever suits the caller, so a filter is free to accept spellings that could never be any of those — and a few do, for things that are genuinely code-shaped:

new Fill({ rgb: [255, 136, 68] });      // or hsv, or a hex string

// a ramp of your own: hex colours spread evenly, or positioned quadruples
new GradientMap({ stops: ['1b2430', 'a8542f', 'f6f3ed'] });
new GradientMap({ stops: [[0, 28, 18, 10], [0.75, 150, 110, 70], [1, 255, 242, 215]] });

// or start from a built-in and edit it - RAMPS is exported for exactly this
new GradientMap({ stops: RAMPS.ice.map(([at, r, g, b]) => [at, r + 30, g, b]) });

GradientMap's stops is a ramp of your own, replacing the named one. Give it hex colours to have them spread evenly, or [position, r, g, b] quadruples when the spacing is the point. steps, cycle and offset all work on it unchanged, and setStops(null) hands the filter back to its named ramp. A mistyped ramp falls back to the named one rather than throwing, like every other value that reaches a filter from outside.

A plain <script> build is also published, exposing everything on a CLARITY global:

<script src="dist/clarity.global.js"></script>
<script>
	const blur = new CLARITY.Blur({ radius: 8 });
</script>

GPU

Shaders are the default. A Pipeline creates a WebGL2 context on first use and runs every filter that has a shader as a fragment shader, ping-ponging two framebuffers so an N-filter chain is N draw calls with no CPU round-trip in between. Where WebGL2 is missing — Node, an old browser, a lost context — it falls back to the CPU silently.

const pipeline = new Pipeline([new Desaturate(), new EdgeDetector()]);
pipeline.usingGPU;            // false when WebGL2 could not be had
pipeline.run(frame);
pipeline.stats.backend;       // 'gpu' | 'cpu' | 'mixed'
pipeline.stats.fallbacks;     // which stages ran on the CPU, and why
pipeline.stats.transfers;     // times the frame crossed between the two

new Pipeline(filters, { gpu: false });   // opt out

Every filter has a shader, so in a browser the whole chain runs on the GPU. Fallback is still per stage rather than all-or-nothing, for the cases where a shader can't compile or a filter's options aren't covered: one stage dropping to the CPU doesn't drag the rest with it. Each maximal run of shader stages is uploaded once, ping-ponged through, and read back once.

The CPU implementation stays the reference. It's the oracle the parity tests compare against and the fallback when there's no GL, so a filter isn't finished until both paths exist and agree — npm run test:gpu runs every case through both and compares.

Writing a shader

A filter declares one, compiled against a prelude that supplies the source texture, the frame size, the channel selector and a u_<key> uniform per schema property:

class Invert extends Filter {
	static shader = `
		void main(){
			writeRGB(vec3(255.0) - srcPixel(vUv).rgb);
		}
	`;
}

Colours are handled in 0–255 space, matching what the CPU implementations compare against. static supportsGPU(filter) says when a shader covers only some of the filter's options; an array of passes handles the multi-draw cases.

A pass may also declare a reduce shader, for filters that need to know something about the whole frame first. It maps each pixel to the quantity being reduced; a pyramid of halving passes collapses that to one texel, which the filter reads back with reduction() as (min, max). That is how Invert's dynamic mode, Contourer and ValueThreshold's auto mode get the frame's range without a readback.

Four more hooks cover what a plain fragment shader can't reach. Each is a static on the filter:

| | for | in the shader | |---|---|---| | outputSize | a filter that changes the frame's size — Rotator | uOutSize | | retains | previous frames — Ghoster, MotionDetector | historyTexel(age, p) | | data | per-instance arrays — Puzzler's shuffle | dataTexel(x, y) | | samples + prepare | whole-image statistics — Posteriser's palette | via data |

samples is the interesting one. A filter that must see every pixel before it can decide anything — a median-cut palette, a set of quartiles — asks for a small point-sampled copy, and prepare is handed it before the shader runs. That is a thumbnail rather than the frame, so it costs about 1% of a readback. The CPU path calls the same prepare with the same sample, so both backends derive their answer from identical pixels.

Filters needing an earlier pass's input declare nothing — a shader mentioning uOriginal gets the stage's input stashed aside for it automatically.

Pipelines

Pipeline is the headless half of Renderer — an ordered filter list with no canvas, no DOM and no frame loop. Use it directly outside the browser, or as the chain behind your own render loop:

const pipeline = new Pipeline([new Desaturate(), new ValueThreshold({ threshold: 120 })]);
const out = pipeline.run(frame);

It only recomputes what can have changed. Everything upstream of the first stage that is dirty, impure or newly reordered comes out of a cache, so tweaking the last filter in a long chain doesn't redo the ones before it — and an unchanged chain on an unchanged frame does no work at all. pipeline.stats reports where the time went and how many stages were skipped.

That requires knowing which filters are safe to cache, so each declares itself:

  • static stateful — output depends on frames already seen (Ghoster, MotionDetector, DifferenceDetector). Must see every frame, in order.
  • static varying — output changes between calls on identical input, because the filter reads the clock or the random source (Wave, Noise, Cloud).

Neither is ever cached. Everything else is pure and is.

A stateful filter's history is thrown away — reset() — whenever it stops being trustworthy: the chain is edited, the filter is removed, one of its properties changes, or it moves between the CPU and the GPU. That last one matters because the two keep separate histories, and blending them makes a trail jump.

Two-input filters

Add, Subtract, Blend, Mask, Multiply and Stamper need a second frame, which a stage supplies:

const maskChain = new Pipeline([new Desaturate(), new ValueThreshold()]);

new Pipeline()
	.add(new Blur({ radius: 6 }))
	.add(new Mask(), { second: maskChain });

second takes an ImageData, a function returning one, or another Pipeline — which is fed the outer run's source, so it branches off the input rather than continuing the chain.

first is the same thing for the other input, and replaces the frame arriving from the stage before. It is what makes these filters even-handed: without it, combining two branches means one of them has to be the chain and the other the argument, though nothing about them differs.

const fog = new Pipeline()
	.add(new Multiply(), { first: across, second: upward });

A stage with a first ignores whatever reached it — the stages above still run, they just stop being read — so in practice it goes on the first stage of a chain, where there is nothing above it to ignore.

Stamper is the exception to how all of those read their second frame. The rest composite two pictures, so a second frame of a different size is stretched to match before the filter sees it. Stamper's second frame is a sprite — it keeps its own size and proportions, and its alpha is what gives each stamp its shape.

new Pipeline()
	.add(new Fill({ colour: '3a4a28' }))
	.add(new Stamper({ count: 14, size: 9, rotation: 20 }), { second: blade });

Stamps sit one per cell of a jittered grid, which is what lets the same filter run as a shader — a fragment shader cannot draw a sprite wherever it likes, only answer for the pixel it was asked about, so count is a density rather than a total.

Each stamp is drawn at its own size, angle and shade, hashed from its cell — shadeJitter is a per-stamp gain on the colour and not on the alpha, so a darker stamp is darker rather than thinner. The cells wrap, so the result tiles; turn wrap off and a stamp overhanging an edge is cut there instead of coming back in on the opposite side, which is what a single picture wants rather than a texture.

Where the stamps land

Stamper also reads an optional third frame, and it is the only filter that takes one. It is a probability map: each cell samples it once, at its own stamp's centre, and the value there is the chance that stamp is drawn at all — white always, black never, mid-grey about half the time. Leave it out and every cell stamps, as before.

const damp = new Pipeline([new Cloud({ seed: 3 }), new Levels({ black: 90 })]);

new Pipeline()
	.add(new Fill({ colour: '3a4a28' }))
	.add(new Stamper({ count: 20, size: 7 }), { second: blade, third: damp });

This is not the same as masking the output, which is the point of it. Fade a finished field of stamps against a mask and the boundary is half-erased sprites — grass that thins by going transparent. Gating the placement means every stamp that survives is whole, so a hard-edged map gives a clump with a ragged edge of complete blades and a soft one gives density falling away.

Two things to expect. It places centres, not coverage, so a clump comes out about a stamp radius larger than the shape you drew. And the effective count is count times the map's coverage — a map that is white over a fifth of the frame needs five times the count for the same density inside it.

Filtering an <img>

<script>
	import { clarity } from '@calrk/clarity/svelte';
</script>

<img src="/sprite.png" use:clarity={'Desaturate/Noise,intensity=20'} alt="" />

The element keeps its identity - same <img>, same CSS, same alt - and only its src changes. The original path is kept on data-clarity-source, so the effect reverts cleanly, re-runs against the untouched original whenever the chain changes, and can be read back by anything else on the page.

It's a Svelte action, but it's a plain function with no Svelte import, so it works in Svelte 4 and 5, in other frameworks, and on its own:

const handle = clarity(document.querySelector('img'), 'Blur,radius=8');
handle.update('Invert');   // re-runs from the original
handle.destroy();          // puts the src back

Options instead of a bare string:

clarity(img, {
	chain: 'Glow,radius=12',
	enabled: !reducedMotion,     // false reverts without unmounting
	hide: true,                  // hide until the result is ready
	crossOrigin: 'anonymous',    // see below
	onError: (error) => ...      // otherwise it warns and reverts
});

Two things worth knowing. Every element shares one WebGL context - a browser hands out about sixteen before it starts dropping the oldest, so a context per sprite breaks quietly. And reading pixels from a cross-origin image taints the canvas, which is the likeliest way this fails in a real app since game assets tend to live on a CDN: it needs crossOrigin here and an Access-Control-Allow-Origin header from the server.

Chains as text

The 'Blur,radius=8/Invert' format is the library's, not the adapter's:

import { buildChain, formatChain, FILTERS } from '@calrk/clarity';

buildChain('Desaturate/Blur,radius=8/Invert!off');   // => Filter[]
formatChain(pipeline.filters);                       // => string
FILTERS.Blur;                                        // name -> constructor

Filters are separated by /, properties by ,, and !off bypasses one. Reading is deliberately forgiving - an unknown filter or property is skipped rather than thrown, because the text usually comes from a URL or an attribute written against some other version - and only properties that differ from their default are written, so the string stays short and stays valid when a default changes. It's what the playground puts in its address bar.

Property schemas

Every filter carries a static schema describing its properties — what each one means, and what values are legal:

Blur.schema
// { radius: { type: 'int', label: 'Radius', min: 1, max: 180, step: 1, default: 10 } }

Clarity ships no UI code. The schema is metadata, so build controls however you like — site/src/controls.js is a ~130-line plain-DOM renderer that handles every filter in the library, and a framework version is shorter still:

{#each Object.entries(filter.schema) as [key, field]}
	<label>{field.label}</label>
	{#if field.type === 'bool'}
		<input type="checkbox" checked={filter.getProperty(key)}
			on:change={(e) => filter.setProperty(key, e.target.checked)}>
	{:else}
		<input type="range" min={field.min} max={field.max} step={field.step}
			value={filter.getProperty(key)}
			on:input={(e) => filter.setProperty(key, e.target.value)}>
	{/if}
{/each}

Always write through setProperty. It coerces per the schema, clamps to the declared range, and rebuilds any derived state — a DOM input hands back a string, so assigning to properties directly leaves you with radius: "10", which works in some arithmetic and silently breaks the rest.

Field types are int, float, bool and select. A numeric field may be nullable, meaning null is legal and stands for "derive this from the frame" — ValueThreshold's auto mode is the one that uses it.

Outside the browser

Clarity has no DOM dependency at all. Node has no global ImageData though, so headless callers must supply one:

import { setImageDataFactory } from '@calrk/clarity';

setImageDataFactory((w, h) => new MyImageData(w, h));

Development

npm install
npm run dev        # the playground, with the library loaded from source
npm run build      # emits dist/ (ESM + UMD + global) and .d.ts files
npm run typecheck
npm test

dist/ is generated and not committed - the tests run against it, so run npm run build once after cloning.

Playground

site/ is the single-page playground live at clarity.clarklavery.com: pick a source, drag filters into a chain, and watch it run. It is also the demo, so it doubles as the answer to "what does this library actually do".

npm run site           # dev server, library loaded from src/ rather than dist/
npm run site:build     # static build into site/dist
npm run deploy         # build, then wrangler deploy to Cloudflare

It builds nothing the library does not already expose: the palette comes from CATALOGUE, the controls from each filter's schema, the code panel from the chain itself. That is the test of whether the metadata is any good - if a new filter needs the playground edited, the metadata was not enough.

Chains live in the URL - #photo/Blur,radius=8/Invert - so a link reproduces an exact stack. A dropped file joins the source list for the session; nothing is uploaded anywhere, so it only exists in that tab.

Deployment is an assets-only Cloudflare Worker (wrangler.jsonc), so there is no server code — a push to main builds and ships it. npm run deploy does the same thing by hand.

Tests

Filter output is pinned by golden images in test/golden/, one per case, compared exactly. Filters that use randomness or time take injectable random and now options so their output is reproducible:

import { Noise, seededRandom } from '@calrk/clarity';

new Noise({ intensity: 40, random: seededRandom(1) });   // same result every run
npm run test:golden           # goldens only
npm run test:gpu              # every case through both backends, compared
npm run test:action           # the <img> action, in a real browser
npm run test:update-golden    # regenerate them, then review the diff before committing
npm run test:fixtures         # regenerate the input images in test/fixtures/
npm run test:sheet            # build the contact sheet (below)

The browser-driven suites - GPU parity, the playground and the <img> action - need Chrome, and skip cleanly when there isn't one. They run it headless with SwiftShader, so they need no GPU and work in CI.

When a golden fails, the actual output and a visual diff are written to test/output/. Never regenerate a golden to make a test pass without looking at what changed.

Contact sheet

npm run test:sheet builds test/contact-sheet/index.html - a single self-contained page showing every filter's input and output side by side, with what each filter does and what you should be able to see. Each card reports the percentage of pixels the filter actually changed, and any filter that changed nothing is highlighted, which is the quickest way to spot one that has silently stopped working.

Open it in a browser after npm run build && npm run test:update-golden.

Licence

MIT - see LICENSE.

The one remaining piece of third-party code is src/vendor/StackBlur.js, by Mario Klingemann, which is also MIT. Its copyright notice is reproduced in LICENSE and is baked into every built bundle.

Filters

59 of them, in eight families. The full reference gives each one a before/after image, an options table and a live playground link - generated from the library by npm run docs, and checked by the test suite, so it cannot describe a filter that no longer works that way.

| Family | Filters | |---|---| | Process | Bilateral, Bleed, Blur, ChromaKey, Convolver, Desaturate, Dither, DotCrawl, Glow, GradientMap, Halftone, HanoverBars, Histogram, Invert, Levels, Morphology, Noise, Pixelate, Posteriser, Skeletiser, Vignette, hsvShifter | | Thresholders | GradientThreshold, MedianThreshold, ValueThreshold | | Salience | EdgeDetector, MotionDetector, ShotDetector, SkinDetector | | Transform | ChromaticAberration, Displace, FishEye, Mirror, Rotator, Tiler, Translator, Wave | | Height Map | Contourer, NormalFlip, NormalGenerator, NormalIntensity | | Starters | Cloud, Crackulate, Fill, Gradient, Voronoi, Woodgrain | | Dual Input | Add, Subtract, Difference, Blend, Mask, Multiply, Stamper | | Misc | Brickulate, DifferenceDetector, Ghoster, ScreenBurn, Puzzler |

Filters to be made

Tracked in FEATURES.md #9, which carries the same list plus an effort rating and the dependencies between them - a custom 3x3 kernel makes Sobel, Laplace and Emboss into presets rather than files, so it goes first.

Other things to work on

Now tracked properly in FEATURES.md, which covers the GPU/shader backend, the renderer object, the dirty-flag skip, and the rest - each grounded in the code with an effort rating and a priority order.