sugarcraft/candy-vcr

Record and replay candy-core Program sessions into cassettes — bug-repro / regression / fuzzing.

Maintainers

Package info

github.com/sugarcraft/candy-vcr

Documentation

pkg:composer/sugarcraft/candy-vcr

Statistics

Installs: 219

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-06-02 07:51 UTC

This package is auto-updated.

Last update: 2026-06-02 07:58:50 UTC


README

PHP port of charmbracelet/x/vcr and a drop-in PHP replacement for charmbracelet/vhs (.tape.gif renderer). Pairs with candy-vt — every frame fed to the GIF encoder is a SugarCraft\Vt\Snapshot taken off candy-vt's Terminal.

Records every Msg fed into a candy-core Program and every frame emitted by view(), with timing, into a cassette file. Replays cassettes by feeding the recorded Msgs back at recorded cadence and asserting frames match (cell-grid equality via candy-vt, with byte-equality fallback).

Contents

Status

🟢 v1 ready — all 7 PRs merged. See plans/x-vcr.md for the slice history.

PR Scope
PR1 Cassette + Event + JsonlFormat
PR2 Recorder + Program::withRecorder()
PR3 Msg serializers — Builtin + Jsonable + Registry
PR4 Player + ByteAssertion + ReplayResult
PR5 ScreenAssertion via candy-vt
PR6 YamlFormat
PR7 bin/candy-vcr CLI + examples + tracking
PR8 Tape lexer/parser/compiler (.tape → Cassette)
PR9 Renderer + FrameStream + FrameDedup (Phase 3 of vhs-replacement)
PR10 Raster + Glyphs + FontLoader (Phase 4 of vhs-replacement)
PR11 GIF encoder — GifEncoder interface + FfmpegGifEncoder + PhpGifEncoder + TapeToGif (Phase 5 of vhs-replacement)

Use cases

  • Bug repro — user runs --record bug.cas, ships the cassette, maintainer replays locally.
  • Regression tests — record a known-good session, replay in CI, diff against expected screen state.
  • Demo capture — alternative to VHS for headless / scriptable recordings (no docker, runs in PHP unit-test process).
  • Fuzzing seeds — mutate recorded Msgs slightly, replay to find edge cases.

Install

composer require sugarcraft/candy-vcr

Cassette format (JSONL)

Full schema reference: docs/CASSETTE.md — includes dual-timestamp (t + tRaw) format, all event kinds, and the header structure.

{"v":1,"created":"2026-05-07T10:00:00Z","cols":80,"rows":24,"runtime":"sugarcraft/candy-core@1.0.0"}
{"t":0.000,"k":"resize","cols":80,"rows":24}
{"t":0.001,"k":"output","b":"�[2J�[H..."}
{"t":0.450,"k":"input","msg":{"@type":"KeyMsg","key":"j"}}
{"t":1.201,"k":"quit"}
  • Line 1 is the header (carries v).
  • Subsequent lines are events keyed by k (resize, input, output, quit) with kind-specific payload fields.
  • t is seconds since cassette start (ms precision).

Cassette formats

candy-vcr ships five cassette serializers, each tuned for a different trade-off between human-readability, file size, and toolchain interop. All five share the same Cassette value-object model and are interchangeable via the Format interface.

Format File extension When to pick it Trade-offs
JsonlFormat (default) .cas, .jsonl, .cassette Tests, version control, day-to-day recording. One JSON document per line, absolute timestamps (t). Human-readable, diff-friendly, no compression.
CompressedJsonlFormat .cas.gz Long recordings, CI artifact storage. Gzipped JSONL. 5–10× smaller on disk; still streams (per-line flush). Loses git-diff friendliness.
RelativeFormat .cas (with dt field) Deterministic replay; cassettes meant to be edited by hand. Delta timestamps (interval since previous event). Easier to reason about edits — no need to re-shift downstream t values.
YamlFormat .yaml, .yml Test fixtures where the cassette is hand-authored. Human-editable, indented. Largest on disk; slowest to parse. Lowest priority for production recordings.
AsciinemaFormat .cast Importing existing asciinema v3 recordings. Read-only interop with asciinema cat/asciinema play. Stdin events become raw-byte inputs (no Msg envelope round-trip).

