# CLAUDE.md

Guidance for working in this repo. Design and roadmap: [PLAN.md](./PLAN.md). Keep this file current: when a decision is made or reversed, record it under **Decisions** with its reason.

## Commands

```sh
pnpm install
pnpm typecheck        # tsc over all packages and apps, then the ```ts examples of the root and package READMEs (scripts/check-readmes.mjs)
pnpm test             # unit tests (Node): CPU references, color, float16, compare, playground logic, @rullino/node on Dawn (needs a Vulkan driver: `apt install mesa-vulkan-drivers` without a GPU)
pnpm test:browser     # GPU tests in headless Chromium (WebGPU), incl. GPU<->CPU parity
pnpm test:all         # both
pnpm test:coverage    # both, with v8 coverage and per-area thresholds (report in coverage/index.html)
pnpm build            # builds every package into dist/ (tsc + scripts/fix-imports.mjs) and the playground (Vite)
pnpm size             # gzip sizes against size-budget.json (after build)
pnpm test:pack        # packs the packages, installs the tarballs in a temp project, runs them in Node and in Chromium (GPU)
pnpm test:e2e         # smoke-tests the built playground in headless Chromium (run after build)
pnpm test:e2e:base    # the built playground served from a sub-path, as GitHub Pages does (run after build)
pnpm test:ml          # the real SlimSAM, MI-GAN and Depth Anything models on ONNX Runtime WASM (downloads 69 MB once into .cache/models)
pnpm test:e2e:ml      # the playground's Object, Remove object and Lens blur tools with the real models (after build; models served locally)
pnpm bench            # GPU timings per filter + a chain at 1024x768 (SwiftShader numbers: compare runs, not machines)
pnpm bench:cpu        # CPU reference vs fast path: the PLAN chains at 12 MP (`chains --size 1155x866` for 1 MP) and every filter at 1024x768 (`filters --only <id>`)
pnpm bench:web        # comparison page (#77): rullino vs PixiJS, Photon, wasm-vips; prints a URL to open in a real browser (`--headless --strict --sizes 320x240 --runs 1` = CI smoke)
pnpm dev              # playground dev server
npx vitest run path/to/file.test.ts   # single file
npx vitest run packages/presets -u    # rewrite golden look snapshots after a deliberate recipe change
npx vitest run packages/filters -u    # rewrite docs/filters.md (filter reference) and docs/parity.md (tolerance table) after changing a filter's schema or tolerance
```

## Layout

- Every package has a public root (`src/index.ts`) and, for `core` and `ml`, an unstable `./internal` subpath (`src/internal.ts`: shader generators, uniform packing, texture caches, model I/O processing). `src/api.test.ts` pins the exact runtime export list of each root.
- `packages/core`
  - `params.ts` parameter schema (float/int/bool/enum/kernel/curve/lut/image/color, optional `unit: 'px'` and UI `group`): types, validation, defaults, `scaleParams`, uniform layout/packing, generated WGSL `Params` struct.
  - `filter.ts` / `define.ts` filter contracts and `defineFilter()`: `pointwise` (CPU reference derived from `pixel()`) and `multipass` (fixed list of passes + whole-image CPU reference).
  - `curve.ts` monotone tone curves sampled to a 256-entry LUT, CPU sampler and WGSL sampler generator.
  - `color.ts`, `oklab.ts` CPU colour maths; `wgsl.ts` the `rl_*` WGSL twins prepended to every shader.
  - `image.ts` `LinearImage`, `sampleImage` and its WGSL twin (image params); `gpu/` context, rgba16float upload/readback, `importImage` / `encodeToTexture` / `readSrgb8` (sRGB edges), area `downscale`, `runMultipass`, `runFilter` (any kind).
  - `pipeline/` `Step` chains: `fuse.ts` (namespacing + fused shader), `pipeline.ts` (cached incremental `Pipeline`), `chain.ts` (`runChainCpu` reference).
  - `resize.ts` CPU area downscale and `fitWithin`.
  - `png.ts` dependency-free PNG encoder (8/16-bit RGBA); `encode.ts` `encodeImage` (PNG / JPEG / WebP from a `LinearImage`); `gpu/export.ts` `exportImage` from a texture.
  - `mask.ts` masks (linear, radial, raster), CPU `maskValue` / `maskImage` / `blendMasked` / `resampleRaster`; `gpu/mask.ts` the WGSL twin, `blendMask` and `renderMask`; `paint.ts` `paintStroke` (brush on raster masks); `wand.ts` `magicWand`; `refine.ts` `refineMask` (guided filter).
  - `geometry.ts` `Geometry` (orientation, perspective, straighten, crop) composed into one homography, `geometryFrame`, CPU `applyGeometryCpu`; `gpu/geometry.ts` `applyGeometry`.
  - `edit.ts` `applyEdit` (document → image: geometry, steps, long-side shrink; GPU with a context, else CPU; textures it makes go back to the pool).
  - `serialize.ts` `serializeChain` / `parseChain` / `migrateChain` (edit documents: chain + geometry + output as JSON); `edit.schema.json` (package root, published as `@rullino/core/edit.schema.json`) is its JSON Schema, checked by `edit-schema.test.ts` with Ajv.
  - `lut.ts` 3D LUTs: `createLut`, `.cube` / HaldCLUT parsers, CPU trilinear sampler and its WGSL twin; `gpu/lut.ts` the cached 3D texture.
- `packages/filters` — one directory per filter: `index.ts` (schema + WGSL + CPU reference) and `*.test.ts` (CPU semantics). `convolution.ts` holds shared CPU convolution code and WGSL pass builders. `index.ts` at the root is the registry; `parity.browser.test.ts` checks every registered filter GPU <-> CPU at defaults and parameter extremes (images and cases in `test-utils.ts`). `fast.ts` holds the building blocks of the fast CPU paths (`convolve1dFast`, table-based `toSrgb` / `toLinear`, `mapChannels`, `mapStraight`, `sampleLutInto`); `fast.test.ts` checks every filter's `fast` against its reference. `bench/cpu-bench.ts` is the CPU bench (bundled and run in Node by `scripts/bench-cpu.mjs`). `auto.ts` `autoAdjust` (suggested white balance + levels).
- `packages/ml` — optional ML on top of core, ONNX Runtime *injected* (`OrtModule`), never imported: `sam.ts` SAM input / mask post-processing (pure TS), `segmenter.ts` `createSamSegmenter` (encode once per image, decode per click), `migan.ts` `createMiganInpainter` (object removal) and `growMask`, `depth.ts` `createDepthEstimator` (relative depth, near = 1); `models.ts` model descriptors at pinned revisions (`SLIMSAM_77`, `MIGAN`, `DEPTH_ANYTHING_V2_SMALL`) and `fetchModel` (progress, Cache API). `test/` holds the real-model integration test (`ml` Vitest project).
- `packages/node` — `renderer.ts` `createRenderer` (Node renderer: loads Dawn from the optional `webgpu` package and defines the WebGPU globals core uses, falls back to the CPU with a `reason`; `render` = file (sharp) or RGBA8 in, `applyEdit`, PNG (own encoder) or JPEG / WebP (sharp, after core's `exportSamples` flattening) out). Its tests run Dawn and sharp for real; `no-sharp.test.ts` mocks sharp away. `browser-vs-node.browser.test.ts` renders the same documents (every look, two mixed edits) on Chromium's WebGPU and, through the `renderOnDawn` Vitest command (`test/dawn-command.ts`, registered in `vitest.config.ts`, loads the sources with the Vite server's `ssrLoadModule`), on Dawn in Node, on two sample photos and a chart.
- `packages/web` — `renderer.ts` `createRenderer` (browser renderer: WebGPU through `createGpuContext`, else the CPU; `apply` / `render` (ImageData or file → encoded Blob) / `pixels` (8-bit ImageData, encoded where it renders: `applyEditPixels` in core's internal)), `worker.ts` the CPU backend's module worker (`applyEdit` with the built-in filters), `protocol.ts` its messages. `renderer.browser.test.ts` runs the worker in Chromium against WebGPU.
- `packages/konva` — `index.ts` `attachEdit` (a `Konva.Image` edited with edit documents: previews at the node's size on screen through `@rullino/web`'s `pixels`, coalesced `set`, full-resolution `export`, `detach`); `konva.browser.test.ts` runs real Konva in Chromium. `konva` is a peer dependency (dev dependency pinned).
- `packages/presets` — `bake.ts` (`bakeLut`: a pointwise chain baked into a 33³ LUT) and `looks.ts` (the looks: grade + vignette + grain, `lookSteps`, `lookLut`; as edit documents `lookEdit`, LUTs named `look:<id>` by `lookLutRef` / `resolveLookLut`); golden charts in `__snapshots__`.
- `apps/playground` — Vite site; consumes packages through their public `index.ts` only. `controls.ts` generates the parameter UI from a schema (curve params share one `curve-editor.ts` with tabs); `chain-ui.ts` is the chain state and the inspector of the selected step (description, generated controls, mask), `film-strip.ts` the chain as a strip of frames under the photo; `mask-ui.ts` the per-step mask panel and `mask-overlay.ts` the on-image handles / brush; `geometry-ui.ts` the Geometry panel, `crop-overlay.ts` / `perspective-overlay.ts` its on-image editors; `effect-browser.ts` / `effect-search.ts` the Ctrl+K effect browser; `histogram-view.ts`; `history.ts` undo/redo, `share.ts` share-link encoding, `code-export.ts` / `code-dialog.ts` "Copy as code" (the Code button: the edit document and browser / Konva / Node snippets, loaded on first use), `zoom.ts` zoom / pan / pinch, `object-select.ts` the Object mask tool (backend choice, lazy model, per-image encoding), `object-remove.ts` Remove object (lazy MI-GAN), `depth-estimate.ts` Estimate depth for the lens blur (lazy Depth Anything); `scripts/smoke.mjs` is the e2e smoke test, `scripts/smoke-ml.mjs` the Object tool's. `scripts/fetch-models.mjs` (repo root) downloads the test models.

- `apps/bench` — the comparison page (#77): `harness.ts` `runBench` (init / first run / change latency / memory / |Δ| vs the first adapter, unit-tested with fake adapters), `adapters/*` one per library (each its own chunk), `chains.ts` the chains as data, `rullino-chains.ts` as rullino steps, `tables.ts` the curve table and colour map rullino computes for the libraries that lack curves / 3D LUTs, `vite.config.ts` cross-origin isolation, library versions and `sizes.json` (gzip per adapter); `scripts/run.mjs` builds, serves and optionally runs it headless.

- `examples/` — one-file examples on the published packages (`^x.y.z` ranges, not workspace links, so StackBlitz can open them from GitHub): `konva-editor` (Konva stage + sliders + look picker + export), `node-batch` (an edit document rendered onto a folder of photos with `@rullino/node`), `worker-fallback` (`@rullino/web` with a backend menu: WebGPU, or the CPU in a worker). Not workspace members and not type-checked; `pnpm test:pack` copies each one's files next to the installed tarballs, builds it with Vite and drives it in Chromium (`node-batch`: runs the script and checks the files and sizes it writes; `worker-fallback`: forced onto the CPU backend with `?backend=cpu`).

- `.claude/skills/` — repo skills: `add-filter` (the recipe for a new filter, tests to mutation check) and `finish-step` (verification, CLAUDE.md upkeep, PR and CI). `.claude/settings.json` enables two official plugins: `claude-md-management` and `skill-creator`.

## Rules

- **Red/green.** Write the test first, run it, see it fail, then implement. For GPU code, also check that the parity test catches a deliberately broken shader before trusting it.
- **Every filter ships with both implementations, CPU semantics tests, and is registered** in `packages/filters/src/index.ts` (which gives it the parity test). The CPU version is the specification: obvious, unoptimised, never mutates its input. Speed goes into an optional `fast` implementation next to it (tested against it by `fast.test.ts`, mutation-checked like a shader), never into the reference.
- **Every parameter has a `label`** (the registry test enforces it); the UI is generated from the schema.
- **Working space is linear, premultiplied RGBA, `rgba16float` on GPU / `Float32Array` on CPU.** sRGB exists only at the I/O edges. Filters that need straight alpha (LUTs, curves, OKLab) must un-premultiply internally.
- **Pointwise WGSL is a pure function** `fn apply(c: vec4f, p: Params) -> vec4f`; no bindings, no globals, no `Params` declaration (it is generated from the schema). This is what lets consecutive pointwise filters fuse into one shader.
- **Multipass pass WGSL** defines `fn run(xy: vec2i, p: Params) -> vec4f` and reads inputs only through the generated `load_<input>(xy)` (clamp to edge). Pass lists are static: anything that varies with params goes in uniforms, so the shader cache keys stay `filter:pass`. Iteration goes through `repeat: '<int param>'` (ping-pong on `inputs[0]`), never through a params-dependent pass list.
- **Reductions** (histograms, global statistics) use filter `buffers` (zeroed `array<atomic<u32>, N>`) and `kind: 'compute'` passes (`dispatch: 'pixels'` or `'single'`); store floats with `bitcast<u32>`.
- **Filters with hard decisions** (thresholds, comparisons) mirror the GPU's f16 intermediates in their CPU reference (round each intermediate image) and may declare a small `tolerance.outliers` fraction. Measure the actual outliers before choosing it.
- **Curve params are read in WGSL only through the generated `curve_<name>(x)`** (never index the LUT yourself): it handles the fused-stage namespacing and linear extrapolation outside [0, 1].
- **Lengths in pixels are `unit: 'px'` params**, defined at the original image's resolution; chains scale them (`setSource({ scale })`, `runChainCpu({ scale })`).
- **In filter WGSL, top-level declarations (`fn`, `const`, `struct`) start at column 0 and locals are indented.** Fusion prefixes top-level names by matching line starts; an indented top-level declaration would not be namespaced and could collide with another filter's.
- **Public API changes are deliberate**: a new export goes into the package root and into its `api.test.ts` list; plumbing other packages or tests need (shaders, packing, caches) goes into `./internal`, outside semver.
- **Library code must build with `types: ["@webgpu/types"]` only** (`tsconfig.build.json`): no Node APIs in `packages/*/src` (tests may use them).
- **Zero runtime dependencies** in `packages/*`. `@rullino/ml` takes ONNX Runtime as an argument (optional peer dependency), it never imports it.
- Parity tests feed CPU and GPU the same **float16-quantised** input, so the tolerance only covers output rounding and math differences. Use odd image sizes to exercise workgroup bounds and row padding.
- Commit small and often; run `pnpm typecheck && pnpm test:all` before pushing (and `pnpm build && pnpm test:e2e` when the playground or filter list changes). When piping their output (`| tail`), use `set -o pipefail`, or a failure slips through to the commit.
- **Workflow**: one branch and one PR per milestone step, based on `main`. Claude may merge its own PR once CI is green and no review thread is open; afterwards delete the branch and start the next one from `main`.

## Decisions

| Decision | Why |
|---|---|
| pnpm workspaces monorepo | core / filters / presets / playground version and test together; playground consumes the packages like an outside user would. |
| Vitest (not `node --test`) | One runner for Node unit tests and browser mode (Playwright), so parity tests share assertions and config. |
| WebGPU tests in headless Chromium with `--enable-unsafe-webgpu` | Hosted CI has no GPU; Chromium falls back to SwiftShader. Verified on day one because it is the riskiest piece of M0. Needs a secure context (Vitest serves from localhost). |
| `playwright` pinned to 1.56.x | Matches the Chromium pre-installed in Claude Code cloud sessions (no download). CI installs its own browser. Bump deliberately. |
| WGSL kept in TS template strings (`/* wgsl */`), not `.wgsl?raw` | Works in any bundler and in Node without a plugin; the library must not depend on Vite. |
| Own float16 conversion (`half.ts`) | `Float16Array` is not available everywhere yet (Node 22). Round-to-nearest-even, tested against known bit patterns. |
| Package `exports` point at `src/*.ts` in the workspace; the published tarball gets `dist/` through `publishConfig.exports` | No build step in the dev loop (Vite, Vitest and tsc read the sources); `pnpm pack` swaps the exports. The build is plain `tsc` (ESM + `.d.ts`, one file per module, no bundler, zero dependencies) plus `scripts/fix-imports.mjs`, because the sources use extensionless relative imports that Node ESM cannot resolve. `tsconfig.build.json` maps `@rullino/*` to the sibling `dist/*.d.ts` (so a package never compiles another's sources) and allows only `@webgpu/types`, so Node types cannot leak into library code. |
| `pnpm test:pack` (tarball contents, Node ESM import + CPU run, Vite bundle + GPU filter in Chromium) runs in the `test` job | Nothing else ever loads `dist/`: the first run failed on a wrong call in the script, but mutations that skip the import rewrite or the `files` field both fail it. `@webgpu/types` is an optional peer dependency of core (the `.d.ts` mention `GPUDevice` etc.). |
| Filter texts live in the schemas (`description`, `tryThis`, param `description`), shown by the playground and the generated reference; `registry.test.ts` requires all three on every filter | One source: changing a filter changes its explanation, and a test fails if a filter lacks one. The cost is bundle size: 18 filters added 2.3 KB gzip (~130 B each), so the filters budget went from 41500 to 48000 for all 51, and the playground's main chunk (it bundles every filter) from 68000 to 72000 (measured with all 51 described: see `pnpm size`). The effect browser and the film strip then took it to 72.8 KB, so the limit went to 76000. A consumer importing single filters pays only for those; if that ever matters, move the prose to a separate `help` module. |
| Size budget in `size-budget.json` (gzip -9 of each package's dist JS, and of the playground's main and ONNX Runtime chunks), limits ~10% above the measured sizes | Measured at M5.2: core 34.7 KB, filters 32.8 (35.9 with gradient and laplacian, 38.5 with the 0.2 effects so far); core 36.8 with `fillShape` (budget 40), playground main chunk 63.4 with the selection (budget 68), presets 2.1, ml 5.2 (6.3 with the integrity check), playground main chunk 57.8, ORT chunk 37.4. A concatenated dist is a ceiling (consumers tree-shake); the budget catches accidental bloat, and raising a limit needs a reason in the PR. |
| npm scope `@rullino/*`, `publishConfig.access: public` | The npm org exists (Free plan); scoped packages default to private, which Free cannot publish. |
| Library packages are public from `0.1.0-rc.0` (lockstep version, `scripts/set-version.mjs`); `prepublishOnly` refuses a publish from the package directory | Their `exports` point at `src/*.ts`; only the tarball made by `pnpm pack` has the dist exports, so releases publish the packed tarballs (`.github/workflows/release.yml`), never a directory. The playground stays private forever. |
| Publishing via npm trusted publishing (OIDC) from `release.yml`, on a `vX.Y.Z[-pre]` tag that is on `main`; `scripts/release-info.mjs` gates tag = version of all four packages, `-pre` publishes to the `next` dist-tag, the rest to `latest`; notes come from the CHANGELOG section | No long-lived token. Never add an `NPM_TOKEN` secret. Each package's Trusted Publisher on npmjs.com must name this exact workflow file. Re-running a half-failed release skips versions already on npm. Node 24 for npm >= 11.5.1. |
| GPU tests never skip when WebGPU is missing | A skipped parity test looks green and is indistinguishable from a deleted one. `adapter.browser.test.ts` runs first in CI and fails if `requestAdapter()` is null. |
| License: MIT | Permissive and conventional for a JS library; see `LICENSE`. |
| `noUncheckedIndexedAccess` on | Catches out-of-range reads elsewhere; in pixel loops, where the bounds are obvious, write `data[i]!`. |
| Pointwise filters declare `alpha: 'straight' \| 'premultiplied'`; core does the (un)premultiply on both CPU and GPU | One place for the alpha rules instead of every filter re-implementing them; parity tests cover both wrappers. Linear-in-colour ops (exposure, white balance, grayscale) stay premultiplied. |
| Perceptual adjustments (contrast, brightness, saturation, vibrance) in OKLab | Hue-stable and perceptually even; plan §2.3. Contrast pivots on 18% grey (L = cbrt 0.18). |
| Perceptual "levels" (invert, threshold level, posterize) in sRGB-encoded units | That is what people mean by "50%" or "invert"; mid grey stays mid grey. |
| Brightness = power curve on OKLab L, gamma = per-channel power on linear RGB | Two different tools: brightness keeps hue/chroma, gamma is the classic channel operation. |
| White balance = luminance-normalised RGB gains | Simple creative control; a proper chromatic adaptation (Bradford / CCT) can come with auto WB in tier B. |
| Enum values are passed to WGSL as the option's index (u32) | Uniforms stay 4 bytes per param; WGSL `switch` on `0u, 1u, ...` in option declaration order. |
| Playground output via `readSrgb8` + `putImageData` on a 2D canvas, not a WebGPU canvas | A WebGPU canvas loses the device in headless Chromium/SwiftShader, so direct presentation could not be tested in CI. Costs one readback per frame; revisit with M2's reduced preview. |
| Sample photos: a few public-domain / CC0 photos in `apps/playground/public/samples` (≤ 2048 px JPEG, credits in its `NOTICE.md`); generated test images kept for the smoke test, which pins one with `?sample=ramps` | Real photos make the demo meaningful; only CC0 / public-domain files, so the MIT repo needs no licence juggling (was: everything generated in code). |
| Fused shaders keyed on filter structure only; params live in a uniform block | Moving a slider re-uploads uniforms but never recompiles. |
| `runPointwise` is the one-filter case of `runFused` | One codegen path; every per-filter parity test also exercises fusion. |
| Pipeline caches each stage's output keyed on everything upstream | Changing a step recomputes from that stage on; with neighbourhood filters (next) the stages before a blur are reused. Fused GPU chains keep f32 between filters, so they are slightly *more* precise than the CPU reference applied in f16 steps would be; chain parity uses summed tolerances. |
| Linear-light area downscale for display and preview | Box filtering premultiplied linear values is the correct way to shrink; `createImageBitmap` / canvas resizes work in sRGB. Only images beyond the device's texture limit get a browser resize first. |
| Playground: display ≤ 2048 px, preview ≤ 768 px while dragging | Two Pipelines with their own caches; release re-renders at display size. Export re-runs the chain on the full-resolution texture in a throwaway Pipeline, so its intermediates do not stay in memory. |
| `multipass` filter kind = static list of named passes over textures | Covers convolutions, edges, morphology without a general graph language; each multipass filter is its own Pipeline stage, so the stages before it are reused when something after it changes. Workgroup-memory tiling is a later optimisation; correctness first. |
| Convolutions work on premultiplied linear values with clamp-to-edge | Blurring premultiplied light is physically right and avoids dark fringes at alpha edges. Sharpening touches colour only (alpha kept) and floors colour at 0. |
| Unsharp mask has no threshold (yet) | A hard threshold makes GPU (f16 intermediates) and CPU (f64) disagree on pixels near it; add a soft one later. |
| Custom kernel is always 5×5 | One shape in the schema and UI; a 3×3 kernel is the centre of the grid. |
| Canny hysteresis = fixed number of 8-connected propagation rounds (`iterations` param) | Exact GPU/CPU parity and a static pass list; enough rounds equal full hysteresis. A convergence check (atomics) can cut the passes later. |
| No ghost-zone (workgroup-memory) hysteresis | Tried: K steps per dispatch on a (16 + 2K)² block, exact parity. On SwiftShader Canny went from 2.4 s to 19.5 s (K=8) / 11.1 s (K=4): barriers and workgroup memory are very slow there and each block redoes ~4x the pixels. Kept on branch `claude/canny-ghost-zone-experiment` for a real-GPU comparison. |
| Canny thresholds on |∇|/4 of sRGB-encoded luminance, 45° directions without atan2 | Perceptual edges; comparing |gy| with tan(22.5°)|gx| gives identical sectors on CPU and GPU. NMS uses `>=` on one side and `>` on the other so a symmetric ridge stays one pixel wide. |
| Parity suite runs every filter on random noise *and* a ring image with fading contrast | Noise never produces connected weak edges or smooth gradients; a "hysteresis runs once" mutation passed on noise alone. |
| Reductions via storage buffers + compute passes inside the multipass model | Otsu needs histogram -> threshold -> apply; keeping it inside one filter means the Pipeline needs no new stage type. Buffers are recreated per run (WebGPU zero-fills new buffers). |
| Explicit bind group layouts for multipass passes | `layout: 'auto'` drops bindings the shader never reads; an input declared but unused then made the bind group invalid. |
| Otsu on a 256-bin histogram of perceptual luminance of the straight colour, transparent pixels skipped | Matches `threshold`'s units; transparent areas would otherwise drag the threshold to black. Ties keep the first maximum on both sides. |
| Adaptive threshold = luminance > Gaussian local mean − C (OpenCV's ADAPTIVE_THRESH_GAUSSIAN_C) | Familiar semantics; σ is a px param so it scales with previews. |
| Texture pool (`createWorkingTexture` / `releaseTexture`) | New textures are lazily zero-filled; with one per pass, allocation dominated long filters (Canny hysteresis 5.8 s -> 2.5 s on SwiftShader with ping-pong alone). Reuse is safe because the queue runs in order and every pass overwrites every pixel. Capped at 16 free textures per device. |
| Gaussian weights by recurrence, taps summed in symmetric pairs | One `exp()` per tap was a large share of blur cost (~-20 to -25%); f32 drift over 150 taps stays far inside the parity tolerance. |
| Benchmarks are a separate Vitest project (`pnpm bench`), not tests | Timing assertions would be flaky on shared runners; results are written to `packages/filters/bench/results.txt` because browser console output is not forwarded. |
| Curves = Fritsch-Carlson monotone cubic sampled to 256 f32 values, linear interpolation, linear extrapolation outside [0, 1] | No overshoot between points; CPU and GPU read the very same LUT; identity curves keep HDR. 1 KB of uniforms per curve. |
| Generated `curve_<name>()` samplers read the global uniform | Pointwise `apply()` gets `Params` by value, which WGSL cannot index dynamically; the sampler is emitted with the filter's code so fusion namespaces it. |
| Curves filter in sRGB-encoded units, RGB curve then channel curves | Matches how photo editors present curves. |
| Levels clip to the output range, HDR included | Classic levels semantics; an unclipped top made narrow input ranges overflow float16. Minimum input range is one 8-bit step. |
| Black point and shadows/highlights on OKLab lightness, global | Hue/chroma stay put. Local (detail-preserving) shadows/highlights is tier B "brilliance". |
| `lut` param: domain (32 B) in the uniform block, table in an `rgba32float` 3D texture cached per `Lut` object and device | Uniforms stay small; one upload per LUT. Stage keys use the LUT's identity (`objectKey`), never its data, so LUTs must be treated as immutable. Multipass filters only (pointwise fusion has no bindings). |
| Manual trilinear LUT interpolation with `textureLoad`, not a hardware sampler | `rgba32float` is not filterable without the optional `float32-filterable` feature, and hardware filtering uses low-precision weights; the manual version matches `applyLutCpu` step for step. |
| `applyLut` works on straight sRGB-encoded colour; intensity blends in the same units | That is what `.cube` / Hald LUTs are authored for; intensity 0 is an exact no-op even for HDR values the LUT would clamp. |
| Vignette darkens by multiplying linear light, lightens towards white; smoothstep on a corner-normalised distance | Multiplying light is what lens falloff does; normalising so corners are 1 keeps `midpoint` meaningful at any aspect ratio and roundness. |
| Grain = PCG-hashed value noise (cells of `size` px) added to sRGB-encoded colour with a midtone-weighted response | Deterministic and identical on CPU/GPU (integer hash, 24-bit floats); film grain is most visible in the midtones, not uniform white noise. The noise pattern differs between preview and full resolution (only its statistics match). |
| Looks are recipes of ordinary pointwise filters, baked once into a 33³ LUT (`bakeLut`), plus vignette and grain steps | Readable, tweakable, no third-party LUT licences (PLAN §7), and one LUT lookup per pixel at run time. A baked grade clamps HDR to [0, 1] and differs from the live chain by < 1/255 on average (up to ~0.02 for saturated colours at the gamut edge). |
| `lookSteps(look, { intensity })` returns plain chain steps | A look is not a new concept for the Pipeline; the playground expands it into editable steps. Intensity scales the LUT blend, vignette and grain together. |
| Golden look charts are Vitest snapshots of a 12-colour chart rounded to 1e-3 | Catches unintended recipe or filter changes; regenerate with `-u` only for deliberate ones. |
| Own PNG encoder over `CompressionStream('deflate')`; JPEG / WebP via `OffscreenCanvas.convertToBlob` | Canvas encoding goes through premultiplied 8-bit storage (lossy for semi-transparent pixels) and cannot write 16 bits; zlib is built into browsers and Node, so the encoder stays dependency-free (filter 0 only: larger files, simple code). |
| 16-bit PNG export; input stays 8-bit for now | 16 bits keep the float16 pipeline's precision on output (PLAN §7); 16-bit / HDR input needs a decoder and is left open. |
| Flattening (JPEG, or an explicit `background`) composites in linear light on the float readback | Same rule as the rest of the pipeline: blending in sRGB darkens edges. Plain 8-bit exports keep the faster GPU encode path. |
| Masks are a property of a step: `out = mix(in, step(in), m)` on premultiplied values, alpha included | Works for every filter kind without touching filters; a masked step is its own Pipeline stage (never fused) plus one blend pass. |
| Mask coordinates normalised to the image, distances in units of the longer side | The same mask at preview, display and export resolution; circles stay circles on non-square images. |
| Raster masks immutable, keyed by identity, GPU textures in a 4-entry LRU per device (evicted ones destroyed) | Same contract as LUTs for caching; painting makes a new raster per pointer move, and a WeakMap left their textures alive until GC. |
| Brush rasters at the image's aspect ratio, ≤ 1024 px | Round dabs; bilinear upsampling at export keeps soft edges soft. Fine detail beyond 1024 px is a future option. |
| Cut-out = `transparency` filter + mask | No separate mechanism; any mask kind can cut out. |
| Magic wand and edge refinement run on the CPU at mask resolution (≤ 1024 px) | A flood fill is linear on the CPU but needs as many GPU passes as the longest path through the region; the guided filter is linear with integral images. Both produce raster masks directly. The image comes from `maskSource` (original shrunk in linear light), cached per image. |
| Wand subtract and eraser remove coverage: `max(0, m − v)` | Adding then subtracting the same soft selection must be a no-op; `min(m, 1 − v)` left soft borders half selected. Dab values are computed in f32 so they cancel exactly. |
| Mask-aware filters (`usesMask`): passes read `mask`, the CPU reference gets it per pixel; the step still blends with its mask | Inpainting must know which pixels to rebuild; one generic mechanism instead of a special stage. Using one without a mask is an error; the playground gives such steps an empty brush mask. |
| Inpainting diffusion returns the input immediately outside the hole; 30 iterations by default | Most pixels are outside the hole; same result, −24%. The remaining cost is the number of full-image passes: 30 (was 60) halves it, slightly less smooth on wide areas (the user's call). |
| Inpainting = normalised convolution + Jacobi diffusion, single resolution | Fits the static multipass model and gives near-invisible results on thin strokes; wide areas need a reach ≥ half their width. Multi-scale / patch-based (PatchMatch) inpainting is a later step. |
| Morphology and median per channel (as OpenCV), on premultiplied values, alpha included; gradient / top-hat / black-hat keep the input's alpha | Simple, familiar semantics and exact parity (min / max / selection never round). Per-channel ordering can create colours that were not in the image; a luminance-ordered (vector) variant can come later. |
| Morphology: one 2-D pass per operation for every element shape (no separable square) | One code path for square / disk / cross; O(r²) per pixel, so radius is capped at 15. A separable square (O(r)) is a performance-review candidate. |
| Median: radius ≤ 3, selection by counting over a `vec4` array; radius 1 by a fixed 19-exchange network (min/max on `vec4`) | No sort and no data-dependent branching on the GPU. The counting loop indexes a private array dynamically, which SwiftShader handles slowly: the network makes the common 3×3 case 7× faster (1310 → 186 ms at 1024×768) and stays exact. |
| Bilateral range distance = OKLab ΔE + alpha, OKLab precomputed in a first pass | Perceptual edges like the other OKLab tools; computing OKLab once per pixel instead of once per tap avoids a cube root per neighbour. Window 2σs, max 40 px. |
| Histogram of the output computed on the CPU from the displayed frame's bytes (`histogramSrgb8`) | The playground already reads every frame back; a GPU reduction would add work for no gain. Luminosity = Rec. 709 weights on encoded values, as photo editors show it. |
| Equalisation (global and CLAHE) on OKLab lightness, CDF interpolated linearly inside each bin | Hue and chroma stay put; the continuous mapping keeps GPU (f32) and CPU (f64) within tolerance even when a pixel lands in a neighbouring bin. Transparent pixels are ignored. |
| CLAHE: tiles × tiles grid (≤ 16), clip at `clipLimit` × mean bin with one even redistribution of the excess, bilinear blend of the 4 nearest tiles' mappings | OpenCV-like semantics without seams. One invocation per tile builds its CDF (reusing the pixel dispatch); tiles are capped at the image size so none is empty. A single redistribution can still give slopes up to ~(clip + 1), so clip 1 is not the identity. |
| Geometry is a frame applied to the source before the chain (Lightroom model), not a chain step | The chain keeps one size throughout, so caching, masks, fusion and the CPU reference are untouched. Geometry produces the Pipeline's source texture; preview, display, the before image, the wand's image and export all use it. |
| Geometry = orientation (quarter turns, flips) → perspective (4 points onto the frame) → straighten (±45°, clockwise positive) → crop, all normalised, composed into one homography; bilinear resampling of premultiplied values, transparent outside | One shader and one CPU path for every transform; normalised parameters mean the same at every resolution. Uncovered corners stay visible (transparent) so the user crops them deliberately; an auto-constrained crop can come later. |
| Turns and flips act on what is shown (`turnGeometry` / `flipGeometry`): crop, perspective quad and straighten follow the content | Changing only `rotate` / flips made 'rotate right' turn the wrong way after a flip (a mirror reverses rotations) and left an existing crop or perspective on other content. |
| The output pixels of a geometry span exactly the crop rectangle (`crop.width·W / outW` per pixel) | The result no longer depends on which corner the pixel grid is anchored to, so turned / flipped crops give exactly the turned / flipped result (it was a sub-pixel shift). Scale is exactly 1 when the crop is a whole number of pixels. |
| While a geometry control is dragged only the preview is reframed; the display image catches up on release | Reframing and reading back the 2048 px display on every move cost ~330 ms per move on SwiftShader; preview only: ~70 ms. |
| Wand clicks are queued and target the step selected at click time | Two quick clicks used to start from the same mask (the second erased the first), and a slow result could land on another step. |
| Masks and the crop live in the framed image's coordinates | Masks are drawn on what is shown. Changing the crop or straighten after drawing a mask moves it relative to the content (it keeps its normalised position). |
| Crop / perspective edited on the image with the frame shown without them; applied on Done | You need to see what is outside the crop or before the correction to place the handles. |
| Export size = long side, linear-light area downscale after the full-resolution chain; never enlarges | Pixel-unit params keep their full-resolution meaning; shrinking after the chain is the correct order. |
| Chains serialise to plain JSON (`serializeChain` / `parseChain`): defaults omitted, raster masks as 8-bit base64, LUTs by reference only | Short share links and saved presets; a LUT table (430 KB at 33³) cannot travel in a link, so callers name LUTs (the playground: `look:<id>`) and a chain with a loaded .cube file cannot be shared. |
| Edit document v2 = v1 + optional `output` (`ExportOptions` + `longSide`), still produced by `serializeChain` / read by `parseChain`; `migrateChain` lifts v1 (a pure copy with `version: 2`), `parseChain` accepts both; JSON Schema (draft 2020-12) shipped in core, validated in tests with Ajv (dev dependency only) | PLAN §6b: one JSON for preview, share link and server render. Extending the existing format keeps every share link working; a separate `applyEdit` builds on it. The schema is for consumers outside JS; `parseChain` stays the authority (filter ids, params, convexity, which a schema cannot check). Unknown `output` keys are rejected so typos do not pass silently. |
| Undo / redo over immutable snapshots (chain + geometry), recorded when a gesture ends (not while dragging or editing the crop) | States are already immutable, so a snapshot is two references; one slider drag or brush stroke is one undo step. Selection changes are not edits. |
| Share links in the URL fragment: JSON, `deflate-raw`, base64url; they name a sample image, never include the user's own | The fragment never reaches a server; the user's photo stays on their machine. |
| Zoom / pan = CSS transform on the stage (zoom 1 = fit, clamped so no empty space shows); handles rescaled through `--px` | Every on-image editor maps pointers through `getBoundingClientRect`, which includes the transform, so masks, crop and perspective work zoomed without changes. Beyond the 2048 px display canvas zooming only magnifies (pixelated past 2:1); full-resolution pixel peeping would need a rendered crop. |
| Ctrl/⌘ + wheel and pinch zoom, wheel / Space + drag / middle drag / two fingers pan; the stage has `touch-action: none` | Matches design tools; capture-phase listeners let these gestures win over the split handle, brush and handles. On phones a finger on the image no longer scrolls the page. |
| `color` param = straight sRGB-encoded [r, g, b] in [0, 1], a `vec4f` (r, g, b, 1) in the uniform block; filters linearise it themselves | That is what colour pickers show (and `<input type=color>` speaks 8-bit hex); CPU and GPU receive the very same encoded values. |
| Optional param `group`: the generated UI shows grouped params as tabs (open tab kept per schema) | The colour mixer has 24 sliders; grouping by property (Hue / Saturation / Luminance) keeps the panel short without special UI code. |
| Colour mixer = 8 OKLCh hue bands (centres on typical colours: orange covers skin, blue the sky) with tent weights between neighbouring centres, faded out below chroma 0.04 | Weights sum to one, so the same change on every band is uniform; greys never move; hue / saturation / luminance in OKLCh keep the other two put. |
| Colour grading = OKLab a/b shift towards a picked hue per range (strength 1 = chroma 0.1), range weights from smoothsteps around a balance point (sum to one), lightness kept | Split toning / three-way grading as photo editors present it; the picked colour gives the direction, the slider the amount. |
| Hue = rotation of OKLab a/b; channel mixer = 3×3 matrix on linear (premultiplied) RGB | Hue keeps lightness and chroma; the channel mixer is the classic linear tool. |
| Auto (`autoAdjust`) = grey-pixel white balance (OKLab chroma < 0.08, 50% strength, at most ±0.25) + levels from 0.5% / 99.5% luminance percentiles (black ≤ 0.35, white ≥ 0.6) and a gamma (≤ 1.3) that pulls the median into [0.3, 0.6]; CPU on a 256 px copy of the framed original | Deterministic, explainable, licence-free. Conservative on purpose: at 70% / gamma 1.5 it cooled warm hay and dusk light and lifted a night sky into noise (checked on the sample photos); it produces ordinary editable steps (inserted first, or updating existing ones), so it is one undo step and fully adjustable. A learned auto-enhance is deferred (FiveK-trained weights are research-only). |
| Stylize filters work in sRGB-encoded units where people judge tone (halftone, dither, emboss, sketch) and in linear light where light adds up (bloom, pixelate means, chromatic aberration) | Same rule as levels vs exposure: perceptual tools in encoded units, physical ones in linear light. |
| Halftone: dot area = ink coverage sampled at the dot centre (pixel `floor(centre + 0.25)`), antialiased over 1 px; CMY on screens at base −30° / +30° / −45° | Area (not radius) keeps the mean tone. The +0.25 keeps centres that fall exactly on pixel boundaries (0° / 90° screens, the 45° diagonal) away from them, where f32 and f64 picked different pixels. Tolerance is wide (1e-2, 2% outliers): dot edges amplify the GPU's sRGB pow() error, unboundedly for near-white tones. Shapes: round, square (side from the area), ring (inner radius 0.55 R, area 0.6975 πR²; rings above ~70% ink outgrow their cell and are clipped, like any dot over 100%). Hex grid: rows √3/2 apart, odd rows shifted by half a spacing, cell area scaled by √3/2 so the tone holds; each pixel only evaluates its own cell, so dots that reach into a neighbouring cell are clipped there. Not done: gooey (smooth-min of neighbouring dots), moiré of two screens. `halftone.browser.test.ts` combines shape, grid and CMY (the registry test varies one param at a time). |
| Dither = threshold per cell, `levels` levels, `scale` px cells: ordered Bayer (2 / 4 / 8), white noise (PCG hash, `seed`) or R2 (Roberts' plastic-ratio lattice in 32-bit fixed point), per channel or on luminance | Deterministic and parallel (error diffusion is sequential); the pattern mean keeps the tone exactly. R2 stands in for blue noise: it needs no 64² table in the bundle and its u32 arithmetic is exact on CPU and GPU (a float `fract` version would not be); true blue noise would need a generated texture. Palettes: dither mono, then chain `gradientMap`. |
| Sketch = min(1, L / blur(L))^darkness on encoded luminance; f16 intermediates mirrored in the CPU reference | The classic invert / blur / colour-dodge collapses to this (1 − blur(1 − L) = blur(L)); the division and exponent amplify f16 rounding, so the reference rounds where the GPU stores. |
| Kuwahara = smoothed variant: four quadrant means blended with ((σ_min + ε) / (σ + ε))^sharpness, two-pass variance | No hard decision (exact parity) and a softer painterly look than picking the minimum. O(r²): ~1 s at r = 4 on SwiftShader; a box-sum version needs E[l²] − μ², too imprecise in f16 intermediates. Performance-review candidate. |
| Edge operators: `gradient` (Sobel / Scharr / Prewitt, normalised to the slope per pixel; magnitude on black, signed axes on mid grey) and `laplacian` (4 or 8 neighbours, centre weight −4 either way) on sRGB-encoded luminance, alpha kept, tolerance abs 5e-4 / rel 1e-3 | Same units as Canny, no hard decisions so parity is tight. The param is `method`, not `operator`: `operator` is a reserved WGSL keyword and breaks the generated `Params` struct. |
| `gradientMap` (duotone / tritone): encoded luminance of the straight colour through a ramp of 2 colours (3 with `tritone`, midtone colour at `midpoint`), interpolated in sRGB-encoded units, `amount` blends in linear light; tolerance abs 5e-4 / rel 1e-3 | What photo editors do (and what Paper's image filters imitate); a fixed three-colour ramp keeps the schema flat. `midtones` and `midpoint` only act with `tritone`, so the registry parity test never exercises them: `gradientMap.browser.test.ts` runs a tritone sweep (mutations of each ramp segment, the amount and the t scale all fail it). More stops would need an array param type. |
| `ascii`: square cells of `cell` px, each drawn as one of ten 5×7 bitmap glyphs (empty to solid, hand-made, ink 0 / 1 / 2 / 4 / 7 / 10 / 15 / 17 / 21 / 35 px) picked by the cell's mean linear luminance (4×4 integer-exact taps at the sub-cell centres) encoded to sRGB; glyph bit = integer arithmetic only; ink / paper colours or `color: 'source'` (mean encoded colour of the taps) | Tapping 16 pixels instead of averaging the whole cell keeps the cost independent of the cell size; integer taps and glyph indexing give exact GPU/CPU parity (measured `outliers: 0`; the only hard decision is the glyph index). Not a real font: a ten-step ramp is all a tone needs, and a table of 70 hand-drawn bitmaps would be pure bulk. `var<private>` glyph table (a `const` array cannot be indexed dynamically). 35 px cells make the bitmaps scale exactly, which is how the test checks the ramp. |
| Selection = one shared mask in `ChainState.selection` (undefined = the whole image); a step opts in with `useSelection` and is then rendered with it instead of its own mask (`effectiveMask`), mask-aware steps (inpaint, patch) never do; the selection is edited through the same mask panel and overlay as a step's mask (`ChainState.selected = SELECTION`) | Region editing as basic editors have it (select once, apply many filters there) without a second mask system: the mask kinds, brush, wand, Object tool, refine and undo all work on it unchanged. Raster masks are immutable and keyed by identity, so sharing one between steps costs one texture. Share links and exports resolve to plain per-step masks, so `serializeChain` and the pipeline know nothing about the selection. Anything that rebuilds a `ChainState` must spread the old one, or extra fields are lost. |
| Shape tools (rectangle, ellipse by its box, freehand lasso) fill a raster mask with `fillShape`: 4×4 coverage per edge pixel, add = max, Alt = subtract = `max(0, m − v)`, polygons even-odd; a preview follows the drag and the shape is applied on release; a click selects nothing | Same algebra as the wand and eraser (adding then subtracting a shape is exact), no new mask kind in the core: a selection is a raster. Anti-aliased edges instead of a hard step. `tools.tool` is shared by every raster mask, so e2e checks that change it put it back. |
| Effect browser (`effect-browser.ts`, Ctrl/⌘ + K, `Browse effects…` button): a `<dialog>` with search (`effect-search.ts`: all words must match name / id / category / description / try-this, name matches first), a card per filter with its description and a thumbnail of the framed original, drawn in batches of 6 through the same `renderer.thumbnails` as the look gallery and redrawn after a load or reframe; filters that do nothing at their defaults declare `preview` params for the thumbnail; effects that need a mask, an image param or a LUT file (`hasThumbnail`) get a card without one. The Add filter menu is gone (the film strip's plus button opens it) | The dropdown could not show what an effect does or find one by what you want ("dots", "warm"). `registry.test.ts` fails when a filter's thumbnail would equal the original, so a new identity-at-defaults filter must bring a `preview`. |
| The chain is shown as a film strip under the photo (`film-strip.ts`): the original, the Selection, one frame per step with the picture after it (thumbnails of the step prefixes through `renderer.thumbnails`, redrawn after each finished render, not while a slider is dragged), and a plus button that opens the effect browser; the sidebar keeps only the inspector of the selected step. Reordering by drag or Alt+← / →, "up to here" (the "to here" button) shows the photo with the later steps switched off | The product is an ordered list of effects, and the picture after each one is what teaches what a step does. `ChainUi` (in `chain-ui.ts`) owns the state and its edits (`add`, `select`, `toggle`, `remove`, `move`) and tells the strip through `subscribe`; the strip is a view. "Up to here" is view state in `main.ts` (`soloUid`): it never enters the chain, history, share links or export. The old list, its Add filter / Add look menus and up / down buttons are gone; e2e adds effects through `window.rullino.add(id)` (the dialog has its own checks). |
| Playground header: version and install line filled in at build time from `packages/core/package.json` by a Vite `transformIndexHtml` plugin (`%RULLINO_VERSION%` / `%RULLINO_INSTALL%`, `@next` for prereleases), links to GitHub, the filter reference, the parity table and the API docs; one line on desktop: the install line shrinks first (ellipsis, `user-select: all` still copies the whole command), the status almost never (`flex-shrink: 0.001`) | The playground is the developer demo (PLAN §6b): a visitor must see what to install and where the code is. No JS for the version, so it is right in the static HTML. A taller header pushed the crop handle out of the e2e viewport ("export during crop editing" failed): keep the header at one line. |
| Look gallery: thumbnails of every look on the framed original (160 px, own Pipeline), refreshed on load and committed geometry; a click appends the look's steps | Shows what a look does before adding it; the looks are applied to the original, not to the current chain, so chain edits do not re-render ten thumbnails. |
| Coverage: v8 over unit + browser projects together, thresholds per area (packages, playground) just below the measured values; `main.ts` excluded (entry wiring, exercised by the e2e smoke test) | Thresholds catch untested new code without flaking on rounding. Raise them as coverage improves, never lower them to pass a PR. The e2e test is not instrumented (minified build), hence the one exclusion. |
| Playground UI modules have browser tests driven by synthetic pointer events; `drag.ts` `capture()` tolerates pointers that cannot be captured | Synthetic pointers cannot be captured (`setPointerCapture` throws); real ones always can, and a drag still works without capture while events reach the handle. |
| GitHub Actions pinned to the latest major, all `node24` runtimes (checkout v7, setup-node v7, pnpm/action-setup v6, cache v6, upload-artifact v7, pages: configure v6, upload-artifact v5, deploy v5); Dependabot proposes the next ones | The Node 20 runtime is deprecated (the runners already forced Node 24 and warned). The `ci` job exercises checkout, setup-node, pnpm, cache and upload-artifact on the PR itself; `pages.yml` and `release.yml` only run on `main` / a tag, so their action inputs were compared between versions (only the runtime changed; `upload-pages-artifact` v5 skips dot-files, and `dist/` has none). |
| CI runs on pull requests, pushes to main and manual dispatch; one job, `test`, is the required check for merging | Running on every branch push as well gave PRs a second, skipped `test` check, which branch protection counts as passed: a PR could be merged before the real run ended. |
| Repo skills for the recurring workflows; official Anthropic plugins only (`claude-md-management`, `skill-creator`) | The add-filter and finish-step recipes were being re-derived every time; third-party skills were reviewed and rejected (overlap, or unvetted install scripts). |
| `@rullino/ml`: ONNX Runtime injected by the caller (`OrtModule`), optional peer dependency; pre / post-processing in pure TS | Keeps zero runtime dependencies, lets apps pick the ORT build (WebGPU / WASM), and lets unit tests use a fake runtime. ORT used directly, not transformers.js: pinned dev builds of ORT, CPU-side processing and no inpainting pipeline (research, Oct 2026). |
| Click-to-select = SlimSAM-77 (Apache-2.0, Xenova's ONNX export at a pinned revision); best-scored of the 3 candidate masks; sigmoid of the logits as a soft mask | Smallest permissive SAM with a working export (14 MB int8 / 40 MB fp32). SAM 2.1 tiny is better but its ONNX card declares no licence and the encoder is ≥ 28 MB; the segmenter API leaves room for it. |
| Backend: a real GPU runs fp32 on the WebGPU EP; a software GPU (SwiftShader, fallback adapter) or none runs the int8 model on WASM | Measured on SwiftShader: WebGPU encode 480 s vs WASM 19 s (identical masks). Quantized int8 ops run poorly on WebGPU. Real-GPU timings are not measured here: check on hardware. |
| Model descriptors are `ModelFile { url, sha256, bytes }`; `fetchModel` checks size and SHA-256 before caching and before returning, a corrupt cache entry is deleted and re-downloaded, failures throw `ModelIntegrityError`; a bare URL string is only checked against Content-Length | A truncated or tampered download would otherwise reach ONNX Runtime (or the cache) as an obscure parse error. A pinned revision already fixes the content; the hash makes the client enforce it. `test:ml` checks the descriptors against the real files, so a revision bump cannot forget the hashes. |
| Model weights fetched from Hugging Face on first use (pinned revision), cached with the Cache API; ORT's WASM shipped by Vite next to its lazy chunk | Nothing ML-related loads until the Object tool is used; a pinned URL never changes, so caching by URL is safe; same-origin WASM avoids a CDN at run time (dist grows by 27 MB, downloaded only on use). |
| Object tool: points accumulate into one object (Alt = exclude), "New object" starts the next on top of the current mask (max) | One SAM prompt per object refines it; several objects add up like wand selections. The image is encoded once per raster size (WeakMap on the mask source image). |
| Real-model tests in their own Vitest project and CI job (`ml`), models cached by revision; not part of the required `test` check | They download from Hugging Face and need about a minute of CPU inference; the required check must not depend on a third-party host. |
| Image params (`type: 'image'`): a `LinearImage` kept by identity, bound as an `rgba32float` texture, read through `image_<name>(uv)` (manual bilinear, normalised coordinates); no uniform space; multipass filters only, not serialisable | Same contract as LUTs (identity-keyed cache, immutable). Lets a result computed outside the pipeline (an ML patch) take part in the chain at any preview / display / export size. Links cannot carry a patch: `serializeChain` throws, like an unreferenced LUT. |
| `patch` filter ("Remove object") = the image over the input (premultiplied over); the step's mask decides where it shows; patch steps go first in the chain and always have a brush mask | The default (empty) image is a no-op. The fill is made from the *original* framed image, so adjustments belong after it; the mask is the grown selection, so the fill covers an object's edge. |
| Object removal = MI-GAN `migan_pipeline_v2.onnx` (MIT code and weights, 28 MB, pinned revision), uint8 NCHW image + mask (255 known, 0 hole) at the mask raster's size (≤ 1024), hole = mask > 0.5 after `growMask(3)` | Smallest recent-enough inpainter with a clear licence (Moebius is 1.2 GB and fixed 512², LaMa 208 MB). The pipeline export resizes to 512 internally and keeps the original outside the hole. Result is 512-class quality inside the hole, upsampled; a full-resolution crop around the hole is a later step. Real-GPU timings not measured (fp32 on WebGPU there, WASM on software GPUs). |
| Depth = Depth Anything V2 **Small** int8 (Apache-2.0, 27 MB, pinned revision), always on WASM; long side 518, multiples of 14; output normalised between the 1st and 99th percentile, near = 1 | Measured on the sample photos: int8 gives the same map as fp32 (99 MB) and a sharper one than Depth Anything 3 Small (105 MB, fp32 only, multi-view output heads we do not need; blurrier edges, opposite sign). The Base / Large / Giant V2 and some V3 sizes are non-commercial. A few seconds on WASM, so no WebGPU path (int8 runs poorly there). |
| `lensBlur`: the depth map is an `image` param (grey, near = 1); each pixel gathers 48 Vogel-spiral samples in a disc whose radius is `radius · clamp((|depth − focus| − range) / 0.5)`, times the map's alpha; a sample counts with weight `clamp(its own radius − distance + 1)` | Empty or transparent depth = no blur, continuously (no hard decision, exact parity: measured passing at abs 3e-4 / rel 1e-3). The reach weight keeps an in-focus foreground from bleeding into the blurred background (the classic gather artefact). The tap table is computed in TS and printed into the WGSL, so CPU and GPU use the same numbers; bilinear input reads avoid nearest-pixel ties. A bokeh shape, highlight boost and edge refinement of the depth are later options. |
| Public API = the package root; plumbing in `@rullino/core/internal` and `@rullino/ml/internal` (unstable, outside semver); `api.test.ts` pins both lists | 138 exports in core were a promise we did not want to keep (shader generators, uniform packing, pooled textures). Found by listing exports unused outside their own package; those that are part of a public signature (option and result types, param types, document types) stay public. 0.x keeps the freedom to move more later. |
| API reference = TypeDoc (`apps/docs`, `pnpm build:docs`) over the four package roots, written to `apps/playground/dist/docs` and deployed with the playground (`…/rullino/docs/`); `treatWarningsAsErrors`, run in CI after the build | Now that the packages are on npm, readers want the signatures. It is a package of its own because TypeDoc needs the TypeScript 5 compiler API and the repo's TypeScript 7 (native) has none: `apps/docs` pins `typescript ~5.9` and runs with `skipErrorChecking` (the WebGPU typings differ between the two, and docs need no type check; `pnpm typecheck` stays the authority). `excludeExternals` keeps `@webgpu/types` comments out; types that public signatures mention (`Mat3`, `Json`, `ImageParam`) are exported as types. The script is `build:docs` because `pnpm docs` is a built-in pnpm command. The generated filter reference (`docs/filters.md`) stays: it comes from the schemas, which TypeDoc cannot read. |
| Parity table = `docs/parity.md`, generated from each filter's `tolerance` by `parityTable` (`reference.ts`), pinned by a file snapshot, linked by a README badge | The promise is "within the published tolerance of each filter", never "same pixels" (f16 intermediates, outliers on hard decisions); a table generated from the numbers the parity tests use cannot overstate it. Loosening a tolerance now shows up in the docs diff. |
| Filter reference = `docs/filters.md`, generated by `packages/filters/src/reference.ts` and pinned by a file snapshot test; READMEs (root quick start included) hold `ts` examples that `pnpm typecheck` compiles against the sources, and the packed packages run their equivalents (`pnpm test:pack`) | Docs written by hand drift; a generated reference cannot, and a CI failure says exactly which command rewrites it. No TypeDoc: the filters describe themselves, and the rest of the API is small enough for examples. The generator is not part of the public API. |
| Playground on GitHub Pages from `.github/workflows/pages.yml` (push to main, Actions deployment); the build uses relative URLs (Vite `base: './'`) and CI's `smoke-base` serves it from `/sites/rullino/` | Works at `<user>.github.io/rullino/` and at a custom domain, with or without a sub-path, so the domain choice (stefanomasneri.com) is a repository setting, not a code change; the test fails on a root-absolute URL, a 404 or a blank canvas. Needs Settings > Pages > Source = GitHub Actions. |
| GPU errors: `createGpuContext({ onError })`; shader and pipeline creation sit in a validation error scope and report a `ShaderCompileError` (shader key, compiler line:column messages, source line), other errors come through `uncapturederror` as `GpuError` (`validation` / `out-of-memory` / `internal`); no handler = `console.error`. The device is requested with the adapter's own `maxTextureDimension2D`, `maxBufferSize` and `maxStorageBufferBindingSize`, exposed as `ctx.limits`; `ctx.lost` is `device.lost` | A failed shader used to mean an all-zero image and silence (see Traps). Errors are asynchronous by nature of WebGPU, so they are callbacks, not exceptions. Default limits (8192 px textures, 256 MB buffers) made large photos fail late. Not done yet: automatic recreation after device loss (the playground does it by hand). |
| From 26 Oct 2026: reach over breadth for 16 weeks (PLAN §6b): no new filters until the week-16 review; priorities are 0.1.0 on `latest`, edit document v2, a fast CPU path, automatic fallback, a Node renderer, real-hardware benchmarks, then Konva / Fabric adapters and a launch | The library has 51 filters and no users; GPU filters alone lose to PixiJS / three.js. The one asset competitors lack is the CPU reference, i.e. a browser preview and a server render that match within a published tolerance. That needs a usable CPU path: the reference took 8–14 s per 12 MP chain in Node. |
| Node renderer = the same WGSL on Dawn (`webgpu` package, optional peer), CPU path as fallback (27 Oct, spike #72) | The published packages ran unchanged on Dawn in Node; on a GPU-less GitHub runner with Mesa lavapipe (`apt install mesa-vulkan-drivers`, `VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json`) all 50 non-mask filters passed parity and 12 MP chains took 0.3–1.4 s warm vs 12–14 s for the CPU reference. Same shaders in browser and server means parity by construction. Costs: `webgpu` is 132 MB installed (all platforms in one package: a problem for size-limited serverless) and Linux servers need a Vulkan driver. Chromium's bundled SwiftShader ICD also works, 3–4× slower. |
| `@rullino/node` is its own package (core, filters, presets as dependencies; `webgpu` optional peer); it is built before the fast CPU path and the browser fallback | Core stays free of Node APIs and of the 132 MB peer; with Dawn as the primary backend the Node renderer is what makes 'preview in the browser, render on the server' true, #74 / #75 are fallbacks (user's call, 27 Oct). Dawn is loaded with a dynamic `import('webgpu')`, so the package works (CPU) without it; `createGpuContext({ gpu })` takes Dawn's entry point instead of a fake `navigator`, but the `GPUBufferUsage` / `GPUTextureUsage` / `GPUShaderStage` / `GPUMapMode` globals core uses must still be defined from `webgpu`'s `globals`. Its unit tests run Dawn for real (never skipped), so CI's `test` job and the release job install lavapipe. |
| Node I/O through `sharp` (optional peer, loaded on first use): decode files with `.rotate()` (EXIF orientation) to 8-bit sRGB RGBA, encode JPEG / WebP from core's `exportSamples` (internal: the same linear-light flattening as `encodeImage`); PNG stays rullino's encoder | sharp (libvips, Apache-2.0) is the standard Node image codec with prebuilt binaries; PNG through our own encoder keeps 16-bit output and exact alpha. Without sharp, RGBA in / PNG out still work and the error says `npm install sharp`. 16-bit / HDR input stays open (the pipeline takes 8-bit sRGB in, PLAN §7). |
| Browser vs Node checked in CI by one browser test and a Vitest browser command (`renderOnDawn`) that renders the same documents on Dawn in Node; bound \|Δ\| ≤ 4e-3 + 4e-3·\|v\| per channel | The promise is 'preview in the browser, render on the server'; the command keeps both sides in one test (no files passed between jobs). The bound is absolute + relative like every parity check: an HDR pixel after unsharp masking (1.067 vs 1.0625, 5 f16 steps) broke an absolute 4e-3. It also asserts the two sides are not bit-identical everywhere, so a test that accidentally compared one implementation with itself would fail. |
| `fromFloat16Array` = lookup in a 65 536-entry table built on first use; `toFloat16Array` stays a loop over the scalar | Every upload / readback converts 48 M values at 12 MP: the table took readback conversion from 2.9 s to 0.15 s (warm) in Node; a hand-inlined `toFloat16Array` gained only ~15% (V8 already inlines the scalar), not worth duplicating the rounding. `half.test.ts` checks both against the scalar functions (all halves exhaustively; rounding edges per exponent + 2 M random bit patterns). |
| Fast CPU path = an optional `fast` on a filter (whole image, resolved params, same contract as `cpu`), used by `runChainCpu({ fast: true })` and always by `applyEdit` without a GPU; `fast.test.ts` holds it to the reference with the filter's GPU tolerance on the parity images plus special values (greys, primaries, HDR, partial alpha) | The reference stays the readable specification and the GPU parity target; a second implementation tested against it can be as ugly as speed needs. Filters without one fall back to the reference. First round (#74): separable convolutions, sRGB by table, the look filters, the colour mixer; ~4–5× on typical chains. Costs 3.3 KB gzip in filters (budget 48000 → 55000): the CPU fallback (#75) and Node need it, so it is not split out. |
| Fast sRGB = linear interpolation in a 4097-entry table on the curved segment (indexed by sqrt(x) for linearToSrgb, where it is steep near 0), exact on the linear segment and outside [0, 1]; within 1e-6 | `pow()` was half the time of the pointwise references. Exact near 0 because levels at gamma 10 amplified a table's relative error there out of tolerance. |
| `pnpm bench:cpu` bundles the bench with Vite (SSR build) and runs it in plain Node; CI runs one filter of it | Vitest's module runner calls every imported function through a getter: `toSrgb` measured 25 ns per call there and 5.5 ns in Node, which hid the fast paths' gains. |
| `@rullino/web` is its own package (core, filters, presets as dependencies), symmetric to `@rullino/node`; the CPU backend runs in a module Web Worker from the first version (user's call, 27 Oct) | One import for browser apps, and a 0.4 s CPU preview must not freeze the page. The worker is `new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })`, which bundlers understand; `scripts/fix-imports.mjs` rewrites that URL to `.js` in `dist` (`pnpm test:pack` bundles the tarball with Vite and runs the worker in Chromium; without the rewrite the build fails). Documents are parsed on the main thread first (errors keep their messages) and the LUTs they name are resolved there and posted with the request, since the resolver is a function. Custom `filters` keep the CPU on the main thread for the same reason. Software adapters (SwiftShader) still count as WebGPU until real-hardware numbers say otherwise. |
| `@rullino/konva` = `attachEdit(node)` that swaps the node's image for a canvas it renders into (preview at width × height × absolute scale × pixel ratio, capped at the source; the source shrunk once per size in linear light; `scale` passed so px params match the export), not a Konva filter; `set` keeps only the latest pending document, one render at a time; the node's size is never touched | Konva filters are synchronous `(imageData) => void` and WebGPU is async (PLAN §6b), so an adapter must render asynchronously and swap the image. Rendering at the on-screen size keeps a 24 MP photo shown at 800 px interactive; coalescing keeps a dragged slider from queueing renders. Previews come back as 8-bit `ImageData` (`renderer.pixels`), encoded on the GPU / in the worker, an eighth of a linear readback. The output's `longSide` is ignored for previews. Geometry can change the aspect ratio: resizing the node is the app's call. |
| Examples live in `examples/<name>` as plain-JS Vite apps with npm ranges on the published packages, opened on StackBlitz straight from GitHub; tested by `pnpm test:pack` against the packed tarballs (built with Vite, driven through the page's own UI; the Node ones run as scripts) | A visitor's first contact is an example that runs in the browser without cloning; workspace links would not resolve there. Testing against the tarballs checks the code the published packages will run, before the release; the example's ranges catch up at the next version. The check polls for the changed pixels instead of calling `set()` itself, so a missing input listener fails it (mutation-checked). |
| Playground "Copy as code" (header button **Code**): a dialog with the edit document (same as a share link, plus the export panel's format / quality / long side as `output`, identity geometry left out) and the few lines that render it with `@rullino/web`, `@rullino/konva` or `@rullino/node`, the document embedded verbatim; its module is a separate chunk loaded on first click | The bridge from the demo to an install: what you tuned is what your app or server renders. Embedding the JSON (not a link to it) keeps the snippet self-contained. `code-export.test.ts` runs the Node snippet as written on `@rullino/node` (a wrong call fails it); the browser and Konva snippets use the same calls as the READMEs, which `pnpm typecheck` compiles. Lazy because the main chunk was 97 B over its budget with it. |
| Image I/O on the GPU for 8-bit sources: `uploadSrgb8` (writeTexture into an `rgba8unorm-srgb` staging texture, decoded by the same pass as `importImage`), `applyEdit` takes `Rgba8Image` and decodes where it renders; readbacks keep one idle staging buffer and one encode target per device (concurrent reads take their own) | The first real-hardware run (#77, RTX 40, 12 MP) loaded images in 473 ms against PixiJS's 14 ms: `decodeSrgb8` (a pow per channel) and the float16 conversion ran on the CPU. Loading went to 25 ms (from 605) even on SwiftShader. Change latency was flat across chains (~36 ms at 12 MP), i.e. readback-bound, and each read allocated a 48 MB texture and buffer. `importImage` cannot be used for raw bytes on Dawn (no `copyExternalImageToTexture` there). |
| Comparison benchmark (#77) = a page (`apps/bench`), not a Node script: the same RGBA8 image in, RGBA8 back in memory out for every library (GPU readbacks counted like WASM copies); per chain: init, first run (cold), median of N one-parameter changes, memory, gzip, and the mean \|Δ\| from rullino's result; rullino exact, the others their closest equivalent with a note per chain | Competitors cannot run the same maths (Photon has no curves / LUT / levels / vignette, libvips no 3D LUT, PixiJS no unsharp / vignette of this kind), so equivalence is approximate by nature: \|Δ\| makes "closest" measurable instead of a claim, and a library that skips a step visibly does less work. Real numbers come only from the maintainer's machines (SwiftShader is meaningless); CI runs it at 320×240 to keep every adapter alive. Served with COOP/COEP because wasm-vips needs SharedArrayBuffer (so not on GitHub Pages). The harness imports rullino lazily so rullino's gzip counts against rullino; it includes the whole filter registry (an app importing a few filters ships less). |
| `shader-f16` not required | Textures are rgba16float but shaders compute in f32; SwiftShader does not expose `shader-f16`. |

## Traps

Things that already bit us. Add new ones as they come up.

- **A texture must have exactly one owner: pooled (`releaseTexture`) or destroyed, never both.** The playground gave the previous image's raw textures to the pool (it judged ownership against the *new* image) and then destroyed them; the pool handed out destroyed textures and every later frame failed with "Destroyed texture used in a submit". Browsers log such errors as warnings, so it went unseen until `createGpuContext({ onError })` made them `console.error` and the e2e smoke test (which fails on console errors) caught it. Track ownership explicitly (a Set of the textures a renderer made), not by comparison.

- **`uncapturederror` events reach a listener only after a round trip to the GPU process** in headless Chromium: a test that triggers an error and waits on a timer sees nothing; `await device.queue.onSubmittedWorkDone()` first (or an error scope's `popErrorScope()`). An adapter can create one device only ("adapter is consumed"): request a new adapter per device.

- **Changing a dependency field of a `package.json` (also a peer dependency) needs `pnpm install` and the lockfile in the commit**: CI installs with `--frozen-lockfile` and fails both jobs in seconds otherwise.
- **A dynamic `import()` of a package the entry already imports statically can deadlock** when the bundler puts both in one chunk and the entry uses top-level `await` (the import waits for the entry to finish): import statically (it bit `scripts/pack-smoke.mjs`).

- **Extensionless relative imports do not load under Node ESM**: `dist/` only works after `scripts/fix-imports.mjs` rewrites them (`./color` -> `./color.js`, `./gpu` -> `./gpu/index.js`); `pnpm test:pack` is what notices. New import forms (e.g. `import x from './x.json'`) need the script to know them.
- **`runChainCpu(steps, src)`** takes the steps first.

- **`navigator.gpu` needs a secure context.** In Chromium, `about:blank` and plain-HTTP non-localhost pages have no `navigator.gpu` even with `--enable-unsafe-webgpu`. Vitest serves from localhost, which counts as secure.
- **Headless Chromium needs `--enable-unsafe-webgpu`** to expose WebGPU; without it `requestAdapter()` returns null. Set in `vitest.config.ts`.
- **`copyTextureToBuffer` needs `bytesPerRow` to be a multiple of 256**; `writeTexture` does not. `readImage` pads and un-pads rows; test with odd widths.
- **Compute dispatch rounds up to whole workgroups**, so shaders must bounds-check `global_invocation_id` against `textureDimensions`. Test with sizes that are not multiples of 8.
- **Parity tests must quantise the input to float16 first**, otherwise the tolerance has to absorb input rounding too and stops catching real bugs.
- **Check that a parity test can fail**: break the shader slightly (e.g. scale by 1.002) and confirm it goes red before trusting it.
- **Configuring a WebGPU canvas in headless Chromium (SwiftShader) loses the device** ("A valid external Instance reference no longer exists"), with or without `STORAGE_BINDING`, old or new headless. Anything a test must check goes through textures and readback.
- **Integer downscale ratios hide weighting bugs** (every coverage weight is 1). Test resizes with fractional ratios.
- **`layout: 'auto'` silently drops unused bindings**, so binding an unused input fails validation with an empty output and no exception. `cachedPipeline` takes explicit layout entries; multipass passes always use them.
- **A failed pipeline/bind group shows up as an all-zero output** (alpha 0), not as an exception. When a GPU test gets zeros, suspect validation first.
- **Only `run()` receives `p`**: helper functions in pass WGSL must read the global `params` uniform (shared pass builders take `params.<name>` expressions).
- **WGSL cannot dynamically index an array held by value** (e.g. the `p` parameter). Index through the global `params` uniform instead (`params.kernel[n / 4][n % 4]`).
- **Multi-pass GPU filters round intermediates to f16**; anything that amplifies a difference (unsharp `amount`) amplifies that rounding, so its tolerance must grow with the gain.
- **Symmetric test kernels hide transposed indexing**: parity tests use an asymmetric kernel.
- **Workgroup memory and barriers are very slow on SwiftShader**: an "optimisation" built on them can be a large regression in CI and cloud sessions. Measure with `pnpm bench` before keeping it.
- **Hard contrast switches make T-junctions**, which non-maximum suppression breaks; Canny test images that need connected weak edges must fade contrast gradually.
- **Random-noise test images miss structure-dependent bugs** (hysteresis, gradient direction). Mutation-check new filters against the ring image too.
- **Do not judge the playground from a screenshot taken after a fixed timeout**: first-use shader compilation on SwiftShader takes seconds. Wait for `#status[data-frame]` to advance, as `scripts/smoke.mjs` does.
- **Resizing a canvas clears it.** The after canvas is resized only when a new frame is drawn, so the previous image stays up while a new one loads.
- **A filter's `id` is not unique**: two filter objects can share it. Anything cached per filter (shaders, Pipeline stages) keys on `objectKey(filter)` too.
- **Async loads in the playground must not overlap**: each destroys the previous textures. `loadImage` queues them and skips superseded ones; swap Pipeline sources before destroying old textures.
- **Integer-like object keys are reordered by JS** (`{ '5': …, '3': … }` iterates '3' first), so enum option keys must not be integer-like.
- **Allocating textures is expensive** (lazy zero-fill): get working textures from `createWorkingTexture` and give intermediates back with `releaseTexture`, never create one per iteration.
- **Browser-mode `console.log` does not reach the terminal** here; use `commands.writeFile` from `vitest/browser` (paths are relative to the repo root).
- **`sampleCurve` caches its LUT per points array**: never mutate a points array after sampling it (the curve editor samples a copy).
- **The registry parity test varies one param at a time**: interactions between params (curve order, combined settings) need a dedicated GPU test. Params with no effect at their partners' defaults (colour grading's colours and balance at strength 0) are not tested at all there: a balance mutation passed.
- **WGSL rejects mixing `&` / `|` with comparisons without parentheses** (`a <= k & k < b` does not compile): write `(a <= k) & (k < b)`. Like any shader error, it shows up as an all-zero output.
- **WGSL `round()` is round-half-to-even, `Math.round` is half-up.** Use `floor(x + 0.5)` on both sides when parity matters.
- **OKLab round trips leave float noise** (~1e-26 instead of 0): assert with a small epsilon, not `>= 0`.
- **Uint8ClampedArray needs `<ArrayBuffer>`** for `new ImageData(...)` under TS's typed-array generics; `readSrgb8` returns that type.
- **A LUT cannot follow the sRGB curve's kink at 0**: grades that push colours out of gamut (saturation boosts) interpolate poorly next to the gamut edge. Compare baked vs live chains as displayed (clipped) and on average, not by the worst pixel alone.
- **`convertToBlob` silently falls back to PNG** for a type the browser cannot encode (e.g. WebP in some browsers); `encodeSamples` checks `blob.type` and throws.
- **Replacing an SVG element while it is dragged drops its pointer capture**: the mask overlay updates attributes and rebuilds elements only when the mask kind changes.
- **UI modules that keep their own copy of shared state go stale** once a second editor exists (panel vs on-image handles): read the current state through a getter.
- **A mask outlives image changes** (normalised coordinates), so a raster can have another image's proportions: refit it (`fitRaster` / `resampleRaster`) before tools that need the image at the raster's size.
- **Playwright's `mouse.click` has no `modifiers` option** (only `page.click` does): hold keys with `keyboard.down` / `up`.
- **`pnpm bench` broke silently once** (a mask-aware filter was registered) when it was not in CI; CI now runs it, without asserting timings.
- **An e2e check can pass without exercising the path it is about**: e.g. preview-only reframing never runs on images ≤ 768 px (preview and display are the same texture). Mutation-check new smoke checks too.
- **Playwright clicks are too slow to provoke async races**: dispatch the events in one `page.$eval` task (as the wand-race check does).
- **A new frame is not proof the latest state is shown**: changes made while a frame is in flight coalesce into the next one. E2E checks of geometry wait for the expected result (sizes, pixels), not for a frame count.
- **Handles on the image edge are half clipped** by an `overflow: hidden` overlay: the crop / perspective overlay uses `overflow: visible`.
- **Playwright version must match the installed Chromium build** in cloud sessions (1.56.x ↔ chromium-1194), or it tries to download a browser. Since late Oct 2026 the cloud image ships chromium-1243 (and a new path layout): until the pin is bumped, shim it locally with `mkdir -p /opt/pw-browsers/chromium_headless_shell-1194/chrome-linux && touch /opt/pw-browsers/chromium_headless_shell-1194/INSTALLATION_COMPLETE && ln -s /opt/pw-browsers/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell /opt/pw-browsers/chromium_headless_shell-1194/chrome-linux/headless_shell` (all GPU tests pass with it). CI installs its own browser and is unaffected.
- **Synthetic `PointerEvent`s cannot be captured**: `setPointerCapture` throws "No active pointer". Dispatch fake touches where no handler captures (the smoke test pinches on `#stage`).
- **WGSL shift amounts must be `u32`** (`4 << (2 * level)` with an `i32` level does not compile): `4 << u32(2 * level)`. As always, an all-zero output.
- **A generous `outliers` allowance hides subtle mutations**: dither at 1% let a shifted threshold (~0.6% flips) pass 14 of 16 cases. Measure with `outliers: 0` first, then allow just above what you measured.
- **`copyExternalImageToTexture` from a `<canvas>`, `<img>` or `VideoFrame` loses the device on SwiftShader** ("A valid external Instance reference no longer exists"), like configuring a WebGPU canvas. Tests import through `ImageBitmap` / `ImageData`.
- **ONNX Runtime's WebGPU backend refuses concurrent session creation** ("another WebGPU EP inference session is being created"): create sessions one after the other (the fake ORT in `segmenter.test.ts` enforces it).
- **ORT's WebGPU EP on SwiftShader is ~25× slower than WASM** (SlimSAM encode 480 s vs 19 s): never benchmark ML on WebGPU in CI or cloud sessions; the app picks WASM on software adapters.
- **A SAM click on a uniform background is ambiguous**: the best-scored mask is often the whole image. Test point prompts on objects, and labels with positive + negative pairs.
- **Node's `fetch` ignores `HTTPS_PROXY`** in CI / cloud sessions: scripts that download (models) use `curl`.
- **MI-GAN's mask is inverted** (255 = known, 0 = hole), and the other ONNX file in the repo (`migan.onnx`) is the same signature; try both conventions on a rectangle before trusting a wrapper (outside the hole must come back unchanged).
- **A frame advancing is not proof that a later state is shown** (again): the Remove object e2e check polls for the changed pixels instead of waiting for `frame > n`; the Object selection's own render had already advanced it.
- **ORT / onnxruntime need external-data files next to the model under their original name** (`model.onnx_data` for the Depth Anything 3 export); loading the `.onnx` alone from another folder fails with "External data path does not exist". Not an issue for the int8 DA2 file (single file).
- **Param names become fields of the generated WGSL `Params` struct**, so WGSL reserved words (`operator`, `filter`, `target`...) fail to compile (all-zero output): the registry parity test notices. And when mutation-checking, a `sed` that matches both the shader and the CPU reference changes nothing: anchor it on the WGSL's indentation.
- **`npm publish` from a package directory ships `exports` pointing at `src/*.ts`** (the dist exports exist only in the `pnpm pack` tarball). `prepublishOnly` refuses it; the release workflow publishes tarballs, which does not run that hook.
- **`npm publish dir/file.tgz` reads a path with a slash and no `./` as a GitHub shorthand (`owner/repo`)** and tries to `git clone` it ("Permission denied (publickey)"). The release workflow uses `./tarballs/...`; `pnpm test:pack` runs `npm publish --dry-run` on each tarball in the same form. The first release attempt (`v0.1.0-rc.0`, never published) failed on this: a tag cannot be reused once the workflow it runs is wrong, so the fix went out as `rc.1`.
- **A cloud session cannot push tags** (the git proxy only accepts the session's branch: "remote end hung up"), so the maintainer pushes release tags (`git tag vX.Y.Z origin/main && git push origin vX.Y.Z`). Never create the GitHub release from the web UI instead: `release.yml` creates it from the CHANGELOG and fails if it already exists.
- **Dawn's `webgpu` package needs a Vulkan driver on Linux**: without one `requestAdapter()` returns null ("vkCreateInstance: Found no drivers"). `apt install mesa-vulkan-drivers` (lavapipe) fixes it; the loader finds it without `VK_ICD_FILENAMES` once installed. Its `postinstall` script only matters on macOS (quarantine flag), so pnpm's blocked build scripts are fine on Linux.
- **Dawn on lavapipe compiles every shader with LLVM, and a fresh CI runner has an empty Mesa shader cache**: the first Dawn render took 3.8 s in `pnpm test` (0.9 s in `test:coverage` right after, cache warm) and once went over Vitest's 5 s default in the release job. Locally the cache is warm and the same test takes 0.2 s, so it never shows here (`MESA_SHADER_CACHE_DISABLE=true` gets closer). Dawn tests declare `{ timeout: 60_000 }`; CI steps have `timeout-minutes` so a real hang fails in minutes (#100).
- **StackBlitz (WebContainers) cannot load Vite 8's native Rolldown binding** ("Cannot find native binding"): examples pin `vite@^7` (Rollup + esbuild), which StackBlitz runs; the repo itself stays on Vite 8. `pnpm test:pack` builds the examples with the repo's Vite, so it cannot notice this: open the StackBlitz link after changing an example's dependencies.
- **TypeDoc renders the root README** (`pnpm build:docs`, warnings are errors): a relative link to a directory (`./examples/x`) fails it, since TypeDoc copies only linked files. Link directories by their GitHub URL; run `pnpm build:docs` when a README changes.
- **`vitest.config.ts` is loaded by plain Node**, which cannot import the workspace TS sources (extensionless imports): browser commands that need the packages load them with `ctx.project.vite.ssrLoadModule('/packages/…/src/index.ts')` and keep only type imports at the top.
- **Do not time small functions under Vitest**: its module runner turns calls to imported functions into getter calls (4–5× slower per call); `pnpm bench:cpu` bundles first. Allocating a 12 MP `Float32Array` (192 MB) costs 0.15–1.1 s in cloud containers (page faults, GC): avoid needless full-image copies.
- **`x ** 3` is a `pow()` call in V8**: write `x * x * x` in pixel loops (it was a third of the OKLab filters' time).
- **Functions cannot be posted to a Worker** (filters, LUT resolvers): resolve on the main thread and send data, or keep that work on the main thread. And Vite's dev server pre-bundles dependencies with esbuild, which breaks `new URL('./worker.js', import.meta.url)` inside them: apps exclude `@rullino/web` from `optimizeDeps` (production builds are fine).
- **Photon 0.3.3's noise functions (`add_noise_rand`, `pink_noise`) trap in WASM** ("unreachable", random source unavailable), and the trap leaves the image borrowed ("attempted to take ownership of Rust value while it was borrowed" on `free()`): the bench skips Photon's grain.
- **wasm-vips: `extractBand` makes a 'multiband' image**, from which colour operations (sharpen's LabS round trip, `colourspace`) have no route: set `interpretation: srgb` with `copy()` first. **pixi-filters' `ConvolutionFilter` `width` / `height` are the image's** (texel size), not the kernel's: 1 × 1 gave a garbage picture (\|Δ\| 118).
- **`performance.measureUserAgentSpecificMemory` throws in headless Chromium** even when cross-origin isolated: catch it and fall back to `performance.memory` (JS heap only, coarse).
- **An e2e check on a two-stage effect must wait for both effects** (lens blur: background blurred *and* the picked focus applied); the first stage alone already satisfies half of the condition.

