Skip to content

feat: add charsets, --trim, animations, and GIF playback#28

Open
fnordpig wants to merge 12 commits into
orhnk:masterfrom
fnordpig:feat/trim-animate-charsets
Open

feat: add charsets, --trim, animations, and GIF playback#28
fnordpig wants to merge 12 commits into
orhnk:masterfrom
fnordpig:feat/trim-animate-charsets

Conversation

@fnordpig
Copy link
Copy Markdown
Contributor

@fnordpig fnordpig commented Apr 7, 2026

Summary

  • 5 new charsets: dense (95-level all-ASCII), braille (dot-matrix), blocks (geometric pixel-art), stipple (pointillist), hybrid (braille+blocks HDR mix)
  • --trim: Variance-based border cropping that detects and removes empty edges (solid color, transparent) before rendering, so content fills the available space
  • 7 animation effects via --animate <effect>: dissolve-in, dissolve-out, swirl-in, swirl-out, whirl-in, whirl-out, ken-burns — retro BBS-style terminal animations using ANSI cursor positioning
  • --duration <seconds>: Control animation timing (default 3s)
  • Animated GIF playback: Auto-detected by .gif extension, decodes all frames and plays in terminal with proper frame delays

Test plan

  • 4 integration tests for trim (black border, no border, transparent border, solid image)
  • Manual: rascii image.png --trim removes black borders
  • Manual: rascii image.png -C dense -c renders with 95-level charset
  • Manual: rascii image.png --trim -a whirl-out -c -d 3 plays vortex animation
  • Manual: rascii animated.gif -c -w 40 plays animated GIF in terminal

Summary by Sourcery

Add image border trimming, new character sets, terminal animations, and animated GIF playback to the ASCII renderer.

New Features:

  • Introduce variance-based image border trimming controllable via a new --trim CLI flag and RenderOptions field.
  • Add several new character sets (dense, braille, blocks, hybrid, stipple) and expose them via the charset option.
  • Implement terminal animation effects (dissolve, swirl, whirl, Ken Burns) with configurable duration for rendering grids.
  • Support animated GIF playback in the terminal by decoding frames and rendering them as precomputed grids.

Enhancements:

  • Expose a grid-based rendering API for images to support animations and GIF playback.
  • Extend RenderOptions builder with trim support and default configuration.

Tests:

  • Add integration-style tests to verify trimming behavior for bordered, borderless, transparent, and solid images.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 7, 2026

Reviewer's Guide

Adds variance-based border trimming, grid-based rendering for animations, CLI options for trim and animation control, new character sets, an animator for terminal effects, and animated GIF playback using pre-rendered grids.

Sequence diagram for animated GIF playback with trimming

sequenceDiagram
    actor User
    participant CLI as rascii_binary
    participant Main as main_rs
    participant Lib as rascii_art
    participant GifR as GifRenderer
    participant ImgR as ImageRenderer
    participant Term as Terminal

    User->>CLI: rascii animated.gif --trim
    CLI->>Main: parse Args
    Main->>Main: detect .gif extension
    Main->>Lib: render_gif(path, RenderOptions{trim:true,...})
    Lib->>GifR: GifRenderer::play(path, options)

    GifR->>GifR: open File and create GifDecoder
    GifR->>GifR: decode frames -> Vec<Frame>
    GifR->>Term: hide cursor, clear screen

    loop for each frame
        GifR->>GifR: convert Frame to DynamicImage
        alt options.trim == true
            GifR->>Lib: trim::trim_image(image)
            Lib-->>GifR: trimmed DynamicImage
        else options.trim == false
            GifR->>GifR: use original image
        end
        GifR->>ImgR: ImageRenderer::new(image, options)
        ImgR->>ImgR: render_grid() -> Grid
        ImgR-->>GifR: Grid
        GifR->>Term: move cursor to home
        GifR->>Term: write Grid cells with ANSI colors
        GifR->>Term: flush stdout
        GifR->>GifR: sleep(frame_delay)
    end
Loading

Sequence diagram for static and animated image rendering with trim and effects

sequenceDiagram
    actor User
    participant CLI as rascii_binary
    participant Main as main_rs
    participant Lib as rascii_art
    participant ImgR as ImageRenderer
    participant Anim as Animator
    participant Term as Terminal

    User->>CLI: rascii image.png --trim -a whirl-out -d 3
    CLI->>Main: parse Args
    Main->>Main: build RenderOptions{trim, charset,...}
    Main->>Main: detect non-gif

    alt animation requested
        Main->>Main: Effect::from_str(effect_name)
        alt effect valid
            Main->>Main: image::open(filename)
            alt args.trim == true
                Main->>Lib: trim_image(&image)
                Lib-->>Main: trimmed DynamicImage
            else args.trim == false
                Main->>Main: use original image
            end
            Main->>Lib: render_grid(&image, &options)
            Lib->>ImgR: ImageRenderer::new(image, options)
            ImgR->>ImgR: render_grid() -> Grid
            ImgR-->>Lib: Grid
            Lib-->>Main: Grid
            Main->>Anim: Animator::new(Grid, Effect, duration_secs)
            Anim->>Anim: play()
            Anim->>Term: hide cursor, clear screen
            loop effect frames
                alt dissolve, swirl, whirl
                    Anim->>Term: draw or clear cells via ANSI
                else ken-burns
                    Anim->>Term: pan viewport over Grid
                end
                Anim->>Term: flush stdout
                Anim->>Anim: sleep per frame timing
            end
            Anim->>Term: show cursor
        else invalid effect
            Main->>Term: print error and exit(1)
        end
    else no animation
        Main->>Lib: render(filename, stdout, &options)
        Lib->>Lib: render_image(path, options)
        Lib->>ImgR: ImageRenderer::new(image, options)
        alt options.trim == true
            Lib->>Lib: trim::trim_image(image)
        end
        ImgR->>ImgR: render_to(stdout)
        ImgR->>Term: write ASCII art
    end
Loading

Class diagram for render options, grids, trimming, animation, and GIF playback

classDiagram
    class RenderOptions {
        +u32~option~ width
        +u32~option~ height
        +bool colored
        +bool background
        +bool invert
        +bool trim
        +&str[] charset
        +with_width(width u32) RenderOptions
        +with_height(height u32) RenderOptions
        +colored(colored bool) RenderOptions
        +background(background bool) RenderOptions
        +invert(invert bool) RenderOptions
        +trim(trim bool) RenderOptions
        +charset(charset &str[]) RenderOptions
    }

    class Cell {
        +String ch
        +String color_pre
        +String color_suf
    }

    class Grid {
        <<type_alias>>
        +Vec~Vec~Cell~~ rows
    }

    class ImageRenderer {
        -&DynamicImage resource
        -&RenderOptions options
        +new(resource &DynamicImage, options &RenderOptions) ImageRenderer
        -get_grayscale(pixel &Rgba~u8~) f64
        -get_char_for_pixel(pixel &Rgba~u8~, maximum f64) char
        +render_to(to &mut Write) Result
        +render(buffer &mut String) Result
        +render_grid() Grid
    }

    class GifRenderer {
        +play(path &str, options &RenderOptions) io::Result
    }

    class Effect {
        <<enum>>
        DissolveIn
        DissolveOut
        SwirlIn
        SwirlOut
        WhirlIn
        WhirlOut
        KenBurns
        +from_str(s &str) Option~Effect~
    }

    class Animator {
        -Grid grid
        -Effect effect
        -Duration duration
        +new(grid Grid, effect Effect, duration_secs f64) Animator
        +play() io::Result
        -all_positions() Vec~(usize,usize)~
        -draw_cell(out &mut Write, row usize, col usize) io::Result
        -clear_cell(out &mut Write, row usize, col usize) io::Result
        -draw_full(out &mut Write) io::Result
        -play_dissolve_in(out &mut Write) io::Result
        -play_dissolve_out(out &mut Write) io::Result
        -play_batched(out &mut Write, positions &(usize,usize)[], reveal bool) io::Result
        -spiral_positions() Vec~(usize,usize)~
        -play_swirl(out &mut Write, from_center bool) io::Result
        -whirl_positions() Vec~(usize,usize)~
        -play_whirl(out &mut Write, outward bool) io::Result
        -play_ken_burns(out &mut Write) io::Result
    }

    class TrimModule {
        <<module>>
        +trim_image(image &DynamicImage) DynamicImage
        -row_variance(image &DynamicImage, y u32) f64
        -col_variance(image &DynamicImage, x u32) f64
        -grayscale(r u8, g u8, b u8) f64
    }

    class Charsets {
        <<module>>
        +BLOCK &str[]
        +BLOCKS &str[]
        +BRAILLE &str[]
        +HYBRID &str[]
        +DENSE &str[]
        +STIPPLE &str[]
        +from_str(s &str) Option~&str[]~
    }

    class RasciiLib {
        <<facade>>
        +render(path, to, options) ImageResult
        +render_image(path, to, options) ImageResult
        +render_to(path, buffer, options) ImageResult
        +render_grid(image &DynamicImage, options &RenderOptions) Grid
        +render_image_to(image &DynamicImage, buffer &mut String, options &RenderOptions) ImageResult
        +render_gif(path &str, options &RenderOptions) io::Result
        +trim_image(image &DynamicImage) DynamicImage
    }

    RasciiLib --> ImageRenderer : uses
    RasciiLib --> GifRenderer : uses
    RasciiLib --> TrimModule : reexports
    RasciiLib --> RenderOptions : configures
    RasciiLib --> Grid : returns

    ImageRenderer --> Grid : builds
    ImageRenderer --> RenderOptions : reads
    ImageRenderer --> Charsets : uses

    GifRenderer --> ImageRenderer : constructs
    GifRenderer --> Grid : stores
    GifRenderer --> RenderOptions : reads
    GifRenderer --> TrimModule : uses

    Animator --> Grid : animates
    Animator --> Effect : uses

    RenderOptions --> Charsets : reference

    Cell --> Grid : element_of
Loading

File-Level Changes

Change Details Files
Introduce a grid-based rendering path to support animations and GIF playback without altering existing text rendering APIs.
  • Add ImageRenderer::render_grid that scales the image, computes per-pixel chars/colors, and returns a 2D Grid of Cells instead of writing to an IO target
  • Add cell::Cell and cell::Grid types to represent rendered character cells with ANSI color prefixes/suffixes
  • Expose render_grid(image, options) from lib.rs using ImageRenderer::render_grid
src/image_renderer.rs
src/cell.rs
src/lib.rs
Add variance-based trim support that removes uniform or transparent borders before rendering, and thread it through render APIs and CLI.
  • Implement trim_image that computes row/column grayscale variance ignoring transparent pixels and crops to the high-variance bounding box
  • Extend RenderOptions with a trim flag and builder method with default false
  • Update render_image, render_to, and render_image_to to conditionally pass a trimmed image to ImageRenderer
  • Add CLI --trim/-t flag and propagate it into RenderOptions, including for animation and GIF paths
src/trim.rs
src/renderer.rs
src/lib.rs
src/main.rs
Support animated GIF playback in the terminal using pre-rendered ASCII grids and frame delays.
  • Add GifRenderer::play that decodes GIF frames, optionally trims them, renders each to a Grid, and loops playback with proper per-frame delays
  • Use ANSI codes to hide the cursor, clear the screen, reposition the cursor, and draw each frame grid
  • Expose render_gif from lib.rs and invoke it from main when the input filename ends with .gif
src/gif_renderer.rs
src/lib.rs
src/main.rs
Add an Animator component with several terminal animation effects driven by grids and a new CLI surface.
  • Define Effect enum and Effect::from_str for dissolve-in/out, swirl-in/out, whirl-in/out, and ken-burns
  • Implement Animator::play plus helpers to draw/clear individual cells and full grids using ANSI cursor addressing
  • Implement dissolve, swirl, whirl, and Ken Burns style effects using randomized or geometric orderings of grid positions with time-based pacing
  • Add CLI --animate/-a and --duration/-d options, parse effects, and route static images through trim + render_grid + Animator instead of plain render
src/animator.rs
src/main.rs
Expand available character sets for rendering and wire them into charset selection.
  • Add BLOCKS, BRAILLE, HYBRID, DENSE, and STIPPLE charset tables tuned for different visual styles and density levels
  • Update charset help text in main.rs to list the new built-ins
  • Extend charsets::from_str to map new charset names to their tables
src/charsets.rs
src/main.rs
Add tests and dependencies to support the new features.
  • Introduce trim-focused tests covering black borders, no borders, transparent borders, and solid images via render_image_to and RenderOptions::trim
  • Add rand dependency to drive randomized dissolve effects in Animator
tests/trim.rs
Cargo.toml
Cargo.lock

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In lib.rs the render function trims the image before calling render_image, but render_image itself conditionally trims again based on options.trim, which causes redundant work and can be confusing; consider centralizing trimming in one place (either only at the top-level entrypoints or only inside the *_image* helpers).
  • The Ken Burns implementation in Animator::play_ken_burns never actually pans because view_y and view_x are computed from rows.saturating_sub(rows) / cols.saturating_sub(cols) (always 0); you likely want to use a non-zero pan range (e.g., a fraction of rows/cols) so the viewport moves over time.
  • The GIF playback loop in GifRenderer::play runs indefinitely (loop { ... }), which means a GIF never naturally terminates; consider adding a configurable number of loops or honoring the CLI animation duration to avoid forcing users to Ctrl-C.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `lib.rs` the `render` function trims the image before calling `render_image`, but `render_image` itself conditionally trims again based on `options.trim`, which causes redundant work and can be confusing; consider centralizing trimming in one place (either only at the top-level entrypoints or only inside the `*_image*` helpers).
- The Ken Burns implementation in `Animator::play_ken_burns` never actually pans because `view_y` and `view_x` are computed from `rows.saturating_sub(rows)` / `cols.saturating_sub(cols)` (always 0); you likely want to use a non-zero pan range (e.g., a fraction of rows/cols) so the viewport moves over time.
- The GIF playback loop in `GifRenderer::play` runs indefinitely (`loop { ... }`), which means a GIF never naturally terminates; consider adding a configurable number of loops or honoring the CLI animation duration to avoid forcing users to Ctrl-C.

## Individual Comments

### Comment 1
<location path="src/animator.rs" line_range="273-274" />
<code_context>
+        let frame_duration = self.duration / total_frames.max(1) as u32;
+        let start = Instant::now();
+
+        for frame in 0..total_frames {
+            let t = frame as f64 / total_frames.max(1) as f64;
+
+            // Pan from top-left to bottom-right
</code_context>
<issue_to_address>
**issue (bug_risk):** Ken Burns effect never pans because `view_x` and `view_y` are always 0.

In `play_ken_burns`, both offsets are derived from zero:

```rust
let view_y = ((rows.saturating_sub(rows)) as f64 * t) as usize;
let view_x = ((cols.saturating_sub(cols)) as f64 * t) as usize;
```

`rows.saturating_sub(rows)` and `cols.saturating_sub(cols)` are always 0, so the viewport never moves. To get a top-left → bottom-right pan, compute offsets from the difference between the full size and the viewport, e.g.:

```rust
let max_y_offset = rows.saturating_sub(view_height);
let max_x_offset = cols.saturating_sub(view_width);
let view_y = (max_y_offset as f64 * t) as usize;
let view_x = (max_x_offset as f64 * t) as usize;
```

(or equivalent), using a defined viewport size so the interpolation has a non-zero range.
</issue_to_address>

### Comment 2
<location path="src/gif_renderer.rs" line_range="37-38" />
<code_context>
+        let grids: Vec<(Grid, Duration)> = frames
+            .iter()
+            .map(|frame| {
+                let (numer, denom) = frame.delay().numer_denom_ms();
+                let delay = Duration::from_millis((numer as u64) / (denom as u64).max(1));
+                let image = DynamicImage::ImageRgba8(frame.buffer().clone());
+                let image = if options.trim {
</code_context>
<issue_to_address>
**issue (bug_risk):** GIF frame delay calculation can produce 0 ms for short delays or higher denominators.

This integer division drops the fractional part and can yield a 0 ms delay for small `numer` or large `denom`, making the animation run much faster than intended. Consider computing in floating point and rounding/clamping instead, e.g.:

```rust
let ms = (numer as f64 / denom.max(1) as f64).max(1.0);
let delay = Duration::from_millis(ms.round() as u64);
```
so non-zero delays never collapse to zero.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/animator.rs
Comment thread src/gif_renderer.rs Outdated
- Remove double-trim: render() was trimming then calling render_image()
  which trimmed again. Trim now only happens in the *_image* helpers.
- Fix Ken Burns pan: viewport is 70% of grid, pans across the remaining
  30%. Previously rows.saturating_sub(rows) was always 0.
- Fix GIF delay truncation: use floating point division with 1ms floor
  to prevent short delays from collapsing to 0ms.
@fnordpig
Copy link
Copy Markdown
Contributor Author

fnordpig commented Apr 7, 2026

Addressed the review feedback in c5310a5:

  • Double-trim in lib.rs: Fixed. render() no longer trims before calling render_image() — trim now only happens once in the *_image* helpers.
  • Ken Burns always-zero pan: Fixed. Viewport is now 70% of the grid and pans across the remaining 30%.
  • GIF delay truncation: Fixed. Float division with 1ms floor replaces integer division.
  • GIF infinite loop: Intentional — standard behavior for terminal GIF players (Ctrl-C to stop). The cursor-hide/show is already handled.

@fnordpig
Copy link
Copy Markdown
Contributor Author

fnordpig commented Apr 7, 2026

@sourcery-ai resolve

@fnordpig
Copy link
Copy Markdown
Contributor Author

fnordpig commented Apr 7, 2026

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The new animator.rs module is referenced from main.rs as animator::... but there is no corresponding mod animator; declaration (e.g., in main.rs or lib.rs), so the crate won’t compile until the module is wired into the module tree.
  • The width/height calculation and image thumbnailing logic in ImageRenderer::render_grid duplicates the logic in the existing render path; consider extracting a shared helper so the two code paths stay consistent if the sizing math ever changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `animator.rs` module is referenced from `main.rs` as `animator::...` but there is no corresponding `mod animator;` declaration (e.g., in `main.rs` or `lib.rs`), so the crate won’t compile until the module is wired into the module tree.
- The width/height calculation and image thumbnailing logic in `ImageRenderer::render_grid` duplicates the logic in the existing render path; consider extracting a shared helper so the two code paths stay consistent if the sizing math ever changes.

## Individual Comments

### Comment 1
<location path="src/gif_renderer.rs" line_range="52-53" />
<code_context>
+            })
+            .collect();
+
+        // Play loop — Ctrl-C to stop
+        loop {
+            for (grid, delay) in &grids {
+                write!(out, "\x1b[H")?;
</code_context>
<issue_to_address>
**issue (bug_risk):** Infinite playback loop for GIFs makes the CLI never exit without SIGINT, which may be surprising for non-interactive use.

Because `render_gif` never returns, the CLI can’t be composed in scripts or other non-interactive contexts. Please make looping configurable (e.g. single pass by default with an opt-in `--loop`/`--repeat` flag) so the process can exit normally and callers can choose if/how to re-run.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/gif_renderer.rs
@fnordpig
Copy link
Copy Markdown
Contributor Author

fnordpig commented Apr 7, 2026

Addressing the second review:

  • "no mod animator declaration" — Incorrect. pub mod animator; is at lib.rs:23, and the crate compiles cleanly. main.rs accesses it via rascii_art::animator.
  • "render_grid duplicates width/height logic" — The existing render_to and render methods already duplicate this same logic. Pre-existing tech debt, not introduced by this PR. Could be a follow-up refactor.
  • "GIF infinite loop" — Responded inline. Intentional, matching chafa/viu/catimg behavior. A --loop N flag is a reasonable follow-up.

No code changes needed for this round.

@fnordpig
Copy link
Copy Markdown
Contributor Author

fnordpig commented Apr 7, 2026

@sourcery-ai resolve

Cells perform biased random walks to their destinations:
- ants-out: all cells start at center, walk outward to position
- ants-in: cells start at random edge positions, walk inward

Each frame redraws all cells at their current positions. 70% bias
toward destination with 30% random drift creates organic movement.

Also fixes clippy warnings in gif_renderer (io::Error::other).
@fnordpig
Copy link
Copy Markdown
Contributor Author

Hi! Gentle nudge on the PR - hoping to use for some logon banners with animations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant