Skip to content

feat(transitions): pluggable frame-by-frame board animations#829

Open
jeffredodd wants to merge 8 commits into
mainfrom
feat-transition-plugins
Open

feat(transitions): pluggable frame-by-frame board animations#829
jeffredodd wants to merge 8 commits into
mainfrom
feat-transition-plugins

Conversation

@jeffredodd

@jeffredodd jeffredodd commented May 31, 2026

Copy link
Copy Markdown
Member

⚠️ Highly Experimental — Beta Feature. Off by default. Enable in Settings → Beta (beta.transition_plugins_enabled) — same pattern as the HTTPS beta. The plugin SDK contract, manifest fields, and runtime semantics are not yet stable and may change before general availability. The /transitions endpoints respond 404 when the flag is off so the feature is fully invisible to users who haven't opted in.

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

  • New beta.transition_plugins_enabled flag (defaults to false).
  • Both /transitions/plugins and /transitions/preview 404 with a "beta required" message until the flag is enabled.
  • Banner on the Transition Lab page and the dev guide makes the experimental status explicit.

SDK & runtime

  • 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 (src/transitions/runner.py) drives the iterator, enforces caps (max_frames, max_runtime_seconds, 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.

Send-path migration

  • All ten user-facing send_characters call sites in main.py and api_server.py migrated to render() so transition plugins work everywhere a page would land. Admin paths (blank/fill/debug) intentionally left raw.

First-party plugins (dogfood the SDK)

Each ships with manifest, README, SETUP guide, and tests ≥93% coverage.

Web UI: Transition Lab

  • New /transitions page with plugin picker, from/to text inputs, per-plugin config form (JSON), and a frame timeline with play/pause/scrub controls.
  • Backed by two new endpoints: GET /transitions/plugins and POST /transitions/preview (runs generate_frames and returns the frame array in-memory, honoring the same caps as production).

External plugin support

  • Registry naming fiestaboard-transition--<name> (vs fiestaboard-plugin--<name> for data plugins). REGISTRY_NAME_RE and plugin_id_from_repo_name updated to accept both.
  • plugins/_template_transition/ is a copy-and-edit starter scaffold.
  • docs/development/TRANSITION_PLUGIN_DEVELOPMENT.md walks authors through the SDK, manifest fields, caps, the test harness, and publishing externally.

Loader hardening (bonus)

  • The plugin loader previously unconditionally clobbered sys.modules["plugins.<name>"], which broke any test that held an imported reference to a plugin module and later applied mock.patch against 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 passing
  • python 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 tests
  • python scripts/validate_plugins.py — all 6 plugins pass after extending the category whitelist
  • Beta gating: endpoints 404 when flag is off, 200 when on (covered by test_endpoints_404_when_beta_disabled)
  • Manual: enable the beta in Settings → Beta, open /transitions against the dev container, run each first-party plugin against HELLOFIESTABOARD on flagship and note, confirm the timeline scrubber works
  • Manual: set Page.transition_strategy = "plugin:typewriter" on a page, confirm a real (or mock) board sees the frame-by-frame send
  • CI green

🤖 Generated with Claude Code

Comment thread src/board_client.py Fixed
Comment thread tests/test_transition_plugin_base.py Fixed
Comment thread tests/test_transitions_api.py Fixed
Comment thread tests/test_transitions_runner.py Fixed
Comment thread tests/test_transitions_runner.py Fixed
@github-actions github-actions Bot added minor and removed minor labels May 31, 2026
Comment thread src/api_server.py Fixed
@jeffredodd jeffredodd marked this pull request as ready for review May 31, 2026 07:41
@jeffredodd jeffredodd requested a review from roblesi as a code owner May 31, 2026 07:41
jeffredodd and others added 5 commits June 4, 2026 20:40
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.
@jeffredodd jeffredodd force-pushed the feat-transition-plugins branch from 4edb415 to 952deca Compare June 5, 2026 03:48
@github-actions github-actions Bot added minor and removed minor labels Jun 5, 2026
Comment thread src/api_server.py
Iteration happens on a worker thread (``asyncio.to_thread``) so a
slow / runaway plugin generator cannot block FastAPI's event loop.
"""
import asyncio
* Update test_trigger_render_path (added in #893 after this branch
  diverged) to assert render() instead of send_characters; the PR
  already migrated _send_trigger_content to render().
* Sort imports in transitions-api.test.ts to satisfy
  simple-import-sort, the JS lint hardening from #891.
@github-actions github-actions Bot added minor and removed minor labels Jun 5, 2026
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.
@github-actions github-actions Bot added minor and removed minor labels Jun 5, 2026
Final formatting pass after the import-sort fixes -- the simple-import-sort
autofix preserved line breaks that prettier wants merged onto one line.
@github-actions github-actions Bot added minor and removed minor labels Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant