The fun of ASCII art and animations

5 July 2025 . 12 min read

The Spark

Recently I saw a youtube video by Ed about how ASCII animations work in the ghostty terminal. This is the video.

He shows how the ghostty webpage has this cool ghost animation with ASCII characters. Though it seems kind of simple, but no sooner I found out it very much isn't. I have been fascinated by ASCII art for a long time and I did a little side quest of converting images into ASCII art (or so) with rust.

This was mainly taken from the rustfully discord server. If you are interested, you can see it here. I will be using it later for the animation too.

I also saw many people use this old school but

   █████╗ ███╗   ███╗ █████╗ ███████╗██╗███╗   ██╗ ██████╗
  ██╔══██╗████╗ ████║██╔══██╗╚══███╔╝██║████╗  ██║██╔════╝
  ███████║██╔████╔██║███████║  ███╔╝ ██║██╔██╗ ██║██║  ███╗
  ██╔══██║██║╚██╔╝██║██╔══██║ ███╔╝  ██║██║╚██╗██║██║   ██║
  ██║  ██║██║ ╚═╝ ██║██║  ██║███████╗██║██║ ╚████║╚██████╔╝
  ╚═╝  ╚═╝╚═╝     ╚═╝╚═╝  ╚═╝╚══════╝╚═╝╚═╝  ╚═══╝ ╚═════╝
	
text style for their texts. Maybe I'll try this in my next side quest

The next few paragraphs have a lot of yapping about my struggles. If you do not care about my issues and efforts, click here to skip to the main part.

Trying to understand the ASCII

So, how do the ascii animations work? It is essentially like all other animations with frames which swap in a sequence. So technically you can just put a video element in your webpage to get this effect.

But that is no fun, is it? Also this works on a website or app but how do you get this in a terminal so that you can flex your riced linux with fancy themes.

Reusing past learnings

As I mentioned, in the past I have made (or rather copied mostly,) an image to ascii converter with rust. I thought I would do the same for the animations. The problem was, the rust image to ascii converter that I had, took colors from the image and rendered it in the console with ANSI colors. So, I just figured I wouldn't take the colors this time or maybe try the same with ANSI colors.

Now, How do I get the frames for my animation, even if I take out the frames from a simple animation with something like ffmpeg, it is certainly not efficient to convert them to ascii one by one. I could use my rust program here, but I will have to store the ascii text so that I don't convert the video to frames or frames to ascii every time I run it. It will effectively be cached in a .txt file let's say.

Understanding what was already done

Let's look back at what the rust implementation of the image to ascii converter did. I was using two crates for this program, the image and the clap crates.

[package]
name = "image-to-ascii"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.5.37", features = ["derive"] }
image = "0.25.6"

At the top of the program, I had an ASCII lookup table for looking up the correct character based on the density, or fill of the required box.

const ASCII_LOOKUP: [&str; 16] = [
    " ", // 0000 - Empty space
    "~", // 0001 - Bottom-left corner
    "$", // 0010 - Bottom-right corner
    ">", // 0011 - Bottom edge
    "╶", // 0100 - Top-left corner
    "=", // 0101 - Left edge
    ">", // 0110 - Diagonal (top-left to bottom-right)
    "=", // 0111 - Heavy horizontal
    "^", // 1000 - Top-right corner
    "+", // 1001 - Diagonal (top-right to bottom-left)
    "$", // 1010 - Right edge
    "$", // 1011 - Heavy right side
    "~", // 1100 - Top edge
    "*", // 1101 - Heavy intersection
    "@", // 1110 - Heavy bottom
    "#", // 1111 - Full block
];

The next step I did was setup command line arguments using clap. These were used to set options for the input and output of the program.

#[derive(Parser)]
struct Cli {
#[clap(name = "path")]
input_path: String,

#[clap(short, long)]
gray_scale: bool,

#[clap(short, long)]
#[clap(long, default_value = "20")]
width: u32,

#[clap(long, default_value = "20")]
height: u32,

#[clap(short, long)]
output: Option<String>,

#[clap(
  long,
  help = "The program will save the image produced for the asciifying to a file"
)]
save_intermediate: Option<String>,

#[clap(short, long)]
fattness: Option<f32>

I resized the image to the width and height as passed through the CLI. The image crate retrieved the x, y and rgba values of each pixel of the image. I set the transparent pixels as a whitespace " " to keep the transparency of png images. The rest of the code was just looking up the correct character from the lookup table created earlier and setting appropriate colors for the characters from these obtained values.

Getting frames for the animations

The first thing needed for the animation are the frames that we will animate eventually. Download any small and simple animation or video you like. The more contrast it has between the background and the subject, the better the translation into ASCII.

ffmpeg is a great tool to use now. We retrieve the frames from the video just downloaded with the following command ffmpeg -i input.mp4 frame_%04d.jpg . This command extracts all the frames from the video.

If the video is a five-second video shot at 25 frames per second (fps). This means each second of the video has 25 frames. Running the above command for this video will generate 125 frames (5 x 25 = 125 frames). And if you were to run it for a 60-second video shot at 30 frames per second, you would get 1800 images (60 x 30 = 1800 frames).

Code Modification and setup for the animation part
I set up similar command line options as the previous program.
#[derive(Parser)]
struct Cli {
    #[clap(name = "path", help = "Path to the input file (image or video)")]
    file_name: String,

    #[clap(
        long,
        default_value = "true",
        help = "Process a video file as an animation"
    )]
    animation: bool,

    #[clap(
        short,
        long,
        help = "Use grayscale ASCII characters instead of colored output"
    )]
    gray_scale: bool,

    #[clap(short, long, default_value = "20", help = "Width of the ASCII output")]
    width: u32,

    #[clap(long, default_value = "20", help = "Height of the ASCII output")]
    height: u32,

    #[clap(short, long, help = "Output directory for the ASCII text frames")]
    output: Option<String>,

    #[clap(long, help = "Save the resized image used for ASCII conversion")]
    save_intermediate: Option<String>,

    #[clap(short, long, help = "Adjust the width scaling factor")]
    fatness: Option<f32>,

    #[clap(
        long,
        default_value = "24",
        help = "Frames per second for animation extraction"
    )]
    fps: u32,

    #[clap(long, help = "Play the animation after processing")]
    play: bool,

    #[clap(
        long,
        default_value = "41",
        help = "Milliseconds delay between frames during playback"
    )]
    delay: u64,

    #[clap(long, help = "Keep intermediate frame images after processing")]
    keep_frames: bool,
}
Generating ASCII maps for the frames

The image extraction part was just calling the ffmpeg command I previously showed within rust. Although you could use any other tool to extract the frames from the video. I used ffmpeg because it is a very popular tool and available on most systems.

fn extract_frames_from_video<P: AsRef<Path>>(
    video_path: P,
    output_dir: P,
    fps: u32,
) -> Result<(), Box<dyn std::error::Error>> {
    let output_pattern = output_dir.as_ref().join("frame_%04d.png");

    let status = Command::new("ffmpeg")
        .arg("-i")
        .arg(video_path.as_ref())
        .arg("-vf")
        .arg(format!("fps={}", fps))
        .arg("-pix_fmt")
        .arg("rgb24")
        .arg(output_pattern)
        .status()?;

    if !status.success() {
        return Err("ffmpeg command failed".into());
    }
    Ok(())
}

We already have the program to convert images to ASCII maps. We can make a simple modification to run the program with a loop on all the frame(images).

If you want to see the rest of the code, you can see it on my github repo.

The result

Here is a demo video of the animation.

The ascii output wasn't as smooth, and also my video choice was not particularly good

That's all in this story, see you in the next one.