feat: add charsets, --trim, animations, and GIF playback#28
Open
fnordpig wants to merge 12 commits into
Open
Conversation
Polar coordinate vortex ordering: cells sorted by radius (primary) with angular twist (secondary), creating a spinning expansion from center outward. 4 full rotations from center to edge.
Contributor
Reviewer's GuideAdds 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 trimmingsequenceDiagram
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
Sequence diagram for static and animated image rendering with trim and effectssequenceDiagram
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
Class diagram for render options, grids, trimming, animation, and GIF playbackclassDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Contributor
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- In
lib.rstherenderfunction trims the image before callingrender_image, butrender_imageitself conditionally trims again based onoptions.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_burnsnever actually pans becauseview_yandview_xare computed fromrows.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::playruns 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
- 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.
Contributor
Author
|
Addressed the review feedback in c5310a5:
|
Contributor
Author
|
@sourcery-ai resolve |
Contributor
Author
|
@sourcery-ai review |
Contributor
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The new
animator.rsmodule is referenced frommain.rsasanimator::...but there is no correspondingmod animator;declaration (e.g., inmain.rsorlib.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_gridduplicates 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Contributor
Author
|
Addressing the second review:
No code changes needed for this round. |
Contributor
Author
|
@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).
Contributor
Author
|
Hi! Gentle nudge on the PR - hoping to use for some logon banners with animations |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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--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).gifextension, decodes all frames and plays in terminal with proper frame delaysTest plan
rascii image.png --trimremoves black bordersrascii image.png -C dense -crenders with 95-level charsetrascii image.png --trim -a whirl-out -c -d 3plays vortex animationrascii animated.gif -c -w 40plays animated GIF in terminalSummary by Sourcery
Add image border trimming, new character sets, terminal animations, and animated GIF playback to the ASCII renderer.
New Features:
Enhancements:
Tests: