Skip to content

Instantly share code, notes, and snippets.

@g023
Created December 9, 2025 04:28
Show Gist options
  • Select an option

  • Save g023/04efea667384c75ff27b93851e9fa488 to your computer and use it in GitHub Desktop.

Select an option

Save g023/04efea667384c75ff27b93851e9fa488 to your computer and use it in GitHub Desktop.
Python ANSI Art to Animated GIF Converter

ansi_to_image.py — ANSI to Animated GIF Converter

Author

g023 - https://github.com/g023 - https://x.com/g023dev

A short guide and reference for using and understanding ansi_to_image.py.

Summary

ansi_to_image.py converts ANSI/ANS art (CP437-encoded text with ANSI escape sequences) into an animated GIF that simulates the drawing process. It supports SGR (color/bold/underline/blink/reverse), 256-color and truecolor escapes, SAUCE metadata, baud-rate streaming simulation (to show animation build-up), and special rendering for block/shade characters ( █ ▓ ▒ ░ ▄ ▀ ▌ ▐).

Requirements

  • Python 3.8+
  • Pillow (PIL fork)

Install deps:

pip install Pillow

CLI Usage

usage: ansi_to_image.py input_file output_file [--width WIDTH] [--height HEIGHT]
                          [--baud BAUD] [--fps FPS] [--font FONT]
                          [--font-size FONT_SIZE]

Options

  • input_file — path to the ANSI file (read with CP437 encoding by default)
  • output_file — path to the output GIF
  • --width — terminal width in characters (default: 80)
  • --height — terminal height in rows (default: 25)
  • --baud — simulated baud rate (default: 14400) used to chunk the input and generate frames
  • --fps — frames per second for the output GIF (default: 20)
  • --font — path to a TTF font to use (default:consola.ttf[win], default:DejaVuSansMono.ttf[other])
  • --font-size — font size in points (default set in code, adjust to fit your font)

Example

python ansi_to_image.py ANSIART/ROY-DS.ANS roy_ds.gif --baud 14400 --fps 10 --font "C:/Windows/Fonts/cour.ttf" --font-size 16
python ansi_to_image.py ANSIART/ROY-SKY.ANS roy_ds_shades2.gif --baud 14400 --fps 10

