sugarcraft / candy-flip
ANSI/GIF animation viewer — ASCII GIF playback (gifterm port) plus .ansi art playback (AnsiArt/ArtLibrary/ArtLoop).
Requires
- php: ^8.3
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-pty: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.0
- sugarcraft/candy-testing: dev-master
Suggests
- ext-gd: Required to decode GIF animations (Player/Decoder); ANSI art playback needs neither.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 04:01:38 UTC
README
CandyFlip
ANSI/GIF animation viewer on the SugarCraft stack for PHP 8.3+. Decodes a .gif on disk via ext-gd, downsamples each frame to a configurable cell grid, and renders the animation into the terminal as 24-bit background-coloured cells (solid preset) or shaded Unicode glyphs from a luminance ramp (density preset) at a configurable framerate. It also plays back .ansi ANSI-art documents — parse, catalog, and timer-free frame pacing (see ANSI art playback below); .ansi playback needs no GD at all.
composer require sugarcraft/candy-flip candy-flip my-animation.gif # solid-block preset (default) candy-flip my-animation.gif density # ASCII luminance ramp
Keys
| Key | Action |
|---|---|
Space |
Pause / resume |
← |
Step back one frame |
→ |
Step forward one frame |
d |
Toggle solid ↔ density preset |
q / Esc |
Quit |
ANSI art playback (AnsiArt / ArtLibrary / ArtLoop)
.ansi art documents (SGR-escaped text, optionally a multi-frame redraw
stream) parse into a cell model — one AnsiCell (glyph + active SGR prefix)
per codepoint — that hosts paint into any surface, or replay verbatim via
AnsiArt::sgrText(). ArtLibrary catalogs a directory of
logo-<slug>[-skinny][-anim]-<16|256|tc>-<WxH>.ansi files and resolves a slug
at the terminal's detected colour depth with a kind/depth cascade (wide form
preferred; depth fallback is adjacent-first). ArtLoop is pure pacing math:
no timers, frameAt(elapsedSeconds) maps wall-clock offset to frame index.
use SugarCraft\Core\Util\ColorProfile; use SugarCraft\Flip\AnsiArt; use SugarCraft\Flip\ArtLibrary; use SugarCraft\Flip\ArtLoop; $library = ArtLibrary::fromDir(__DIR__ . '/logo'); // optional ->mergeManifest('logos.jsonl') $depth = ArtLibrary::profileTag(ColorProfile::detect()); // 'tc' | '256' | '16' $art = $library->pick('art-deco-gilt', $depth); // ?AnsiArt, cascade-resolved if ($art !== null && $art->isAnimated()) { $loop = ArtLoop::new($art->frameCount()); // 0.1 s/frame + 2.5 s closing hold $frame = $art->frameCells($loop->frameAt(microtime(true) - $startedAt)); foreach ($frame as $y => $row) { foreach ($row as $x => $cell) { $surface->put($x, $y, $cell->glyph, $cell->sgr); // paint cells, or echo $art->sgrText($i) } } }
Implementation notes
The decoder uses PHP's built-in imagecreatefromstring() for in-memory single-frame extraction — no temporary files are written to disk. It walks the GIF89a byte-stream manually to:
- Parse the Logical Screen Descriptor and Global Color Table (GCT) from the header.
- Walk the frame stream, extracting each frame's Graphic Control Extension (GCE) delay, disposal method (0–3), and transparent-color index.
- Extract per-frame Local Color Table (LCT) when present; fall back to the GCT otherwise.
- Reassemble a minimal single-frame GIF payload in memory and pass it to
imagecreatefromstring(). - Area-average downsample the resulting
GdImageto the requested cell grid, skipping transparent pixels in the average.
GIF parsing is hand-rolled to avoid loading the entire animation into a GdImage at once; each frame is decoded independently. The decoder caps at 256 frames, refuses logical screens larger than 2,000,000 pixels, and caps the output grid at 100,000 cells, so memory usage stays bounded for typical animations and for hostile headers alike.
Architecture
| File | Role |
|---|---|
Decoder |
Reads the GIF, extracts per-frame GCE delay + disposal + transparency + local color table + image left/top offset, hands each frame to GD via imagecreatefromstring(), area-average downsamples the composited canvas to a cell grid, returns a list of {@see Frame}. Compositing handles DISPOSAL_NONE/KEEP (0/1) to leave the canvas, DISPOSAL_BACKGROUND (2) to clear the prior rect, and DISPOSAL_PREVIOUS (3) to restore a canvas snapshot. |
Frame |
Pure value — 2-D RGB grid in cell coordinates with per-frame $delay (centiseconds), $disposal method (0–3), and $transparent flag. |
Renderer |
ANSI emitter. Two presets: solid (24-bit background-coloured spaces) or density (luminance ramp). withAdaptiveSize() queries the TTY via SizeIoctl so the output never overflows the viewport; withConstraints() accepts explicit row/col limits for testing. |
Player |
SugarCraft Model — index + paused + preset + renderer state. Cmd::tick(...) schedules frame advance using per-frame delays. Handles WindowSizeMsg by re-clamping the renderer to the new viewport (rows-1 for the status line). |
TickMsg |
Frame-tick message produced by the Cmd. |
The decoder caps at 256 frames and at a 2,000,000-pixel logical screen so a runaway file — or a crafted header — can't OOM the runtime before the first frame is even composited; pause + manual step are always available even on long animations.
Test
composer install vendor/bin/phpunit
Snapshot tests
Rendering output is pinned via candy-testing's assertGoldenAnsi golden-file
snapshots. Any change to the ANSI cell output must be intentional — re-record the
fixture with UPDATE_GOLDENS=1 vendor/bin/phpunit to accept a new canonical render.
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.
Design antecedent: namzug16/gifterm.
