A browser tool for turning a photograph — or a clip — into glitch/ASCII artwork through a stack of intermingle-able processors, exportable at 1×/2×/4×.
bun install
bun dev # http://localhost:3000
bun build # static site → dist/ (what GitHub Pages deploys)
bun verify # headless engine checks + sample renders into out/Pushes to main build dist/ and deploy to GitHub Pages
(https://colinstark.github.io/glitchinarium/). One-time setup: repo
Settings → Pages → Source: GitHub Actions.
Drop an image, then build a layer stack. Order is the whole interface — a gradient map above an ASCII layer recolours the glyphs, below it recolours what the glyphs sample.
Layers come in three kinds:
- Processors transform the image and composite the result back (blend mode + opacity + optional mask).
- Mask layers compute a grayscale field from the image as it is at that point in
the stack and publish it under a name (
L3). Effects below can scope themselves to it. Because masks read the live accumulator, "edges of whatever the datamosh just broke" is just a matter of stack order. - Datamosh additionally publishes a mask of the regions it disturbed, so an ASCII layer can be bound to where the image broke rather than to a selection you drew.
Every non-mask layer also has blend mode, opacity, and an optional mask binding (stencil or inverted stencil, with feather). Direct Lock, Mask, and Sweep icon buttons let you drive supported values with a mask, lock them against Shuffle, or sweep them across exported frames. Active buttons stay highlighted beside the value. Numeric values can be typed directly or dragged vertically: up increases, down decreases, Shift makes finer adjustments, and Escape cancels a drag.
Grouped the same way they appear in + Add layer. One-line summaries; modes and quirks that change the look live on the layer card.
| Layer | What it does |
|---|---|
| Levels | Exposure, contrast, gamma, saturation, optional posterize. Cheap first layer. |
| Gradient map | Map luma/hue/sat onto a colour ramp. Above ASCII it recolours glyphs; below, it recolours what they sample. |
| Palette lock | Snap every colour to a short palette (named, custom, or median-cut auto), with optional ordered dither so gradients survive as texture. |
| Spot colour | Flat press ink over a luma/hue/sat band. Preserve tone keeps form instead of flooding. |
| Layer | What it does |
|---|---|
| Dither | Block-resolution quantise (Bayer, noise, Atkinson, Floyd). Optional mask-driven subdivide varies chunk size across the frame. |
| Hatch | Marks (cross, tick, chars…) that can rotate along a curl-noise flow — engraver hatching, not a rigid grid. |
| Screen | True AM print screen: fixed lattice, variable dot size. Mono / RGB / CMYK (classic rosette angles). |
| Weave | Progressive stitch fills (cross, checker, basket, bayer). Dark = solid cloth, mid = open weave; supports subdivide. |
| Layer | What it does |
|---|---|
| ASCII | Glyph grid from tone. Placement: grid, flow (curl-aligned), or phyllotaxis. Subdivide + mask → variable-resolution cells. |
| Edge trace | Glyphs along contours only, rotated to the edge tangent — outlines of form, not area fill. |
| Echo | Offset colour copies of ink already in the stack (misregistered type/print). Keys on dark marks below it. |
| Scatter | Sparse blue-noise symbols (arrows, marks, brackets…), clumped by noise rather than gridded. |
| Contour | Topographic isolines of blurred luminance; optional filled bands. |
| Layer | What it does |
|---|---|
| Ripple | Displacement by concentric, catenary, hypar, or curl fields (not just a sine). |
| Spiral | Twirl, logarithmic (equiangular), or phyllotaxis (golden-angle) rotation. |
| Kaleido / Tile | Radial folds, square/hex mirror tiles, or trencadís Voronoi shards with grout. |
| Layer | What it does |
|---|---|
| Pixel sort | Sort runs by luma/etc. Directions: axis, angle, or flow (streaks follow curl noise). |
| RGB split | Per-channel offset: linear, radial (lens-like), or curl. |
| Datamosh | Codec failure: smear, blocks, real 8×8 DCT, or rowshift. Can emit a mask of disturbed regions for layers above. |
| Scanline smear | Hold-last-sample horizontal stretch — sync loss, not block drag. |
| Region echo | Copy-paste rectangles (optional staircase edges + keyline). Count is seed-stable across sizes. |
| Block corruption | Macroblocks of flat saturated colour in noise clusters, with sideways colour runs. |
| Crystal glass | Irregular glass-brick tessellation, posterised fills, hold streaks, sparks, optional half-frame seam. |
| Detection | Fake vision boxes on detail maxima (overlay, not a mask). |
| Layer | What it does |
|---|---|
| Grain | Substrate: paper, canvas, riso, film, dust. Modulates rather than paints over — put high in the stack for “object, not filter.” |
| Glow | Thresholded bloom: only highlights blur and add back, so edges stay hard. |
| CRT | Scanlines, RGB grille, barrel, phosphor bleed, vignette — all spacings in artwork units. |
| Layer | What it does |
|---|---|
| Border | Ornamental pixel lattice frame (motif from seed + unit size), corner blocks, optional outer matte. |
| Layer | What it does |
|---|---|
| Mask | Publishes a grayscale field named like L3 for stencils and parameter modulation. Sources include luma, edges, saliency, noise, flow, voronoi, shapes, chroma key, and the paint brush (with cling for edge-aware reach). Edge style / tear define the boundary more than the source does. |
Stack order is composition: top of the list runs first. Masks can be selected anywhere in the stack. A mask below its consumer samples the source image to avoid circular dependencies; a mask above samples the image at its stack position. Randomize scopes (Tone / Halftone / …) match these categories.
A mask can act as a stencil (where an effect applies) or as an intensity dial (how hard it bites). The second is the interesting one.
Click the Mask button beside a supported parameter: black gives min, white gives max.
That is the displacement-map idea generalised — a painted gradient can drive ripple
amplitude, dither weight, sort run length, or how coarse the ASCII is.
Grid processors can't have a per-pixel cell size, so subdivide uses a quadtree
instead: start from the coarsest grid and split each cell into four wherever the mask
asks for detail. Cells still tile exactly, and you get genuinely variable-resolution
ASCII. The gradient-ascii preset demonstrates it.
The brush is a mask source, not a paint layer. You paint grey, and any processor
below binds to it. Painting intensity over a datamosh layer's amount scratches
corrosion in at exactly the strength you brushed. [ ] resize, c toggles cling,
e erases, ⌘Z undoes, esc finishes. Strokes are stored normalised with radii in
artwork units, so a mask painted on the preview lands identically on a 4× export.
Cling makes the brush content-aware. At 0 the brush is a circle. Turn it up and
size stops meaning radius and starts meaning reach: paint spreads through pixels that
look alike and pays to cross local contrast, so one stroke down a wall fills to the
building's silhouette instead of bleeding into the sky. The travel cost is calibrated on
each image's own gradient histogram — ordinary texture is free, unusual contrast is a
wall — so the dial means the same thing on a flat studio shot and a noisy street photo.
Cling reads the picture, and edges are resolution-dependent, so a clinging stroke is
approximately rather than exactly scale-invariant (as edge and detail sources
already are); the plain circular brush keeps its exact guarantee. Mask tear has a
milder version of the same caveat: large tears use a coarser offset lattice at preview
sizes than at export, so the tear fringe you approve in the preview is a little
smoother than what exports.
The edge is the signature. edgeStyle breaks a mask boundary into stairs, a bayer
dissolve, a checker or a weave; tear displaces it along a noise flow first. Those
change the result far more than the choice of source does.
This is the one invariant that matters and the one that breaks silently.
The source image defines an artwork-unit space whose longest edge is 1000 units.
Every processor parameter is stored in artwork units (or normalised 0..1) and converted
to pixels at use time via ctx.u(value). A ~900px preview and a 6000px export therefore
describe the same composition; the export just resolves it with more detail.
A processor that reaches for a raw pixel count instead will produce a different artwork
at export size, and you will not find out until you export. bun verify renders every
processor at 1× and at 4×, downsamples the 4× result and diffs them — that is what the
"Scale contract" table is checking.
Periodic processors (screen, weave, crt, dither, hatch, palette, grain)
declare a feature param, and the harness raises it to something resolvable before
comparing. A halftone dot with a 1.9px pitch cannot be drawn correctly at preview
resolution no matter how the code is written — testing at the default pitch measures
Nyquist, not scale compliance, and would hide a real bug behind a number everyone
learns to ignore. At a resolvable pitch every one of them lands under 11, and dither
and weave hit 0.00.
The app warns you in the layer panel when a feature drops below ~2 preview pixels, so you know the export will be sharper than what you are looking at.
One entry stays loose by nature: dither with atkinson/floyd at a fine block
size. Error diffusion is chaotic, so a hair of input difference cascades. The block grid
and tonal structure are identical; the exact speckle is not. Ordered methods are
pixel-stable.
Randomness follows from the same rule: per-pixel variation must come from spatial
noise sampled in artwork units, never from a sequential rng() call per pixel — a call
sequence produces a different pattern when the pixel count changes.
Drop a video and it behaves like a still that can be moved. A Clip panel appears at the top of the sidebar: scrub to the moment you want to design against, tune the stack there as usual, then set an in / out range and a rate and export the range as frames.
Mechanically the clip is decoded into a canvas, and that canvas is the source drawable — so the preview, the thumbnail, the artwork-unit space and the export planner are the same code they were for a still. Nothing downstream knows the source moves. Frames are pulled by seeking rather than by playing: one frame through a real stack takes far longer than realtime, so anything driven by playback would drop most of what it decoded.
Two honest limits come with that choice:
- The browser will not tell us a file's frame rate. There is no API for it. So the extraction rate is one you pick, not one that is read off the file.
- Seeks land on the nearest decoded frame. Ask for a rate finer than the source's and you get repeats rather than new information. The panel says so under the frame count.
The frame-exact alternative is WebCodecs, which needs a container demuxer — a dependency this project does not have and should not grow for this.
Output is frames, not a movie. Frame numbers are unpadded and start at 1, so tell ffmpeg both:
ffmpeg -framerate 12 -start_number 1 -i 'clip-glitchinarium-2x-%d.png' \
-pix_fmt yuv420p out.mp4Unpadded means a plain alphabetical listing puts frame 10 before frame 2. ffmpeg
reads the numbers rather than the order, so it is unaffected; a file browser or a
for f in *.png loop is not.
One good frame is usually the start of a series. Any parameter marked with a
number, a centre point or a colour can be armed for a sweep with the Sweep
icon beside its value: that reveals a from / to pair, and
a batch export renders N frames interpolating between them.
Arm as many as you like, across as many layers as you like — every sweep
advances on the same t, so amplitude and wavelength moving together stay in
step rather than becoming two unrelated series. Turn on New seed for each frame in the
export menu and each frame also gets its own seed, which on its own (no sweeps
armed) is just "give me ten of these".
A clip is a third axis, and it composes with the other two: with a video loaded the frame count comes from in/out/rate instead of the steps field, and any armed sweep spreads itself across the footage rather than running as a separate series.
Endpoints are inclusive: 10 steps from 0.2 to 0.4 renders both 0.2 and 0.4.
Params whose step is a whole number — grid columns, fold count, region count —
snap to integers, because 27.4 columns is not a finer setting, it is a value the
slider could never produce. Selects and toggles are deliberately not sweepable;
there is no halfway between catenary and hypar.
Frames run strictly one at a time through a single shared worker: each frame already sizes itself to the whole memory budget, so two at once would only make both clamp, and one worker means the glyph fonts load once instead of hundreds of times.
Save to chooses where they land, and the two options fail differently:
- ZIP — one download with a
manifest.jsonrecording the exact param values, seed and clip time behind every file. An archive has no useful prefix, so nothing is written until the last frame lands and cancelling halfway gives you nothing. Fine for ten frames, not for three hundred. - Folder — pick a directory and each frame is written the moment it is
encoded, so a cancel keeps everything rendered so far. Needs
showDirectoryPicker, so it is Chromium-only; elsewhere the button is disabled and ZIP is the only path. It is the default for a video, since that is the run most likely to be long.
Clips are capped at 600 frames, so a mistyped rate cannot queue seventy thousand renders.
Sweeps are stored on the layer next to its locks and mods, so they travel with saved presets, but they never reach the renderer: they resolve into ordinary param values on a per-frame copy of the stack. The pipeline, the worker and the scale contract see nothing new.
Preview renders at ≤900 CSS px (scaled by device pixel ratio, capped at 2× — so up to 1800px on retina) with a per-layer buffer cache, so editing layer 5 of 6 only recomputes 5→end (~3.5× faster than a cold render in practice). Export re-runs the stack from the original image at up to 4× the requested output and box-downsamples, so even a 1× export is supersampled when the browser memory budget permits. Export uses no cache. Its planner estimates the active stack's peak working set and reduces supersampling, then output size, to stay within a 512 MiB ceiling.
Crisp on the grid processors snaps their cell to a multiple of the resolve factor so
hard edges survive the downsample instead of turning to grey.
- The stage is a plain Canvas 2D element that letterboxes the preview. All image processing is plain typed arrays — never through a sketch library.
@napi-rs/canvasis a dev dependency used only byverify.jsto run the pipeline headlessly. It is not part of the app.
The browser suite runs against the production build and checks numeric edits, parameter locks and mask bindings, preset dialogs, series downloads, gradient editing, font errors, brush shortcuts, video input, and narrow-screen layouts.
bunx playwright install chromium # first run on a new machine
bun run verify:ui # builds, then runs the browser checksScreenshots are written to out/ui/. To use an existing compatible Chromium
installation, set PLAYWRIGHT_EXECUTABLE_PATH to its executable. The test server
binds to loopback on an available port and shuts down when the suite finishes.