{"_id":"@calrk/clarity","_rev":"2-f9040372c485d0e96797b864ccd1b276","name":"@calrk/clarity","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@calrk/clarity","version":"0.1.0","keywords":["canvas","image","filter","imagedata","image-processing","image-filter","webgl","webgl2","shader","svelte","svelte-action"],"author":{"url":"https://clarklavery.com","name":"Clark Lavery"},"license":"MIT","_id":"@calrk/clarity@0.1.0","maintainers":[{"name":"calrk","email":"calrk1@gmail.com"}],"homepage":"https://clarity.clarklavery.com","bugs":{"url":"https://github.com/calrk/Clarity/issues"},"dist":{"shasum":"aab3cc51b6fcd1259bdb7ef3fc015884920d7df1","tarball":"https://registry.npmjs.org/@calrk/clarity/-/clarity-0.1.0.tgz","fileCount":80,"integrity":"sha512-J6uSYLuf10T5gKBL9LOEECUmAy9rs9Pb8WjAcSCPxcWaFuVa8FOf1BdQMTbrRTzw4er5oK2gSC1vpPU8vZs60Q==","signatures":[{"sig":"MEUCIQDgDoYw4m97ZtEwnPKXoobXOD2frPGxrhg6ry2JEtSdBAIgQIK3PAkLCSr4mD0TPSwgZ/jv17mCtDhJyFo8BGN/+oA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":2253012},"main":"./dist/clarity.umd.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/clarity.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/clarity.js","require":"./dist/clarity.umd.cjs"},"./svelte":{"types":"./dist/svelte/index.d.ts","import":"./dist/svelte.js"},"./package.json":"./package.json"},"gitHead":"b75e351f5e653997c6c037792cb33a6b464333ca","scripts":{"dev":"vite --config site/vite.config.js","docs":"node test/helpers/make-docs.js","site":"vite --config site/vite.config.js","test":"node --test test/*.test.js","build":"tsc --noEmit && vite build && vite build --config vite.svelte.config.ts","deploy":"npm run site:build && wrangler deploy","pretest":"npm run build && npm run site:build","test:gpu":"node --test test/gpu-parity.test.js","typecheck":"tsc --noEmit","site:build":"vite build --config site/vite.config.js","test:sheet":"node test/helpers/make-gpu-output.js && node test/helpers/make-contact-sheet.js","test:action":"node --test test/action.test.js","test:golden":"node --test test/golden.test.js","site:preview":"vite preview --config site/vite.config.js","test:fixtures":"node test/helpers/make-fixtures.js","prepublishOnly":"npm run build && npm test","test:gpu-images":"node test/helpers/make-gpu-output.js","test:update-golden":"node test/helpers/update-golden.js"},"_npmUser":{"name":"calrk","email":"calrk1@gmail.com"},"repository":{"url":"git+https://github.com/calrk/Clarity.git","type":"git"},"_npmVersion":"11.6.2","description":"Canvas image filter library - composable, pipeline-style filters over ImageData","directories":{},"sideEffects":false,"_nodeVersion":"24.13.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vite":"^6.0.5","pngjs":"^7.0.0","wrangler":"^4.118.0","pixelmatch":"^7.2.0","typescript":"^5.7.2","puppeteer-core":"^24.0.0","vite-plugin-dts":"^4.4.0"},"_npmOperationalInternal":{"tmp":"tmp/clarity_0.1.0_1785965238011_0.021098158663347633","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@calrk/clarity","version":"0.2.0","description":"Canvas image filter library - composable, pipeline-style filters over ImageData","type":"module","license":"MIT","author":{"name":"Clark Lavery","url":"https://clarklavery.com"},"homepage":"https://clarity.clarklavery.com","repository":{"type":"git","url":"git+https://github.com/calrk/Clarity.git"},"bugs":{"url":"https://github.com/calrk/Clarity/issues"},"keywords":["canvas","image","filter","imagedata","image-processing","image-filter","webgl","webgl2","shader","svelte","svelte-action"],"publishConfig":{"access":"public"},"sideEffects":false,"main":"./dist/clarity.umd.cjs","module":"./dist/clarity.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/clarity.js","require":"./dist/clarity.umd.cjs"},"./svelte":{"types":"./dist/svelte/index.d.ts","import":"./dist/svelte.js"},"./package.json":"./package.json"},"scripts":{"dev":"vite --config site/vite.config.js","site":"vite --config site/vite.config.js","site:build":"vite build --config site/vite.config.js","site:preview":"vite preview --config site/vite.config.js","deploy":"npm run site:build && wrangler deploy","build":"tsc --noEmit && vite build && vite build --config vite.svelte.config.ts","typecheck":"tsc --noEmit","pretest":"npm run build && npm run site:build","prepublishOnly":"npm run build && npm test","test":"node --test test/*.test.js","test:golden":"node --test test/golden.test.js","test:update-golden":"node test/helpers/update-golden.js","test:fixtures":"node test/helpers/make-fixtures.js","test:sheet":"node test/helpers/make-gpu-output.js && node test/helpers/make-contact-sheet.js","test:gpu":"node --test test/gpu-parity.test.js","test:gpu-images":"node test/helpers/make-gpu-output.js","test:action":"node --test test/action.test.js","docs":"node test/helpers/make-docs.js"},"devDependencies":{"pixelmatch":"^7.2.0","pngjs":"^7.0.0","puppeteer-core":"^24.0.0","typescript":"^5.7.2","vite":"^6.0.5","vite-plugin-dts":"^4.4.0","wrangler":"^4.118.0"},"gitHead":"19999d619af356f44b9dfd99f2832fbf09e2a49a","_id":"@calrk/clarity@0.2.0","_nodeVersion":"24.13.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-4hdsQZf3dEP0fQdIEhKIPuhEdpLGL1/63IcaebP1Ez5LjyUP//hUEmZ0uW7J8YigI049nzICZtti6LYVX1EDvA==","shasum":"e936691f7837be63949c6d80eb964233c2bdbe2d","tarball":"https://registry.npmjs.org/@calrk/clarity/-/clarity-0.2.0.tgz","fileCount":89,"unpackedSize":3247017,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDTHrSbg0B7X9PZvTTMFIaDofIYw7V3tPjsEVf65jOt+wIgeUh5CoI99TeSLk3eOkdcQ5WNVT1ePmJp91MMKhTK0yU="}]},"_npmUser":{"name":"calrk","email":"calrk1@gmail.com"},"directories":{},"maintainers":[{"name":"calrk","email":"calrk1@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/clarity_0.2.0_1787253405849_0.3764697511240376"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-05T21:27:17.552Z","modified":"2026-08-20T19:16:46.268Z","0.1.0":"2026-08-05T21:27:18.213Z","0.2.0":"2026-08-20T19:16:46.065Z"},"bugs":{"url":"https://github.com/calrk/Clarity/issues"},"author":{"name":"Clark Lavery","url":"https://clarklavery.com"},"license":"MIT","homepage":"https://clarity.clarklavery.com","keywords":["canvas","image","filter","imagedata","image-processing","image-filter","webgl","webgl2","shader","svelte","svelte-action"],"repository":{"type":"git","url":"git+https://github.com/calrk/Clarity.git"},"description":"Canvas image filter library - composable, pipeline-style filters over ImageData","maintainers":[{"name":"calrk","email":"calrk1@gmail.com"}],"readme":"Clarity\n=======\n\nFifty-nine composable image filters for canvas — blur, edge detection, chromatic\naberration, posterising, normal maps, motion detection — running as fragment\nshaders by default, with a CPU implementation of every one of them behind it.\n\nEvery filter takes `ImageData` and returns `ImageData`, so they compose by\nchaining. You never write a shader, manage a context, or touch a framebuffer.\n\n**[Open the playground →](https://clarity.clarklavery.com/)**\n\n[![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](docs/playground.png)](https://clarity.clarklavery.com/)\n\nStack filters, point it at an image or your webcam, and watch it run. The chain\nlives in the URL, so any stack you build is a link you can send:\n\n- [Colour bleed and chromatic aberration](https://clarity.clarklavery.com/#books/Bleed,radius=24/ChromaticAberration,xdistance=16) — the composite-video look\n- [Posterised to six colours](https://clarity.clarklavery.com/#landscape/Posteriser,colours=6) — median-cut palette, per frame\n- [A cloud texture from nothing](https://clarity.clarklavery.com/#blank/Cloud,iterations=6) — the starters need no input at all\n- [Height map to normal map](https://clarity.clarklavery.com/#heightmap/NormalGenerator) — then [flipped and re-lit](https://clarity.clarklavery.com/#heightmap/NormalGenerator/NormalIntensity)\n- [Speckle eaten without moving the edges that remain](https://clarity.clarklavery.com/#rorschach/Morphology,mode=open,radius=3) — open, then [close](https://clarity.clarklavery.com/#rorschach/Morphology,mode=close,radius=3) to fill the gaps instead\n- [Scrambled like a puzzle](https://clarity.clarklavery.com/#books/Puzzler,horizontalSegs=8,verticalSegs=6)\n- [A CRT](https://clarity.clarklavery.com/#landscape/FishEye,amount=0.35/HanoverBars,mode=scanlines/Vignette,amount=0.7,radius=0.4,softness=0.7) — which is not a filter but three of them: a lens curve, scanlines, and a corner falloff\n\nWhy you might want it\n---------------------\n\n- **The GPU path is the default, not an add-on.** An N-filter chain is N draw\n  calls with no CPU round-trip between them. Where there's no WebGL2 it falls\n  back silently, per stage rather than all-or-nothing.\n- **CSS can't do most of these.** `filter: blur()` already covers blur and\n  saturation, and does it better. Clarity is for the ones the platform has no\n  answer for — chromatic aberration, colour bleed, posterising, normal\n  generation, motion and difference detection, puzzling.\n- **Filters describe themselves.** Each carries a schema of its properties, so\n  a host app can build controls for filters it has never heard of. Clarity\n  ships no UI code at all.\n- **Chains are text.** `'Blur,radius=8/Invert'` round-trips through a URL, a\n  data attribute or a saved preset.\n- **No dependencies, no DOM requirement.** It runs in Node against a plain\n  `ImageData`, which is how its own test suite works.\n\nInstall\n-------\n\n```sh\nnpm install @calrk/clarity\n```\n\nUsage\n-----\n\nA single filter is a function from `ImageData` to `ImageData`. If that is all\nyou need, that is all there is:\n\n```js\nimport { Blur } from '@calrk/clarity';\n\nconst frame = ctx.getImageData(0, 0, canvas.width, canvas.height);\nctx.putImageData(new Blur({ radius: 8 }).process(frame), 0, 0);\n```\n\nFor anything moving, `Renderer` owns the canvas, the source, the ordered chain\nand the frame loop — and puts the whole stack on the GPU:\n\n```js\nimport { Renderer, Blur, EdgeDetector, Invert } from '@calrk/clarity';\n\nconst renderer = new Renderer(canvas)\n\t.source(video)\n\t.add(new Blur({ radius: 8 }))\n\t.add(new EdgeDetector({ fast: true }))\n\t.add(new Invert());\n\nrenderer.start();\n```\n\n`source()` takes an image, a video, a canvas or a webcam stream. `start()` runs\na `requestAnimationFrame` loop; `render()` does one frame. `move(from, to)`,\n`insert`, `remove` and `clear` reorder the chain live, mid-playback.\n\nEach filter takes a typed options bag, exposes `enabled` to bypass it without\nremoving it from the chain, and describes its own tweakable properties through a\n[schema](#property-schemas). The two-input filters (`Add`, `Subtract`,\n`Difference`, `Blend`, `Mask`, `Multiply`, `Displace`, `Stamper`) take\n`process([frameA, frameB])`, and `Stamper` will read a third if you give it one\n— `process([frame, sprite, probabilityMap])`.\n\n**Options are not properties.** A property is one scalar, because it has to\nsurvive a chain string, a URL and a generated control. An option is whatever\nsuits the caller, so a filter is free to accept spellings that could never be\nany of those — and a few do, for things that are genuinely code-shaped:\n\n```js\nnew Fill({ rgb: [255, 136, 68] });      // or hsv, or a hex string\n\n// a ramp of your own: hex colours spread evenly, or positioned quadruples\nnew GradientMap({ stops: ['1b2430', 'a8542f', 'f6f3ed'] });\nnew GradientMap({ stops: [[0, 28, 18, 10], [0.75, 150, 110, 70], [1, 255, 242, 215]] });\n\n// or start from a built-in and edit it - RAMPS is exported for exactly this\nnew GradientMap({ stops: RAMPS.ice.map(([at, r, g, b]) => [at, r + 30, g, b]) });\n```\n\n`GradientMap`'s `stops` is a ramp of your own, replacing the named one. Give it\nhex colours to have them spread evenly, or `[position, r, g, b]` quadruples when\nthe spacing is the point. `steps`, `cycle` and `offset` all work on it unchanged,\nand `setStops(null)` hands the filter back to its named ramp. A mistyped ramp\nfalls back to the named one rather than throwing, like every other value that\nreaches a filter from outside.\n\nA plain `<script>` build is also published, exposing everything on a `CLARITY`\nglobal:\n\n```html\n<script src=\"dist/clarity.global.js\"></script>\n<script>\n\tconst blur = new CLARITY.Blur({ radius: 8 });\n</script>\n```\n\nGPU\n---\n\n**Shaders are the default.** A `Pipeline` creates a WebGL2 context on first use\nand runs every filter that has a shader as a fragment shader, ping-ponging two\nframebuffers so an N-filter chain is N draw calls with no CPU round-trip in\nbetween. Where WebGL2 is missing — Node, an old browser, a lost context — it\nfalls back to the CPU silently.\n\n```js\nconst pipeline = new Pipeline([new Desaturate(), new EdgeDetector()]);\npipeline.usingGPU;            // false when WebGL2 could not be had\npipeline.run(frame);\npipeline.stats.backend;       // 'gpu' | 'cpu' | 'mixed'\npipeline.stats.fallbacks;     // which stages ran on the CPU, and why\npipeline.stats.transfers;     // times the frame crossed between the two\n\nnew Pipeline(filters, { gpu: false });   // opt out\n```\n\n**Every filter has a shader**, so in a browser the whole chain runs on the GPU.\nFallback is still **per stage** rather than all-or-nothing, for the cases where\na shader can't compile or a filter's options aren't covered: one stage dropping\nto the CPU doesn't drag the rest with it. Each maximal run of shader stages is\nuploaded once, ping-ponged through, and read back once.\n\nThe CPU implementation stays the reference. It's the oracle the parity tests\ncompare against and the fallback when there's no GL, so a filter isn't finished\nuntil both paths exist and agree — `npm run test:gpu` runs every case through\nboth and compares.\n\n### Writing a shader\n\nA filter declares one, compiled against a prelude that supplies the source\ntexture, the frame size, the channel selector and a `u_<key>` uniform per schema\nproperty:\n\n```js\nclass Invert extends Filter {\n\tstatic shader = `\n\t\tvoid main(){\n\t\t\twriteRGB(vec3(255.0) - srcPixel(vUv).rgb);\n\t\t}\n\t`;\n}\n```\n\nColours are handled in **0–255 space**, matching what the CPU implementations\ncompare against. `static supportsGPU(filter)` says when a shader covers only\nsome of the filter's options; an array of passes handles the multi-draw cases.\n\nA pass may also declare a `reduce` shader, for filters that need to know\nsomething about the whole frame first. It maps each pixel to the quantity being\nreduced; a pyramid of halving passes collapses that to one texel, which the\nfilter reads back with `reduction()` as (min, max). That is how `Invert`'s\ndynamic mode, `Contourer` and `ValueThreshold`'s auto mode get the frame's range\nwithout a readback.\n\nFour more hooks cover what a plain fragment shader can't reach. Each is a\n`static` on the filter:\n\n| | for | in the shader |\n|---|---|---|\n| `outputSize` | a filter that changes the frame's size — `Rotator` | `uOutSize` |\n| `retains` | previous frames — `Ghoster`, `MotionDetector` | `historyTexel(age, p)` |\n| `data` | per-instance arrays — `Puzzler`'s shuffle | `dataTexel(x, y)` |\n| `samples` + `prepare` | whole-image statistics — `Posteriser`'s palette | via `data` |\n\n`samples` is the interesting one. A filter that must see every pixel before it\ncan decide anything — a median-cut palette, a set of quartiles — asks for a\nsmall point-sampled copy, and `prepare` is handed it before the shader runs.\nThat is a thumbnail rather than the frame, so it costs about 1% of a readback.\nThe CPU path calls the same `prepare` with the same sample, so both backends\nderive their answer from identical pixels.\n\nFilters needing an earlier pass's input declare nothing — a shader mentioning\n`uOriginal` gets the stage's input stashed aside for it automatically.\n\nPipelines\n---------\n\n`Pipeline` is the headless half of `Renderer` — an ordered filter list with no\ncanvas, no DOM and no frame loop. Use it directly outside the browser, or as the\nchain behind your own render loop:\n\n```js\nconst pipeline = new Pipeline([new Desaturate(), new ValueThreshold({ threshold: 120 })]);\nconst out = pipeline.run(frame);\n```\n\nIt only recomputes what can have changed. Everything upstream of the first stage\nthat is dirty, impure or newly reordered comes out of a cache, so tweaking the\nlast filter in a long chain doesn't redo the ones before it — and an unchanged\nchain on an unchanged frame does no work at all. `pipeline.stats` reports where\nthe time went and how many stages were skipped.\n\nThat requires knowing which filters are safe to cache, so each declares itself:\n\n- `static stateful` — output depends on frames already seen (`Ghoster`,\n  `MotionDetector`, `DifferenceDetector`). Must see every frame, in order.\n- `static varying` — output changes between calls on identical input, because\n  the filter reads the clock or the random source (`Wave`, `Noise`, `Cloud`).\n\nNeither is ever cached. Everything else is pure and is.\n\nA stateful filter's history is thrown away — `reset()` — whenever it stops\nbeing trustworthy: the chain is edited, the filter is removed, one of its\nproperties changes, or it moves between the CPU and the GPU. That last one\nmatters because the two keep separate histories, and blending them makes a\ntrail jump.\n\n### Two-input filters\n\n`Add`, `Subtract`, `Blend`, `Mask`, `Multiply` and `Stamper` need a second frame,\nwhich a stage supplies:\n\n```js\nconst maskChain = new Pipeline([new Desaturate(), new ValueThreshold()]);\n\nnew Pipeline()\n\t.add(new Blur({ radius: 6 }))\n\t.add(new Mask(), { second: maskChain });\n```\n\n`second` takes an `ImageData`, a function returning one, or another `Pipeline` —\nwhich is fed the outer run's *source*, so it branches off the input rather than\ncontinuing the chain.\n\n`first` is the same thing for the other input, and replaces the frame arriving\nfrom the stage before. It is what makes these filters even-handed: without it,\ncombining two branches means one of them has to be the chain and the other the\nargument, though nothing about them differs.\n\n```js\nconst fog = new Pipeline()\n\t.add(new Multiply(), { first: across, second: upward });\n```\n\nA stage with a `first` ignores whatever reached it — the stages above still run,\nthey just stop being read — so in practice it goes on the first stage of a\nchain, where there is nothing above it to ignore.\n\n`Stamper` is the exception to how all of those read their second frame. The rest\ncomposite two *pictures*, so a second frame of a different size is stretched to\nmatch before the filter sees it. `Stamper`'s second frame is a **sprite** — it\nkeeps its own size and proportions, and its alpha is what gives each stamp its\nshape.\n\n```js\nnew Pipeline()\n\t.add(new Fill({ colour: '3a4a28' }))\n\t.add(new Stamper({ count: 14, size: 9, rotation: 20 }), { second: blade });\n```\n\nStamps sit one per cell of a jittered grid, which is what lets the same filter\nrun as a shader — a fragment shader cannot draw a sprite wherever it likes, only\nanswer for the pixel it was asked about, so `count` is a density rather than a\ntotal.\n\nEach stamp is drawn at its own size, angle and **shade**, hashed from its cell —\n`shadeJitter` is a per-stamp gain on the colour and not on the alpha, so a darker\nstamp is darker rather than thinner. The cells wrap, so the result tiles; turn\n`wrap` off and a stamp overhanging an edge is cut there instead of coming back in\non the opposite side, which is what a single picture wants rather than a texture.\n\n### Where the stamps land\n\n`Stamper` also reads an optional **third** frame, and it is the only filter that\ntakes one. It is a probability map: each cell samples it once, at its own stamp's\ncentre, and the value there is the chance that stamp is drawn at all — white\nalways, black never, mid-grey about half the time. Leave it out and every cell\nstamps, as before.\n\n```js\nconst damp = new Pipeline([new Cloud({ seed: 3 }), new Levels({ black: 90 })]);\n\nnew Pipeline()\n\t.add(new Fill({ colour: '3a4a28' }))\n\t.add(new Stamper({ count: 20, size: 7 }), { second: blade, third: damp });\n```\n\nThis is not the same as masking the output, which is the point of it. Fade a\nfinished field of stamps against a mask and the boundary is half-erased sprites —\ngrass that thins by going transparent. Gating the placement means every stamp\nthat survives is whole, so a hard-edged map gives a clump with a ragged edge of\ncomplete blades and a soft one gives density falling away.\n\nTwo things to expect. It places **centres, not coverage**, so a clump comes out\nabout a stamp radius larger than the shape you drew. And the effective count is\n`count` times the map's coverage — a map that is white over a fifth of the frame\nneeds five times the count for the same density inside it.\n\nFiltering an `<img>`\n--------------------\n\n```svelte\n<script>\n\timport { clarity } from '@calrk/clarity/svelte';\n</script>\n\n<img src=\"/sprite.png\" use:clarity={'Desaturate/Noise,intensity=20'} alt=\"\" />\n```\n\nThe element keeps its identity - same `<img>`, same CSS, same `alt` - and only\nits `src` changes. The original path is kept on `data-clarity-source`, so the\neffect reverts cleanly, re-runs against the untouched original whenever the\nchain changes, and can be read back by anything else on the page.\n\nIt's a Svelte *action*, but it's a plain function with no Svelte import, so it\nworks in Svelte 4 and 5, in other frameworks, and on its own:\n\n```js\nconst handle = clarity(document.querySelector('img'), 'Blur,radius=8');\nhandle.update('Invert');   // re-runs from the original\nhandle.destroy();          // puts the src back\n```\n\nOptions instead of a bare string:\n\n```js\nclarity(img, {\n\tchain: 'Glow,radius=12',\n\tenabled: !reducedMotion,     // false reverts without unmounting\n\thide: true,                  // hide until the result is ready\n\tcrossOrigin: 'anonymous',    // see below\n\tonError: (error) => ...      // otherwise it warns and reverts\n});\n```\n\nTwo things worth knowing. Every element shares **one** WebGL context - a browser\nhands out about sixteen before it starts dropping the oldest, so a context per\nsprite breaks quietly. And reading pixels from a **cross-origin** image taints\nthe canvas, which is the likeliest way this fails in a real app since game\nassets tend to live on a CDN: it needs `crossOrigin` here *and* an\n`Access-Control-Allow-Origin` header from the server.\n\n### Chains as text\n\nThe `'Blur,radius=8/Invert'` format is the library's, not the adapter's:\n\n```js\nimport { buildChain, formatChain, FILTERS } from '@calrk/clarity';\n\nbuildChain('Desaturate/Blur,radius=8/Invert!off');   // => Filter[]\nformatChain(pipeline.filters);                       // => string\nFILTERS.Blur;                                        // name -> constructor\n```\n\nFilters are separated by `/`, properties by `,`, and `!off` bypasses one.\nReading is deliberately forgiving - an unknown filter or property is skipped\nrather than thrown, because the text usually comes from a URL or an attribute\nwritten against some other version - and only properties that differ from their\ndefault are written, so the string stays short and stays valid when a default\nchanges. It's what the playground puts in its address bar.\n\nProperty schemas\n----------------\n\nEvery filter carries a `static schema` describing its properties — what each one\nmeans, and what values are legal:\n\n```js\nBlur.schema\n// { radius: { type: 'int', label: 'Radius', min: 1, max: 180, step: 1, default: 10 } }\n```\n\nClarity ships **no UI code**. The schema is metadata, so build controls however\nyou like — `site/src/controls.js` is a ~130-line plain-DOM renderer that handles\nevery filter in the library, and a framework version is shorter still:\n\n```svelte\n{#each Object.entries(filter.schema) as [key, field]}\n\t<label>{field.label}</label>\n\t{#if field.type === 'bool'}\n\t\t<input type=\"checkbox\" checked={filter.getProperty(key)}\n\t\t\ton:change={(e) => filter.setProperty(key, e.target.checked)}>\n\t{:else}\n\t\t<input type=\"range\" min={field.min} max={field.max} step={field.step}\n\t\t\tvalue={filter.getProperty(key)}\n\t\t\ton:input={(e) => filter.setProperty(key, e.target.value)}>\n\t{/if}\n{/each}\n```\n\nAlways write through `setProperty`. It coerces per the schema, clamps to the\ndeclared range, and rebuilds any derived state — a DOM input hands back a\n*string*, so assigning to `properties` directly leaves you with `radius: \"10\"`,\nwhich works in some arithmetic and silently breaks the rest.\n\nField types are `int`, `float`, `bool` and `select`. A numeric field may be\n`nullable`, meaning `null` is legal and stands for \"derive this from the frame\"\n— `ValueThreshold`'s auto mode is the one that uses it.\n\n### Outside the browser\n\nClarity has no DOM dependency at all. Node has no global `ImageData` though, so\nheadless callers must supply one:\n\n```js\nimport { setImageDataFactory } from '@calrk/clarity';\n\nsetImageDataFactory((w, h) => new MyImageData(w, h));\n```\n\nDevelopment\n-----------\n\n```sh\nnpm install\nnpm run dev        # the playground, with the library loaded from source\nnpm run build      # emits dist/ (ESM + UMD + global) and .d.ts files\nnpm run typecheck\nnpm test\n```\n\n`dist/` is generated and not committed - the tests run against it, so run\n`npm run build` once after cloning.\n\n### Playground\n\n`site/` is the single-page playground live at\n**[clarity.clarklavery.com](https://clarity.clarklavery.com/)**: pick a source,\ndrag filters into a chain, and watch it run. It is also the demo, so it doubles\nas the answer to \"what does this library actually do\".\n\n```sh\nnpm run site           # dev server, library loaded from src/ rather than dist/\nnpm run site:build     # static build into site/dist\nnpm run deploy         # build, then wrangler deploy to Cloudflare\n```\n\nIt builds nothing the library does not already expose: the palette comes from\n`CATALOGUE`, the controls from each filter's schema, the code panel from the\nchain itself. That is the test of whether the metadata is any good - if a new\nfilter needs the playground edited, the metadata was not enough.\n\nChains live in the URL - `#photo/Blur,radius=8/Invert` - so a link reproduces an\nexact stack. A dropped file joins the source list for the session; nothing is\nuploaded anywhere, so it only exists in that tab.\n\nDeployment is an assets-only Cloudflare Worker (`wrangler.jsonc`), so there is\nno server code — a push to `main` builds and ships it. `npm run deploy` does the\nsame thing by hand.\n\n### Tests\n\nFilter output is pinned by golden images in `test/golden/`, one per case,\ncompared exactly. Filters that use randomness or time take injectable `random`\nand `now` options so their output is reproducible:\n\n```js\nimport { Noise, seededRandom } from '@calrk/clarity';\n\nnew Noise({ intensity: 40, random: seededRandom(1) });   // same result every run\n```\n\n```sh\nnpm run test:golden           # goldens only\nnpm run test:gpu              # every case through both backends, compared\nnpm run test:action           # the <img> action, in a real browser\nnpm run test:update-golden    # regenerate them, then review the diff before committing\nnpm run test:fixtures         # regenerate the input images in test/fixtures/\nnpm run test:sheet            # build the contact sheet (below)\n```\n\nThe browser-driven suites - GPU parity, the playground and the `<img>` action -\nneed Chrome, and skip cleanly when there isn't one. They run it headless with\nSwiftShader, so they need no GPU and work in CI.\n\nWhen a golden fails, the actual output and a visual diff are written to\n`test/output/`. Never regenerate a golden to make a test pass without looking at\nwhat changed.\n\n### Contact sheet\n\n`npm run test:sheet` builds `test/contact-sheet/index.html` - a single\nself-contained page showing every filter's input and output side by side, with\nwhat each filter does and what you should be able to see. Each card reports the\npercentage of pixels the filter actually changed, and any filter that changed\n*nothing* is highlighted, which is the quickest way to spot one that has\nsilently stopped working.\n\nOpen it in a browser after `npm run build && npm run test:update-golden`.\n\nLicence\n-------\n\n**MIT** - see [LICENSE](LICENSE).\n\nThe one remaining piece of third-party code is `src/vendor/StackBlur.js`, by\nMario Klingemann, which is also MIT. Its copyright notice is reproduced in\nLICENSE and is baked into every built bundle.\n\nFilters\n=======\n\n59 of them, in eight families. The **[full reference](docs/FILTERS.md)**\ngives each one a before/after image, an options table and a live playground link -\ngenerated from the library by `npm run docs`, and checked by the test suite, so it\ncannot describe a filter that no longer works that way.\n\n| Family | Filters |\n|---|---|\n| Process | `Bilateral`, `Bleed`, `Blur`, `ChromaKey`, `Convolver`, `Desaturate`, `Dither`, `DotCrawl`, `Glow`, `GradientMap`, `Halftone`, `HanoverBars`, `Histogram`, `Invert`, `Levels`, `Morphology`, `Noise`, `Pixelate`, `Posteriser`, `Skeletiser`, `Vignette`, `hsvShifter` |\n| Thresholders | `GradientThreshold`, `MedianThreshold`, `ValueThreshold` |\n| Salience | `EdgeDetector`, `MotionDetector`, `ShotDetector`, `SkinDetector` |\n| Transform | `ChromaticAberration`, `Displace`, `FishEye`, `Mirror`, `Rotator`, `Tiler`, `Translator`, `Wave` |\n| Height Map | `Contourer`, `NormalFlip`, `NormalGenerator`, `NormalIntensity` |\n| Starters | `Cloud`, `Crackulate`, `Fill`, `Gradient`, `Voronoi`, `Woodgrain` |\n| Dual Input | `Add`, `Subtract`, `Difference`, `Blend`, `Mask`, `Multiply`, `Stamper` |\n| Misc | `Brickulate`, `DifferenceDetector`, `Ghoster`, `ScreenBurn`, `Puzzler` |\n\nFilters to be made\n==================\n\nTracked in [FEATURES.md](FEATURES.md) #9, which carries the same list plus an\neffort rating and the dependencies between them - a custom 3x3 kernel makes\nSobel, Laplace and Emboss into presets rather than files, so it goes first.\n\nOther things to work on\n=======================\n\nNow tracked properly in [FEATURES.md](FEATURES.md), which covers the GPU/shader\nbackend, the renderer object, the dirty-flag skip, and the rest - each grounded\nin the code with an effort rating and a priority order.","readmeFilename":"README.md"}