feat(transitions): pluggable frame-by-frame board animations#829
Open
jeffredodd wants to merge 8 commits into
Open
feat(transitions): pluggable frame-by-frame board animations#829jeffredodd wants to merge 8 commits into
jeffredodd wants to merge 8 commits into
Conversation
Adds a new TransitionPlugin type alongside the existing data plugins. Transition plugins produce a sequence of intermediate grids that the runtime sends frame-by-frame, enabling typewriter reveals, slot-machine spins, dissolves, and anything else that requires custom per-frame control beyond Vestaboard's hardware transitions. Highlights - New TransitionPluginBase in src/plugins/base.py with a minimal generate_frames(from, to, device, config) -> Iterator[(grid, ms)] contract. Manifests opt in via plugin_type: "transition" and the new "transition" category. - Host-side TransitionRunner in src/transitions/runner.py drives the iterator, enforces caps (max_frames, max_runtime, min_interval_ms floor), and uses interruptible sleeps for clean cancellation. - BoardClient.render() façade routes "plugin:<id>" strategies through the runner and holds a per-board lock so concurrent rotation / trigger / manual sends serialise instead of interleaving frames. - All ten user-facing send_characters call sites in main.py and api_server.py migrated to render(); admin paths (blank/fill/debug) intentionally left raw. - Three first-party plugins to dogfood the SDK: typewriter, simple_dissolve, slot_machine. Each ships with manifest, README, SETUP guide, and tests at >=93% coverage. - /transitions/plugins and /transitions/preview endpoints power a new Transition Lab page in the web UI with a timeline scrubber and per-plugin config form -- authors can preview against arbitrary from/to text without a real board. - Loader hardened to reuse existing sys.modules entries when a plugin module was already imported, eliminating a test-pollution class where dynamic loading clobbered references held by other tests. - External transition plugins live in their own repos via the existing plugin-registry.json mechanism with a fiestaboard-transition--<name> naming convention. Docs - docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md walks authors through the SDK, manifest fields, caps, the test harness, and publishing externally. - plugins/_template_transition is a copy-and-edit starter scaffold. - README adds a Transition Plugins subsection linking the three first-party transitions and the dev guide. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ories - Add beta.transition_plugins_enabled (off by default) following the same pattern as https_enabled. The /transitions/* endpoints return 404 when the flag is off so the SDK is fully hidden until operators opt in. Surfaced in the Transition Lab title, the dev guide, and the README as "Beta / Experimental" so users know the contract may change. - Update scripts/validate_plugins.py and its skip list to include the new "transition" category and the _template_transition scaffold -- CI's Plugin Tests step caught this on the first push. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-user Claude Code state file slipped into a prior commit. Drop it from the index and add an ignore rule. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review fixes: - board_client.render() built-in path now signals cancel BEFORE acquiring the send lock, so a non-plugin send actually interrupts an in-flight plugin transition instead of waiting up to max_runtime_seconds for it to finish. The shared cancel Event is also rotated per run so a freshly-started transition can't be pre-cancelled by a signal aimed at the previous run. - Beta gate is enforced end-to-end: settings service + page create / update / import + a defense-in-depth check in board_client.render refuse plugin:<id> strategies when transition_plugins_enabled is off. Previously only the /transitions/* endpoints were gated. - /transitions/preview runs the plugin generator on a worker thread with asyncio.wait_for so a slow / runaway plugin can't block the FastAPI event loop, and it now honors max_runtime_seconds. - TransitionRunner skips the final to_grid snap on cancellation so an interrupting send doesn't briefly flash the cancelled page's target. - TransitionRunner._resolve_from_grid no longer falls back to read_current_message (network round-trip whose result was usually rejected by the isinstance check); blank grid is the only fallback. - Plugin loader's sys.modules reuse shortcut now requires a __fiestaboard_loaded__ sentinel set on successful exec_module, and removes the entry on exec failure so a half-initialized module from a failed earlier load isn't reused. - is_valid_strategy strips the plugin: suffix and requires a non-empty id; "plugin: " is now rejected at settings-write time instead of silently degrading to a non-animated send. Bot lint cleanups: - Drop unused typing imports (Callable, Dict, Any, Iterator) and an unused `import pytest` flagged by github-code-quality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stricter ruff rules (UP006/UP007/UP028/UP035/RUF005/RUF013/B007/B027/B904/ F821/TID252) landed in #895 after this branch diverged. Modernize typing annotations on the new transition modules, switch transitions runner to absolute imports, fix implicit Optionals in test fixtures, add `from exc` to a re-raise, and tag the optional cleanup() hook with a B027 noqa.
4edb415 to
952deca
Compare
| Iteration happens on a worker thread (``asyncio.to_thread``) so a | ||
| slow / runaway plugin generator cannot block FastAPI's event loop. | ||
| """ | ||
| import asyncio |
The stricter eslint rules from #891 require external imports, then internal (@/), then relative (./) groups separated by blank lines, and type imports to come before value imports from the same module.
Final formatting pass after the import-sort fixes -- the simple-import-sort autofix preserved line breaks that prettier wants merged onto one line.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new transition plugin type alongside the existing data plugins. Transition plugins produce a sequence of intermediate board grids that the runtime sends frame-by-frame — enabling typewriter reveals, slot-machine spins, dissolves, and anything else that needs custom per-frame control beyond Vestaboard's hardware transitions.
This is the plugin-system response to the kind of effect wonkybutt's PR #711 reaches for: rather than hardcoding more strategies into
board_client.py, we expose a real extension point so the community (and we) can ship many of these without core changes.What's in the PR
Beta gating
beta.transition_plugins_enabledflag (defaults tofalse)./transitions/pluginsand/transitions/preview404 with a "beta required" message until the flag is enabled.SDK & runtime
TransitionPluginBaseinsrc/plugins/base.pywith a minimalgenerate_frames(from, to, device, config) -> Iterator[(grid, ms)]contract.plugin_type: "transition"and the new"transition"category.TransitionRunner(src/transitions/runner.py) drives the iterator, enforces caps (max_frames,max_runtime_seconds,min_interval_msfloor), and uses interruptible sleeps for clean cancellation.BoardClient.render()façade routes"plugin:<id>"strategies through the runner and holds a per-board lock so concurrent rotation / trigger / manual sends serialise instead of interleaving frames.Send-path migration
send_characterscall sites inmain.pyandapi_server.pymigrated torender()so transition plugins work everywhere a page would land. Admin paths (blank/fill/debug) intentionally left raw.First-party plugins (dogfood the SDK)
typewriter— left-to-right char-by-char revealsimple_dissolve— random-order tile revealslot_machine— each column spins through random tile codes before locking left-to-right (our distinct visual identity for this PR, not a port of feat: Add "Quiet Library" batched word-by-word transition #711)Each ships with manifest, README, SETUP guide, and tests ≥93% coverage.
Web UI: Transition Lab
/transitionspage with plugin picker, from/to text inputs, per-plugin config form (JSON), and a frame timeline with play/pause/scrub controls.GET /transitions/pluginsandPOST /transitions/preview(runsgenerate_framesand returns the frame array in-memory, honoring the same caps as production).External plugin support
fiestaboard-transition--<name>(vsfiestaboard-plugin--<name>for data plugins).REGISTRY_NAME_REandplugin_id_from_repo_nameupdated to accept both.plugins/_template_transition/is a copy-and-edit starter scaffold.docs/development/TRANSITION_PLUGIN_DEVELOPMENT.mdwalks authors through the SDK, manifest fields, caps, the test harness, and publishing externally.Loader hardening (bonus)
sys.modules["plugins.<name>"], which broke any test that held an imported reference to a plugin module and later appliedmock.patchagainst it. Now reuses the existing module when one is already imported from the same file path. Surfaced while wiring up the new API tests but applies to the whole loader.Test plan
pytest tests/ plugins/— 3506 passingpython scripts/run_plugin_tests.py— all 6 plugins pass at ≥93% coverage (typewriter 100%, slot_machine 100%, simple_dissolve 93%)npx vitest run— 994 web tests passing including new transition API client testspython scripts/validate_plugins.py— all 6 plugins pass after extending the category whitelisttest_endpoints_404_when_beta_disabled)/transitionsagainst the dev container, run each first-party plugin againstHELLO→FIESTABOARDon flagship and note, confirm the timeline scrubber worksPage.transition_strategy = "plugin:typewriter"on a page, confirm a real (or mock) board sees the frame-by-frame send🤖 Generated with Claude Code