โ† All apps

CandyFlip

๐ŸŽž CandyFlip

ASCII GIF viewer

port of gifterm gif ascii-art ext-gd truecolor

Decodes a .gif on disk via ext-gd, downsamples each frame to a configurable cell grid, and renders the animation as ANSI-coloured Unicode block-glyphs. Two presets: solid 24-bit blocks or a luminance-ramp ASCII rendering.

Install

composer require sugarcraft/candy-flip

Quickstart

candy-flip my-animation.gif         # solid blocks (default)
candy-flip my-animation.gif density # luminance ramp

// Programmatic:
use SugarCraft\Core\Program;
use SugarCraft\Core\ProgramOptions;
use SugarCraft\Flip\Decoder;
use SugarCraft\Flip\Player;

$frames = Decoder::decode('cat.gif', cellsW: 80, cellsH: 30);
(new Program(new Player($frames),
             new ProgramOptions(useAltScreen: true)))->run();

What's in the box

ext-gd decodeWalks the GIF byte stream for image-descriptor offsets, hands each frame to GD, area-average downsamples to cell space.
Area-average downsamplingHigher quality than nearest-neighbor โ€” averages all source pixels in each cell region, skipping transparent pixels. {@see SugarCraft\Flip\Downsampler}.
Floyd-Steinberg ditheringError-diffusion dithering against a fixed palette via {@see SugarCraft\Flip\Dither\FloydSteinberg}. Source image is not modified.
Local color tablesPer-frame Local Color Table (LCT) extracted from the GIF Image Descriptor; falls back to the Global Color Table (GCT) when no LCT is present.
GCE transparency + disposalGraphic Control Extension (GCE) transparent-color index and disposal method (none/restore-bg/restore-prev) tracked per-frame in {@see SugarCraft\Flip\Frame}.
Two presetssolid โ€” full-cell โ–ˆ in 24-bit truecolor. density โ€” luminance ramp ` .:-=+*#%@`.
Pause + stepSpace pauses; โ† / โ†’ step through frames manually.
Preset toggled swaps solid โ†” density without losing your position.
Adaptive sizingRenderer::withAdaptiveSize() queries the TTY via SizeIoctl (ioctl TIOCGWINSZ) so the output never overflows the viewport. Call withConstraints() with explicit limits to test rendering at fixed dimensions.
Frame cacheWeakMap-backed memoization caches rendered frame output by Frame object identity โ€” repeated playback skips re-rendering, and entries are dropped automatically when the Frame is garbage-collected.
Frame capDecoder caps at 256 frames so a runaway file can't OOM the runtime.
Cmd::tick drivenCmd::tick($interval, โ€ฆ) schedules frame advance โ€” no busy-waiting in the main fiber.

Source & demos

Try the quickstart โ†’

API

ClassMethodDescription
Decoderdecode(path, cellsW, cellsH)Load and resize GIF
FrameCachenew()WeakMap-backed render cache
FrameCacheget(frame) / set(frame, rendered) / has(frame) / delete(frame) / clear()Cache read/write/reset
Playernew(frames)Create player
Programnew(player, options)Create program
Programrun()Start the event loop
ProgramOptionsnew(useAltScreen)Configure terminal mode
RendererwithAdaptiveSize()Query TTY size via SizeIoctl, clamp output
RendererwithConstraints(rows, cols)Set explicit render limits (testing)
RendererrenderFrame(frame, preset)Render a frame to ANSI string

Demos.

VHS-recorded GIFs of every example shipped with the app. Regenerated automatically on every push that touches the source.

Play

Play

Synthetic gif decoded + rendered through both presets.