Create ASCII art from videos with Lua and FFmpeg

ASCII art has long been a creative way to represent images using text characters. With modern tools like Lua scripting and the powerful multimedia framework FFmpeg, you can transform video frames into dynamic ASCII art, adding a unique visual flair to your multimedia projects.
Introduction to ASCII art and its creative applications
ASCII art uses standard text characters to visually represent images. This technique is ideal for creative coding projects, retro-style games, terminal-based applications, and unique video effects. By converting video frames into ASCII art, you can create engaging visual experiences that stand out in digital art, web design, and interactive installations.
Setting up the Lua and FFmpeg environment
Before diving into scripting, ensure you have Lua, LuaRocks (Lua's package manager), and FFmpeg installed. These tools provide the foundation for our video-to-ASCII conversion process.
For Ubuntu/Debian:
sudo apt-get update
sudo apt-get install lua5.4 luarocks ffmpeg
For macOS:
brew install lua luarocks ffmpeg
For Windows:
Install using the official installers or package managers like Chocolatey:
choco install lua ffmpeg
# Luarocks might require manual setup or alternative package managers on windows.
# Refer to the official luarocks documentation for Windows installation instructions.
Verify your installations by checking their versions in your terminal:
lua -v
luarocks --version
ffmpeg -version
Extracting video frames using FFmpeg
To convert video frames into ASCII art, you first need to extract individual frames from your video. FFmpeg makes this straightforward. Using appropriate scaling ensures consistent output dimensions for the ASCII conversion process.
# Create a directory for the frames if it doesn't exist
mkdir -p frames
# Extract frames at 10 fps, scaled to 640px width (height adjusted automatically)
ffmpeg -i input.mp4 -vf "fps=10,scale=640:-1" frames/frame_%04d.png
This command extracts frames from input.mp4
at a rate of 10 frames per second. The -vf
option
applies a video filter: fps=10
sets the frame rate, and scale=640:-1
resizes each frame to 640
pixels wide while maintaining the original aspect ratio. The frames are saved as sequentially
numbered PNG files (e.g., frame_0001.png
, frame_0002.png
) in the frames
directory.
Converting frames to ASCII art with Lua scripting
Now, let's create a Lua script (ascii.lua
) that takes an image file, processes it using FFmpeg to
get pixel data, and converts that data into ASCII characters. This approach avoids external Lua
image processing libraries, relying solely on Lua and FFmpeg for greater reliability and fewer
dependencies.
-- ascii.lua
-- ASCII art character set from dense to sparse
local chars = {"@", "#", "S", "%", "?", "*", "+", ";", ":", ",", ".", " "}
-- Alternative simpler set: local chars = {"@", "#", "$", "=", "*", "+", ":", "-", ".", " "}
-- Convert pixel brightness (grayscale) to an ASCII character
local function pixelToChar(r, g, b)
-- Calculate luminance using the standard formula for perceived brightness
local brightness = (r * 0.299 + g * 0.587 + b * 0.114) / 255
-- Map brightness (0.0 to 1.0) to an index in the chars table
-- math.max ensures index is at least 1, math.min ensures it doesn't exceed table length
local index = math.floor(brightness * (#chars - 1)) + 1
index = math.max(1, math.min(index, #chars)) -- Clamp index to valid range
return chars[index]
end
-- Convert an image file to an ASCII art string using FFmpeg and ffprobe
local function imageToAscii(imagePath, targetWidth)
targetWidth = targetWidth or 80 -- Default ASCII width if not provided
-- 1. Get image dimensions using ffprobe
-- Use -v error to suppress verbose output, get width,height in csv format
local ffprobeCmd = string.format("ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 %s", imagePath)
local dims
-- Use pcall for safe execution of external command
local success, err = pcall(function()
-- Use "r" mode for reading text output
local f = io.popen(ffprobeCmd, "r")
if not f then error("Failed to execute ffprobe command.") end
dims = f:read("*l") -- Read the first line (e.g., "1920x1080")
f:close()
if not dims or dims == "" then error("ffprobe returned empty dimensions.") end
end)
if not success then
return nil, "Error getting image dimensions: " .. (err or "Unknown ffprobe error")
end
-- Parse dimensions
local originalWidth, originalHeight = dims:match("(%d+)x(%d+)")
if not originalWidth then
return nil, "Could not parse dimensions from ffprobe output: '" .. dims .. "'"
end
originalWidth, originalHeight = tonumber(originalWidth), tonumber(originalHeight)
-- 2. Calculate target height maintaining aspect ratio
-- Adjust height by ~0.5 factor because terminal characters are often taller than wide
local targetHeight = math.floor(originalHeight * (targetWidth / originalWidth * 0.5))
-- Ensure targetHeight is at least 1
targetHeight = math.max(1, targetHeight)
-- 3. Use FFmpeg to get raw RGB pixel data, scaled to target dimensions
-- -f rawvideo outputs raw pixel data, -pix_fmt rgb24 specifies 24-bit RGB
local ffmpegCmd = string.format("ffmpeg -i %s -vf scale=%d:%d -f rawvideo -pix_fmt rgb24 -",
imagePath, targetWidth, targetHeight)
local rawPixelData
success, err = pcall(function()
-- Use "rb" mode for reading binary data, crucial on Windows
local f = io.popen(ffmpegCmd, "rb")
if not f then error("Failed to execute ffmpeg command.") end
rawPixelData = f:read("*a") -- Read all binary data
f:close()
if not rawPixelData or #rawPixelData == 0 then error("ffmpeg returned empty pixel data.") end
end)
if not success then
return nil, "Error processing image with ffmpeg: " .. (err or "Unknown ffmpeg error")
end
-- 4. Convert raw pixel data (RGB triplets) to ASCII string
local asciiArt = ""
local expectedDataSize = targetWidth * targetHeight * 3 -- 3 bytes per pixel (R, G, B)
if #rawPixelData < expectedDataSize then
-- Warn about incomplete data but proceed if possible
print(string.format("Warning: Incomplete pixel data received. Expected %d bytes, got %d.", expectedDataSize, #rawPixelData))
end
local pos = 1
for y = 1, targetHeight do
for x = 1, targetWidth do
-- Check if enough data remains for one pixel (3 bytes)
if pos + 2 <= #rawPixelData then
local r = string.byte(rawPixelData, pos)
local g = string.byte(rawPixelData, pos + 1)
local b = string.byte(rawPixelData, pos + 2)
asciiArt = asciiArt .. pixelToChar(r, g, b)
pos = pos + 3
else
-- Not enough data for a full pixel, add padding or break
asciiArt = asciiArt .. " " -- Add a space as padding
if x < targetWidth then -- Fill rest of the row with spaces if data ended mid-row
asciiArt = asciiArt .. string.rep(" ", targetWidth - x)
end
goto next_row -- Jump out of inner loop if data ends prematurely
end
end
asciiArt = asciiArt .. "\n" -- Newline after each row
::next_row:: -- Label for goto statement
end
return asciiArt, nil -- Return result and nil for error
end
-- Main execution block: parses command-line arguments and calls imageToAscii
local function main()
if not arg[1] then
print("Usage: lua ascii.lua <image_path> [output_file]")
os.exit(1)
end
local imagePath = arg[1]
local outputFile = arg[2]
-- Call the conversion function
local result, err = imageToAscii(imagePath)
-- Handle potential errors during conversion
if err then
io.stderr:write("Error: " .. err .. "\n")
os.exit(1)
end
-- Print the resulting ASCII art to the console
print(result)
-- Optionally save the result to a specified file
if outputFile then
local file, openErr = io.open(outputFile, "w")
if file then
file:write(result)
file:close()
print("\nASCII art saved to " .. outputFile)
else
io.stderr:write("\nError: Could not write to file '" .. outputFile .. "': " .. (openErr or "Unknown error") .. "\n")
-- Don't exit here, maybe user only wanted console output
end
end
end
-- Run the main function
main()
Run the script on a single extracted frame to test it:
lua ascii.lua frames/frame_0001.png
To save the ASCII art output directly to a text file:
lua ascii.lua frames/frame_0001.png ascii_frames/frame_0001.txt
Creating an ASCII art video
To convert the entire sequence of frames into an ASCII art video, you need to process each frame with the Lua script and then assemble the results. One common method is to convert each ASCII text file back into an image and then use FFmpeg to create a video from these images. This requires an additional tool like ImageMagick for the text-to-image conversion step.
Install ImageMagick
ImageMagick provides the convert
tool needed to render text files as images.
# Ubuntu/Debian
sudo apt-get install imagemagick
# macOS
brew install imagemagick
# Windows (using chocolatey)
choco install imagemagick.app
Processing script (bash)
This shell script automates the process: runs the Lua script on all frames, converts the resulting text files to images using ImageMagick, and finally compiles these images into a video using FFmpeg.
#!/bin/bash
# Set variables
FRAME_DIR="frames"
ASCII_TXT_DIR="ascii_frames"
ASCII_IMG_DIR="ascii_images"
OUTPUT_VIDEO="ascii_video.mp4"
FRAME_RATE=10 # Should match the extraction frame rate
FONT="Courier" # Monospaced font is recommended
POINT_SIZE=10
IMG_BACKGROUND="black"
IMG_FOREGROUND="white"
# --- safety checks ---
# Check if Lua script exists
if [ ! -f "ascii.lua" ]; then
echo "Error: ascii.lua script not found in the current directory."
exit 1
fi
# Check if frame directory exists and is not empty
if [ ! -d "$FRAME_DIR" ] || [ -z "$(ls -A $FRAME_DIR/*.png 2>/dev/null)" ]; then
echo "Error: '$FRAME_DIR' directory not found or contains no PNG frames."
echo "Please run the FFmpeg frame extraction command first."
exit 1
fi
# Check if ImageMagick's convert command is available
if ! command -v convert &> /dev/null; then
echo "Error: ImageMagick 'convert' command not found. Please install ImageMagick."
exit 1
fi
# Check if FFmpeg command is available
if ! command -v ffmpeg &> /dev/null; then
echo "Error: 'ffmpeg' command not found. Please install FFmpeg."
exit 1
fi
# --- processing Steps ---
echo "Starting ASCII art generation process..."
# Create output directories
mkdir -p "$ASCII_TXT_DIR"
mkdir -p "$ASCII_IMG_DIR"
# Count total frames for progress reporting
FRAME_FILES=("$FRAME_DIR"/frame_*.png)
FRAME_COUNT=${#FRAME_FILES[@]}
CURRENT_FRAME=0
echo "Step 1: Processing $FRAME_COUNT frames into ASCII text..."
# Process all frames into text files
for frame_png in "${FRAME_FILES[@]}"; do
CURRENT_FRAME=$((CURRENT_FRAME + 1))
base_name=$(basename "$frame_png" .png)
output_txt="$ASCII_TXT_DIR/${base_name}.txt"
# Run Lua script, redirect stdout to file, stderr remains on console
if ! lua ascii.lua "$frame_png" "$output_txt"; then
echo "Error processing frame $frame_png with Lua script. Aborting."
exit 1
fi
# Simple progress indicator
printf "Processing frame %d/%d (%.0f%%)\r" "$CURRENT_FRAME" "$FRAME_COUNT" $(echo "scale=2; 100 * $CURRENT_FRAME / $FRAME_COUNT" | bc)
done
echo -e "\nASCII text generation complete."
echo "Step 2: Converting ASCII text files to images..."
# Convert ASCII text frames to PNG images using ImageMagick's convert tool
# Use 'caption:@' to read text from file. adjust font, size, colors as needed.
TXT_FILES=("$ASCII_TXT_DIR"/*.txt)
TXT_COUNT=${#TXT_FILES[@]}
CURRENT_TXT=0
for txt_file in "${TXT_FILES[@]}"; do
CURRENT_TXT=$((CURRENT_TXT + 1))
base_name=$(basename "$txt_file" .txt)
output_png="$ASCII_IMG_DIR/${base_name}.png"
# Use caption:@ which attempts to fit text; may need tweaking
if ! convert -background "$IMG_BACKGROUND" -fill "$IMG_FOREGROUND" -font "$FONT" -pointsize "$POINT_SIZE" \
caption:@"$txt_file" "$output_png"; then
echo "Error converting $txt_file to image using ImageMagick. Aborting."
exit 1
fi
printf "Converting text file %d/%d\r" "$CURRENT_TXT" "$TXT_COUNT"
done
echo -e "\nImage generation complete."
echo "Step 3: Creating final ASCII video from images..."
# Create video from the generated ASCII images using FFmpeg
# -framerate: input frame rate
# -i: input pattern for images
# -c:v libx264: video codec (h.264 is widely compatible)
# -pix_fmt yuv420p: pixel format for compatibility
# -crf 23: constant rate factor (quality level, lower is better quality, 18-28 is typical)
# -y: overwrite output file without asking
if ! ffmpeg -framerate "$FRAME_RATE" -i "$ASCII_IMG_DIR/frame_%04d.png" \
-c:v libx264 -pix_fmt yuv420p -crf 23 \
-y "$OUTPUT_VIDEO"; then
echo "Error creating video with FFmpeg. Aborting."
exit 1
fi
echo "-------------------------------------"
echo "ASCII video creation successful!"
echo "Output file: $OUTPUT_VIDEO"
echo "-------------------------------------"
exit 0
Save this script (e.g., create_ascii_video.sh
), make it executable
(chmod +x create_ascii_video.sh
), and run it from your terminal: ./create_ascii_video.sh
. This
script includes checks for necessary tools and directories, provides progress updates, and compiles
the final ascii_video.mp4
.
Practical examples and use cases
ASCII art videos generated using Lua and FFmpeg can be employed in various creative applications:
- Terminal-based animations: Display dynamic art directly in the console.
- Retro video effects: Add a nostalgic or stylized look to video processing projects.
- Web art installations: Create unique interactive experiences using text rendered via HTML/CSS or Canvas.
- Unique video intros/outros: Make your content stand out with a distinct visual style.
- Educational tools: Demonstrate image and video processing concepts visually.
- Artistic filters: Apply a unique text-based filter to videos for social media, music videos, or digital art projects.
Tips for optimizing ASCII art quality
Character set selection
The choice of characters in the chars
table within ascii.lua
significantly impacts the final
look. Experiment with different sets:
-- Denser character set for potentially more detail and smoother gradients
local chars_detailed = {"$", "@", "B", "%", "8", "&", "W", "M", "#", "*", "o", "a", "h", "k", "b", "d", "p", "q", "w", "m", "Z", "O", "0", "Q", "L", "C", "J", "U", "Y", "X", "z", "c", "v", "u", "n", "x", "r", "j", "f", "t", "/", "\\", "|", "(", ")", "1", "{", "}", "[", "]", "?", "-", "_", "+", "~", "<", ">", "i", "!", "l", "I", ";", ":", ",", "\"", "^", "`", "'", ".", " "}
-- Simpler character set for a cleaner, potentially blockier or higher contrast look
local chars_simple = {"@", "#", "$", "=", "*", "+", ":", "-", ".", " "}
-- Choose one set in your ascii.lua script by assigning it to the 'chars' variable
local chars = chars_simple -- Or chars_detailed
Ensure the characters are ordered roughly from visually dense (darkest) to sparse (lightest).
Frame preprocessing
Enhance the input frames before ASCII conversion using FFmpeg filters during the initial extraction step for potentially better results:
# Example: increase contrast and apply a subtle sharpening filter before extracting
ffmpeg -i input.mp4 -vf "fps=10,scale=640:-1,eq=contrast=1.3,unsharp=5:5:0.5:5:5:0.0" frames/frame_%04d.png
Adjusting contrast, brightness (eq=brightness=0.1
), saturation (eq=saturation=1.5
), or applying
sharpening (unsharp
) can sometimes improve the definition of features that get translated into
ASCII characters. Experimentation is key.
Resolution considerations
- ASCII Width: The
targetWidth
variable in the Lua script (defaulting to 80) determines the number of characters per line. 80-120 is often suitable for terminal display or standard text viewers. Higher values capture more detail but result in larger text files and images. - Aspect Ratio: The Lua script includes a basic adjustment (
* 0.5
) totargetHeight
to account for typical character height/width ratios in monospaced fonts. You might need to fine-tune this factor (e.g.,* 0.45
,* 0.55
) based on the specific font used for display or in the ImageMagickconvert
step to avoid stretched or squashed output. - Font Choice: The font used in the
convert
command (specified via-font
) and how the final video is viewed significantly affects the appearance. Monospaced fonts (like Courier, Monaco, Consolas, Fira Code, DejaVu Sans Mono) are essential for proper alignment, as each character occupies the same width.
Performance considerations
Processing video, especially frame by frame with external script calls, can be resource-intensive:
- Frame Rate: Lowering the frame rate (e.g., 5-10 FPS via
fps=5
in the initial FFmpeg command and-framerate 5
in the final one) significantly reduces the number of frames to process. This is often sufficient for the ASCII art effect and drastically cuts down processing time. - Resolution: Keep the
scale
value in the initial FFmpeg extraction and thetargetWidth
in the Lua script reasonable (e.g., 640px width for extraction, 80-120 chars for ASCII width). Higher resolutions exponentially increase processing time and data size at each step. - Parallel Processing: For multi-core systems, you could modify the shell script to process
frames in parallel (e.g., using
xargs -P
orparallel
), which could significantly speed up the Lua script execution and ImageMagick conversion steps. - Memory Usage: While this script-based approach is less memory-intensive than loading entire videos into memory, handling many large intermediate image files can still consume considerable disk space. Ensure you have sufficient storage.
Modern alternatives and enhancements
While this Lua and FFmpeg approach is effective and educational, consider these alternatives for different needs or potentially better performance:
- Dedicated Libraries/Tools: Many programming languages have libraries specifically for ASCII
art generation (e.g., Python's
pyfiglet
for text banners,jp2a
command-line tool for JPEGs, various JavaScript libraries for web use). Some might offer more sophisticated algorithms or direct video processing capabilities. - GPU Acceleration: If you have a compatible NVIDIA GPU and an FFmpeg version compiled with CUDA
support, you can leverage hardware acceleration for faster frame extraction, scaling, and
potentially even the final video encoding step (
-c:v h264_nvenc
). This requires specific FFmpeg flags and setup. - WebAssembly (Wasm): For creating interactive, browser-based ASCII art effects from video
streams or uploads without server-side processing, compiling C/C++/Rust code that performs image
analysis and character mapping to WebAssembly is a powerful option. Libraries like
ffmpeg.wasm
even allow running FFmpeg directly in the browser. - Direct FFmpeg Filters: FFmpeg itself has some obscure or experimental filters that might attempt text-based rendering, though they are generally less flexible than custom scripting.
Enhancing video processing with Lua scripting
Lua's strength lies in its simplicity, speed, and ease of integration as a scripting language. Combining Lua scripting with a powerful tool like FFmpeg allows for flexible and efficient automation of complex multimedia tasks beyond just ASCII art. You can extend this concept to:
- Batch process entire directories of videos with custom logic.
- Create complex FFmpeg filter chains dynamically controlled by Lua logic based on video properties or external data.
- Generate dynamic video overlays or effects based on audio analysis performed by FFmpeg/ffprobe.
- Build interactive command-line tools for specialized video manipulation workflows.
This method provides a hands-on way to explore video processing and creative coding using accessible tools. For robust, scalable, and professional-grade video workflows, including complex encoding, transformations, and content analysis at scale, dedicated cloud services built upon technologies like FFmpeg offer significant advantages in terms of performance, reliability, and feature set. Transloadit's Video Encoding service, for instance, utilizes FFmpeg extensively under the hood to power versatile Robots like 🤖 /video/encode for efficient transcoding across various formats and codecs, and 🤖 /video/thumbs for reliable thumbnail generation, handling the complexities of multimedia processing pipelines.