eger
A parallel, async image/video → ASCII-art pipeline for Rust, built on top of
iascii. eger turns images, animated GIFs,
and video into colored (or plain) ASCII/ANSI text, gives you the building
blocks to animate text and render big block-letter banners, and lets you
drive the color of any of that ASCII art — a single image, a banner, a
GIF, or a video — from your own math instead of (or on top of) iascii's
own pixel coloring.
use *;
let config = builder
.from_file
.max_width
.color_depth
.build?;
let ascii = image_to_string?;
println!;
Why eger
- One conversion path, several entry points. Single image,
Vec<String>, a file, a batch of files, an animated GIF, or a live video stream — they all funnel through the sameiascii-backed conversion, so sizing, luminance, ramp, and color settings behave identically everywhere. - Two kinds of parallelism, used deliberately. Rayon parallelizes
across files/frames in batch, video, and colorizing pipelines (always
on), and optionally within a single large conversion (row-level, via the
parallelfeature) — see Parallelism below. - A real animation layer, not just a converter.
textandbannergive youTypewriter,Rainbow,Wave,Blink,Marquee,Pulse, and fully custom (subprocess- or closure-driven) animations;colorizegives you the same idea at the pixel level, for art whose own text content isn't animated — a converted image, a banner, a GIF, or a video. - Color driven by math, not just presets.
colorize::PixelAnimationships useful built-ins (Rainbow,Wave,Pulse,Blink,Plasma,Ripple), butPixelAnimation::Customhands you a plain(x, y, frame, ch) -> Rgbfunction — write whatever math you want (gradients, noise, distance fields, cellular automata) and it colors correctly and efficiently, with no support needed from the crate beyond the coordinates it's called with. - Terminal-friendly by construction. Auto-detected color depth and
width, cursor-addressed diffed repainting (
DiffGranularity) for low-flicker updates, and a real terminal-size query — the pieces a TUI or terminal game needs, without pulling in a full TUI framework. - Runs in the browser too. The
wasmfeature exposes awasm-bindgensurface for image/GIF → ASCII conversion and frame-at-a-time text animation, driven by your ownrequestAnimationFrame/setIntervalloop.
Feature flags
| Feature | Default | Pulls in | Enables |
|---|---|---|---|
parallel |
✅ on | iascii/parallel |
Row-level parallel conversion for one large image/frame (see below). |
video |
off | tokio |
The video module, async image APIs, render::play_terminal[_diffed], render::dispatch_async. Requires ffmpeg/ffprobe on PATH at runtime. |
wasm |
off | wasm-bindgen |
The wasm module — browser-facing image/GIF/text-animation bindings. target_arch = "wasm32" only; not meant to be combined with video. |
colorize needs no feature flag — it's always available, built on the
rayon dependency the crate already pulls in unconditionally for its
always-on across-file/frame parallelism.
# Default (parallel image conversion, no video/wasm):
= "1"
# With video streaming/playback:
= { = "1", = ["video"] }
# For a WASM build:
= { = "1", = false, = ["wasm"] }
What you can build with it
- Terminal UIs and dashboards — render a static image or a live-updating
ASCII visualization as part of a larger TUI;
render::diff_frameandDiffGranularitygive you flicker-free partial repaints instead of clear-and-redraw. - Terminal ASCII games —
bannerfor title screens and score displays,text's animation styles for HUD elements and transitions,colorizefor a rainbow/plasma/ripple title screen or a custom color effect, andrender::terminal_size/auto_widthto adapt to whatever terminal the player is running. - Video and GIF playback in the terminal —
video::play_videoandvideo::play_gifstream frames directly to stdout, timed to the source frame rate/delay, with bounded memory viastream_video_frames(or its colorized counterpart,stream_colorized_video_frames) rather than decoding the whole file up front. - Batch ASCII art pipelines —
render_batch_parallel/render_batch_asyncconvert a whole directory of images in parallel and write results out per-file. - Stylized, non-photorealistic ASCII art — take any converted image,
banner, GIF, or video and recolor it with
colorize::PixelAnimationinstead of (or as well as) its actual pixel colors: a plasma-shaded video, a rainbow-swept banner, a custom radial gradient over a logo. - Browser ASCII art — compile with
--features wasm --no-default-featuresand call the exported functions directly from JS.
Usage
Rendering a single image
use *;
use Path;
let config = builder
.from_file
.auto_width // fits the current terminal, falls back to 100
.color_depth
.build?;
// To a String, to Vec<String>, or straight to stdout/a file:
let s = image_to_string?;
let lines = image_to_lines?;
image_to_file?;
Batch-converting a directory (Rayon, always on)
use *;
use RenderTarget;
let config = builder
.from_dir
.num_threads
.build?;
let results = render_batch_parallel?;
for in results
Animated GIFs
let frames = gif_to_lines?;
for in frames
Video (requires the video feature and ffmpeg/ffprobe on PATH)
use *;
use Arc;
let config = new;
play_video.await?;
For long or high-resolution video, prefer stream_video_frames (bounded
channel, chunked Rayon conversion) over render_video_frames, which holds
every frame in memory.
Text animation
use *;
let opts = TextAnimOptions ;
play_text?;
Block-letter banners
use *;
// Plain, uncolored 7-row block text:
for line in banner_lines
// Animated, colored banner frames:
let opts = TextAnimOptions ;
let frames = banner_frames?;
Pixel-level color animation (colorize)
text/banner color one character (or one banner glyph) at a time.
colorize colors one grid cell at a time, so it works the same way over
a converted image, a banner, a GIF frame, or a video frame — anything
that's ultimately rows of characters.
use *;
// Animate a static image's color over 30 frames instead of re-converting it:
let img = open?.to_rgb8;
let grid = convert?;
let frames = colorize_grid_frames;
// Recolor a banner with a diagonal rainbow sweep:
let lines = banner_lines;
let frame = colorize_lines;
// Fully custom math — a radial gradient, ignoring frame/char entirely:
let art = colorize_lines;
For content that's already animating frame to frame (a GIF or a video), recolor the existing sequence in place — each frame's position in the sequence drives the color animation's time input, so the two stay in sync:
// GIF: gif_to_lines already gives you plain (uncolored) per-frame lines.
let gif_frames = gif_to_lines?;
let lines_only: = gif_frames.into_iter.map.collect;
let colored = colorize_sequence;
// Video (requires the `video` feature): stream, don't collect, for long files.
use *;
use mpsc;
let = channel;
let animation = Plasma ;
spawn;
while let Some = rx.recv.await
render_colorized_video_frames is the same idea without the channel, for
short clips where holding every frame in memory is fine (same tradeoff as
render_video_frames vs. stream_video_frames).
Low-flicker terminal repainting
use ;
let patch = diff_frame;
print!; // only the changed rows are redrawn
WASM (requires the wasm feature)
// compiled with: wasm-pack build --no-default-features --features wasm
use *;
let ascii = image_bytes_to_string?;
import init from "./pkg/eger.js";
await ;
const art = ;
document.. = art;
Parallelism
eger deliberately separates where parallel work happens:
- Across files/frames (
render_batch_parallel,video::stream_video_frames, andcolorize's multi-frame functions —colorize_frames,colorize_grid_frames,colorize_sequence,colorize_joined_sequence, andvideo::stream_colorized_video_frames's per-chunk recoloring) — always on, plainrayon::par_iter/into_par_iterover independent units of work this crate already owns. No feature flag needed. - Within one conversion (
image::convert, used by every single-image and per-frame conversion call site) — gated behind theparallelfeature (on by default), forwarded toiascii'sconvert_with_pool, splitting one image/frame's rows across the pool.iascii's ownparallel_thresholddecides per-call whether that's worth it, so a small thumbnail transparently stays sequential.
Both levels share a single Rayon ThreadPool per Config (built lazily on
first use via Config::thread_pool, cached behind a OnceLock), so a batch
job or a video stream doesn't spin up a new pool — and a fresh set of OS
threads — per file or per frame. Nesting (a batch item large enough to also
trigger the inner row-level split) is a supported, non-deadlocking Rayon
pattern, and is covered by regression tests in image.rs and video.rs.
colorize's frame-level parallelism uses Rayon's default global pool
(rayon::prelude::*'s par_iter/into_par_iter) rather than a Config's
cached pool, since coloring doesn't need Config at all — it operates on
plain text, not on anything iascii produces from a Config.
num_threads(0) (or leaving it unset when the machine can't report
available_parallelism) defers to Rayon's own default sizing.
Coloring performance (colorize)
- Runs, not per-cell escapes. Adjacent same-color cells — including
across a row boundary, since ANSI color state isn't reset by a bare
\non any real terminal — are merged into a singleescape .. resetpair. A uniform-color frame (Pulse,Blink, aCustomclosure that ignores position) costs one escape pair for the whole frame, not one per character. - Escape strings are cached per frame. Each distinct
Rgbvalue's ANSI escape string is formatted once and reused for every later occurrence in that frame, so animations with many repeating-but-not- adjacent colors (Rainbow,Plasma) don't reformat the same string over and over. - No forced cloning.
colorize_lines/colorize_framesare generic overS: AsRef<str>, so callers already holding borrowed&strrows (likecolorize_joined_sequence, splitting one joined video frame on\n) pass them straight through instead of allocating ownedStrings first. - Dimensions computed once per call, not once per generated frame —
colorize_frames/colorize_grid_framesmeasurelines' width/height a single time and reuse it across every frame, since the source art's shape doesn't change frame to frame. - Bounded memory for video.
stream_colorized_video_framescolors video frames as they're decoded, chunk by chunk, instead of requiring the whole video's frames in memory first — the same bounded-memory designstream_video_framesuses for plain video rendering.
Resource cleanup
- The Rayon thread pool cached on
Configis torn down normally when theConfig(and everyArcclone of it) is dropped — no manual shutdown needed. video'sffmpegchild process is spawned withkill_on_drop(true)and is killed explicitly the moment its output channel's receiver is dropped, rather than being left to write into a pipe nobody is draining — which would otherwise blockffmpegindefinitely and hang the correspondingchild.wait().stream_colorized_video_framesinherits this: if its own output channel's receiver is dropped, it stops reading from the underlying plain-frame stream, which propagates the same way.
Benchmarks
Criterion benchmarks live in benches/ and cover image conversion at
several sizes/color depths, banner/text/colorize frame generation, and
(with --features video) the video frame-conversion hot path:
See benches/README.md for what each group measures.
MSRV
Rust 1.80, as declared in Cargo.toml's rust-version.
License
Dual-licensed under MIT or Apache-2.0, at your option.