Auto-detection. Player::loadAny() and the CLI commands (inspect, replay, diff, stats) accept either a cassette or a .tape source and pick the right loader via SugarCraft\Vcr\Format\CassetteLoader: extension first, then content sniff (first non-blank non-comment line — { is JSON, a known tape directive keyword is a tape source). .cas/.jsonl files dispatch to JsonlFormat or RelativeFormat depending on whether the first event carries t or dt. Anything else throws \InvalidArgumentException.

Tape vs cassette. A .tape file (the VHS DSL) is compiled through the Lexer → Parser → Compiler pipeline before it becomes a Cassette — the on-disk format is source code, not serialized events. CassetteLoader::isTape() exposes the sniff in case the caller needs to short-circuit (e.g. render-tape --dry-run only runs the compile pass).

Timestamp modes

Cassettes support two timestamp modes:

Mode Field Use case
absolute (default) t = seconds since cassette start Playback timing
relative dt = interval since previous event (like asciinema v3) Deterministic replay; easier manual editing

Select the mode at record time via Recorder::withFormat():

use SugarCraft\Vcr\Recorder;
use SugarCraft\Vcr\Format\RelativeFormat;

// Relative timestamps (delta between events)
$recorder = Recorder::open('/tmp/session.cas')
    ->withFormat(new RelativeFormat());

(new Program($model))
    ->withRecorder($recorder)
    ->run();

The Player::open() factory auto-detects which format a cassette uses by examining the first event line — dt field means RelativeFormat, t field means JsonlFormat. No format parameter needed on replay.

Absolute mode example:

{"t":0.000,"k":"output","b":"$ "}
{"t":0.500,"k":"output","b":"ls\r\n"}
{"t":0.502,"k":"output","b":"file1.txt file2.txt\r\n"}

Relative mode example (same events):

{"dt":0.000,"k":"output","b":"$ "}
{"dt":0.500,"k":"output","b":"ls\r\n"}
{"dt":0.002,"k":"output","b":"file1.txt file2.txt\r\n"}

The header carries timestampMode so the format is self-describing. Backwards compatibility is preserved — cassettes without a timestampMode key default to absolute.

Gzip compression

Cassettes can be gzip-compressed by using the .gz extension or by using CompressedJsonlFormat directly:

use SugarCraft\Vcr\Format\CompressedJsonlFormat;

$format = new CompressedJsonlFormat();

// Write compressed cassette
$format->write($cassette, '/tmp/session.cas.gz');

// Read compressed cassette (auto-detects .gz extension)
$cassette = $format->read('/tmp/session.cas.gz');

Compressed cassettes are typically 5-10x smaller than plain JSONL, making them suitable for CI storage and git repositories. The format uses streaming gzip with per-line flush to maintain memory efficiency for large cassettes.

Asciinema import (L2)

Import asciinema v3 cast files as candy-vcr Cassettes for replay:

use SugarCraft\Vcr\Format\AsciinemaFormat;

$cassette = (new AsciinemaFormat())->read('/path/to/session.cast');
$player = new Player($cassette);
$result = $player->play(programFactory: $factory, speed: Player::SPEED_REALTIME);

The importer handles asciinema v3's relative timestamps, converts o (stdout) events to output events, i (stdin) events to input events, and x (exit) events to quit events.

PHP API

The core types in SugarCraft\Vcr\:

Class Role
Cassette Immutable container: header: CassetteHeader, events: list<Event>. Accessors eventCount(), header(), duration().
CassetteHeader Schema metadata — $version, $cols, $rows, $runtime, $theme, $typingSpeed, $env, $timestampMode, $created. Constants CURRENT_VERSION, TIMESTAMP_MODE_ABSOLUTE, TIMESTAMP_MODE_RELATIVE.
Event One recorded line. Fields: t: float, kind: EventKind, payload array.
EventKind Enum: Resize, Input, Output, Quit.
Recorder Record-side. Construct via Recorder::open($path). Methods: withFormat(Format), withHook(Hook), withIdleTrim($threshold, $compressedMax=0.5), recordResize($cols, $rows), recordInputBytes($bytes), recordOutput($bytes), recordQuit(), close(). Helpers defaultHeader($cols, $rows, $runtime), filteredHostEnv($regex), hooks(): HookRegistry.
Player Replay-side. Factories: Player::open($path) (extension-only), Player::loadAny($path) (extension + content sniff via CassetteLoader, accepts .tape sources). Methods: withIdleTrim(?float), play(programFactory, ?assertion, ?speed, ?idleThresholdSeconds, ?useRawTimestamps). Constants SPEED_INSTANT, SPEED_REALTIME, INSTANT_YIELD_SECONDS.
Format\Format (interface) write(Cassette, $path), read($path): Cassette, encode(Cassette): string, decode(string): Cassette. Implementations: JsonlFormat (default), CompressedJsonlFormat, RelativeFormat, YamlFormat, AsciinemaFormat (read-only).
Format\CassetteLoader load($path): Cassette — auto-detects cassette vs tape source. isTape($path): bool — exposes the sniff.
ReplayResult DTO returned by Player::play(). $ok: bool, diffSummary(): string, $expectedBytes, $actualBytes.

Quickstart

Record a session:

use SugarCraft\Core\Program;
use SugarCraft\Vcr\Recorder;

(new Program($model))
    ->withRecorder(Recorder::open('/tmp/session.cas'))
    ->run();
// cassette is closed automatically on QuitMsg

Read a recorded cassette back:

use SugarCraft\Vcr\Format\JsonlFormat;

$cassette = (new JsonlFormat())->read('/tmp/session.cas');
foreach ($cassette->events as $event) {
    echo $event->kind->value, ' @ ', $event->t, "\n";
}

The CLI lands in PR7.

CLI

vendor/bin/candy-vcr record       --output session.cas -- bash -c 'echo hi'  # capture a real PTY session
vendor/bin/candy-vcr inspect      session.cas                                # list events
vendor/bin/candy-vcr replay       session.cas --speed=realtime               # stream output to stdout
vendor/bin/candy-vcr replay       session.cas --idle-trim=1.0                # clamp long gaps to 1s during replay
vendor/bin/candy-vcr diff         a.cas b.cas                                # structural diff
vendor/bin/candy-vcr stats        session.cas                                # show cassette statistics
vendor/bin/candy-vcr render-tape demo.tape                                 # render .tape to .gif
vendor/bin/candy-vcr render-batch demos/                                     # render all .tape files in directory

record (PR P6.5.1) spawns the given command under a fresh master/slave PTY, drops the host stdin into raw mode, runs the candy-pty byte pump with a Recorder tee'd onto every stdin/master-output chunk, and writes a session-<timestamp>.cas cassette (override with --output PATH). The recorded child gets a controlling terminal by default so Ctrl+C reaches it (use --no-ctty to disable); the host termios is restored on every exit path including thrown exceptions. The cassette can then be replayed via vendor/bin/candy-vcr replay … or loaded by tests through Player::play().

inspect shows each event's timestamp, kind, and a short payload summary (with --since=<seconds> / --until=<seconds> filters). replay streams the cassette's recorded output bytes to stdout — --speed=realtime honours the recorded cadence (use it for visual demos), --speed=instant flushes everything as fast as the kernel will accept it. diff compares headers + per-event payloads and exits non-zero on any difference. stats prints event tallies by kind, total duration, input message type breakdown, and output byte counts with per-event averages.

Recording commands

vendor/bin/candy-vcr record -- vim /tmp/scratch
vendor/bin/candy-vcr record --output bash-session.cas --cols 132 --rows 40 -- bash -l
vendor/bin/candy-vcr record --no-ctty -- /bin/echo 'hello, world'   # non-interactive child, no Ctrl+C wiring
vendor/bin/candy-vcr record --shell                                  # spawn $SHELL -l (or /bin/sh -l)
vendor/bin/candy-vcr record --env -- bash -c 'echo hi'              # capture filtered host env into cassette header
vendor/bin/candy-vcr record --idle-trim 1.0 -- bash demo.sh         # compress idle gaps > 1s (asciinema-style trim)
vendor/bin/candy-vcr replay  --no-trim session.cas --speed=realtime # restore real cadence on a trimmed cassette

Roughly equivalent to asciinema rec / charmbracelet's shirley, but writes the candy-vcr JSONL cassette so the existing inspect / replay / diff / stats commands and the Player::play() API work without conversion. Subsequent plan steps will layer in --idle-trim (P6.5.3) and a host-termios safety net via register_shutdown_function + signal handlers (P6.5.4).

--shell (PR P6.5.2)

Spawn the user's $SHELL -l (falling back to /bin/sh -l when $SHELL is empty or non-executable) instead of an explicit positional command. Useful for "capture what my prompt does" demos without enumerating the shell binary every time. Mutually exclusive with positional <cmd>.

--env and --env-regex=PATTERN (PR P6.5.2)

Env capture is opt-in--env snapshots the host environment into the cassette header. By default, keys matching the conservative secret-name regex /(SECRET|TOKEN|KEY|PASSWORD|API|CRED|AUTH|PRIV)/i are stripped before they hit disk. The bias is "rather strip-too-much than leak" — KEYBOARD_LAYOUT is stripped because it contains KEY. Override the regex with --env-regex=PATTERN when you need a narrower (or wider) filter; passing --env-regex implies --env.

--env-allow-secrets (PR P6.5.2)

DANGEROUS — for trusted, isolated environments only. When this flag is set, secret-key filtering is disabled entirely and the cassette will contain credential values verbatim (API tokens, passwords, private keys, etc.). Only use this flag when recording in a fully isolated environment and you understand that the resulting cassette must never be shared or stored in an untrusted location.

vendor/bin/candy-vcr record --env-allow-secrets -- bash -c 'echo $GITHUB_TOKEN'
# GITHUB_TOKEN value is now in the cassette in plain text

Captured env lands on the cassette header as a JSON object:

{"v":1,"created":"...","cols":80,"rows":24,"runtime":"sugarcraft/candy-vcr@record","env":{"HOME":"/home/me","LANG":"en_US.UTF-8","PATH":"/usr/bin:/bin","TERM":"xterm-256color"}}

Recorder::filteredHostEnv(string $regex = SECRET_KEY_REGEX): array<string,string> is the public helper invoked under the hood; tests can drive it directly without spawning a child.

--idle-trim N and replay --no-trim (PR P6.5.3)

Borrowed from asciinema, idle-trim compresses long inter-event gaps so a 30-second make build doesn't take 30 seconds to replay. When the gap between consecutive events exceeds N seconds, the recorder writes the event with both t (the compressed timestamp) and tRaw (the original wall-clock timestamp). The compressed gap defaults to 0.5 s (or N, whichever is smaller).

{"v":1,"created":"...","cols":80,"rows":24,"runtime":"sugarcraft/candy-vcr@record"}
{"t":0,"k":"output","b":"pre\r\n"}
{"t":0.5,"k":"output","b":"post\r\n","tRaw":1.234}
{"t":0.515,"k":"quit","tRaw":1.249}

Replay defaults to the compressed timeline. Pass --no-trim to replay to honour tRaw instead — useful when the original cadence matters (demos, race-condition repros). Events without tRaw (older cassettes, or untrimmed events) replay using t, so the format stays backward-compatible. The Player::play(... useRawTimestamps: true) flag exposes the same behaviour to PHP callers.

Host TTY safety net (PR P6.5.4)

record puts the host stdin into raw mode while the recorded program runs. The in-band finally restores it on every PHP-controlled exit path (clean exit, exception). For exits that bypass finally — SIGTERM, SIGHUP, fatal errors — the command installs:

  • register_shutdown_function([RecordCommand::class, 'rescueRestore']) — fires on every PHP run shutdown, including fatal errors.
  • pcntl_signal(SIGTERM/SIGHUP, [RecordCommand::class, 'handleRescueSignal']) with pcntl_async_signals(true) — restores then re-raises the signal with the default handler so the process still dies with the right status.

SIGKILL cannot be intercepted by anything. As a mitigation, while recording is in flight the command drops a marker file at sys_get_temp_dir() . '/candy-vcr-rescue.<pid>' containing the host TTY's device path (resolved via posix_ttyname(STDIN)). If a hard kill leaves your terminal stuck in raw mode, run stty sane < /path/to/your/tty (you'll find the path in the marker file, which is cleaned up on every clean exit).

