Shape-based ASCII art renderer in C. Converts images and video into ASCII art using 6D shape vectors instead of scalar brightness, producing sharp contour-aware output with readable edges.
Supports images, live terminal video, video transcoding, webcam, and runs in the browser via WebAssembly.
Traditional ASCII art maps each cell to a character by brightness alone. Glif instead samples 6 overlapping circles arranged in a 3x2 staggered grid within each cell, producing a 6-dimensional shape vector. Characters are matched by nearest-neighbor distance in 6D Euclidean space, so a diagonal stroke matches / rather than a medium-brightness character like +.
The pipeline:
- Lightness map — Convert sRGB pixels to linear luminance via a 256-entry LUT
- Grid decomposition — Divide the image into cells (default 10x20px, 2:1 aspect)
- Shape vectors — Sample 6 internal circles per cell into a Vec6
- External sampling — Sample 10 circles outside each cell boundary into a Vec10
- Directional contrast — Diff internal vs. neighboring external samples, normalize, exponentiate
- Global contrast — Normalize by max component, exponentiate
- Character matching — Find nearest character Vec6 in the precomputed font database
- Adaptive contrast — Per-frame percentile analysis adjusts contrast for dark/bright scenes
- Temporal stabilization — EMA smoothing of normalization, shape vectors, contrast stats, and character hysteresis to reduce flicker in video
make # build glif CLI
make wasm # build WebAssembly (requires Emscripten)
make test # run unit tests (185 tests)
make debug # build with AddressSanitizer + UBSanRequires a C11 compiler and OpenMP. On macOS:
brew install libompNo other dependencies — stb_image.h, stb_truetype.h, Nuklear, and Clay are vendored.
# Plain ASCII
./glif photo.png -f fonts/GeistMono-Regular.ttf
# ANSI truecolor terminal output
./glif photo.png -f fonts/GeistMono-Regular.ttf -c
# Auto-fit to terminal, colored
./glif photo.png -f fonts/GeistMono-Regular.ttf -a -c
# PPM image output (scale 4 for crisp glyphs)
./glif photo.png -f fonts/GeistMono-Regular.ttf -o output.ppm -s 4
# Dark mode PPM (black background, colored glyphs)
./glif photo.png -f fonts/GeistMono-Regular.ttf -o output.ppm --dark
# High contrast for line art
./glif diagram.png -f fonts/GeistMono-Regular.ttf -d 3.0 -g 3.0A monospace TTF font is required via -f. The font's glyph shapes directly affect output quality — different fonts produce different results.
| Flag | Description | Default |
|---|---|---|
-f, --font <path> |
Monospace TTF font (required) | — |
-w, --cell-width <px> |
Cell width in pixels | 10 |
-h, --cell-height <px> |
Cell height in pixels | 20 |
-d, --dir-crunch <f> |
Directional contrast exponent | 1.25 |
-g, --global-crunch <f> |
Global contrast exponent | 1.5 |
-a, --auto-fit |
Fit output to terminal size | off |
-c, --color |
ANSI truecolor terminal output | off |
-o, --output <file> |
Write PPM image file | — |
-s, --scale <n> |
PPM render scale | 4 |
--dark |
Black background + colored glyphs | off |
--video <W> <H> |
Video mode: read raw RGB24 frames from stdin | — |
--fps <n> |
Target framerate | 30 |
--pipe-ppm |
Video: write PPM frames to stdout | off |
--pipe-raw |
Video: write raw RGB24 to stdout | off |
--v4l2 <device> |
Video: write to v4l2loopback (Linux) | — |
--adapt-floor <0-255> |
Adaptive contrast noise floor | off |
--adapt-ceil <0-255> |
Adaptive contrast ceiling | 80 |
--output-glif <path> |
Video: write .glif binary file | — |
--compress |
Enable deflate compression for .glif output | off |
--quant <bits> |
RGB bits to keep (4–8, 8=off) | 6 with --compress |
--threshold <n> |
Temporal snap threshold (0–255) | 4 with --compress |
--audio |
Enable crushed PCM audio (requires --output-glif) |
off |
--audio-pcm <path> |
Path to raw PCM file (s16le, mono, 44100 Hz) | — |
--audio-rate <hz> |
Output sample rate (2000–48000) | 8000 |
--audio-depth <bits> |
Output bit depth (4 or 8) | 8 |
--keep-audio <path> |
Embed original audio file (Opus/OGG/AAC) in .glif | — |
Pipe raw RGB24 frames from ffmpeg to render video as live ASCII art:
# Color ASCII video, auto-fit to terminal
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 640x480 - 2>/dev/null | \
./glif --video 640 480 -f fonts/GeistMono-Regular.ttf -c -a --fps 30
# Higher resolution input
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 640x480 - 2>/dev/null | \
./glif --video 640 480 -f fonts/GeistMono-Regular.ttf -c -a --fps 30 --dark
# Adaptive contrast (better for varying lighting)
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 640x480 - 2>/dev/null | \
./glif --video 640 480 -f fonts/GeistMono-Regular.ttf -c -a --fps 30 \
--adapt-floor 5 --adapt-ceil 80Uses diff-based rendering — only changed cells are emitted as ANSI escape codes. A byte-cost estimator dynamically chooses between diff and full redraw per frame.
Transcode any video into an ASCII art MP4 with audio using the convenience script:
# High-res (default) — 4x8 cells, scale 4, high contrast
./scripts/glif-transcode.sh movie.mp4
# Dark scenes — lower contrast, preserves shadow detail
./scripts/glif-transcode.sh movie.mp4 --dark
# Low-res retro — 120x40 grid, small file size
./scripts/glif-transcode.sh movie.mp4 --lo
# Custom output path
./scripts/glif-transcode.sh movie.mp4 -o output.mp4The script probes the input for resolution and framerate, runs the full ffmpeg -> glif -> ffmpeg pipeline, and muxes the original audio track. ASCII art compresses well with x264 — large flat regions and repeated glyphs give favorable compression ratios.
| Preset | Cells | Scale | Contrast | Use case |
|---|---|---|---|---|
--hi (default) |
4x8 | 4 | 2.5 / 2.5 | Dense, crisp, high contrast |
--dark |
4x8 | 4 | 1.3 / 1.3 | Dark scenes, night footage |
--lo |
10x20 | 1 | 2.5 / 2.5 | Small file, retro look |
Or build the pipeline manually:
ffmpeg -i input.mp4 -f rawvideo -pix_fmt rgb24 -s 640x480 - 2>/dev/null | \
./glif --video 640 480 -f fonts/GeistPixel-Square.ttf --pipe-raw --dark -s 4 | \
ffmpeg -f rawvideo -pix_fmt rgb24 -video_size 2560x1920 -framerate 30 -i - \
-c:v libx264 -pix_fmt yuv420p output.mp4Capture video as a compact .glif binary file, then play it back in the browser with the WASM player.
Capture:
# Capture video to .glif with deflate compression
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 640x480 - 2>/dev/null | \
./glif --video 640 480 -f fonts/GeistPixel-Square.ttf --output-glif output.glif --compress --fps 30
# Hi-res capture (4x8 cells, high contrast)
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 854x358 - 2>/dev/null | \
./glif --video 854 358 -f fonts/GeistPixel-Square.ttf -w 4 -h 8 -d 2.5 -g 2.5 \
--output-glif output.glif --compress --dark --fps 30
# Capture with crushed PCM audio (8kHz 8-bit retro sound)
ffmpeg -i video.mp4 -f s16le -acodec pcm_s16le -ac 1 -ar 44100 /tmp/audio.pcm -y
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 854x358 - 2>/dev/null | \
./glif --video 854 358 -f fonts/GeistPixel-Square.ttf -w 4 -h 8 \
--output-glif output.glif --compress --dark --fps 30 \
--audio --audio-pcm /tmp/audio.pcm
# Extra crushed (4kHz 4-bit — maximum retro)
... --audio --audio-pcm /tmp/audio.pcm --audio-rate 4000 --audio-depth 4
# Original audio passthrough (Opus/AAC — full quality)
ffmpeg -i video.mp4 -vn -c:a libopus audio.opus -y
ffmpeg -i video.mp4 -f rawvideo -pix_fmt rgb24 -s 854x358 - 2>/dev/null | \
./glif --video 854 358 -f fonts/GeistPixel-Square.ttf -w 4 -h 8 \
--output-glif output.glif --compress --dark --fps 30 \
--keep-audio audio.opusWeb player (web/player.html):
Open web/player.html in a browser to play .glif files. Features:
- Drag-and-drop or click to open
.gliffiles - Play/pause, seek, and speed controls (0.25x–4x)
- Crushed PCM audio playback (toggle with A key or speaker button)
- Original audio passthrough when available (toggle with Q key or HQ button)
- HDR contrast enhancement toggle (shader-based tone mapping)
- Fullscreen support
- Keyboard shortcuts: Space (play/pause), F (fullscreen), H (HDR), A (audio), Q (original audio), arrow keys (seek/speed)
- Auto-loads
mk421.glifif present in the same directory
make wasm-player # build WASM player
cd web && python3 -m http.server 8000 # serve locallySelf-contained HTML embed:
Bundle a .glif file into a single HTML file — no server, no external dependencies:
./scripts/glif-embed.sh video.glif # → video.html
./scripts/glif-embed.sh video.glif -o embed.html # custom output name
./scripts/glif-embed.sh video.glif --video original.mp4 # embed with compare overlayThe output HTML inlines the WASM binary, JS player, and .glif data (base64-encoded) into a single file. Opens directly in any browser with auto-play, HDR toggle, and audio toggle (if the .glif contains audio). The --video flag embeds the original video for a side-by-side compare overlay (toggle with V key).
Programmatic API (web/glif-player.js):
import { GlifPlayer } from './glif-player.js';
const player = new GlifPlayer();
await player.mount(document.getElementById('canvas'));
const response = await fetch('output.glif');
await player.loadFile(await response.arrayBuffer());
player.onframe = (frame) => console.log(`Frame ${frame}/${player.frames}`);
player.play();
// Controls
player.pause();
player.seek(42);
player.setSpeed(2.0); // 0.1x to 10x
player.setHDR(0.7); // 0.0 (off) to 1.0 (full effect)
player.destroy();The compressed format uses per-frame optimal codec selection — each frame is encoded with whichever of 7 deflate-based codecs (plain, delta, filtered, palette, planar, and their delta variants) produces the smallest output. Delta variants use the previous frame as reference; scene changes naturally fall back to non-delta keyframes.
When --compress is enabled, two lossy preprocessing steps are applied by default to dramatically improve compression (override with --quant 8 --threshold 0 to disable):
- Color quantization (
--quant 6) — Drops 2 LSBs per RGB channel, creating more repeated values for deflate - Temporal thresholding (
--threshold 4) — Snaps cells whose RGB changed by ≤4 per channel to the previous frame, reducing noisy delta changes from ~79% to ~40% of cells
Turn your webcam into a live ASCII art camera visible in Zoom, Google Meet, etc.
Linux (v4l2loopback):
# One-liner with convenience script
./scripts/glif-webcam.sh
# Options
./scripts/glif-webcam.sh --src /dev/video0 --dst /dev/video10 --scale 2 --dark
./scripts/glif-webcam.sh --light --font fonts/GeistMono-Regular.ttfThe script auto-loads v4l2loopback if needed and uses direct v4l2 output (2 processes) when available, falling back to a 3-process pipe.
macOS / Windows: Use --pipe-raw piped through ffmpeg to OBS Virtual Camera. See docs/webcam.md for setup on all platforms.
Real-time ASCII art overlay for any web video. Works on YouTube, Vimeo, Twitch, and any page with <video> elements.
Install from Chrome Web Store: Click the badge above and press "Add to Chrome".
Install from GitHub Release: Download the .zip from Releases, unzip, open chrome://extensions, enable Developer Mode, click "Load unpacked", select the unzipped extension/ folder.
Build from source:
make wasm-ext # build WASM for the extension
# Load extension/ as an unpacked extension in chrome://extensionsFeatures:
- Video overlay — ASCII art rendered via WebGL, positioned over the original video
- Webcam interception — Overrides
getUserMediaso Zoom/Teams/Meet transmit ASCII art to other participants - SPA navigation — Detects
pushState/replaceStateURL changes (YouTube, etc.) and re-attaches overlays - Deferred video detection — MutationObserver catches
<video>elements added after page load - Persistence — Enabled state auto-restores on new tabs and page refreshes
- Real-time controls — Edge/contrast sliders and hi-res toggle update immediately
cd bindings/node && npm installconst glif = require('@artalis/glif');
const result = glif.render('photo.png', 'fonts/GeistMono-Regular.ttf');
console.log(result.toPlainText());Quick start — run the bundled examples:
cd bindings/node/examples && npm install
npm start # render raccoon.jpg (plain ASCII)
node render.mjs --color # ANSI truecolor
node render.mjs photo.png font.ttf --color # custom image + font
node pipeline.mjs # step-by-step low-level APImake shared
pip install bindings/pythonimport glif
result = glif.render('photo.png', 'fonts/GeistMono-Regular.ttf')
print(result.to_plain_text())Quick start — run the bundled examples (from bindings/python/):
python -m examples.render ../../images/raccoon.jpg ../../fonts/GeistMono-Regular.ttf
python -m examples.render ../../images/raccoon.jpg ../../fonts/GeistMono-Regular.ttf --color
python -m examples.pipeline ../../images/raccoon.jpg ../../fonts/GeistMono-Regular.ttfThe C core compiles to WebAssembly via Emscripten. All tools run entirely client-side — no server processing.
| Tool | URL | Description |
|---|---|---|
| Demo | index.html |
Drag-drop images/video/webcam, live ASCII rendering |
| Player | player.html |
Play .glif files with audio, HDR, fullscreen |
| Transcoder | transcode.html |
Create .glif from video in-browser |
| Compare | compare.html |
Side-by-side original vs ASCII via ffmpeg-wasm |
make wasm && make wasm-player && make wasm-encode # build all WASM targets
cd web && python3 -m http.server 8000 # serve locallyTry it live at glif.artalis.io
src/
image.c/h sRGB-to-linear lightness via LUT
sampling.c/h Circle sampling geometry and precomputed offset masks
grid.c/h Image-to-grid decomposition, Vec6/Vec10 computation
font.c/h Font rasterization and character shape database
contrast.c/h Directional + global + adaptive contrast enhancement
match.c/h Nearest-neighbor character matching with LRU cache
output.c/h Plain, ANSI, PPM, raw pipe, .glif binary writer
compress.c/h Deflate-based compression codecs (7 frame types)
blip.c/h Crushed PCM audio encoder (downsample + quantize)
glif.c/h .glif binary decoder (GlifReader)
temporal.c/h Temporal smoothing (normalization, shape, contrast, hysteresis)
vec6.h Header-only 6D/10D vector math
main.c CLI entry point
platform/wasm/ WASM entry points (demo, player, extension, encoder)
platform/linux/ v4l2loopback output
extension/ Chrome extension (Manifest V3)
web/ Web suite (demo, player, transcoder, compare)
vendor/ Vendored single-header libraries (stb, Nuklear, Clay, utest.h)
tests/ Unit tests (185 tests across 12 test files)
scripts/ Convenience scripts for transcoding, webcam, and embed
tools/ Benchmark and diagnostic tools
Input image/frame
|
sRGB -> linear luminance (LUT)
|
Grid decomposition (cells)
|
6 internal circles -> Vec6 shape vector
10 external circles -> Vec10 contrast vector
|
Directional contrast (internal vs. neighbors)
Global contrast (normalize + exponentiate)
Adaptive contrast (per-frame percentile scaling)
|
[Temporal smoothing for video]
|
Nearest-neighbor match in 6D space -> character
|
Output: ASCII | ANSI | PPM | raw | .glif | WebGL
Three optimizations enable real-time throughput:
- sRGB LUT — 256-entry lookup table replaces per-pixel
powf()(21x speedup) - Precomputed circle masks — Offset tables built at startup eliminate per-pixel distance tests; interior cells skip bounds checking (4.4x speedup)
- OpenMP + SIMD — All pipeline stages parallelized; inner loops use SIMD reduction (2x+ on multicore)
Benchmarks on Apple M3 Pro (11 cores), -O2:
| Image | Resolution | Cells | Per-frame | FPS |
|---|---|---|---|---|
| raccoon.jpg | 679x679 | 2,211 | 0.69 ms | 1,440 |
| wildboar.jpg | 1100x731 | 3,960 | 0.91 ms | 1,096 |
make tools/bench
./tools/bench images/raccoon.jpg -f fonts/GeistMono-Regular.ttfmake test # 185 C unit tests across 12 modules
npm run test:ext # Chrome extension smoke tests (requires npm install)C tests cover all pipeline stages: vector math, sampling, image loading, grid computation, contrast enhancement, character matching, output formats (including lossy preprocessing round-trips), temporal smoothing, deflate compression codecs, .glif round-trip encoding/decoding, and crushed PCM audio encoding/decoding. Uses Sheredom's utest.h framework.
Extension tests use Puppeteer to launch Chrome with the extension loaded, navigate to a test page with a synthetic video, and verify the full overlay lifecycle: injection, overlay creation, WebGL context, params/hi-res updates, disable/re-enable, SPA navigation (pushState), and replaceState no-op.
This question comes up, so here's the reasoning.
The codebase processes untrusted input (images, fonts, .glif files) through vendored C libraries (stb_image, stb_truetype, miniz). The attack surface is real. We address it with defense-in-depth rather than language-level guarantees:
pledge()/unveil()sandboxing on OpenBSD and Linux (seccomp-bpf + Landlock) — every CLI tool locks down syscalls and filesystem visibility after arg parsing, before touching input-D_FORTIFY_SOURCE=2 -fstack-protector-strong— compile-time and runtime buffer overflow detection- AddressSanitizer + UBSan in debug builds
- All inputs bounds-checked at system boundaries (
strtolwith full validation, size overflow checks before allocation) - LLM-assisted auditing for memory safety, use-after-free, and missing bounds checks
Why not Rust? Rust solves memory safety at the type system level, which is genuinely valuable at scale — large teams, deep dependency trees, long-lived codebases with contributor churn. For a small, focused project with vendored deps and one or two authors who understand every line, the tradeoffs don't pay off:
- FFI kills the safety story. The core pipeline calls stb_image, stb_truetype, and miniz on every frame. Each call crosses an
unsafeFFI boundary. You get Rust's complexity without Rust's guarantees where they matter most. - Borrow checker fights the domain.
GlifReaderholds a buffer and references into it (self-referential). The video pipeline reuses allocated buffers across frames and passes raw pointers between stages. These are natural in C and a restructuring project in Rust. - You solve Rust problems, not domain problems. Time spent on lifetime annotations,
Arc<Mutex<>>wrappers, orphan rule workarounds, andPin/Unpinincantations is time not spent on sampling geometry, contrast curves, or codec strategies. - Cargo supply chain risk. A typical Rust CLI pulls 200+ transitive crates from random maintainers. One compromised crate, one typosquat — you're shipping malicious code. This project vendors 3 single-header libraries you can read end to end.
- Compile time. Clean build: ~2 seconds. A comparable Rust project with
image,clap,rayon: 30–90 seconds. Fast iteration matters for exploratory work.
Why not Zig? Zig gets a lot right — comptime over macros, explicit allocators, no hidden control flow, C interop for free. For a new project not needing Emscripten or OpenMP, it would be a strong choice. For this project, the ecosystem isn't there yet: no stb replacements, no stable 1.0, and the WASM build relies heavily on Emscripten features (EXPORTED_FUNCTIONS, MODULARIZE, SINGLE_FILE) that Zig's WASM target doesn't support.
Why not C++? No. Everything C gives you here but with a language that actively fights simplicity. The real risk isn't technical — it's cultural. C++ doesn't have a culture of restraint. Your parse_args is 200 lines of clear strcmp chains; in C++ someone would reach for a CLI parsing library or a template-based argument parser. Both worse.
Why not Go / Java / C#? Managed languages add runtime weight and GC pauses for problems this project doesn't have. The per-frame pipeline runs in <1ms — Go's GC alone can exceed that. No manual memory layout control, no SIMD, and no WASM story that matches the current Emscripten setup.
Why not Python? About 2 frames per century.
Standalone utilities for working with .glif files:
| Tool | Description |
|---|---|
glif_verify |
Validate .glif file integrity, dump frame stats |
glif_transcode |
Re-encode .glif files with current compression |
make tools/glif_verify tools/glif_transcode
./tools/glif_verify input.glif
./tools/glif_transcode input.glif output.glifImplements the ASCII rendering technique from Alex Harri's article "Rendering ASCII art from images". The core insight — representing characters as multi-dimensional shape vectors sampled from overlapping circles — comes from that article.
| Library | Author | License |
|---|---|---|
| stb_image | Sean Barrett | Public domain |
| stb_image_write | Sean Barrett | Public domain |
| stb_truetype | Sean Barrett | Public domain |
| stb_rect_pack | Sean Barrett | Public domain |
| Nuklear | Micha Mettke | MIT / Public domain |
| Clay | Nic Barker | zlib |
| utest.h | Sheredom | Unlicense |
| pledge | Justine Tunney | ISC |
A monospace TTF font is required (-f flag). Fonts are user-provided and not included in the repository. During development, Geist Mono (SIL Open Font License) and SF Mono (Apple proprietary, not redistributable) were used for testing.
raccoon.jpgandwildboar.jpg— Creative Commons licensed test images- Utility images (
checkerboard.png,gradient.png,shapes.png,stripes.png) — generated, no attribution needed
MIT License. Copyright (c) 2026 Mark Farkas.
