sugarcraft / sugar-reel
Terminal video player for SugarCraft — plays mp4 (and more) by decoding frames on the fly and rendering them to ASCII / ANSI / truecolor half-block / sixel / kitty output.
Requires
- php: ^8.3
- sugarcraft/candy-buffer: dev-master
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-flip: dev-master
- sugarcraft/candy-mosaic: dev-master
- sugarcraft/candy-palette: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/candy-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-22 20:01:27 UTC
README
Terminal video player — plays mp4 / gif / avi / webm and more on
the fly, rendering each frame as ASCII, ANSI 256-color, truecolor half-blocks,
or via modern graphics protocols (sixel / kitty / iTerm2). Like mpv -vo tct,
but in PHP and reusing the SugarCraft rendering stack throughout.
composer require sugarcraft/sugar-reel
use SugarCraft\Reel\Player; // Play a video with auto-detected terminal capability. $player = Player::open('clip.mp4', cols: 80, rows: 24); // Run it (Space=play, q=quit). (new \SugarCraft\Core\Program($player))->run();
Status: Step 7 ✓ — full implementation with ffmpeg decode pipe, pure-PHP GIF fallback, all rendering modes (ascii/ansi256/truecolor/ half-block/sixel/kitty/iTerm2), delta repaint, seek, speed control, and a runnable example.
Install
composer require sugarcraft/sugar-reel
Requires:
- PHP 8.3+
ffmpegandffprobein$PATHformp4/avi/webmplaybackext-gdfor.gifplayback (pure-PHP fallback, no ffmpeg needed)- A terminal with at least 256-color support for ANSI modes
Usage
# Built-in synthetic test pattern (no video file needed) php examples/play.php # Play a real video file php examples/play.php video.mp4 # Force a specific rendering mode php examples/play.php video.mp4 halfblock php examples/play.php video.mp4 ascii # Force auto mode (probe terminal, pick best available) php examples/play.php video.mp4 auto # Set terminal dimensions SUGAR_REEL_COLS=120 SUGAR_REEL_ROWS=40 php examples/play.php
Rendering modes
| Mode | Description | Terminal requirement |
|---|---|---|
ascii |
Grayscale luminance ramp ( .,:;i1tfLCG08@) |
Any |
ansi256 |
256-color cube + grey ramp | 256-color |
truecolor |
24-bit RGB truecolor | 24-bit color |
halfblock |
24-bit ▀ half-blocks, 2× vertical resolution |
24-bit color |
sixel |
Sixel graphics protocol (DEC) | Sixel-capable |
kitty |
Kitty graphics protocol (APC \x1b_G) |
Kitty-compatible |
iterm2 |
iTerm2 inline image (OSC 1337) | iTerm2 / WezTerm |
auto |
Probe terminal, pick best available (default) | — |
Auto mode probes the terminal using Mosaic::diagnose() (for sixel/kitty/
iTerm2) and falls back to ColorProfile::detect() for ANSI modes.
Luminance ramp selection
ASCII/ANSI256 text modes use a luminance ramp to map pixel brightness to characters. Three named ramps are available:
| Ramp | Characters | Best for |
|---|---|---|
minimal |
.:-=+*#%@ |
Low-resolution / high contrast |
standard |
.,:;i1tfLCG08@ |
General use (default) |
dense |
`` .`^",:;Il!i><~+_-?][}{1)( | \/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$`` |
// Use the dense ramp for more detailed ASCII output Reel::open('video.mp4')->withRamp('dense')->play();
Remote & embedded playback
Authenticated / signed streams
Reel::openUrl() plays an http(s) stream and re-presents request headers on
every open and every decoder rebuild (a seek or a resize re-spawns ffmpeg, so
a short-lived signed URL or bearer token must be re-sent each time):
use SugarCraft\Reel\Reel; Reel::openUrl( 'https://cdn.example.com/private/clip.mp4', headers: [ 'Authorization' => 'Bearer <token>', 'User-Agent' => 'my-app/1.0', ], allowedHosts: ['cdn.example.com'], // optional host allowlist )->play();
Headers are validated at the boundary: a name or value containing a CR, LF or
NUL (HPP / request-splitting), an empty name, a colon in the name, or a
malformed field line all throw InvalidArgumentException before ffmpeg is
ever spawned. Migration note: the second parameter used to be
$allowedHosts; it is now $headers (prefer named arguments). The old
positional shape fails loudly — a positional host list is rejected outright as
the legacy allowedHosts shape (its own migration message), null as a
TypeError. A User-Agent value is passed to
ffmpeg -user_agent; every other header rides
in a single -headers block. Both flags are emitted only for network sources —
for a local file they are dropped and a reason is logged. Non-http(s) URL
schemes (rtsp://, rtmp://, …) are not opened by openUrl(). Calling
openUrl() without allowedHosts: also logs a one-line SSRF advisory
(ssrf.no_allowlist, host only — never the path or query): ffmpeg resolves DNS
and follows redirects, so an unbounded remote URL can reach internal hosts.
// Mutate headers fluently on an existing Reel (validated the same way): $reel = $reel->withHeaders(['Cookie' => 'session=…']);
Embedding the player without owning the loop
Reel::play() builds its own candy-core Program and blocks. To drive playback
from a host that already owns a Program/event loop, use Reel::toPlayer() to
get the TEA Player model paused and mount it yourself. Building the model
opens the source, so its ffmpeg child is spawned immediately — a host that
mounts and never starts must still call stop() to release it.
$player = Reel::openUrl($url, $headers)->toPlayer(); // paused; decoder child already spawned // Mount $player in your own Program, or fold it into a larger Model. // When the host leaves the player screen, release the child processes: $player->stop(); // idempotent — stops audio companion + closes decoder/ffmpeg
Host contract — the caller that owns the loop is responsible for:
- Resize: forward terminal resizes to the player as a
WindowSizeMsg.Playerclamps columns to[10, 200]and rows to[5, 80](theMIN_COLS/MAX_COLS/MIN_ROWS/MAX_ROWSconstants) and ignores a resize that does not change the clamped cell grid. A change rebuilds the decoder off the render hot path (inupdate, never inview) and off the signal itself: the handler only records the target and returns a short debounce command, and the re-spawn happens once the burst settles — so dragging a terminal edge costs oneffmpegre-open, not one per character cell. The host must actually run the commandupdate()returns, or the player keeps decoding at the superseded size. - Quit keys: decide when to quit. Standalone playback quits on
q,Esc, orCtrl-Cand each of those paths callsPlayer::stop()first; a host embedding the player may route different keys but must still callstop()on teardown so noffmpeg/audio child is orphaned. - Tick: the player self-schedules with
Cmd::tick()while playing; do not poll it from a separate timer.toPlayer()returns it paused — call$player->play()(or sendSpace) to start. - Source binding:
toPlayer()requires a bound source (a path or URL). An unboundReel::new()throwsInvalidArgumentExceptionrather than silently substituting the synthetic pattern thatplay()uses.
Tell the player how big one cell is
In the graphics modes (kitty, sixel, iTerm2) the terminal's cell pixel size
is a decode input: the frame is rasterised at cols × cellPxW by
rows × cellPxH so the image fills the pixel box instead of being resampled up
from one pixel per cell. Only the host knows that number, so Reel accepts it
through withCellPx() and forwards it to Player::open() (and to play(),
which builds the same player internally):
use SugarCraft\Mosaic\Mosaic; use SugarCraft\Reel\Reel; use SugarCraft\Reel\Render\Mode; // Mosaic::auto()/probe() runs Detect::probe(), whose XTWINOPS 16t round-trip // yields ['cellWidth'=>…, 'cellHeight'=>…] — or null whenever there is no // interactive TTY (CI, piped stdout) or the terminal does not answer within // ~100 ms. Cache it at the application boundary; do not re-query per frame. $cell = Mosaic::auto()->fontSize(); $reel = Reel::open('clip.mp4') ->withMode(Mode::Kitty) ->withSize(120, 45); if ($cell !== null) { $reel = $reel->withCellPx($cell['cellWidth'], $cell['cellHeight']); } // 120·cellWidth × 45·cellHeight pixels when measured; the historical // 10×20 assumption when the terminal gave nothing back. $player = $reel->toPlayer();
Reel deliberately does not probe the terminal itself: a query issued from
inside a Model builder would block and would contend with whatever the embedding
program already owns, so the measurement is injected. Callers that never
withCellPx() keep the long-standing 10×20 assumption unchanged (it is
Player::open()'s own default), and cellPx() returns null until one is
supplied — a non-positive dimension throws InvalidArgumentException.
Keyboard controls
| Key | Action |
|---|---|
Space |
Pause / resume |
← |
Seek backward 10 frames |
→ |
Seek forward 10 frames |
[ |
Decrease playback speed (−0.25×, min 0.25×) |
] |
Increase playback speed (+0.25×, max 4.0×) |
0–9 |
Seek to 0–90% of video duration |
m |
Cycle to next rendering mode |
q / Esc |
Quit |
resize |
Terminal resize (SIGWINCH) re-scales video automatically |
loop |
Loop is set at open time via Reel::new()->withLoop(true)->play() (no keyboard shortcut) |
Architecture
video file (mp4/gif/avi/webm)
│
▼
┌───────────────────┐ ┌─────────────────┐
│ VideoSource::probe│ │ DecoderFactory │
│ (ffprobe JSON) │────▶│ create() │
└───────────────────┘ └────────┬─────────┘
│
┌────────────┴────────────┐
│ │
GifDecoder FfmpegDecoder
(pure PHP / GD) (ffmpeg pipe)
│ │
└────────────┬───────────┘
▼
┌──────────────────────┐
│ RgbFrame (rgb24) │
└──────────┬─────────────┘
│
┌─────────┴──────────────┐
│ FrameRenderer / │
│ Mosaic bridge │
└─────────┬──────────────┘
│
┌─────────┴──────────────┐
│ Player (TEA Model) │
│ tick() → view() │
└─────────┬──────────────┘
│
┌─────────▼──────────────┐
│ Program (candy-core) │
│ raw mode + alt screen│
└────────────────────────┘
- Decode:
FfmpegDecodershells out toffmpegfor raw RGB frames (pre-scaled to cell dimensions).GifDecoderwraps candy-flip's pure-PHP GIF decoder. - Render: Delegates to candy-mosaic for sixel/kitty/iTerm2. Uses candy-palette for color mapping. Delta repaint via candy-buffer.
- Pace:
Cmd::tick()wall-clock alignment viaSync, no busy-waiting. - Audio:
AudioPlayershells out toffplayormpv --no-videoas the audio master clock.
Prior art
SugarReel has no single upstream. Its decode → render → pace pipeline draws on three terminal-video projects, credited here:
- maxcurzi/tplay — Rust terminal media player.
- seatedro/glyph — edge-aware ASCII/ANSI video renderer.
- joelibaceta/video-to-ascii — Python video-to-ASCII player.
The rendering stack is reused from the SugarCraft ecosystem rather than reinvented: candy-mosaic (image → cell renderers), candy-flip (downsampling / dithering), candy-palette (color mapping), and candy-core (TEA runtime + frame pacing).
Known limitations
-
Audio plays at 1.0× regardless of playback speed. Changing speed with
[/]only affects video pacing. The audio companion (ffplay/mpv) always plays at normal speed. A/V will diverge noticeably when using speeds other than 1.0×. -
Seeking repositions audio but not frame-exactly. A seek creates a new AudioPlayer at the correct offset, but the video frame timing and audio timing are only approximately synchronized (frame-skip resync keeps them close at 1.0×).
-
GIF playback fills the terminal in HalfBlock mode (each cell = 2 source rows). In text modes (ascii/ansi256/truecolor) the GIF renders at its native pixel dimensions without 2× vertical scaling.
-
Seek is fast, slightly less frame-exact (keyframe snap).
seekToSeconds()uses ffmpeg input seeking (-ssplaced before-i), which decodes from the keyframe at or just before the requested time rather than walking the whole file. That is what makes scrubbing a multi-GB network stream instant; the trade-off is that the landed frame can be a touch less frame-exact than output seeking, andvideoTime/frameIndexare set to the requested target, so the on-screen clock can lead the first displayed frame by up to one GOP on sparsely-keyed sources. Index-based backward seeks (withSeek()to an earlier frame) instead reopen from t0 and decode forward, which is frame-exact. -
Audio pause/seek on Windows.
AudioPlayerno longer relies onSIGSTOP/SIGCONT(which are absent on Windows and unreliable under a PTY): pause terminates the subprocess and resume re-spawnsffplay/mpvfrom the banked playback position (-ss/--start). The trade-off is a short re-open latency on resume, and the audio clock is quantised to the last pause boundary rather than frame-exact. -
Audio headers are forwarded, but only in the two dialects
ffplay/mpvunderstand. The request headers given toReel::openUrl()ride on ffmpeg's video input (-headers/-user_agent) and on the audio companion:ffplaygets the same-headersblob,mpvgets one--http-header-fields="Name: value"per pair (rendered byHttpHeaders::toMpvHeaderFields(), which includesUser-Agent, so mpv is never also given--user-agent). A header that neither child can be handed — a per-request nonce, or a cookie the video side obtained by redirect — still will not reach audio. On a signed or 403-gated stream audio therefore now authenticates the same way video does; a local path drops the headers altogether and logsheader.ignored_local_source.audio(with any credentials in the URL redacted) rather than sending credentials to a file. -
Decode is synchronous with an optional per-tick budget.
next()reads the ffmpeg stdout pipe — non-blocking, behind a boundedstream_select()deadline — so a stalled network source surfaces as end-of-stream instead of hanging the loop. A tick that falls behind by many frames will decode the catch-up frames inline; set a budget viaPlayer::open(..., frameBudgetMs: 8.0)to cap that work per tick (remaining catch-up converges over subsequent ticks instead of blocking one). The budget is checked between frames, so it cannot preempt a single frame decode already in flight; per-read bounding comes from the pipe timeout.