The static handlers are signal-safe (no allocation, no logging) and idempotent; calling rescueRestore() twice in a row is a no-op.

Recording overhead (PR P6.5.6)

The PosixPump recorder tap (PR P6.1) is a single conditional recorder->recordOutput($bytes) call per master-read chunk — no extra syscalls on the hot path, no per-chunk serialization beyond appending a JSON line to the open cassette stream.

Benchmark (tests/Integration/ShirleyOverheadTest.php, median of 5 timed runs after a warmup, time bash -c 'seq 100000'):

Scenario Median wallclock
Pump WITHOUT recorder ~47 ms
Pump WITH recorder ~40 ms
Measured overhead within noise (≤2% per plan target)

The CI bound is set to 5 % to absorb shared-runner jitter while still catching the regression class this test exists to flag (a real serialization-per-chunk regression would land at dozens of percent).

Hook system (L4)

Hooks intercept and transform events during recording, enabling sanitization, metadata injection, and custom logging:

use SugarCraft\Vcr\Recorder;
use SugarCraft\Vcr\Hook\SanitizingHook;
use SugarCraft\Vcr\Hook\MetadataHook;

$recorder = Recorder::open('/tmp/session.cas');

// Remove sensitive keys from all events
$recorder->withHook(new SanitizingHook(
    removeKeys: ['API_KEY', 'SECRET_TOKEN'],
));

