Date: 2026-05-21
Question: Can candy-vcr take over .tape β .gif rendering from the upstream charmbracelet/vhs binary the CI workflow currently uses?
Verdict: Yes β with a focused 6β10 week build. The biggest hidden cost is that candy-vt (the terminal-grid emulator candy-vcr would render through) is essentially unbuilt today. candy-vcr's existing Cassette + Player layer is solid and reusable; the tape compiler, renderer, rasterizer, and GIF encoder are net-new.
candy-vcr today is a Msg-flow recorder/replayer (Program β JSONL cassette β replay with assertions). charmbracelet/vhs is a declarative tape DSL β terminal rendering β GIF. They solve different problems. To make candy-vcr cover what vhs does, we build a new pipeline on top of the existing Cassette + Player layer:
.tape β Lexer/Parser/Compiler β Cassette β Player β Terminal (candy-vt)
β
Snapshot stream
β
Rasterizer (gd/imagick)
β
PNG frames
β
GifEncoder (ffmpeg)
β
.gif
ffmpeg is already in the CI runner image. gd, imagick, and ffi PHP extensions all load in local PHP. The PHP-native path is realistic; we don't need Chromium or ttyd. Strategy: ship as additive (new CLI command, new container image variant) and A/B alongside upstream vhs so rollback is one workflow-file change.
Every phase below (Phase 0 through Phase 7) ships through the same lifecycle. Read this once; it isn't repeated under each phase.
Spawn one subagent (general-purpose or oac:coder-agent) per phase. Wait for completion before spawning the next. Concurrent subagents collide on shared root files (MATCHUPS.md, root README.md, root composer.json, .codenomad/worktreeMap.json); see the AGENTS.md gotchas. Background mode is fine, but only one in flight at a time.
This also prevents context bloat in the orchestrating session β each subagent reports a short summary; the orchestrator never carries the full diff in its window.
The subagent reads its scope from this document, makes the changes, never expands scope without checking back. Phase 1 (candy-vt) is large enough that it should be sub-divided across multiple PR subagents (one for value objects, one for the CSI parser, one for the theme catalog, etc.) β still one at a time.
Per affected lib:
composer install --quiet
vendor/bin/phpunit # tests
vendor/bin/phpstan analyze --level=max 2>/dev/null || true # if configured
vendor/bin/php-cs-fixer fix --dry-run --diff 2>/dev/null || true # if configuredCurrent state (verified 2026-05-21):
- phpunit: configured per lib.
- phpstan: only
candy-core/phpstan.neonexists today. Phase 0 should addphpstan.neontocandy-vcrandcandy-vt; subsequent phases inherit. Aim forlevel: maxfrom day one β easier than back-fitting later. - php-cs-fixer: not configured. Phase 0 should add a root
.php-cs-fixer.dist.phpso every subsequent phase has a lint gate.
PHPUnit hang gotcha (PTY/FFI tests in candy-pty, candy-vcr's PTY-recording paths) applies β see the feedback_phpunit_kill_pattern project memory: backgrounded pkill -f phpunit watchdog at 120s if needed.
After the local gate passes, spawn a separate review subagent (use oac:code-review skill, or general-purpose with the diff as input). The reviewer:
- Cross-checks against the phase spec in this document.
- Validates against
candy-vt/CALIBER_LEARNINGS.mdandcandy-vcr/CALIBER_LEARNINGS.md. - Checks project conventions: PSR-12,
declare(strict_types=1),finalclasses, immutable + fluent +mutate()pattern,Lang::t()for user-facing exceptions.
If the reviewer finds issues, spawn one more subagent to apply fixes. Re-run the local gate. Re-review. Cycle until clean. Two cycles is normal; three suggests this document's phase spec is unclear and should be tightened first.
Every phase updates docs that the change affects:
Per touched lib:
<slug>/README.mdβ quickstart still works; new public APIs documented with code snippets.<slug>/CALIBER_LEARNINGS.mdβ append patterns and anti-patterns from the phase.<slug>/.vhs/*.tape+.gifβ if the lib gains demo-worthy behavior.docs/lib/<slug>.htmlβ public webpage if user-facing surface changed.
For cross-cut additions (new candy-vt lib, new CLI subcommands):
- Root
README.mdβ library count, table row, test-loop snippet. - Root
MATCHUPS.mdβ status icon updates. - Root
PROJECT_NAMES.mdβ naming entry for any new lib. docs/index.htmlβ homepage tile.- Root
CALIBER_LEARNINGS.mdβ cross-cut pattern.
This repo's CLAUDE.md / AGENTS.md tell humans to run caliber refresh before commit. For AI-driven PRs under this document, skip Caliber. If Caliber's pre-commit hook fires and stages files on its own, unstage them and proceed:
git restore --staged \
CLAUDE.md AGENTS.md .claude/ .cursor/ .cursorrules \
.github/copilot-instructions.md .github/instructions/ \
.agents/ .opencode/ 2>/dev/null
git commit ... # continue as if Caliber never ranHand-written additions to a lib's CALIBER_LEARNINGS.md (per Β§5) are intentional content edits β keep those staged. Only unstage files Caliber regenerates on its own.
If a stop hook complains that Caliber isn't set up on the machine, ignore it.
The project's ship-pr skill handles: stage β commit (author Joe Huss <detain@interserver.net>) β push β create PR β merge β checkout master β pull.
Every gh invocation in this repo MUST be prefixed with unset GITHUB_TOKEN &&. The repo's GITHUB_TOKEN conflicts with local gh auth. The ship-pr skill knows this; any direct gh invocations outside the skill need the same prefix:
unset GITHUB_TOKEN && gh pr create ...
unset GITHUB_TOKEN && gh pr merge <n> --merge --delete-branchAfter gh pr merge <n> --merge --delete-branch (the skill passes --delete-branch, so the remote is gone), the subagent does local cleanup before reporting done:
git checkout master
git pull --ff-only
git branch -d ai/<slug>-<short> # delete the merged local branch
git fetch --prune # drop stale remote-tracking refs
git status # must show "working tree clean" on masterIf git branch -d refuses (says the branch isn't merged), something went wrong β investigate before forcing. Don't use -D to bypass; the merge may have failed silently.
Before the next phase spawns, the orchestrator verifies:
- Current branch is
master. git statusis clean.git log -1 --onelineshows the merge commit just pulled.
Never queue two phases concurrently. Never start a new phase from a stale branch. Phases too large for one PR (candy-vt) get split internally but each sub-PR runs through this same cleanup gate before the next sub-PR begins.
Per the project's ship-as-you-go cadence and feedback_pr_size memory: bundle 2β4 related items per PR when they share a coherent scope, rather than one-feature-per-PR. For this document that mostly means within a phase β not across phases.
| Component | Where | Status |
|---|---|---|
| Cassette format | candy-vcr/src/Cassette.php, CassetteHeader.php |
β Solid |
| Cassette serializers | candy-vcr/src/Format/ β Jsonl, Relative, Yaml, Asciinema, CompressedJsonl |
β 5 formats |
| Player | candy-vcr/src/Player.php |
β Speed modes, idle-trim |
| Recorder | candy-vcr/src/Recorder.php |
β |
| CLI scaffold | candy-vcr/src/Cli/ β record, inspect, replay, diff, stats, migrate |
β 6 subcommands |
| PTY abstraction | candy-pty/src/ |
β Reusable for shell exec |
| Program/Msg primitives | candy-core/src/ |
β |
| Component | Status | Effort |
|---|---|---|
| candy-vt terminal emulator | β Only CursorShape.php exists |
2β3 weeks |
| Tape lexer/parser/compiler | β Not started | 1 week |
| Frame stream / dedup | β Not started | 1 week |
| Rasterizer (gd / imagick) | β Not started | 1β2 weeks |
| Theme system + ANSI palette | β Not started | included in candy-vt |
| GIF encoder | β Not started | Β½ week |
| Bundled monospace TTF | β Need to vendor JetBrainsMono or similar | Β½ day |
render-tape CLI subcommand |
β Not started | Β½ week |
| candy-vcr PHPUnit suite green | β Currently broken β TickModel::subscriptions() unimplemented |
Β½ day |
Names follow project conventions: final classes, declare(strict_types=1), immutable + fluent, public readonly properties.
candy-vt/src/ (new β terminal-grid emulator)
Terminal.php β public surface; cell grid + cursor state + scroll region
Cell.php β char + fg + bg + attrs (readonly)
CellGrid.php β 2D grid with diff & dirty-region tracking
Cursor.php β pos + shape + visibility
Parser/
Parser.php β VT100/VT500/XTerm CSI parser (state machine)
CsiHandler.php β SGR, CUP, ED, EL, DECSET, scroll region, etc.
OscHandler.php β title, hyperlink, color queries
Theme/
Theme.php β 256-color palette + 16 named slots + default fg/bg
Themes.php β bundled catalog (TokyoNight first; Dracula, Solarized later)
Snapshot.php β frozen frame: (CellGrid, Cursor, time)
candy-vcr/src/Tape/ (new β tape DSL β events)
Lexer.php β line-oriented tokenizer
Parser.php β directives β AST nodes
Ast/
Directive.php β base interface
Type.php Enter.php Sleep.php Set.php Env.php Ctrl.php
Arrow.php Backspace.php Tab.php Wait.php
Compiler.php β AST β cassette Event stream + CassetteHeader
candy-vcr/src/Render/ (new β frame production)
Renderer.php β drives Terminal + emits Snapshots
FrameStream.php β iterable of Snapshots at fps cadence
FrameDedup.php β collapse identical adjacent frames
candy-vcr/src/Raster/ (new β pixels)
Rasterizer.php β interface; Snapshot β image
GdRasterizer.php β ext-gd backend (default)
ImagickRasterizer.php β ext-imagick backend (optional, higher AA)
FontLoader.php β TTF resolver
Glyphs.php β per-(char, fg, bg, attrs) tile cache
candy-vcr/src/Encode/ (new β GIF assembly)
GifEncoder.php β interface
FfmpegGifEncoder.php β shell to ffmpeg (default β ffmpeg already in CI)
PhpGifEncoder.php β pure-PHP fallback (no shell-out)
candy-vcr/src/Cli/RenderTapeCommand.php (new CLI entrypoint)
candy-vcr/fonts/ (new β bundled assets)
JetBrainsMono-Regular.ttf
JetBrainsMono-Bold.ttf
The existing candy-vcr/src/Event.php / Cassette.php / Player.php stay unchanged β the tape compiler produces events in the same shape the Player already consumes, so the orchestration layer is free.
Each phase ships independently; each ends with composer install && vendor/bin/phpunit on touched libs.
This phase exists to make every later phase faster. Bundle into 1 PR.
- Fix candy-vcr PHPUnit suite. Implement the missing
TickModel::subscriptions()(likely justreturn null;). - Add
candy-vcr/phpstan.neonatlevel: max, with pathssrc/andtests/. Fix any findings inline; if any can't be fixed without scope creep, add// @phpstan-ignore-next-linewith an inline comment referencing this phase. - Add
candy-vt/phpstan.neonsame shape (still mostly empty source tree but the gate is in place for Phase 1). - Add root
.php-cs-fixer.dist.phpwith PSR-12 + strict types + readonly preferences. Run it acrosscandy-core,candy-vcr,candy-vtto seed the baseline (other libs adopt over time). - Baseline benchmark. Run the existing Player on a medium cassette (~150 events) and capture wall time + memory. Establishes the per-event cost the render work will add to.
- Decision log committed to
candy-vcr/CALIBER_LEARNINGS.md:- Primary rasterizer backend:
ext-gd(universal availability). - Primary GIF encoder:
FfmpegGifEncoder(ffmpeg already in CI image). - Font: JetBrainsMono Regular + Bold (OFL license, monospace, broad glyph coverage).
- Primary rasterizer backend:
- Documentation (per protocol Β§5):
candy-vcr/README.mdβ note the new gates (phpstan + cs-fixer) under "Development".- Root
README.mdβ note php-cs-fixer in the development section.
The longest pole. candy-vt is its own self-contained foundation lib; the renderer is just its first consumer.
Scope priorities (deliver smallest useful surface first):
- Value objects:
Cell(char + fg + bg + attrs bitfield),CellGrid(2D array + dimensions + diff tracking),Cursor(row, col, shape, visible). - CSI parser as a state machine. States: GROUND, ESC, CSI_ENTRY, CSI_PARAM, CSI_INTERMEDIATE, CSI_IGNORE, OSC_STRING, DCS_*. Reference: ECMA-48 + xterm ctlseqs.
- CSI handler dispatch table covering the minimum surface our directives need to render:
SGR(colors + bold/italic/underline/inverse),CUP(cursor position),CUU/CUD/CUF/CUB(cursor up/down/forward/back),ED(erase display),EL(erase line),DECSET/DECRST(mode set/reset β including cursor visibility), scroll region (DECSTBM). - OSC handler: window title, hyperlinks (we may not need but document).
- Theme: 16 ANSI palette slots + 256-color cube + default fg/bg. Bundle
Themes::tokyoNight()first (the only theme used in 277 monorepo tapes). Add Dracula, Solarized, GruvBox, Catppuccin later. Snapshot::of(Terminal $t, float $time): immutable frozen frame.
Test plan:
- Golden-input tests: feed known byte sequences (
"\x1b[31mhello\x1b[0m","\x1b[2;5Hxyz", etc.), assert cell grid state. - Snapshot tests: drive Terminal with output from real commands (captured separately β e.g.,
ls --color,htop -bn1); assert the rendered cells match a golden file. - Theme test: every named color in TokyoNight resolves to its hex.
Deliverable: candy-vt v0.1 with public API: Terminal::new(cols, rows, theme) β feed(bytes) β snapshot().
Lexer: line-oriented; tokens are line-leading verbs plus arguments.
Output <path>Set <key> <value>(key β Theme, FontSize, Width, Height, TypingSpeed, FontFamily, Padding, Margin, etc.)Type "..."Enter,Tab,Backspace,Up,Down,Left,Right,Space,EscapeCtrl+<letter>Sleep <duration>(s, ms, m suffixes)Env <KEY> "<value>"Hide,Show,Wait <duration> [Screen /regex/],Screenshot <path>β out of v1 scope but parse without error if encountered, with a--strictflag that opts into rejection.
Parser: recursive-descent or PCRE-multi-line; emits a list<Directive> AST. Location-marked errors. Validate Set keys against allowlist.
Compiler: walks the AST and produces a Cassette with:
CassetteHeadercarrying cols/rows (fromSet Width/Set Heighttranslated through font metrics), theme name, env map, fps.- A timed stream of
Eventobjects matchingcandy-vcr/src/Event.php.Type "abc"expands to three KeyMsg events spaced atTypingSpeedms.Enteris a single CR event.Sleep 1sis a timing-only event that advances the clock without input.
Regression corpus: the 841 existing .tape files in the monorepo. Parse-coverage target: 100%. Any directive that fails to parse becomes a tracked gap.
Test plan:
- Unit tests per directive.
- Corpus test: walk every
.tapefile in the repo and assert it parses without error. - Round-trip test: parse β compile β decompile β re-parse should be stable for canonical inputs.
Renderer orchestrates Player + Terminal:
- For each
Eventfrom the Cassette, feed its bytes toTerminal. - Advance the virtual clock by the event's
dt. - At every
1/fpsboundary, capture aSnapshotfrom the Terminal. - Hand snapshots downstream as an iterator.
FrameDedup: if Snapshot::equals(previous), increment the previous frame's hold duration instead of emitting a duplicate. Critical for GIF size β upstream vhs does this and so must we (typical tape has 80-95% redundant frames).
Honor:
Set TypingSpeed(default 50ms inter-keystroke).Sleep(real-time pause in the virtual clock).- Arrow / Ctrl sequences emit the right CSI / control characters.
GdRasterizer (default):
- For each
Snapshot, allocate an image at(cols Γ cellW, rows Γ cellH). - Walk cells; for each, look up glyph tile from
Glyphscache; blit at cell position. - Render cursor block last if visible.
- Apply selection/underline/inverse decorations.
FontLoader:
- Resolve TTF from
candy-vcr/fonts/. - JetBrainsMono Regular + Bold are first-class.
- Italics: synthesize via slant if the italic font isn't bundled (or bundle JetBrainsMono-Italic too).
Glyphs cache:
- Per-
(char, fg, bg, bold, italic, underline)tuple, pre-render an image tile and cache it for the rest of the render. - A typical terminal frame has thousands of cells but only ~50 unique (char, attrs) combinations β caching converts the rasterization cost from
O(cells)toO(unique tiles + cell blits). Single biggest perf lever.
Cell metrics:
- Default 8Γ16 px at FontSize 14; scale linearly with
Set FontSize. - Document the calculation so users can predict GIF dimensions.
UTF-8 / wide chars:
- BMP characters render at 1 cell width.
- CJK / fullwidth render at 2 cell widths (use
mb_strwidth+Width::char()). - Emoji: deferred to v2. Document the limit.
ImagickRasterizer (alternative):
- Slightly better anti-aliasing; useful if visual drift from upstream becomes a complaint.
- Kept as an option, not the default.
FfmpegGifEncoder (default):
- Write frames as PNGs to a temp dir.
- Invoke ffmpeg with two-pass palette generation:
ffmpeg -framerate 30 -i frame%05d.png \ -vf "split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5" \ -loop 0 out.gif - Honor per-frame hold from FrameDedup via VFR input (
-fps_mode vfr) or per-framedurationdirectives in a concat list.
PhpGifEncoder (fallback):
- Pure-PHP using
imagegif()per frame + custom LZW for the animation chunks. - Slow; exists only for environments without ffmpeg.
Goal: visual parity with upstream vhs at "looks the same at a glance", not byte parity. Document the expectation.
RenderTapeCommand:
candy-vcr render-tape <tape-file>
[--output <gif>] # default: same path as input with .gif extension
[--font <ttf>] # default: bundled JetBrainsMono-Regular
[--theme <name>] # default: TokyoNight
[--fps <n>] # default: 30
[--backend gd|imagick] # default: gd
[--encoder ffmpeg|php] # default: ffmpeg
[--strict] # error on unknown directives instead of skipping
Wire into the existing Symfony Console app in candy-vcr/src/Cli/. Update candy-vcr/bin/candy-vcr only if entry-point changes are needed.
Add render-batch <dir> command that processes every *.tape under a directory in one PHP process, avoiding repeated PHP startup overhead for the CI case.
Container image:
- New variant
ghcr.io/<org>/vhs-runner-php:latest. - Drops:
vhsbinary,ttyd, Chromium libraries. - Keeps:
ffmpeg, PHP 8.3 + ext-gd + ext-mbstring + ext-intl + ext-curl + ext-ssh2. - Adds: bundled TTF files into the image (or rely on the lib's
fonts/directory). - Maintained by
.github/workflows/vhs-runner-image.yml.
Workflow changes:
- New job
vhs-candy-vcrruns in parallel with the existingvhsjob on a seed lib (candy-corefirst). The job invokesphp candy-vcr/bin/candy-vcr render-batch <lib>/.vhs/. - After visual sign-off on the seed lib, expand to 5 more libs.
- After a 2-week soak with no regressions, the upstream
vhs "$tape"line is replaced. - The container-image swap is a separate PR.
Rollback: one-line revert in .github/workflows/vhs.yml. The upstream-vhs container variant lives for 2β3 releases as a safety net.
Target: β€30 min wall-clock for the full 841-tape render (matches current ceiling under upstream vhs).
Per-tape estimates (informed; will be measured during Phase 0 and Phase 4):
| Step | Per-tape (median) | Notes |
|---|---|---|
| Tape parse | <50 ms | Trivial string parsing |
| Cassette compile | <50 ms | AST walk |
| Render (5s session Γ 30fps Γ dedup β ~50 unique frames) | 200β500 ms | Terminal feed + snapshot |
| Rasterize 50 frames at 800Γ480, glyph cache warm | 500 ms β 2 s | Dominated by glyph blits |
| GIF encode via ffmpeg + palettegen | 500 ms β 2 s | Two-pass palette overhead |
| Per-tape total | 1β5 s | |
| 841 tapes serial | 14β70 min | Borderline |
Parallelization saves it: the existing CI matrix shards by lib (sugar-dash gets 4Γ). At ~15 effective shards, 841/15 β 56 tapes per shard Γ 5 s β 5 min β well inside the budget.
| Bottleneck | Where | Mitigation |
|---|---|---|
| CSI parser hot loop in pure PHP | candy-vt Parser | Profile after Phase 1; convert inner state machine to a switch-table; consider memoizing common SGR-only sequences. |
| Glyph rendering dominates rasterization | Raster/Rasterizer | The Glyphs cache is the mitigation β without it, expect 10Γ slower. Measure cache hit rate; aim for >95% on typical tapes. |
ffmpeg palettegen runs per-frame by default |
Encode/FfmpegGifEncoder | Use palettegen=stats_mode=diff once globally with paletteuse=dither=bayer:bayer_scale=5. |
| PHP startup overhead Γ 841 tapes | CLI invocation | render-batch command processes a whole directory in one PHP process. |
| Frame dedup defeated by cursor blink or animation | Render/FrameDedup | Cap blink rate to 1Hz; document that animated demos won't dedup well. |
| Memory growth from accumulated snapshots | Render/FrameStream | Iterator-based; never hold more than 2 snapshots (current + previous) in memory. |
| Temp PNG disk write/read for ffmpeg pipe | Encode/FfmpegGifEncoder | If disk I/O bites, pipe PNGs to ffmpeg stdin via proc_open instead of temp files. |
ext-gd imagettftext per-glyph cost |
Raster/GdRasterizer | Glyph cache solves the steady-state; the cold-cache cost on the first tape is unavoidable but small (~50 glyphs to warm). |
Performance work is gated on real numbers from Phase 0 and Phase 4 benchmarks, not premature optimization.
| Directive | Usage in 841 monorepo tapes | v1 status | Behavior |
|---|---|---|---|
Output <path> |
every tape | β supported | Write GIF to path |
Set FontSize <n> |
266 | β supported | Scales cell metrics linearly |
Set Width <n> / Set Height <n> |
201 | β supported | Terminal cols/rows |
Set TypingSpeed <Xms> |
101 | β supported | Inter-keystroke delay during Type |
Set Theme "..." |
277 | β supported (TokyoNight first; others lazy-loaded) | Maps to candy-vt theme catalog |
Set Padding / Set Margin |
0 | Cosmetic chrome; defer to v2 | |
Set FontFamily |
0 | v1 ships JetBrainsMono only | |
Type "..." |
311+ | β supported | Each char as KeyMsg at TypingSpeed cadence |
Enter Tab Backspace |
311+ | β supported | Key Msg sequences |
Arrow keys Up/Down/Left/Right |
16+ | β supported | CSI sequences |
Ctrl+<letter> |
9+ | β supported | Control character |
Sleep <duration> |
widespread | β supported | Real-time clock advance |
Env KEY "..." |
1+ | β supported | Exported into child env |
Hide / Show |
0 | Frame-capture toggle; defer | |
Wait /regex/ |
0 | β not in v1 | Useful directive; v2 |
Screenshot <path> |
0 | β not in v1 | Single-frame capture; v2 |
Copy / Paste |
0 | β not in v1 | Clipboard; v2 |
Source <tape> |
0 | β not in v1 | Tape inclusion; v2 |
PlaybackSpeed / LoopOffset |
0 | β not in v1 | v2 |
ScrollUp / ScrollDown / Page* |
0 | β not in v1 | Need terminal scrollback first |
CursorBlink |
0 | β not in v1 | Defeats dedup; defer |
v1 covers everything actually used in the monorepo. v2 covers the remainder for parity with upstream vhs.
- Unit tests per phase live in the matching lib (
candy-vt/tests/,candy-vcr/tests/Tape/,candy-vcr/tests/Render/, etc.). - Visual regression: ~10 curated golden GIFs checked into the repo. CI re-renders and compares against goldens β file-hash for byte-deterministic builds; SSIM threshold if we need fuzziness.
- Corpus parse test: assert every
.tapefile in the monorepo parses without error. No rendering required β just lexer/parser/compiler coverage. - Smoke test:
php candy-vcr/bin/candy-vcr render-tape candy-vt/.vhs/snapshot-demo.tapemust produce a non-empty GIF. Commit one canonical golden. - Performance smoke test:
time render-batchagainst a 10-tape directory; CI fails if wall time exceeds threshold.
- Phase 0 PR: Fix candy-vcr PHPUnit; commit baseline benchmark to CALIBER_LEARNINGS.md.
- Phase 1 PRs (multiple): candy-vt incremental β
Cell+CellGrid+Cursor; then Parser/state-machine; then CSI handler; then Theme + Themes. - Phase 2 PR: Tape lexer/parser/compiler; corpus parse test passes.
- Phase 3 PR: Renderer + FrameDedup.
- Phase 4 PRs: GdRasterizer + Glyphs + FontLoader; bundle TTF.
- Phase 5 PR: FfmpegGifEncoder; smoke test produces a viewable GIF.
- Phase 6 PR:
render-tapeandrender-batchCLI commands. - Phase 7 PRs: New runner-image variant; parallel
vhs-candy-vcrworkflow job on a seed lib; gradual expansion; soak; finally drop upstream-vhs.
Each PR ends with vendor/bin/phpunit per affected lib and (for Phase 7) a visual diff on the seed library's GIFs.
| Risk | Likelihood | Mitigation |
|---|---|---|
| Visual drift from upstream (font, theme palette, kerning) | High | Tune until "close enough"; document the expectation; offer ImagickRasterizer as a fallback if needed. |
| Performance overshoot (>30 min CI budget) | Medium | Phase 0 + Phase 4 benchmarks gate Phase 7; if budget breaks, expand matrix sharding before reverting. |
| candy-vt scope creep (terminal emulator is its own project) | High | Strict v1 scope: only what the renderer needs. Defer DECSAVE, alt-screen, mouse, BiDi to v2. |
| Hidden directives in newer tapes break parser | Low | Corpus parse test runs before render; --strict flag for opt-in rejection. |
Determinism change (vhs is non-deterministic, candy-vcr is byte-deterministic) breaks git diff --quiet re-render check |
Medium | Switch the re-render check to a manifest-hash comparison; deterministic output is actually an upgrade. |
Rollback is always a one-line workflow revert. The upstream-vhs runner image lives in parallel for 2β3 releases.
- Shell to headless Chromium (mimics upstream vhs internals). Rejected β defeats the PHP-native ethos; re-introduces the Chromium container weight we'd be removing.
- Use ImageMagick CLI for everything (no PHP image libs). Rejected as the primary path β ffmpeg has stronger GIF tooling (
palettegen+paletteuse) and is already in the image. KeptImagickRasterizeras an alternative backend. - Pure-PHP everything (no ffmpeg shell-out). Rejected as default β pure-PHP LZW encoding is 5β10Γ slower. Kept as
PhpGifEncoderfor environments where shelling out isn't allowed.
Independent of the render work; the existing recorder/player has room to grow:
- Fix the broken PHPUnit (
TickModel::subscriptions()). - Tape-format auto-detection (
.tapevs cassette by extension and header sniff). - Dry-run mode (
render-tape --dry-runprints the compiled event stream without rendering). inspect --framesmode that lists the snapshot timeline.- Document the existing cassette formats (Jsonl vs CompressedJsonl trade-offs) in the README.
Things upstream Go can't easily do that PHP can leverage:
- ReactPHP async loop for parallel tape compilation + rendering within one process.
- Distributed rendering via Amp/promise pools β one batch process per CPU core.
- Incremental re-rendering: only re-render tapes whose mtime > corresponding GIF mtime. Bypass the full corpus on no-op PRs.
- Live preview mode: open a terminal window, watch as a tape renders frame-by-frame. Useful for tape authoring.
| Phase | Estimate | Critical path |
|---|---|---|
| 0 β Unblock | Β½ day | yes |
| 1 β candy-vt | 2β3 weeks | yes (longest pole) |
| 2 β Tape parser | 1 week | yes |
| 3 β Renderer | 1 week | yes |
| 4 β Rasterizer | 1β2 weeks | yes |
| 5 β GIF encoder | Β½ week | yes |
| 6 β CLI | Β½ week | yes |
| 7 β CI migration + soak | 1 week | yes |
| Total | 6β10 weeks focused work |
Yes, achievable. The biggest hidden cost is building candy-vt itself, not the candy-vcr work. With ffmpeg already in CI and gd/imagick available in PHP, the PHP-native path is realistic without re-introducing the Chromium/ttyd container weight.
Recommended sequencing: ship Phase 0 first (cheap, unblocks measurement), then Phase 1 (the candy-vt base) as a long-running parallel workstream, then Phases 2β7 in order once Phase 1 has a usable Terminal API. Each phase ships as its own PR(s); the renderer is additive throughout β upstream vhs keeps running in CI until Phase 7's soak proves the candy-vcr path is reliable.
(End of file - total 554 lines)