PHPackages                             sugarcraft/candy-vcr - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. sugarcraft/candy-vcr

ActiveLibrary[Testing &amp; Quality](/categories/testing)

sugarcraft/candy-vcr
====================

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

0325PHP

Since Jun 29Pushed 1mo agoCompare

[ Source](https://github.com/sugarcraft/candy-vcr)[ Packagist](https://packagist.org/packages/sugarcraft/candy-vcr)[ RSS](/packages/sugarcraft-candy-vcr/feed)WikiDiscussions master Synced 3w ago

READMEChangelogDependenciesVersions (1)Used By (0)

CandyVcr
========

[](#candyvcr)

PHP port of [`charmbracelet/x/vcr`](https://github.com/charmbracelet/x/tree/main/vcr)**and** a drop-in PHP replacement for [`charmbracelet/vhs`](https://github.com/charmbracelet/vhs) (`.tape` → `.gif` renderer). Pairs with [candy-vt](../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](../candy-vt/), with byte-equality fallback).

Contents
--------

[](#contents)

- [Status](#status) · [Use cases](#use-cases) · [Install](#install)
- [Cassette formats](#cassette-formats) — Jsonl / CompressedJsonl / Relative / Yaml / Asciinema
- [PHP API](#php-api) — `Cassette`, `Recorder`, `Player`, `Player::loadAny()`
- [Assertion classes](#assertion-classes) — Byte / Screen / Contains / Regex
- [Matcher classes](#matcher-classes) — Passthrough / Content / TimingTolerant
- [Hook system](#hook-system-l4) · [Migration system](#cassette-migration)
- [CLI commands](#cli) — `record` · `inspect` · `replay` · `diff` · `stats` · `migrate` · `render-tape` · `render-batch`
- [Tape DSL](#tape-compiler-pr8) — Lexer / Parser / Compiler / Decompiler / full directive table
- [Renderer pipeline](#frame-renderer-pr9) — Renderer / FrameStream / FrameDedup
- [Rasterizer](#frame-rasterizer-phase-4) — GdRasterizer / ImagickRasterizer / Glyphs / FontLoader
- [GIF encoder](#gif-encoder-phase-5) — FfmpegGifEncoder / PhpGifEncoder / TapeToGif
- [Visual regression goldens](#visual-regression-goldens)
- [Development](#development) · [CI integration](#ci-integration)

Status
------

[](#status)

🟢 **v1 ready** — all 7 PRs merged. See [`plans/x-vcr.md`](../plans/x-vcr.md) for the slice history.

PRScopePR1Cassette + Event + JsonlFormatPR2Recorder + `Program::withRecorder()`PR3Msg serializers — Builtin + Jsonable + RegistryPR4Player + ByteAssertion + ReplayResultPR5ScreenAssertion via candy-vtPR6YamlFormatPR7`bin/candy-vcr` CLI + examples + trackingPR8Tape lexer/parser/compiler (`.tape` → Cassette)PR9Renderer + FrameStream + FrameDedup (Phase 3 of vhs-replacement)PR10Raster + Glyphs + FontLoader (Phase 4 of vhs-replacement)PR11GIF encoder — GifEncoder interface + FfmpegGifEncoder + PhpGifEncoder + TapeToGif (Phase 5 of vhs-replacement)Use cases
---------

[](#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
-------

[](#install)

```
composer require sugarcraft/candy-vcr
```

Cassette format (JSONL)
-----------------------

[](#cassette-format-jsonl)

> **Full schema reference:** [`docs/CASSETTE.md`](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

[](#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.

FormatFile extensionWhen to pick itTrade-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

[](#timestamp-modes)

Cassettes support two timestamp modes:

ModeFieldUse case`absolute` (default)`t` = seconds since cassette startPlayback timing`relative``dt` = interval since previous event (like asciinema v3)Deterministic replay; easier manual editingSelect 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

[](#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)
---------------------

[](#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
-------

[](#php-api)

The core types in `SugarCraft\Vcr\`:

ClassRole`Cassette`Immutable container: `header: CassetteHeader`, `events: list`. 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
----------

[](#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
---

[](#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-.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=` / `--until=` 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

[](#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)

[](#--shell-pr-p652)

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 ``.

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

[](#--env-and---env-regexpattern-pr-p652)

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)

[](#--env-allow-secrets-pr-p652)

**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` 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)

[](#--idle-trim-n-and-replay---no-trim-pr-p653)

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)

[](#host-tty-safety-net-pr-p654)

`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.'` 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)

[](#recording-overhead-pr-p656)

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'`):

ScenarioMedian wallclockPump WITHOUT recorder~47 msPump WITH recorder~40 msMeasured 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)

[](#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)

[](#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).

ClassSemantics`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)

[](#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

[](#render-tape-options)

OptionShortDescription`--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---frames)

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

```
timecursor_row,cursor_colgrid_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

[](#render-batch-options)

Same as `render-tape` plus:

OptionShortDescription`--output-dir``-o`Output directory for .gif files (default: same as source dir)`--recursive``-r`Search recursively for .tape files#### GIF encoding pipeline

[](#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-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)
-------------------

[](#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:**

DirectiveSupportedNotes`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+`✅Control character via `ord($letter) & 0x1F`; round-trips as upper-case `Ctrl+C`.`Sleep `✅Advances virtual clock only; suffixes `ms`/`s`/`m` accepted.`Set  `✅Key allowlist below.`Env KEY "value"`✅Adds to cassette header env map.`Output `✅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 `⚠️Parsed, no-op (deferred to v2).`Screenshot `✅Captures current frame to PNG path during render.`Source `✅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 keyEffect`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 `ms` / `s` / `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

[](#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 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)
--------------------

[](#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:**

ClassRole`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)
--------------------------

[](#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:**

ClassRole`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](#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)
---------------------

[](#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:**

EncoderDescription`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)

`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)

[](#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](../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:**

ClassUse 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)

[](#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
-------------------------

[](#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 (`.php.gif` + `.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
-----------

[](#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

[](#fonts)

`candy-vcr/fonts/` ships [JetBrainsMono](https://github.com/JetBrains/JetBrainsMono) (Regular, Bold, Italic, BoldItalic) as the default rasterizer font family. JetBrainsMono is distributed under the [SIL Open Font License, version 1.1](fonts/LICENSE) — 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
--------------

[](#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)

[](#current-state--soak-phase-7)

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

JobContainerScopeBlocking?`render` (legacy)`ghcr.io/detain/vhs-runner:latest`All libs listed in the hand-maintained `all=(...)` arrayYes — fails CI on render error`vhs-candy-vcr` (new)`ghcr.io/detain/sugarcraft-vhs-runner-php:latest`Narrow seed lib (`candy-core`)**No** — `continue-on-error: true` during soakThe candy-vcr job invokes `php candy-vcr/bin/candy-vcr render-batch /.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-` 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)

[](#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-vcr` → `vhs`. 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

[](#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      # 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
--------------

[](#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
-------

[](#license)

MIT

###  Health Score

24

—

LowBetter than 31% of packages

Maintenance59

Moderate activity, may be stable

Popularity17

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/b1036e0717211b8030b83cbe729e8ba6ba442fdbd5285fb97a39d7dcfe339342?d=identicon)[detain](/maintainers/detain)

---

Top Contributors

[![detain](https://avatars.githubusercontent.com/u/1364504?v=4)](https://github.com/detain "detain (80 commits)")

### Embed Badge

![Health badge](/badges/sugarcraft-candy-vcr/health.svg)

```
[![Health](https://phpackages.com/badges/sugarcraft-candy-vcr/health.svg)](https://phpackages.com/packages/sugarcraft-candy-vcr)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
