sugarcraft / sugar-video
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/sugar-animation: dev-master
- sugarcraft/sugar-cellbuffer: dev-master
- sugarcraft/sugar-colorprofile: dev-master
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-image: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/sugar-testing: dev-master
Suggests
- ext-gd: Required for .gif playback and animated .webp decoding (the pure-PHP routes; no ffmpeg needed). mp4/avi/webm need the ffmpeg and ffprobe binaries on $PATH — a runtime toolchain, not a Composer package.
- ext-mbstring: Used by the WebVTT subtitle parser for UTF-8-safe cue truncation in the caption overlay.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:03:09 UTC
README
Formerly published as
sugarcraft/sugar-reel(renamed 2026-10). Namespace changed:SugarCraft\Reel\→SugarCraft\Video\.
Terminal video player — plays mp4 / gif / avi / webm / animated webp 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-video
use SugarCraft\Video\Player; // Play a video with auto-detected terminal capability. $player = Player::open('clip.mp4', cellsW: 80, cellsH: 24); // Run it (Space=play, q=quit). (new \SugarCraft\Core\Program($player))->run();
Status: complete — full implementation with ffmpeg decode pipe, pure-PHP GIF fallback, all rendering modes (ascii/ansi256/truecolor/ half-block/quarter-block/sixel/kitty/iTerm2), delta repaint, seek, speed control, and a runnable example.
Install
composer require sugarcraft/sugar-video
Requires:
- PHP 8.3+
ffmpegandffprobein$PATHformp4/avi/webmplaybackext-gdfor.gifplayback (pure-PHP fallback, no ffmpeg needed)ext-gdbuilt with WebP for animated.webpplayback (pure-PHP container walk + per-frame GD decode — no ffmpeg needed, and no ffmpeg on a stock host can decode animated WebP, so this is the route, not a fallback)- 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 synthetic # 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_VIDEO_COLS=120 SUGAR_VIDEO_ROWS=40 php examples/play.php synthetic
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 |
quarterblock |
24-bit quarter-block characters, 2×2 sub-cell 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 Probe::colorProfile() 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\Video\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 sugar-core Program and blocks. To drive playback
from a host that already owns a Program/event loop, use Reel::toPlayer() to
get the 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\Image\Mosaic; use SugarCraft\Video\Reel; use SugarCraft\Video\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.
Subtitles
Subtitles are WebVTT text that paints beneath the frame during playback. Attach
a track to a Reel and it flows through to the player untouched:
use SugarCraft\Video\Reel; // The .vtt is read and parsed when play()/toPlayer() builds the Player; an // unreadable path simply means "no subtitles" rather than a fatal. Reel::open('clip.mp4') ->withSubtitles('clip.vtt') ->play();
Format support. WebVtt::parse() accepts standard WebVTT and is lenient
enough to read most .srt files directly: numeric cue indices and ,
millisecond separators are tolerated, NOTE/STYLE/REGION blocks are
skipped, inline tags (<i>, <c>, cues' <c.class>) are stripped, and HTML
entities are decoded. Cue identifiers and cue settings lines
(position:, align: …) are parsed away but not honoured — the renderer owns
placement. A track over WebVtt::MAX_CUES (10,000) cues is truncated to that
bound.
Where they paint. In every rendering mode the active cue for the current
video time is appended as one caption line directly below the frame,
left-aligned, truncated or space-padded to the player's cell width (the
one-line-per-row invariant). Cue lookup is a binary search on the timeline
(WebVtt::cueAt()), so adding a track does not slow the per-frame path. There
is no keyboard shortcut: the toggle is attaching (or not attaching) the track
at open time.
Hosts that want their own placement can skip the integration entirely — parse
the track with WebVtt::parse() and hand the object to
Player::open(subtitles:), or query cueAt() yourself and compose the text
into your own view.
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) |
subtitles |
Attached at open time via Reel::withSubtitles() (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 (MVC model) │
│ tick() → view() │
└─────────┬──────────────┘
│
┌─────────▼──────────────┐
│ Program (sugar-core) │
│ raw mode + alt screen│
└────────────────────────┘
- Decode:
FfmpegDecodershells out toffmpegfor raw RGB frames (pre-scaled to cell dimensions).GifDecoderwraps sugar-animation's pure-PHP GIF decoder. - Render: Delegates to sugar-image for sixel/kitty/iTerm2. Uses sugar-colorprofile for color mapping. Delta repaint via sugar-cellbuffer.
- Pace:
Cmd::tick()wall-clock alignment viaSync, no busy-waiting. - Audio:
AudioPlayershells out toffplayormpv --no-videoas the audio output companion; the Player's own wall clock stays master (audio may drift; it is never the pacing source).
Prior art
The 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: sugar-image (image → cell renderers), sugar-animation (downsampling / dithering), sugar-colorprofile (color mapping), and sugar-core (Model–Update–View 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.
-
Sixel encoding is pure PHP (no external accelerator).
sixelmode encodes through sugar-image'sSixelRenderer; the former preference for an externalchafabinary on PATH was retired oncecandy-imageshipped as the chafa frontend, leaving sugar-image as the single sixel encoder in the ecosystem. Frames are decoded at the pixel grid each mode asks for (graphics modes at cells·cellPx, text modes at the mode cell grid), so the pure-PHP encoder needs no upscaling and the DCS output is protocol-identical; the trade-off is PHP time per large sixel frame, and no child process is ever spawned from the video render path. -
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.
Public API
The entry points worth knowing: Reel (open/openUrl/play/toPlayer + withMode/withFps/withLoop/withRamp/withSubtitles/withAudio/withAllowedHosts), Player (open/fromDecoder + pause/play/stop/isStopped/withSeek/withAudio/frameAt), AudioPlayer (start/stop/mute/unmute/isMuted), Synthetic::generate, WebVtt::parse, VideoSource::probe, HttpHeaders::parse, Probe, DecoderFactory::new, and RendererFactory (auto/autoMode/supports/supportedModes/diagnose).
Ceilings that shape what plays: source capture is capped at 64 MiB per file (GifDecoder::MAX_SOURCE_BYTES, AnimatedWebpDecoder::MAX_SOURCE_BYTES); animated WebP at MAX_FRAMES 4096 frames, MAX_CANVAS_PIXELS 2048² per canvas and MAX_TOTAL_PIXELS 32 Mi-px across the file; GIF residency at MAX_TOTAL_FRAME_PIXELS 8 000 000 decoded pixels per playthrough; subtitles at 4 MiB (Reel::MAX_SUBTITLE_BYTES) and 10 000 cues (WebVtt::MAX_CUES, announced when truncated); the terminal geometry clamps to 10-200 columns and 5-80 rows (Player::MIN/MAX_COLS, MIN/MAX_ROWS). ffprobe output drains under an 8 MiB ceiling and a 10 s deadline; each ffmpeg frame read is bounded by a per-fill timeout.
Color and terminal honesty
The graphics ladder in RendererFactory::auto() descends Sixel → Kitty → iTerm2 → TrueColor (block-glyph rung when the profile is symbol-safe) → half-block → ANSI-256 → plain ASCII, gated per rung by supports(); unsupported explicit modes raise a typed refusal naming the best mode for this seat, and graphics modes refuse loudly when the PHP lacks ext-gd. NO_COLOR is honored transitively: sugar-colorprofile's probe (CLICOLOR_FORCE/FORCE_COLOR/NO_COLOR chain) reports reduced capability and the ladder steps down accordingly.
Localization
User-facing strings live in lang/en.php behind Lang::t(); English is the only locale shipped today, and unknown keys fall back to the raw key rather than crashing.