Skip to content

Instantly share code, notes, and snippets.

@davsclaus
Created May 25, 2026 12:35
Show Gist options
  • Select an option

  • Save davsclaus/0ae50182df7bf0228e50faae10bee598 to your computer and use it in GitHub Desktop.

Select an option

Save davsclaus/0ae50182df7bf0228e50faae10bee598 to your computer and use it in GitHub Desktop.
TamboUI animated GIF export implementation plan (issue #356)

Plan: TamboUI — animated GIF export from Buffer frames

Context

TamboUI already has a complete recording pipeline: .tapeInteractionPlayerRecordingBackend captures List<TimedFrame>AsciinemaAnimation writes .cast files. The .cast format requires external tools (agg) to convert to GIF. Users want an out-of-the-box way to produce animated GIFs from recordings — no extra tooling.

The building blocks already exist:

  • TimedFrame holds Buffer + timestampMs snapshots
  • Buffer has full cell grid with Cell.symbol() + Cell.style() (fg, bg, bold, italic, etc.)
  • Color.toRgb() converts any color variant to RGB values
  • SvgExporter defines the cell rendering geometry (charHeight=20, fontAspectRatio=0.61, lineHeight=24.4)
  • ExportProperties has default fg/bg colors
  • ImageData already uses BufferedImage + Graphics2D (in resize) and ImageIO (PNG export)
  • Java's ImageIO includes a GIF writer with animation support via IIOMetadata

Approach

New module: tamboui-gif (or add to tamboui-core export package)

Add a GifAnimationExporter that converts List<TimedFrame> to an animated GIF using pure JDK APIs — no external dependencies.

Two-part implementation

Part 1: BufferRenderer — render a Buffer to a BufferedImage

A utility that renders a Buffer to a java.awt.image.BufferedImage using Graphics2D:

public final class BufferRenderer {
    static BufferedImage render(Buffer buffer, Rect region, BufferRenderOptions options);
}

Rendering logic (mirrors SvgExporter):

  1. Calculate image dimensions: width = cols * cellWidth, height = rows * lineHeight
  2. Create BufferedImage(width, height, TYPE_INT_RGB)
  3. Fill with default background color
  4. Set Graphics2D font to a monospace font at the configured size
  5. For each cell in the region:
    • Skip Cell.CONTINUATION cells (wide char placeholders)
    • If cell has background color (or REVERSED modifier): fill rect with bg color
    • Set fg color, apply bold (derive font), apply italic (derive font)
    • Draw cell.symbol() at the correct pixel position
  6. Return the image

Font handling:

  • Default to "Monospaced" (JDK built-in, guaranteed available)
  • Use FontMetrics to measure actual glyph width and center within cell
  • Allow user to specify a preferred font name (e.g., "Fira Code", "JetBrains Mono")

Cell geometry (reuse SvgExporter constants):

  • charHeight = 20 → font size ~16pt (need FontMetrics calibration)
  • cellWidth = charHeight * fontAspectRatio (default 0.61 → ~12.2px)
  • lineHeight = charHeight * 1.22 (→ ~24.4px)
  • These can be configurable via options

Part 2: GifAnimationExporter — render List to animated GIF

public final class GifAnimationExporter {
    static void write(List<TimedFrame> frames, Path output, GifOptions options) throws IOException;
    static byte[] write(List<TimedFrame> frames, GifOptions options);
}

Logic:

  1. Deduplicate consecutive identical frames (reuse AsciinemaAnimation.deduplicateFrames() pattern)
  2. For each unique frame:
    • Render frame.buffer()BufferedImage using BufferRenderer
    • Calculate delay from timestamp difference (in centiseconds for GIF)
  3. Write animated GIF using JDK's ImageIO:
    • Get ImageWriter for "gif" format
    • Set looping via GIF application extension metadata (NETSCAPE2.0 for infinite loop)
    • Write each frame with IIOMetadata containing frame delay and disposal method
  4. Flush and close

GifOptions:

public final class GifOptions {
    String fontName = "Monospaced";
    int fontSize = 16;                    // point size
    double fontAspectRatio = 0.61;
    boolean chrome = false;               // macOS-style window chrome
    String title = null;                  // window title (if chrome)
    boolean loop = true;                  // infinite loop
    int maxWidth = 0;                     // scale down if > 0
    StylePropertyResolver styles = null;  // custom fg/bg defaults
}

Java animated GIF API (pure JDK, no deps)

The standard approach using javax.imageio:

ImageWriter writer = ImageIO.getImageWritersByFormatName("gif").next();
ImageOutputStream ios = ImageIO.createImageOutputStream(output);
writer.setOutput(ios);
writer.prepareWriteSequence(null);

for (each frame) {
    BufferedImage img = renderFrame(frame);
    IIOMetadata metadata = writer.getDefaultImageMetadata(
        new ImageTypeSpecifier(img), writer.getDefaultWriteParam());
    
    // Set frame delay and disposal
    configureGifMetadata(metadata, delayCs, disposalMethod);
    
    // Set looping (on first frame only)
    if (firstFrame) {
        configureLooping(metadata, 0); // 0 = infinite loop
    }
    
    writer.writeToSequence(new IIOImage(img, null, metadata), null);
}

writer.endWriteSequence();

The GIF metadata manipulation uses IIOMetadataNode to set:

  • "delayTime" — frame delay in centiseconds (1/100th second)
  • "disposalMethod" — typically "restoreToBackgroundColor"
  • "NETSCAPE2.0" application extension — for looping

Integration with existing recording flow

The current flow ends at .cast. The new flow adds a parallel output:

RecordingBackend collects List<TimedFrame>
    ├── AsciinemaAnimation.toCast() → .cast file  (existing)
    └── GifAnimationExporter.write() → .gif file  (new)

RecordingBackend.writeCastFromDrawFrames() already has the List<TimedFrame>. After writing .cast, it can optionally also write .gif based on config.

Alternatively, expose the List<TimedFrame> from RecordingBackend so callers can choose the output format.

Integration with ExportRequest (single-frame)

For single-frame GIF/PNG export (screenshots), add to the existing fluent API:

export(buffer).gif().options(o -> o.fontName("Fira Code")).toFile(path);
export(buffer).png().options(o -> o.fontSize(14)).toFile(path);

This reuses BufferRenderer for single-frame rasterization.

Files to create/modify

New files (in tamboui-core)

  • export/raster/BufferRenderer.java — Buffer → BufferedImage rendering
  • export/raster/BufferRenderOptions.java — font, cell size, colors config
  • export/gif/GifAnimationExporter.java — List → animated GIF
  • export/gif/GifOptions.java — animation options (loop, chrome, font)
  • export/gif/GifFormat.java — Format implementation for single-frame GIF
  • export/gif/GifExporter.java — Encoder implementation for single-frame
  • export/png/PngFormat.java — Format implementation for single-frame PNG
  • export/png/PngExporter.java — Encoder for PNG

Modified files

  • export/Formats.java — add GIF and PNG format constants
  • export/ExportRequest.java — add gif() and png() shorthand methods
  • export/ExportStep.java — possibly needs binary output support (toBytes() already exists, but toFile() uses Writer — need OutputStream path for binary formats)
  • internal/record/RecordingBackend.java — optionally write GIF alongside .cast

Key design decisions

  1. Pure JDK — no external dependencies. Java's ImageIO GIF writer is basic but sufficient (256 color palette per frame, which matches terminal color space well).

  2. BufferRenderer as shared utility — used by both animated GIF and single-frame PNG/GIF export. Could later be used for SVG-to-raster conversion too.

  3. Color quantization — GIF is limited to 256 colors per frame. Terminal UIs typically use far fewer, so this should rarely be an issue. For complex screens, use a simple median-cut quantizer or rely on ImageIO's built-in dithering.

  4. Binary vs text export — current ExportStep uses Writer/Appendable which is text-oriented. For binary formats (GIF, PNG), we need an OutputStream path. Either extend ExportStep or create a separate BinaryExportStep.

Verification

  1. Write a unit test that creates a Buffer with styled cells and renders to GIF
  2. Write a demo that records a TUI session and outputs both .cast and .gif
  3. Verify the GIF plays correctly in a browser/image viewer
  4. Verify font rendering quality with different monospace fonts
  5. Test with Unicode characters and wide characters (CJK, emoji)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment