sugarcraft / candy-mosaic
Image-to-cell renderer — PNG/JPEG/static GIF to terminal via Sixel, Kitty, iTerm2, or half-block Unicode fallback. Port of charmbracelet/x/mosaic.
Requires
- php: ^8.3
- ext-gd: *
- ext-mbstring: *
- react/promise: ^3.3
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-palette: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- react/http: ^1.11
Suggests
- react/http: Enables ImageSource::fromUrlAsync() for non-blocking remote image loading on the ReactPHP event loop (^1.11).
This package is auto-updated.
Last update: 2026-08-08 11:06:40 UTC
README
Image-to-cell renderer for the terminal — PNG/JPEG/static GIF decoded via ext-gd and rendered via the best available protocol:
- Sixel — xterm, foot, mlterm, wezterm, contour
- Kitty graphics protocol — kitty, ghostty, wezterm
- iTerm2 inline images (OSC 1337) — iTerm2, wezterm, mintty
- Half-block Unicode (▀ + 24-bit fg/bg) — universal fallback
- Quarter-block Unicode (░▒▓█ 2×2) — higher fidelity than half-block
composer require sugarcraft/candy-mosaic
Quickstart
use SugarCraft\Mosaic\Mosaic; use SugarCraft\Mosaic\ImageSource; $mosaic = Mosaic::halfBlock(); $image = ImageSource::fromFile('cat.png'); $ansi = $mosaic->render($image, width: 40, height: 20); echo $ansi;
API
use SugarCraft\Mosaic\KittyOptions; use SugarCraft\Mosaic\Renderer\QuarterBlockRenderer; // Probe terminal once, pick best protocol $mosaic = Mosaic::probe(); // Force a specific backend $mosaic = Mosaic::sixel(); $mosaic = Mosaic::kitty(); $mosaic = Mosaic::iterm2(); $mosaic = Mosaic::halfBlock(); // Render — returns ANSI bytes $ansi = $mosaic->render($image, width: 40, height: 20); // Builder for fine-grained control $mosaic = Mosaic::builder() ->withRenderer(new QuarterBlockRenderer()) ->withResize(width: 40, height: 20) ->build(); // Kitty: virtual-image placement (transmit once, place at multiple offsets) // Step 1 — transmit with a specific id and store as virtual (a=p) $renderer = (Mosaic::kitty())->renderer(); $opts = KittyOptions::transmit(1)->withUseVirtual(true); $transmitted = $renderer->renderWithOptions($image, 40, null, $opts); // Step 2 — place the same image at a different cell offset (a=p, same id) $opts = KittyOptions::place(1, x: 5, y: 10)->withZIndex(5); $placed = $renderer->renderWithOptions($image, 40, null, $opts); // Kitty: zlib compression (f=1) for large images $opts = KittyOptions::transmit()->withCompression(1); $compressed = $renderer->renderWithOptions($image, 40, null, $opts);
Supported image formats
- PNG, JPEG, static GIF — via ext-gd (
imagecreatefrompng,imagecreatefromjpeg,imagecreatefromgif) - Palette PNGs are automatically converted to truecolor before processing.
Remote images
Load images straight from a URL. fromUrl() is synchronous (PHP stream
wrappers — http/https/file/data, redirects followed); fromUrlAsync()
is non-blocking on the ReactPHP event loop and resolves with a decoded
ImageSource, ideal for fetching many posters concurrently without stalling
the render loop.
use SugarCraft\Mosaic\ImageSource; use SugarCraft\Mosaic\Mosaic; // Synchronous (blocks) — handy for scripts/CLIs. $image = ImageSource::fromUrl('https://example.com/poster.png', [ 'Authorization' => 'Bearer ' . $token, // optional request headers ]); echo Mosaic::halfBlock()->render($image, width: 24, height: 36); // Asynchronous — resolves with an ImageSource on the loop. ImageSource::fromUrlAsync('https://example.com/poster.png') ->then(fn (ImageSource $img) => Mosaic::probe()->render($img, 24, 36)) ->then(fn (string $ansi) => print($ansi));
fromUrlAsync() needs the suggested react/http package
(composer require react/http); without it the returned promise rejects with
an install hint rather than fataling. Pass your own pre-configured
React\Http\Browser as the third argument to share a connector/timeout.
Security: as with
fromFile(), the source-trust decision is yours. Both methods honour every PHP/redirect scheme, so a user-influenced URL can reach local files (file://) or internal hosts (SSRF). Only pass URLs you control or have validated against an allow-list. Header values containing CR/LF are rejected to prevent request splitting.
Persistent render cache
Encoding a poster (fetch → GD-decode → scale → protocol-encode) is expensive.
DiskCache stores the finished ANSI/sixel/kitty bytes on disk keyed by the
poster's identity, so a redraw — even across process restarts — is an O(1) file
read. It pairs with the in-memory AdaptiveImage LRU (which avoids re-encoding
within a session).
use SugarCraft\Mosaic\DiskCache; use SugarCraft\Mosaic\ImageSource; use SugarCraft\Mosaic\Mosaic; $mosaic = Mosaic::probe(); $cache = new DiskCache($_SERVER['HOME'] . '/.cache/posters', maxEntries: 512); // Key includes the protocol — the same image at the same size encodes // differently for sixel vs kitty vs half-block. $key = DiskCache::key($url, width: 24, height: 36, protocol: $mosaic->protocol()); $ansi = $cache->getOrCompute($key, fn (): string => $mosaic->render(ImageSource::fromUrl($url), 24, 36)); echo $ansi;
get()/getOrCompute() touch an entry on a hit, and put() evicts the
approximately least-recently-used entries once the directory exceeds
maxEntries (mtime is 1-second-resolution, so the cap is always honoured but
same-second writes order arbitrarily). Writes are atomic (temp file + rename)
and keys are hashed to derive the filename, so an arbitrary key can never escape
the cache directory.
KittyOptions — virtual-image placement and compression
The Kitty renderer supports two advanced options via KittyOptions:
-
Virtual-image placement (
a=p): Transmit an image once withwithUseVirtual(true), then place it at multiple on-screen locations usingwithUseVirtual(true)with the same image ID. The first render stores the image data in the terminal; subsequent renders reference it by ID and offset, reducing bandwidth. -
Zlib compression (
f=1): PasswithCompression(1)to compress the PNG payload with zlib before base64-encoding. Useful for large images on slow links; adds modest CPU overhead.
use SugarCraft\Mosaic\KittyOptions; // Transmit once (a=T, the default) $opts = KittyOptions::transmit(imageId: 1); $first = $renderer->renderWithOptions($image, 40, null, $opts); // Place at a different cell offset using the same transmitted image (a=p) $opts = KittyOptions::place(imageId: 1, x: 5, y: 10) ->withZIndex(5); // z-index for stacking order $placed = $renderer->renderWithOptions($image, 40, null, $opts); // Zlib-compressed transmit $opts = KittyOptions::transmit()->withCompression(1); $compressed = $renderer->renderWithOptions($image, 40, null, $opts);
Protocol detection
Every renderer implements Renderer::delete(string $imageId): string
which emits the protocol-specific sequence to remove a previously
rendered image. The $imageId is the numeric image identifier passed
during rendering (Kitty) or a placeholder for interface compatibility
(iTerm2 Pop ignores it).
| Renderer | Sequence | Notes |
|---|---|---|
| Kitty | APC a=d |
Deletes specific image by id |
| iTerm2 | OSC 1337 Pop | Removes topmost image from stack; $imageId ignored |
| Sixel | (none) | DECSIXEL has no delete command; returns '' |
| HalfBlock | (none) | Plain text SGR; no stored image identity; returns '' |
| QuarterBlock | (none) | Plain text SGR; no stored image identity; returns '' |
| Chafa | (none) | External command; no persistent image identity; returns '' |
Animation
Drive a sequence of frames through AnimationDriver — a Model-implementing class that uses Cmd::tick() for per-frame timing and Renderer::delete() / Renderer::renderFrame() for clean per-frame redraws on Kitty/iTerm2 terminals.
use SugarCraft\Mosaic\Animation; use SugarCraft\Mosaic\AnimationDriver; use SugarCraft\Mosaic\ImageSource; use SugarCraft\Mosaic\Mosaic; $frames = [ ImageSource::fromFile('frame1.png'), ImageSource::fromFile('frame2.png'), ImageSource::fromFile('frame3.png'), ]; $animation = Animation::fixed($frames, delayMs: 100); $driver = new AnimationDriver( animation: $animation, renderer: (Mosaic::kitty())->renderer(), cellWidth: 40, cellHeight: 20, imageId: 1, ); // Use in a Program: // new Program($driver, ...); echo $driver->view(); // renders first frame immediately
Animation is an immutable value object (list<ImageSource> + list<int> $delaysMs). Use Animation::fixed($frames, $delayMs) for uniform delays, or new Animation($frames, $delaysMs) for per-frame control. withFrame($index, $frame, $delayMs) returns a new instance with one replaced frame.
AnimationDriver composes Animation + current frame index + paused flag. Call withIndex() / withPaused() / withImageId() for fluent state changes. On Kitty-capable terminals, KittyRenderer::renderFrame($image, $width, $height, $imageId) renders a single frame with a stable id that delete($imageId) can later target.
ImageLayer — image boxes in a text frame
ImageLayer is the turnkey way to put real images inside a text UI. Instead of
emitting pixel bytes into the frame (which would wreck the layout), you reserve a
cell-sized marker block where the image belongs and hand the collected bytes
to the View. The runtime paints each marker it finds.
use SugarCraft\Core\View; use SugarCraft\Mosaic\ImageLayer; $layer = new ImageLayer(); // Reserve a 20x10 box and stash the rendered bytes. $cell = $layer->place($ansiBytes, 20, 10); // ...compose $cell into the frame like any other text... return new View($frame, images: $layer->placements());
Identical bytes register once — dedup is by content hash (xxh3), so the same
image shown in several places shares one id and paints at every marker. The id
space is the Private-Use-Area window (ImageOverlay::MAX_IMAGES, 6400); once it
is exhausted, further placements get a blank block of the requested size rather
than a wrong image.
Getting the assigned id: placeTracked()
Some widgets need the overlay id as well as the marker — for example a poster
card that takes withImage($bytes, $id), or code that wants to surgically clear
just the cells one image occupied. placeTracked() returns both as a
PlacedImage:
$placed = $layer->placeTracked($ansiBytes, 20, 10); if ($placed->imageId !== null) { $card = $card->withImage($ansiBytes, $placed->imageId); } $frame = $placed->marker; // same string place() would have returned
place() keeps its plain string return and is simply a delegate to
placeTracked(), so frame-composition callers are unaffected.
Two things about PlacedImage::$imageId are worth knowing:
- It is
nullexactly when the id space is exhausted, and then$markeris a blank block.nullnever means "unknown" — it means "this will not paint". - It identifies the content, not the call. Because dedup is by byte hash, the same bytes always get the same id regardless of size, order, or repetition. If you previously keyed ids by something call-shaped (a URL, a requested cell size, a list index), that mapping does not survive.
Do not try to recover the id from array_key_last($layer->placements()). A
dedup hit re-assigns an existing key without moving it, so that reports the
highest id rather than the one just placed; and the exhaustion path writes no
placement at all, so it reports a stale unrelated id.
Architecture
SugarCraft\Mosaic
├── Animation # Immutable frame sequence value object
├── AnimationDriver # Model; drives Animation onto a Renderer via tick()
├── FrameTickMsg # Internal Msg for frame-advance ticks
├── ImageLayer # Per-frame image registry: marker blocks + placements
├── ImageSource # Image bytes + metadata (bytes, format, aspect ratio)
├── KittyOptions # Kitty protocol options (transmit / place / compress)
├── Lang # i18n facade
├── Mosaic # Facade: probe / builder / render
├── PixelGrid # 2-D cell grid (foreground, background, alpha, char)
├── PlacedImage # placeTracked() result: marker block + assigned id
└── Renderer
├── ChafaRenderer # External command renderer
├── HalfBlockRenderer # Unicode ▀ with 24-bit fg/bg
├── Iterm2Renderer # iTerm2 OSC 1337
├── KittyRenderer # Kitty APC graphics (chunked PNG)
├── QuarterBlockRenderer # Unicode ░▒▓█ 2×2 sub-pixel
└── SixelRenderer # DEC sixel with median-cut quantizer