// Add CI metadata to the first output event
$recorder->withHook(new MetadataHook([
    'CI_RUN_ID' => getenv('GITHUB_RUN_ID'),
    'test_name' => 'MyTest::testViewOutput',
]));

(new Program($model))->withRecorder($recorder)->run();

Available hooks:

  • SanitizingHook — removes keys or replaces patterns via regex
  • MetadataHook — injects metadata into the first output event
  • Custom hooks implement SugarCraft\Vcr\Hook\Hook (beforeSave($event): ?Event + afterCapture($event): void)

Hooks are managed by Hook\HookRegistry (addHook, beforeSave, afterCapture, count, clear). Recorder::withHook() appends to its private registry; returning null from beforeSave() drops the event entirely.

Matcher classes (L3 — replay-side flexibility)

SugarCraft\Vcr\Matcher\EventMatcher controls when a replayed event "matches" the recorded one. Use cases: timing-tolerant replays (CI that drifts a few ms), content-only diffs (ignore wall-clock drift entirely).

Class Semantics
PassthroughMatcher Default — every event matches; pairs with ByteAssertion for the actual diff.
ContentMatcher Matches on (kind, payload); ignores t. Useful when replay timing isn't deterministic.
TimingTolerantMatcher Matches when |t_actual - t_recorded| ≤ $tolerance. Construct with $tolerance: float (seconds).
use SugarCraft\Vcr\Matcher\TimingTolerantMatcher;
$matcher = new TimingTolerantMatcher(tolerance: 0.05);  // ±50 ms
$matcher->matches($recordedEvent, $actualEvent);  // bool

Custom matchers implement EventMatcher::matches(Event $recorded, Event $actual): bool.

Rendering .tape files to GIF (PR12)

The render-tape and render-batch commands convert .tape files to animated GIFs. They use the TapeToGif pipeline: Lexer → Parser → Compiler → Player → Terminal → Renderer → FrameStream → FrameDedup → Rasterizer → GifEncoder.

# Render a single .tape file
vendor/bin/candy-vcr render-tape demo.tape

# Custom output path
vendor/bin/candy-vcr render-tape demo.tape -o output.gif

# Specify theme and fps
vendor/bin/candy-vcr render-tape demo.tape --theme Dracula --fps 20

# Use imagick backend instead of gd
vendor/bin/candy-vcr render-tape demo.tape --backend imagick

# Use pure-PHP encoder (fallback when ffmpeg unavailable)
vendor/bin/candy-vcr render-tape demo.tape --encoder php

# Strict mode — fail on unknown directives
vendor/bin/candy-vcr render-tape demo.tape --strict

# Batch render all .tape files in a directory
vendor/bin/candy-vcr render-batch demos/

# Batch render recursively
vendor/bin/candy-vcr render-batch demos/ --recursive

# Custom output directory for batch
vendor/bin/candy-vcr render-batch demos/ -o output-gifs/

render-tape options

Option Short Description
--output -o Output .gif path (default: same as input with .gif extension)
--font -f TTF font family name (default: JetBrainsMono)
--theme -t Theme name (default: TokyoNight). Options: TokyoNight, TokyoNightLight, TokyoNightStorm, Dracula, SolarizedDark
--fps Frames per second (default: 30)
--backend -b Rasterizer backend: gd (default) or imagick
--encoder -e GIF encoder: ffmpeg (default) or php
--strict Error on unknown directives instead of skipping
--dry-run Print the compiled event stream as JSONL to stdout; no GIF is written

--dry-run runs Lexer → Parser → Compiler but skips the Renderer → Rasterizer → Encoder. The first stdout line is the compiled header tagged with "_header"; subsequent lines are one event per line as {"t":…, "kind":…, "payload":…}. Useful for diffing two tape files' event streams or inspecting what Type "..." compiles into without spending the GIF render cost. The -o flag (if also passed) is ignored in dry-run mode.

candy-vcr render-tape demo.tape --dry-run | head -5

inspect --frames

inspect <cassette|.tape> --frames walks the cassette through a Renderer and Terminal at the configured fps (default 30), printing one line per snapshot:

time<TAB>cursor_row,cursor_col<TAB>grid_sha1

grid_sha1 is a deterministic digest of every (row, col, char, fg, bg, attrs) cell tuple plus the cursor state. Two snapshots that would rasterize to identical pixels share the same hash. The footer reports total frame count, frames before dedup, and unique frames after FrameDedup — handy for sizing the GIF-encoding job before committing to a full render.

candy-vcr inspect demo.tape --frames --fps 60

render-batch options

Same as render-tape plus:

Option Short Description
--output-dir -o Output directory for .gif files (default: same as source dir)
--recursive -r Search recursively for .tape files

GIF encoding pipeline

The TapeToGif class wires together the full render pipeline:

  1. Parsing — Lexer tokenizes the .tape source, Parser produces an AST
  2. Compilation — Compiler converts the AST into a Cassette with timed events
  3. Rendering — Player drives the cassette events into a Terminal, Renderer produces frames via FrameStream
  4. Deduplication — FrameDedup collapses visually identical adjacent frames
  5. Rasterization — Rasterizer converts each Snapshot to a PNG using glyph tiles
  6. Encoding — FfmpegGifEncoder produces the final GIF (or PhpGifEncoder as fallback)

Frame hold durations are tracked through the dedup stage and passed to the encoder for accurate VFR (variable frame rate) timing.

Cassette migration

Cassette format versions evolve over time. The migrate command upgrades cassettes automatically:

# Migrate in-place (creates session.cas.bak backup)
candy-vcr migrate session.cas

# Migrate to a new file
candy-vcr migrate session.cas upgraded.cas

# Dry-run — validate without writing
candy-vcr migrate session.cas --dry-run

# Show registered migrators
candy-vcr migrate --info

The migration system is pluggable via SugarCraft\Vcr\Migration\CassetteMigrator. V1ToV2Migrator upgrades v1 cassettes by adding sequential event IDs, explicit encoding metadata on output events, and other structural improvements. Future version migrators slot in without modifying the core infrastructure.

Tape compiler (PR8)

candy-vcr ships a SugarCraft\Vcr\Tape layer that parses .tape files (the VHS DSL) into a Cassette that the existing Player can replay. This decouples the render pipeline (Phase 3+) from the tape format.

use SugarCraft\Vcr\Tape\Compiler;

$source = file_get_contents('demo.tape');
$result = Compiler::parseSource($source);

if (!empty($result['errors'])) {
    foreach ($result['errors'] as $error) {
        echo $error->getLine(), ': ', $error->getMessage(), "\n";
    }
    exit(1);
}

$cassette = (new Compiler())->compile($result['ast'], 'demo.tape');
// Feed to Player::play() for replay...

Supported directives:

Directive Supported Notes
Type "..." Each char emits an Input event at TypingSpeed cadence. UTF-8 string literals preserved byte-for-byte.
Enter Raw byte \r.
Tab Raw byte \t.
Backspace Raw byte \x7f.
Space Raw byte ' '.
Escape Raw byte \x1b.
Up / Down / Left / Right CSI cursor: \x1b[A / [B / [D / [C.
Ctrl+<letter> Control character via ord($letter) & 0x1F; round-trips as upper-case Ctrl+C.
Sleep <duration> Advances virtual clock only; suffixes ms/s/m accepted.
Set <key> <value> Key allowlist below.
Env KEY "value" Adds to cassette header env map.
Output <path> Accepted (stored on the OutputDirective AST node for the render step; not encoded as a Cassette event).
Hide, Show Suppresses/resumes cursor rendering in output GIF from this point.
Wait <duration> ⚠️ Parsed, no-op (deferred to v2).
Screenshot <path> Captures current frame to PNG path during render.
Source <path> Inlines and compiles another .tape file at this point.

The Set directive's key parameter is validated against an allowlist; unknown keys raise a ParseError:

Set key Effect
Theme Theme name (TokyoNight, TokyoNightLight, TokyoNightStorm, Dracula, SolarizedDark). Stored on CassetteHeader::$theme.
FontSize Default rasterizer font size in pixels (Renderer default 14).
Width / Height Initial terminal cols / rows (stored on the header).
TypingSpeed Inter-character delay for Type runs. Accepts <n>ms / <n>s / <n>m.
FontFamily TTF family name (resolved by FontLoader).
Padding / Margin Reserved for the rasterizer; accepted but not yet enforced.
PlaybackSpeed Speed multiplier (e.g. 2.0 = 2x speed, 0.5 = half speed). Applied in FrameStream during rendering.
// AST nodes (under SugarCraft\Vcr\Tape\Ast):
// TypeDirective · EnterDirective · TabDirective · BackspaceDirective ·
// SpaceDirective · EscapeDirective · ArrowDirective · CtrlDirective ·
// SleepDirective · SetDirective · EnvDirective · OutputDirective ·
// HideDirective · ShowDirective · WaitDirective · ScreenshotDirective ·
// ParseError

The Compiler::compile() method produces a Cassette with a CassetteHeader carrying the configured cols/rows/theme/env and a list of Event objects typed as EventKind::Input with raw bytes (['b' => string]) payloads. Sleep directives advance the virtual clock without emitting events, so inter-event timing is preserved for the Player.

Corpus coverage: All 841+ .tape files in the monorepo parse without error and compile to valid Cassettes (verified by TapeCorpusTest).

Decompiler — Cassette → tape source

SugarCraft\Vcr\Tape\Decompiler is the reverse of the Compiler: it walks a Cassette's events back into tape source text. The plan called this out as the round-trip safety net for the Tape compiler (parse → compile → decompile → re-parse should be stable for canonical inputs).

use SugarCraft\Vcr\Tape\Compiler;
use SugarCraft\Vcr\Tape\Decompiler;

$result = Compiler::parseSource(file_get_contents('demo.tape'));
$cassette = (new Compiler())->compile($result['ast'], 'demo.tape');

$source = (new Decompiler())->decompile($cassette);
file_put_contents('demo-roundtripped.tape', $source);

Heuristics:

  • Sleep threshold — gaps over 100ms between adjacent Input events emit an explicit Sleep <n>ms directive. Smaller gaps are absorbed as the implicit TypingSpeed cadence between Type chars.
  • Space heuristic — a lone space byte sandwiched between printable bytes folds into the current Type "..." group; a solitary space gets its own Space line.
  • Type grouping — runs of printable input bytes merge into one Type "..." line and break on any control byte, non-input event, or Sleep-worthy gap.

Limitations:

  • Hide and Show directives emit events into the Cassette but the Decompiler does not yet reconstruct them on round-trip (v2).
  • Wait is parsed but emits no events (deferred to v2).
  • Screenshot and Output are render-side only and leave no Cassette trace.
  • Non-printable single bytes that don't map to a known directive become # unprintable byte 0x.. dropped comments — they can't be expressed in tape source.
  • Ctrl+letter is reconstructed from raw control bytes 1..26, normalised to upper-case (so Ctrl+c in source round-trips as Ctrl+C).

tests/Tape/RoundTripTest.php covers Type "hello", Enter, Sleep, Set Theme, Ctrl+C, all four arrows, Backspace, Tab, Escape, Env, plus a combined multi-directive tape. For each: Lexer → Parser → Compiler → Cassette → Decompiler → Lexer → Parser → Compiler → Cassette2 and asserts the two event streams match timestamp-for-timestamp.

Frame renderer (PR9)

candy-vcr ships a SugarCraft\Vcr\Render layer that converts a compiled Cassette into a stream of terminal Snapshot frames at configurable fps, with optional deduplication of identical adjacent frames:

use SugarCraft\Vcr\Player;
use SugarCraft\Vcr\Render\Renderer;
use SugarCraft\Vcr\Render\FrameDedup;
use SugarCraft\Vt\Terminal;

// Open a cassette (from tape compiler or direct record)
$player = Player::open('demo.cas');

// Create terminal emulator and renderer
$terminal = Terminal::new(80, 24);
$renderer = new Renderer($player, $terminal, fps: 30.0);

// Get frame stream and optionally dedup identical frames
$stream = $renderer->render($player, $terminal, 30.0);
$deduped = FrameDedup::dedup($stream);

foreach ($deduped as $index => $snapshot) {
    // $snapshot is a SugarCraft\Vt\Snapshot with grid + cursor + time
    printf("Frame %d at t=%.3f\n", $index, $snapshot->time);
}

Key classes:

Class Role
Renderer Orchestrates Player + Terminal; produces FrameStream
FrameStream \IteratorAggregate yielding Snapshot at fps cadence
FrameDedup Static filter collapsing identical adjacent frames

Frame dedup: Typical terminal recordings have 80–95% identical frames (e.g., cursor blink, idle time between keystrokes). FrameDedup::dedup() collapses consecutive identical frames into a single frame, reducing downstream GIF encoder work significantly. The holdMax parameter (default 300) caps how many identical frames can be collapsed to prevent pathological cases.

Snapshot equality: Two Snapshot objects are equal when their grid and cursor state match, regardless of capture time. This enables frame dedup across different virtual timestamps. The equalsWithTime() method compares all three fields (grid, cursor, time) for exact reproducibility checks.

Performance note: Cell equality comparison is O(cols × rows) per frame — for a typical 80×24 terminal that's 1920 cell comparisons. At 30fps with dedup disabled, that's ~57,600 cell comparisons per second. This is acceptable for now (Phase 3) but is a known bottleneck for optimization in Phase 4.

Frame rasterizer (Phase 4)

The SugarCraft\Vcr\Raster namespace converts terminal Snapshot frames into PNG images for GIF encoding:

use SugarCraft\Vcr\Raster\GdRasterizer;
use SugarCraft\Vcr\Raster\FontLoader;

$rasterizer = new GdRasterizer(fontSize: 14, fontFamily: 'DejaVuSansMono');

$png = $rasterizer->rasterize($snapshot, cellW: 8, cellH: 16);

$pngData = stream_get_contents(fopen('php://memory', 'r+'), null, 0);
imagepng($png);
imagedestroy($png);

Cell metrics (FontSize 14, JetBrainsMono / DejaVuSansMono):

  cellW = 8px, cellH = 16px
  80×24 terminal → 640×384 px
  120×40 terminal → 960×640 px

Architecture:

Class Role
FontLoader Resolves TTF font paths from fonts/ bundle + system dirs
Glyphs Per-(char, fg, bg, bold, italic, underline) tile cache — the performance key
Rasterizer Interface: rasterize(Snapshot, cellW, cellH, ?FontLoader): GdImage|Imagick
GdRasterizer Default ext-gd backend; blits tiles + renders cursor
ImagickRasterizer ext-imagick alternative; better anti-aliasing

Bundled fonts: fonts/JetBrainsMono-{Regular,Bold,Italic,BoldItalic}.ttf (default family) plus DejaVuSansMono.ttf and DejaVuSansMono-Bold.ttf as a fallback. FontLoader tries the fonts/ dir first, then system font directories (/usr/share/fonts/, ~/.fonts/, etc.). See the Fonts section under Development for licensing and override details.

Glyphs cache: Typical terminal frames have thousands of cells but only ~50 unique (char, attrs) combinations. The tile cache makes rasterization O(unique tiles) instead of O(cells). Cache key: "$char|$fg|$bg|$bold|$italic|$underline".

Wide chars: CJK and fullwidth characters get a 2×-wide tile; the rasterizer advances 2 columns after blitting. Checked via mb_strwidth($char) > 1.

Cursor shapes:

  • Block (shape=1): glyph rendered in reverse-video (fg/bg swapped)
  • Underline (shape=2): filled rect at y = cellH × 0.75
  • Bar (shape=3): narrow filled rect at left edge

FontLoader API. new FontLoader($fontDirs = []) resolves TTF paths. load($family, $size, $style = 'regular') returns the resolved path (throws if missing); resolve($family, $style) returns ?string; lastResolvedPath() returns the most recent hit. Search order: bundled candy-vcr/fonts/$fontDirs overrides → /usr/share/fonts/{truetype,opentype}~/.fonts/~/.local/share/fonts/.

Glyphs API. new Glyphs($cellW, $cellH, $theme, $fontFamily = Glyphs::DEFAULT_FONT_FAMILY, $fontSize = 14) builds a per-(char, fg, bg, attrs) tile cache. Accessors: cellWidth(), cellHeight(), fontFamily(), fontSize(), theme(), cacheStats(): array{hits: int, misses: int}, tile($char, $fg, $bg, $bold, $italic, $underline): GdImage, tileWide(...) (2× width for CJK / fullwidth), measure($char): array{cellW, cellH}. The instance is hoisted onto the rasterizer as a property (Section A) so the cache survives across every snapshot in one tape render — a (cellW, cellH, theme, fontFamily, fontSize) fingerprint invalidates and rebuilds when any of those change.

GIF encoder (Phase 5)

The SugarCraft\Vcr\Encode namespace converts a stream of rasterized PNG frames into an animated GIF:

use SugarCraft\Vcr\Encode\FfmpegGifEncoder;
use SugarCraft\Vcr\Encode\TapeToGif;

$tapeToGif = TapeToGif::create(['encoder' => 'ffmpeg']);
$tapeToGif->render('demo.tape', 'demo.gif');

Pipeline: .tape → Lexer → Parser → Compiler → Cassette → Player → Terminal → Renderer → FrameStream → FrameDedup → Rasterizer → FfmpegGifEncoder → .gif

Encoders:

Encoder Description
FfmpegGifEncoder Default; uses ffmpeg with two-pass palette generation. CFR via -framerate; VFR via concat demuxer with process substitution.
PhpGifEncoder Pure-PHP fallback using native GD LZW encoding. Slower than ffmpeg (~5-10×) but requires no external binaries.

TapeToGif options:

$tapeToGif->render($tapePath, $outputPath, [
    'fps'       => 30.0,        // frames per second
    'theme'    => 'TokyoNight', // theme name
    'fontSize'  => 14,           // terminal font size in pixels
    'fontFamily' => 'JetBrainsMono', // TTF font family name
    'backend'  => 'gd',        // 'gd' (default) or 'imagick'
    'encoder'  => 'ffmpeg',   // 'ffmpeg' (default) or 'php'
]);

VFR (Variable Frame Rate): When frameHolds differ between frames, FfmpegGifEncoder writes a concat demuxer file and pipes it to ffmpeg's stdin:

file 'frame00000.png'
duration 0.033
file 'frame00001.png'
duration 0.100
...
file 'frame00004.png'
duration 0.033
file 'frame00004.png'

The last frame is listed twice to give it a display duration (the entry before it carries the duration). This produces accurate per-frame timing without re-encoding artifacts.

Two-pass palette: palettegen=stats_mode=diff computes an optimal 256-color palette by analyzing frame-to-frame pixel differences. paletteuse=dither=bayer:bayer_scale=5 applies the palette with ordered dithering. This produces significantly better quality than single-pass GIF encoding.

Requirements: ffmpeg must be in $PATH for FfmpegGifEncoder. The symfony/process package is required as a runtime dependency.

Examples

examples/record.php, examples/replay.php, examples/inspect.php — runnable scripts using a tiny CounterModel. The examples/cassettes/counter.cas fixture is a real recording you can play with:

php examples/record.php examples/cassettes/counter.cas
bin/candy-vcr inspect examples/cassettes/counter.cas
bin/candy-vcr stats   examples/cassettes/counter.cas
bin/candy-vcr replay  examples/cassettes/counter.cas --speed=realtime
php examples/replay.php examples/cassettes/counter.cas

Replay (PR4)

use SugarCraft\Vcr\Player;
use SugarCraft\Vcr\Assert\ByteAssertion;

$player = Player::open('/tmp/session.cas');
$result = $player->play(
    programFactory: fn ($input, $output, $loop) => new Program(
        new MyModel(),
        new ProgramOptions(
            useAltScreen: false, catchInterrupts: false, hideCursor: false,
            input: $input, output: $output, loop: $loop,
        ),
    ),
    assertion: new ByteAssertion(),
    speed: Player::SPEED_INSTANT,  // or SPEED_REALTIME for demo replay
);

if (!$result->ok) {
    echo $result->diffSummary();
    exit(1);
}

Player::play walks the cassette and feeds each event into the program: resize → WindowSizeMsg, input bytes → re-parsed via InputReader and dispatched, input msg envelope → decoded via the serializer registry, quit → program->quit(). Output events accumulate into the expected byte buffer; the program's actual output stream is captured and compared via the supplied assertion.

Idle time trimming: In SPEED_REALTIME mode, long pauses between events can slow down CI tests. Set idleThresholdSeconds: 0.5 (or withIdleTrim(0.5) on the Player) to clamp pauses longer than 500 ms to 500 ms, making tests run faster while still honoring shorter pauses. The fluent withIdleTrim() form is useful when the threshold is configured once at the call site:

$result = $player->withIdleTrim(0.5)->play(
    programFactory: $factory,
    speed: Player::SPEED_REALTIME,
);
// Or pass it explicitly to play():
$result = $player->play(
    programFactory: $factory,
    speed: Player::SPEED_REALTIME,
    idleThresholdSeconds: 0.5,  // Skip long pauses in CI
);

ByteAssertion is the strict baseline — exact byte equality with a hex-and-printable diff window on failure. ScreenAssertion (cell-grid equality via candy-vt) is the recommended choice for round-trip tests:

use SugarCraft\Vcr\Assert\ScreenAssertion;

$result = $player->play(
    programFactory: $factory,
    assertion: new ScreenAssertion(cols: 80, rows: 24),
);

It feeds both expected and actual byte streams into separate SugarCraft\Vt\Terminal\Terminal instances and compares the resulting cell grids. ANSI-level reorderings — redundant SGR re-emission, equivalent cursor moves, partial vs full repaints — collapse to the same grapheme grid, so a recording → replay round trip passes even when the byte streams differ. Failure messages list the first 5 differing cells with (row,col) coordinates and the expected vs actual graphemes.

ContainsAssertion provides flexible partial matching — it passes when the expected substring is found anywhere within the actual output:

use SugarCraft\Vcr\Assert\ContainsAssertion;

$result = $player->play(
    programFactory: $factory,
    assertion: new ContainsAssertion(),
);
// Passes if actual output contains "Ready." anywhere
// even if the full byte stream differs from expected
$this->assertTrue($result->ok);

This is useful when you only care about specific content appearing in the output (e.g. a status message, prompt, or error keyword) without requiring exact formatting. The comparison is case-sensitive; empty substring always matches.

RegexAssertion accepts a PCRE pattern (with delimiters) and supports multiline / case-insensitive / dot-all flags on the constructor:

use SugarCraft\Vcr\Assert\RegexAssertion;

$result = $player->play(
    programFactory: $factory,
    assertion: new RegexAssertion(
        pattern: '/Ready in \\d+ms\\./',
        multiline: true,
        caseInsensitive: false,
        dotAll: false,
    ),
);

Invalid PCRE patterns throw \InvalidArgumentException at construction.

Assertion class summary:

Class Use when
ByteAssertion Strict byte-equality; the safest baseline.
ScreenAssertion Cell-grid equality through candy-vt; collapses ANSI re-orderings. Default for round-trip tests.
ContainsAssertion Substring check; expected must appear anywhere in actual.
RegexAssertion PCRE regex match; supports multiline / caseInsensitive / dotAll.
Assertion (interface) compare(string $expected, string $actual): array{0: bool, 1: string} — implement for custom assertions.

Msg serializers (PR3)

SugarCraft\Vcr\Msg\Registry::default() is preloaded with:

  • BuiltinSerializer — covers 19 Msgs: KeyMsg, MouseClickMsg / MotionMsg / WheelMsg / ReleaseMsg, WindowSizeMsg, FocusGainedMsg / FocusLostMsg / BlurMsg, FocusInMsg / FocusOutMsg, PasteStartMsg / EndMsg / Msg, BackgroundColorMsg, ForegroundColorMsg, CursorPositionMsg. Tag is the unqualified class name.
  • JsonableSerializer — catch-all for any Msg implementing \JsonSerializable. Tag is the FQCN; data is the jsonSerialize() result. Round-trip works when the constructor's parameter names match the keys returned by jsonSerialize().
use SugarCraft\Vcr\Msg\Registry;
$registry = Registry::default();
$envelope = $registry->encode($msg);  // ['@type' => 'KeyMsg', …] or null
$decoded  = $registry->decode($envelope);  // Msg|null

Custom serializers slot in via $registry->register(new MyOne()).

Visual regression goldens

candy-vcr/tests/golden/ holds 10 curated .tape files (TokyoNight, Dracula, plain Type+Enter, sleep-heavy, Ctrl-sequence, arrow keys, wide CJK, Set Width/Height, multi-frame animation, idle-rich) and their committed .gif baselines under both encoders (<name>.php.gif + <name>.ffmpeg.gif, 20 GIFs, ~400 KB total).

tests/Encode/VisualRegressionTest.php re-renders each tape and compares to the golden via:

  • SHA-256 byte hash for PhpGifEncoder (deterministic encoder).
  • SSIM ≥ 0.95 via ffmpeg's compare filter for FfmpegGifEncoder (auto-skipped when command -v ffmpeg is empty); pixel-diff fallback when SSIM filter is unavailable.

To regenerate goldens intentionally (after an encoder change or a theme palette tweak):

php candy-vcr/scripts/refresh-goldens.php           # safe — warns + exits 2 if >3 tapes drift
php candy-vcr/scripts/refresh-goldens.php --force   # commit the new baseline

The manifest lives at candy-vcr/tests/golden/MANIFEST.md.

Development

composer install
vendor/bin/phpunit                                          # test suite
vendor/bin/phpstan analyze                                  # static analysis (level: max)
vendor/bin/php-cs-fixer fix --config=../.php-cs-fixer.dist.php  # lint + auto-fix style

Code style is enforced by php-cs-fixer via the root .php-cs-fixer.dist.php (PSR-12 + declare_strict_types + strict_param + short array syntax). Append --dry-run --diff to preview without writing.

Fonts

candy-vcr/fonts/ ships JetBrainsMono (Regular, Bold, Italic, BoldItalic) as the default rasterizer font family. JetBrainsMono is distributed under the SIL Open Font License, version 1.1 — the full license text is bundled alongside the TTFs. Glyphs::DEFAULT_FONT_FAMILY resolves to JetBrainsMono, with DejaVuSansMono (also bundled) retained as a fallback when JetBrainsMono is unavailable. To use a different family pass it to the Glyphs constructor (or set font_family on the rasterizer); FontLoader searches the bundled fonts/ dir first, then /usr/share/fonts/{truetype,opentype}, ~/.fonts/, and ~/.local/share/fonts/.

CI integration

candy-vcr is replacing the upstream charmbracelet/vhs binary for .vhs/*.tape rendering across the SugarCraft monorepo. The migration is gated on a soak period where both renderers run side-by-side on a narrow seed lib.

Current state — soak (Phase 7)

Both renderers run in parallel inside .github/workflows/vhs.yml:

Job Container Scope Blocking?
render (legacy) ghcr.io/detain/vhs-runner:latest All libs listed in the hand-maintained all=(...) array Yes — fails CI on render error
vhs-candy-vcr (new) ghcr.io/detain/sugarcraft-vhs-runner-php:latest Narrow seed lib (candy-core) Nocontinue-on-error: true during soak

The candy-vcr job invokes php candy-vcr/bin/candy-vcr render-batch <lib>/.vhs/ --encoder ffmpeg, performs an in-job smoke check that every produced file is a non-empty GIF, and uploads the result as a vhs-candy-vcr-<lib> workflow artifact with 7-day retention so reviewers can pull both renderers' artifacts and diff them visually.

The vhs-runner-php image bakes in PHP 8.3 + ext-gd, ext-curl, ext-ssh2, ffmpeg, and the bundled JetBrainsMono fonts. The Dockerfile lives at scripts/Dockerfile.vhs-runner and is rebuilt by .github/workflows/vhs-runner-php-image.yml whenever the Dockerfile or workflow changes.

Cutover (separate PR, after soak)

When the soak shows consistent parity on the seed lib:

  1. Expand vhs-candy-vcr's matrix to cover more libs (one PR per batch).
  2. Once every lib in the legacy all=(...) array renders cleanly through candy-vcr, flip continue-on-error: true to false so candy-vcr blocks CI.
  3. Delete the legacy render + render-sugar-dash + changed jobs and rename vhs-candy-vcrvhs. The hand-maintained all=(...) matrix moves into the candy-vcr job.
  4. Drop the vhs-runner (Go binary) image build workflow once nothing references it.

Rollback

The vhs-candy-vcr job is a single self-contained stanza in .github/workflows/vhs.yml. To disable it during the soak (e.g. if it floods the artifact bucket or produces noisy logs):

git revert <sha-of-section-h-pr>     # one-line revert of the workflow file

The legacy render job is untouched by the soak, so reverting only removes the parallel candy-vcr job — existing CI keeps working.

Snapshot tests

Render output is covered by golden-file snapshot tests. Fixture files live in tests/fixtures/ with a .golden extension and are compared against actual ANSI byte output via SugarCraft\Testing\Snapshot\Assertions::assertGoldenAnsi(). To re-record fixtures after intentional output changes:

UPDATE_GOLDENS=1 vendor/bin/phpunit

License

MIT