eger
A high-level, parallel ASCII-art media pipeline built on top of iascii.
eger turns images and (optionally) videos into ASCII/ANSI art, with a
fluent configuration API, Rayon-parallel batch processing, and — behind the
video feature — an async, ffmpeg-backed video pipeline with terminal
playback.
At a glance
| Module | Responsibility |
|---|---|
[config] |
Fluent Config/ConfigBuilder: input source, sizing, color, parallelism. |
[image] |
Single-image and Rayon-parallel batch image → ASCII rendering. Always on. |
[video] |
ffmpeg-streamed video → ASCII frame sequences, plus terminal playback. video feature only. |
[render] |
Output targets (stdout / file / String / Vec<String>) and color-depth detection. |
[error] |
Unified ConfigError / egerError / Result. |
Feature flags
-
default — synchronous image rendering only.
image::render_batch_parallel(Rayon-based parallel batch processing) works out of the box and does not require thevideofeature. -
video(off by default) — pulls intokioand enables:- the
videomodule itself (requiresffmpeg/ffprobeonPATHat runtime — there's no bundled/vendored ffmpeg), - the async (
_async) variants of the image API (render_image_file_async,render_batch_async), render::dispatch_asyncandrender::play_terminal.
These all share the same Tokio runtime dependency, which is why they're gated together rather than each having their own flag.
- the
Quick start
[]
= "0.1"
use *;
With features = ["video"]:
use Arc;
use *;
async
Configuration
Config is built once via ConfigBuilder and is cheap to share (wrap it in
Arc<Config> for the video/async APIs, which already expect that). All
setters are infallible and chainable; validation happens once, centrally, in
ConfigBuilder::build:
- the source path exists (
from_file) or is a real directory (from_dir), - the batch regex pattern compiles,
- the underlying
iascii::config::ConfigBuilderitself validates cleanly.
Calling from_file and from_dir more than once (in either order) is not
an error — whichever was called last wins, since each just overwrites the
builder's internal path/pattern/is_dir state.
Input sources
// Single file
builder.from_file;
// Every file in a directory whose name matches a regex
builder.from_dir;
InputSource::Directory resolution (Config::files) is:
- non-recursive — subdirectories are skipped even if their name
matches the pattern (only
Path::is_file()entries are considered), - case-sensitive — pattern matching goes through
regex, with no implicit(?i)flag, - sorted — results are
Vec::sort-ed for deterministic ordering regardless ofread_dir's OS-dependent iteration order, which matters for reproducible parallel batch runs, - an error, not an empty
Vec, when nothing matches (ConfigError::NoMatchingFiles).
Output
ConfigBuilder::output sets a default output path (Config.output).
Every *_to_file convenience function (image_to_file, video_to_file)
prefers an explicitly-passed path over this default, and only falls back to
config.output when None is passed — so a single Config can be reused
across calls that override the destination per-file and calls that rely on
the configured default.
Rendering pipelines
Images
render_image_file/render_dynamic_image— single image, sync.render_image_file_async— same, off-loaded to Tokio's blocking pool (videofeature).render_batch_parallel— every file fromConfig::files(), converted in parallel on a dedicated Rayon pool sized byconfig.num_threads. A per-file failure (e.g. a corrupt image) surfaces as anErrentry for that file without aborting the rest of the batch — theVecresult always has one entry per input file, each independentlyOk/Err.render_batch_async— one Tokio task per file so I/O overlaps, while the CPU-bound decode+convert step for each still runs on the blocking pool (videofeature).- Convenience wrappers:
image_to_string,image_to_lines,image_to_file.
Video (video feature)
Frames are streamed as raw RGB24 straight out of an ffmpeg pipe (no
temporary frame files) and converted in bounded chunks (chunk_size = num_threads * 4, minimum 1) on a single, reused Rayon pool — this caps
memory to roughly one chunk of frames at a time while still overlapping the
next chunk's pipe read with the current chunk's CPU-bound conversion.
probe—ffprobefor resolution/fps/frame count.render_video_frames— the full frame pipeline, with an optionaldepth_overrideto render the same video at a differentColorDepthwithout rebuildingConfig.video_to_lines/video_to_file— convenience wrappers.video_to_filejoins frames with"\n\x1E\n"(ASCII record separator) so they can be split back out losslessly.play_video— renders and immediately plays back in-terminal at the source's native fps, without touching disk.
Every video entry point returns egerError::FfmpegNotFound if ffmpeg
isn't on PATH, or egerError::Ffmpeg(status, stderr) if it runs but
exits non-zero (e.g. probing a missing/corrupt file).
Color depth
render::detect_render_depth() picks a ColorDepth appropriate for the
current terminal, honoring:
NO_COLOR(any value) → plain text,TERM=dumb→ plain text,- stdout not actually being a terminal (e.g. piped to a file) → plain text,
- otherwise, the richest depth the terminal advertises:
COLORTERM=truecolor/24bit→TrueColor;TERMcontaining256color→Ansi256; otherwiseAnsi16.
This is purely an opt-in convenience — nothing in the crate calls it
automatically. Config::color_depth (defaulting to ColorDepth::TrueColor)
is what every rendering entry point actually uses unless a caller passes an
explicit override (e.g. render_video_frames's depth_override).
Testing
# Synchronous image pipeline only
# Full pipeline, including the video/ffmpeg-backed tests
Video tests generate their own tiny synthetic clips at runtime via
ffmpeg -f lavfi -i testsrc=... (no checked-in fixture files) and skip
themselves at runtime (printing a message rather than failing) if
ffmpeg/ffprobe aren't found on PATH. image_pipeline.rs and
video_pipeline.rs are black-box integration tests that only exercise the
crate's public API (eger::prelude::*), the way an external consumer
would.
Known sharp edges
ConfigError::MediaTypeNotFoundis currently unreachable.Config::builder(media_type)always takes aMediaTypeup front, so nothing inConfigBuilder::buildcan ever produce this variant today. If you're relying onmatches!(err, ConfigError::MediaTypeNotFound)somewhere, it will never trigger — treat it as reserved for a future API change (e.g. aDefault-derived builder) rather than a case you need to handle now.num_threadsisn't validated.ConfigBuilder::num_threadsstores whateverusizeit's given, including0, without special-casing it. Downstream,rayon::ThreadPoolBuilder::num_threads(0)falls back to Rayon's own default parallelism rather than erroring, so0is usable but is not the same as "use the default" being explicit — omitting the call entirely (which defaults tostd::thread::available_parallelism()) is the clearer way to express that.color_depthis always applied, never optional, on the single-image convenience API.render_dynamic_imagecallsrender::dispatch(target, &grid, Some(config.color_depth))— there is no code path throughimage_to_string/image_to_lines/image_to_filethat produces plain, escape-free output. To get plain text, either post-process with a plain-grid render function directly, or driverender::dispatchwithNoneat a lower level.