Skip to content

About

Image glitching for fun and profit

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

62 Commits

Folders and files

Repository files navigation

Glitchinarium

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.

How it works

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.

Processors

Grouped the same way they appear in + Add layer. One-line summaries; modes and quirks that change the look live on the layer card.

Tone

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.

Halftone

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.

Glyph

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.

Warp

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.

Glitch

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).

Texture

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.

Frame

Layer What it does
Border Ornamental pixel lattice frame (motif from seed + unit size), corner blocks, optional outer matte.

Mask

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.

Masks do two different jobs

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.

The scale contract

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.

Video input

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.mp4

Unpadded 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.

Batch export

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.json recording 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.

Rendering

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.

Notes

  • 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/canvas is a dev dependency used only by verify.js to run the pipeline headlessly. It is not part of the app.

UI checks

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 checks

Screenshots 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.

About

Image glitching for fun and profit

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages