Implements the core figment plugin: timer-driven SVG selection, REVEAL → HOLD → DISSOLVE state machine, trigger API, and get_figment_state() for overlay rendering. process() is a deliberate no-op; scroll.py will call get_figment_state() instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
6.4 KiB
Python
191 lines
6.4 KiB
Python
"""
|
|
Figment effect plugin — periodic SVG glyph overlay.
|
|
|
|
Owns the figment lifecycle: timer, SVG selection, state machine.
|
|
Delegates rendering to render_figment_overlay() in engine/layers.py.
|
|
|
|
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
|
|
convention in effects_plugins/__init__.py.
|
|
|
|
NOT added to the EffectChain order — process() is a no-op. The overlay
|
|
rendering is handled by scroll.py calling get_figment_state().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from dataclasses import dataclass
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
|
|
from engine import config
|
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|
from engine.figment_render import rasterize_svg
|
|
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
|
|
from engine.themes import THEME_REGISTRY
|
|
|
|
|
|
class FigmentPhase(Enum):
|
|
REVEAL = auto()
|
|
HOLD = auto()
|
|
DISSOLVE = auto()
|
|
|
|
|
|
@dataclass
|
|
class FigmentState:
|
|
phase: FigmentPhase
|
|
progress: float
|
|
rows: list[str]
|
|
gradient: list[int]
|
|
center_row: int
|
|
center_col: int
|
|
|
|
|
|
class FigmentEffect(EffectPlugin):
|
|
name = "figment"
|
|
config = EffectConfig(
|
|
enabled=False,
|
|
intensity=1.0,
|
|
params={
|
|
"interval_secs": 60,
|
|
"display_secs": 4.5,
|
|
"figment_dir": "figments",
|
|
},
|
|
)
|
|
|
|
def __init__(self, figment_dir: str | None = None, triggers: list[FigmentTrigger] | None = None):
|
|
self.config = EffectConfig(
|
|
enabled=False,
|
|
intensity=1.0,
|
|
params={
|
|
"interval_secs": 60,
|
|
"display_secs": 4.5,
|
|
"figment_dir": figment_dir or "figments",
|
|
},
|
|
)
|
|
self._triggers = triggers or []
|
|
self._phase: FigmentPhase | None = None
|
|
self._progress: float = 0.0
|
|
self._rows: list[str] = []
|
|
self._gradient: list[int] = []
|
|
self._center_row: int = 0
|
|
self._center_col: int = 0
|
|
self._timer: float = 0.0
|
|
self._last_svg: str | None = None
|
|
self._svg_files: list[str] = []
|
|
self._scan_svgs()
|
|
|
|
def _scan_svgs(self) -> None:
|
|
figment_dir = Path(self.config.params["figment_dir"])
|
|
if figment_dir.is_dir():
|
|
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
|
|
|
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
|
return buf
|
|
|
|
def configure(self, cfg: EffectConfig) -> None:
|
|
# Preserve figment_dir if the new config doesn't supply one
|
|
figment_dir = cfg.params.get(
|
|
"figment_dir", self.config.params.get("figment_dir", "figments")
|
|
)
|
|
self.config = cfg
|
|
if "figment_dir" not in self.config.params:
|
|
self.config.params["figment_dir"] = figment_dir
|
|
self._scan_svgs()
|
|
|
|
def trigger(self, w: int, h: int) -> None:
|
|
"""Manually trigger a figment display."""
|
|
if not self._svg_files:
|
|
return
|
|
|
|
# Pick a random SVG, avoid repeating
|
|
candidates = [s for s in self._svg_files if s != self._last_svg]
|
|
if not candidates:
|
|
candidates = self._svg_files
|
|
svg_path = random.choice(candidates)
|
|
self._last_svg = svg_path
|
|
|
|
# Rasterize
|
|
try:
|
|
self._rows = rasterize_svg(svg_path, w, h)
|
|
except Exception:
|
|
return
|
|
|
|
# Pick random theme gradient
|
|
theme_key = random.choice(list(THEME_REGISTRY.keys()))
|
|
self._gradient = THEME_REGISTRY[theme_key].main_gradient
|
|
|
|
# Center in viewport
|
|
figment_h = len(self._rows)
|
|
figment_w = max((len(r) for r in self._rows), default=0)
|
|
self._center_row = max(0, (h - figment_h) // 2)
|
|
self._center_col = max(0, (w - figment_w) // 2)
|
|
|
|
# Start reveal phase
|
|
self._phase = FigmentPhase.REVEAL
|
|
self._progress = 0.0
|
|
|
|
def get_figment_state(self, frame_number: int, w: int, h: int) -> FigmentState | None:
|
|
"""Tick the state machine and return current state, or None if idle."""
|
|
if not self.config.enabled:
|
|
return None
|
|
|
|
# Poll triggers
|
|
for trig in self._triggers:
|
|
cmd = trig.poll()
|
|
if cmd is not None:
|
|
self._handle_command(cmd, w, h)
|
|
|
|
# Tick timer when idle
|
|
if self._phase is None:
|
|
self._timer += config.FRAME_DT
|
|
interval = self.config.params.get("interval_secs", 60)
|
|
if self._timer >= interval:
|
|
self._timer = 0.0
|
|
self.trigger(w, h)
|
|
|
|
# Tick animation — snapshot current phase/progress, then advance
|
|
if self._phase is not None:
|
|
# Capture the state at the start of this frame
|
|
current_phase = self._phase
|
|
current_progress = self._progress
|
|
|
|
# Advance for next frame
|
|
display_secs = self.config.params.get("display_secs", 4.5)
|
|
phase_duration = display_secs / 3.0
|
|
self._progress += config.FRAME_DT / phase_duration
|
|
|
|
if self._progress >= 1.0:
|
|
self._progress = 0.0
|
|
if self._phase == FigmentPhase.REVEAL:
|
|
self._phase = FigmentPhase.HOLD
|
|
elif self._phase == FigmentPhase.HOLD:
|
|
self._phase = FigmentPhase.DISSOLVE
|
|
elif self._phase == FigmentPhase.DISSOLVE:
|
|
self._phase = None
|
|
|
|
return FigmentState(
|
|
phase=current_phase,
|
|
progress=current_progress,
|
|
rows=self._rows,
|
|
gradient=self._gradient,
|
|
center_row=self._center_row,
|
|
center_col=self._center_col,
|
|
)
|
|
|
|
return None
|
|
|
|
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
|
|
if cmd.action == FigmentAction.TRIGGER:
|
|
self.trigger(w, h)
|
|
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(cmd.value, (int, float)):
|
|
self.config.intensity = float(cmd.value)
|
|
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(cmd.value, (int, float)):
|
|
self.config.params["interval_secs"] = float(cmd.value)
|
|
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
|
|
if cmd.value in THEME_REGISTRY:
|
|
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
|
|
elif cmd.action == FigmentAction.STOP:
|
|
self._phase = None
|
|
self._progress = 0.0
|