How it works (high level)

  • The AnsiParser class is a streaming ANSI parser. It:

    • Maintains a screen buffer (width × height) of cells. Each cell stores: (char, fg_rgb, bg_rgb, bold, underline, blink, reverse).
    • Supports SGR (\x1b[...m) including 16-colors, 256-color (38;5;N / 48;5;N), and truecolor (38;2;R;G;B / 48;2;R;G;B).
    • Maintains cursor_x, cursor_y, saved cursor, and pending incomplete escape sequences (so input can be parsed in chunks).
    • Detects and parses SAUCE records (if present) to obtain width/height metadata.
  • The AnsiToGifConverter class handles rendering and GIF output. It:

    • Loads a monospace font (tries user-specified font first, then common system fonts). It prefers CP437-capable fonts if available.
    • Measures character cell size using font.getbbox("W") for width and font.getbbox("█") for height (blocks) and computes line_height to avoid row overlap.
    • Renders each cell to an image: draws per-cell background, then draws foreground content. For block/shade characters the renderer draws shapes/patterns rather than depending on the font glyph:
      • => filled rectangle (solid)
      • ▌ ▐ ▄ ▀ => half-rectangles with correct orientation
      • ░ ▒ ▓ => dot/grid patterns approximating 25/50/75% coverage
    • Handles reverse (SGR 7) by swapping effective fg/bg for the cell at render time.
    • Simulates blink (on/off frames) and draws a blinking cursor.
    • Produces frames according to the simulated baud rate: number of bytes processed per frame is derived from baud and fps. A final pause is appended to the animation.

SAUCE support

If a SAUCE record is present at the end of the file, the converter will detect it and, if the SAUCE TInfo fields contain valid width/height, automatically resize the terminal dimensions and buffer prior to rendering. SAUCE is detected and parsed from the file bytes.

Recommended font settings

  • If glyphs look vertically misaligned, try adjusting --font-size or pass a different font. The script prints the font that was loaded.

Performance & tuning

  • Frames count is driven by the simulated --baud rate and --fps. Lower the baud or raise fps to reduce the number of frames.
  • The renderer draws shading patterns per cell — slightly heavier than drawing text only — but still performs well for typical ANSI sizes (80×25).

Troubleshooting

  • Raw CSI text (e.g. "[0;1;30m") visible in output:
    • Ensure your ANSI file is not truncated. The parser buffers incomplete escape sequences across chunks, but a file that is malformed or has broken escapes may render literal characters.
  • Lines appear shifted:
    • This script treats LF as CR+LF (moves cursor to column 0 on newline) which matches how many ANSI art files were authored. If you need LF-only handling, ask to add a toggle option (--lf-only).
  • Block/shade characters aren’t visible or look wrong:
    • Try specifying a CP437 font with --font.
    • The renderer also draws shapes for these characters when the font glyph is missing; adjust --font-size if blocks appear too big/small.
  • Colors not matching another renderer:
    • The script maps bold (SGR 1) to bright colors (0..7 → 8..15) to match common terminal/ANSI-art conventions.
    • 256-color and truecolor sequences are supported (38;5;n, 38;2;r;g;b).

Examples

Convert a single ANSI file to GIF (default settings):

python ansi_to_image.py ANSIART/ROY-DS.ANS roy_ds.gif

Reduce frames by simulating a slower baud rate (fewer bytes per frame):

python ansi_to_image.py ANSIART/ROY-DS.ANS roy_ds_slow.gif --baud 1200 --fps 10

Development notes / future improvements

  • Add a --no-baud single-pass option that processes the whole file and captures fewer frames (useful when you just want a static render or minimal frames).
  • Embed or ship a CP437 bitmap font in the repo and default to it for pixel-perfect rendering.
  • Implement per-glyph bitmap rendering by including an internal CP437 font bitmap and blitting glyphs — this yields exact fidelity to old renderers.
  • Add support for OSC/OSC 0 (window title) or other long control sequences if needed.

Where to look in the code

  • AnsiParser — ANSI escape parsing, SGR handling, cursor movement, SAUCE parsing.
  • AnsiToGifConverter — font loading, char cell measurement, block/shade rendering, frame generation and GIF saving.
import sys
import re
import time
import struct
import argparse
import os
import math
from PIL import Image, ImageDraw, ImageFont, ImagePalette
# Author: g023 - https://github.com/g023 - https://x.com/g023dev
# --- Constants ---
# Standard ANSI Colors (0-15)
ANSI_COLORS = [
(0, 0, 0), # 0: Black
(170, 0, 0), # 1: Red
(0, 170, 0), # 2: Green
(170, 85, 0), # 3: Brown/Yellow
(0, 0, 170), # 4: Blue
(170, 0, 170), # 5: Magenta
(0, 170, 170), # 6: Cyan
(170, 170, 170), # 7: White (Light Gray)
(85, 85, 85), # 8: Bright Black (Dark Gray)
(255, 85, 85), # 9: Bright Red
(85, 255, 85), # 10: Bright Green
(255, 255, 85), # 11: Bright Yellow
(85, 85, 255), # 12: Bright Blue
(255, 85, 255), # 13: Bright Magenta
(85, 255, 255), # 14: Bright Cyan
(255, 255, 255) # 15: Bright White
]
# Generate 256 Color Palette
def generate_xterm_256_palette():
palette = list(ANSI_COLORS)
# 6x6x6 Color Cube (16-231)
for r in [0, 95, 135, 175, 215, 255]:
for g in [0, 95, 135, 175, 215, 255]:
for b in [0, 95, 135, 175, 215, 255]:
palette.append((r, g, b))
# Grayscale Ramp (232-255)
for i in range(24):
v = 8 + i * 10
palette.append((v, v, v))
return palette
XTERM_PALETTE = generate_xterm_256_palette()
class AnsiParser:
"""
Advanced ANSI Parser that maintains the state of a virtual terminal.
"""
def __init__(self, width=80, height=25, default_fg=7, default_bg=0):
self.width = width
self.height = height
self.default_fg = default_fg
self.default_bg = default_bg
# Terminal State
self.reset_state()
# Buffer: List of lists of (char, fg_rgb, bg_rgb, bold, underline, blink)
self.buffer = []
self.clear_screen()
def get_rgb(self, color_code):
if isinstance(color_code, tuple):
return color_code
if 0 <= color_code < len(XTERM_PALETTE):
return XTERM_PALETTE[color_code]
return (255, 255, 255)
def reset_state(self):
self.cursor_x = 0
self.cursor_y = 0
self.fg = self.default_fg
self.bg = self.default_bg
self.fg_rgb = self.get_rgb(self.default_fg)
self.bg_rgb = self.get_rgb(self.default_bg)
self.bold = False
self.underline = False
self.blink = False
self.reverse = False
self.saved_cursor = (0, 0)
self.visible = True
# Buffer for partial/streamed escape sequences when parsing in chunks
self._pending = ''
def clear_screen(self):
# Initialize buffer with empty spaces and default attributes
# buffer entries: (char, fg_rgb, bg_rgb, bold, underline, blink, reverse)
self.buffer = [[(' ', self.fg_rgb, self.bg_rgb, False, False, False, False)
for _ in range(self.width)] for _ in range(self.height)]
def clear_line(self, row, mode=2):
# mode 0: cursor to end, 1: start to cursor, 2: whole line
if 0 <= row < self.height:
line = self.buffer[row]
start = 0
end = self.width
if mode == 0:
start = self.cursor_x
elif mode == 1:
end = self.cursor_x + 1
for i in range(start, end):
if 0 <= i < self.width:
line[i] = (' ', self.fg_rgb, self.bg_rgb, False, False, False, False)
def process_sgr(self, params):
if not params:
params = [0]
i = 0
while i < len(params):
code = params[i]
if code == 0: # Reset
self.fg = self.default_fg
self.bg = self.default_bg
self.fg_rgb = self.get_rgb(self.default_fg)
self.bg_rgb = self.get_rgb(self.default_bg)
self.bold = False
self.underline = False
self.blink = False
self.reverse = False
elif code == 1:
# Bold: many ANSI art setups treat bold as bright color
self.bold = True
if isinstance(self.fg, int) and 0 <= self.fg < 8:
self.fg_rgb = self.get_rgb(self.fg + 8)
elif code == 4: self.underline = True
elif code == 5: self.blink = True
elif code == 7: self.reverse = True
elif code == 22:
# Reset bold
self.bold = False
if isinstance(self.fg, int):
self.fg_rgb = self.get_rgb(self.fg)
elif code == 24: self.underline = False
elif code == 25: self.blink = False
elif code == 27: self.reverse = False
elif 30 <= code <= 37:
# Standard foreground colors (0-7)
self.fg = code - 30
if self.bold and 0 <= self.fg < 8:
# Map bold to bright variant
self.fg_rgb = self.get_rgb(self.fg + 8)
else:
self.fg_rgb = self.get_rgb(self.fg)
elif code == 38: # Extended FG
if i + 1 < len(params):
mode = params[i+1]
if mode == 5 and i + 2 < len(params): # 256 color
self.fg = params[i+2]
self.fg_rgb = self.get_rgb(self.fg)
i += 2
elif mode == 2 and i + 4 < len(params): # Truecolor
self.fg = (params[i+2], params[i+3], params[i+4])
self.fg_rgb = self.fg
i += 4
elif code == 39:
self.fg = self.default_fg
self.fg_rgb = self.get_rgb(self.fg)
elif 40 <= code <= 47:
self.bg = code - 40
self.bg_rgb = self.get_rgb(self.bg)
elif code == 48: # Extended BG
if i + 1 < len(params):
mode = params[i+1]
if mode == 5 and i + 2 < len(params): # 256 color
self.bg = params[i+2]
self.bg_rgb = self.get_rgb(self.bg)
i += 2
elif mode == 2 and i + 4 < len(params): # Truecolor
self.bg = (params[i+2], params[i+3], params[i+4])
self.bg_rgb = self.bg
i += 4
elif code == 49:
self.bg = self.default_bg
self.bg_rgb = self.get_rgb(self.bg)
elif 90 <= code <= 97:
self.fg = code - 90 + 8 # Bright FG
self.fg_rgb = self.get_rgb(self.fg)
elif 100 <= code <= 107:
self.bg = code - 100 + 8 # Bright BG
self.bg_rgb = self.get_rgb(self.bg)
i += 1
def parse_data(self, data):
"""
Parses a chunk of ANSI data and updates the terminal state.
"""
# Prepend any pending partial escape sequence from previous chunk
if getattr(self, '_pending', ''):
data = self._pending + data
self._pending = ''
i = 0
length = len(data)
while i < length:
char = data[i]
# Handle ESC (\x1b) and single-byte CSI (\x9b)
if char == '\x1b' or char == '\x9b':
# If ESC is last, save pending
if i + 1 >= length:
self._pending = data[i:]
break
# If ESC, require '[' for CSI; if single-byte CSI, parameters start immediately
if char == '\x1b':
if data[i+1] != '[':
# Not a CSI we handle; skip the ESC and continue
i += 1
continue
j = i + 2
else:
j = i + 1
# Collect intermediate bytes until final byte (ASCII @-~)
param_str = ''
while j < length and not ('@' <= data[j] <= '~'):
param_str += data[j]
j += 1
# Incomplete sequence at end of chunk
if j >= length:
self._pending = data[i:]
break
cmd = data[j]
# Private mode (starts with '?')
if param_str.startswith('?'):
params = [int(p) for p in param_str[1:].split(';') if p.isdigit()]
if cmd == 'h':
if 25 in params: self.visible = True
elif cmd == 'l':
if 25 in params: self.visible = False
else:
params = [int(p) if p else 0 for p in param_str.split(';')] if param_str else []
# Execute CSI command
if cmd == 'm':
self.process_sgr(params)
elif cmd in ('H', 'f'):
r = params[0] if len(params) > 0 else 1
c = params[1] if len(params) > 1 else 1
self.cursor_y = max(0, min(self.height - 1, r - 1))
self.cursor_x = max(0, min(self.width - 1, c - 1))
elif cmd == 'A':
n = params[0] if params else 1
self.cursor_y = max(0, self.cursor_y - n)
elif cmd == 'B':
n = params[0] if params else 1
self.cursor_y = min(self.height - 1, self.cursor_y + n)
elif cmd == 'C':
n = params[0] if params else 1
self.cursor_x = min(self.width - 1, self.cursor_x + n)
elif cmd == 'D':
n = params[0] if params else 1
self.cursor_x = max(0, self.cursor_x - n)
elif cmd == 'J':
mode = params[0] if params else 0
if mode == 2:
self.clear_screen()
self.cursor_x = 0
self.cursor_y = 0
elif mode == 0:
self.clear_line(self.cursor_y, 0)
for r in range(self.cursor_y + 1, self.height):
self.clear_line(r, 2)
elif mode == 1:
for r in range(0, self.cursor_y):
self.clear_line(r, 2)
self.clear_line(self.cursor_y, 1)
elif cmd == 'K':
mode = params[0] if params else 0
self.clear_line(self.cursor_y, mode)
elif cmd == 's':
self.saved_cursor = (self.cursor_x, self.cursor_y)
elif cmd == 'u':
self.cursor_x, self.cursor_y = self.saved_cursor
i = j + 1
continue
# Control characters
if char == '\n':
# Treat LF as newline + carriage return (move to column 0) for ANSI art compatibility
self.cursor_x = 0
self.cursor_y += 1
if self.cursor_y >= self.height:
self.buffer.pop(0)
self.buffer.append([(' ', self.fg_rgb, self.bg_rgb, False, False, False, False) for _ in range(self.width)])
self.cursor_y = self.height - 1
elif char == '\x08': # Backspace
# Move cursor one position left (and optionally clear)
self.cursor_x = max(0, self.cursor_x - 1)
elif char == '\r':
self.cursor_x = 0
elif char == '\t':
self.cursor_x = (self.cursor_x // 8 + 1) * 8
if self.cursor_x >= self.width:
self.cursor_x = self.width - 1
elif char == '\x0c':
self.clear_screen()
self.cursor_x = 0
self.cursor_y = 0
elif ord(char) >= 32:
if 0 <= self.cursor_x < self.width and 0 <= self.cursor_y < self.height:
self.buffer[self.cursor_y][self.cursor_x] = (char, self.fg_rgb, self.bg_rgb, self.bold, self.underline, self.blink, self.reverse)
self.cursor_x += 1
if self.cursor_x >= self.width:
self.cursor_x = 0
self.cursor_y += 1
if self.cursor_y >= self.height:
self.buffer.pop(0)
self.buffer.append([(' ', self.fg_rgb, self.bg_rgb, False, False, False, False) for _ in range(self.width)])
self.cursor_y = self.height - 1
i += 1
class AnsiToGifConverter:
def __init__(self, font_path=None, font_size=16, width=80, height=25, baud_rate=14400):
self.parser = AnsiParser(width, height)
self.font_size = font_size
self.baud_rate = baud_rate
# Load Font
font_loaded = False
if font_path:
try:
self.font = ImageFont.truetype(font_path, font_size)
font_loaded = True
except:
pass
if not font_loaded:
# Try system fonts (prioritize ones with block characters)
font_candidates = []
if os.name == 'nt': # Windows
font_candidates = [
# "Px437_IBM_VGA_8x16.ttf", # CP437 font with all chars including blocks (download if needed)
# now for terminal
# "C:/Windows/Fonts/lucon.ttf" # Lucida Console
"C:/Windows/Fonts/consola.ttf", # Consolas # seems to work good enough
# "C:/Windows/Fonts/cour.ttf", # Courier New
]
else: # Linux/Mac
font_candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
"/System/Library/Fonts/Monaco.dfont" # Mac
]
for candidate in font_candidates:
try:
self.font = ImageFont.truetype(candidate, font_size)
print(f"Loaded font: {candidate}")
font_loaded = True
break
except:
pass
if not font_loaded:
print("Warning: Could not load a monospace font. Using default (may not be monospace, rendering may be incorrect).")
self.font = ImageFont.load_default()
# Calculate Character Size
# Use 'W' for width (widest), '█' for height (includes blocks)
bbox_w = self.font.getbbox("W")
bbox_block = self.font.getbbox("█")
self.char_w = int(bbox_w[2] - bbox_w[0])
self.char_h = int(bbox_block[3] - bbox_block[1])
# Ensure minimum size
if self.char_w < 1: self.char_w = 8
if self.char_h < 1: self.char_h = 16
# Add minimal padding to prevent row overlap
self.line_height = self.char_h + 2
self.img_w = width * self.char_w
self.img_h = height * self.line_height
def get_color_rgb(self, color_code, is_fg=True):
# Handle Truecolor
if isinstance(color_code, tuple):
return color_code
# Handle Palette
if 0 <= color_code < len(XTERM_PALETTE):
return XTERM_PALETTE[color_code]
return (255, 255, 255) if is_fg else (0, 0, 0)
def render_frame(self, frame_idx=0):
img = Image.new('RGB', (self.img_w, self.img_h), self.get_color_rgb(self.parser.default_bg, False))
draw = ImageDraw.Draw(img)
# Blink state: on for 10 frames, off for 10 frames (at 20fps = 0.5s)
blink_on = (frame_idx // 10) % 2 == 0
for y in range(self.parser.height):
for x in range(self.parser.width):
char, fg_rgb, bg_rgb, bold, underline, blink, reverse = self.parser.buffer[y][x]
# Determine effective foreground/background based on reverse flag
if reverse:
cell_fg = bg_rgb
cell_bg = fg_rgb
else:
cell_fg = fg_rgb
cell_bg = bg_rgb
# Draw background always (handles non-black backgrounds and reverse)
draw.rectangle(
[x * self.char_w, y * self.line_height, (x + 1) * self.char_w, (y + 1) * self.line_height],
fill=cell_bg
)
# Foreground
if blink and not blink_on:
continue # Don't draw text if blinking and in off state
# Special handling for block characters (render as solid/patterned shapes)
cell_x = x * self.char_w
cell_y = y * self.line_height
cell_w = self.char_w
cell_h = self.line_height
if char == '█':
draw.rectangle([cell_x, cell_y, cell_x + cell_w, cell_y + cell_h], fill=cell_fg)
elif char in '▓▒░':
# Shade blocks: draw a dot-grid pattern to approximate density
def _draw_shade(shade_char):
# grid size
cols = max(2, cell_w // 4)
rows = max(2, cell_h // 4)
for gy in range(rows):
for gx in range(cols):
if shade_char == '░':
cond = ((gx + gy) % 4) == 0
elif shade_char == '▒':
cond = ((gx + gy) % 2) == 0
else: # '▓'
cond = ((gx + gy) % 4) != 0
if cond:
px = cell_x + gx * (cell_w / cols)
py = cell_y + gy * (cell_h / rows)
pw = int(math.ceil(cell_w / cols))
ph = int(math.ceil(cell_h / rows))
draw.rectangle([px, py, px + pw, py + ph], fill=cell_fg)
_draw_shade(char)
elif char in '▄▀▌▐':
# Half blocks: draw half rectangles using cell dimensions
mid_y = cell_y + (cell_h // 2)
mid_x = cell_x + (cell_w // 2)
if char == '▄':
draw.rectangle([cell_x, mid_y, cell_x + cell_w, cell_y + cell_h], fill=cell_fg)
elif char == '▀':
draw.rectangle([cell_x, cell_y, cell_x + cell_w, mid_y], fill=cell_fg)
elif char == '▌':
draw.rectangle([cell_x, cell_y, mid_x, cell_y + cell_h], fill=cell_fg)
elif char == '▐':
draw.rectangle([mid_x, cell_y, cell_x + cell_w, cell_y + cell_h], fill=cell_fg)
elif char != ' ':
# Draw regular character; simulate bold by overdrawing slightly if needed
y_draw = cell_y + getattr(self, 'y_offset', 0)
if bold:
# Draw a thicker glyph by drawing twice with a 1px offset
draw.text((cell_x + 1, y_draw), char, font=self.font, fill=cell_fg)
draw.text((cell_x, y_draw), char, font=self.font, fill=cell_fg)
if underline:
draw.line(
[x * self.char_w, (y + 1) * self.line_height - 2, (x + 1) * self.char_w, (y + 1) * self.line_height - 2],
fill=fg_rgb, width=1
)
# Draw Cursor
if self.parser.visible and blink_on:
cx = self.parser.cursor_x
cy = self.parser.cursor_y
if 0 <= cx < self.parser.width and 0 <= cy < self.parser.height:
cursor_color = self.parser.fg_rgb
draw.rectangle(
[cx * self.char_w, (cy + 1) * self.line_height - 4, (cx + 1) * self.char_w, (cy + 1) * self.line_height - 1],
fill=cursor_color
)
return img
def parse_sauce(self, data):
# SAUCE record is 128 bytes at end of file
# ID: "SAUCE" (5 bytes)
# Version: "00" (2 bytes)
# Title: 35 bytes
# Author: 20 bytes
# Group: 20 bytes
# Date: 8 bytes (YYYYMMDD)
# FileSize: 4 bytes (unsigned int)
# DataType: 1 byte
# FileType: 1 byte
# TInfo1: 2 bytes
# TInfo2: 2 bytes
# TInfo3: 2 bytes
# TInfo4: 2 bytes
# Comments: 1 byte
# Flags: 1 byte
# Filler: 22 bytes
if len(data) >= 128:
sauce_rec = data[-128:]
if sauce_rec[:5] == b'SAUCE':
title = sauce_rec[7:42].decode('cp437', 'ignore').strip()
author = sauce_rec[42:62].decode('cp437', 'ignore').strip()
group = sauce_rec[62:82].decode('cp437', 'ignore').strip()
print(f"SAUCE Record Found: Title='{title}', Author='{author}', Group='{group}'")
# Extract dimensions if available (TInfo1 = Width, TInfo2 = Height)
# Note: DataType 1 is Character based.
datatype = sauce_rec[94]
if datatype == 1:
w = struct.unpack('<H', sauce_rec[96:98])[0]
h = struct.unpack('<H', sauce_rec[98:100])[0]
if w > 0 and h > 0:
print(f"SAUCE Dimensions: {w}x{h}")
self.parser.width = w
self.parser.height = h
self.parser.clear_screen() # Resize buffer
self.img_w = w * self.char_w
self.img_h = h * self.char_h
return True
return False
def convert(self, input_data, output_file, fps=20):
# Check for SAUCE
has_sauce = self.parse_sauce(input_data.encode('cp437', 'ignore'))
# Remove SAUCE from data if present to avoid rendering it
if has_sauce:
# The SAUCE record is strictly the last 128 bytes.
# Sometimes there is a EOF char before it.
# We'll just process the whole thing, usually SAUCE is not valid ANSI so it might look like garbage or be ignored.
# Better to strip it.
input_data = input_data[:-128]
frames = []
# Baud Rate Simulation
# Bytes per second = baud_rate / 10 (8N1 = 10 bits per byte)
bytes_per_sec = self.baud_rate / 10
bytes_per_frame = int(bytes_per_sec / fps)
if bytes_per_frame < 1: bytes_per_frame = 1
total_len = len(input_data)
processed = 0
print(f"Converting... Total bytes: {total_len}, Bytes/Frame: {bytes_per_frame}")
while processed < total_len:
chunk = input_data[processed : processed + bytes_per_frame]
self.parser.parse_data(chunk)
processed += len(chunk)
# Capture frame
# Optimization: Only capture if something changed?
# For baud rate simulation, we want to see the drawing process.
frames.append(self.render_frame(len(frames)))
if processed % (bytes_per_frame * 10) == 0:
print(f"Processed {processed}/{total_len} bytes...")
# Add a few seconds of pause at the end
for _ in range(fps * 2):
frames.append(self.render_frame(len(frames)))
print(f"Saving GIF to {output_file} with {len(frames)} frames...")
frames[0].save(
output_file,
save_all=True,
append_images=frames[1:],
duration=1000 // fps,
loop=0,
optimize=True # Use PIL's optimization
)
print("Done!")
def main():
parser = argparse.ArgumentParser(description="ANSI to Animated GIF Converter")
parser.add_argument("input_file", help="Path to input ANSI file")
parser.add_argument("output_file", help="Path to output GIF file")
parser.add_argument("--width", type=int, default=80, help="Terminal width (default: 80)")
parser.add_argument("--height", type=int, default=25, help="Terminal height (default: 25)")
parser.add_argument("--baud", type=int, default=14400, help="Simulated Baud Rate (default: 14400)")
parser.add_argument("--fps", type=int, default=20, help="Frames Per Second (default: 20)")
parser.add_argument("--font", type=str, default=None, help="Path to TTF font file")
parser.add_argument("--font-size", type=int, default=18, help="Font size (default: 18)")
args = parser.parse_args()
if not os.path.exists(args.input_file):
print(f"Error: Input file '{args.input_file}' not found.")
return
# Read input with CP437 encoding (standard for ANSI art)
try:
with open(args.input_file, 'r', encoding='cp437') as f:
data = f.read()
except Exception as e:
print(f"Error reading file: {e}")
return
converter = AnsiToGifConverter(
font_path=args.font,
font_size=args.font_size,
width=args.width,
height=args.height,
baud_rate=args.baud
)
converter.convert(data, args.output_file, fps=args.fps)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment