feat(effects): add plugin architecture for visual effects
- Extract effects as fully decoupled plugins in engine/effects/ - Add EffectConfig, EffectContext dataclasses and EffectPlugin protocol - Add EffectRegistry for plugin discovery and management - Add EffectChain for ordered pipeline execution - Move built-in effects to effects_plugins/ directory - Add interactive effects config picker during startup - Add NTFY command handler for /effects commands - Add tests for effects system (24 new tests) - Update AGENTS.md with effects plugin documentation - Add conventional commits section to AGENTS.md chore: add coverage.xml to .gitignore
This commit is contained in:
105
engine/app.py
105
engine/app.py
@@ -249,6 +249,110 @@ def pick_font_face():
|
||||
print()
|
||||
|
||||
|
||||
def pick_effects_config():
|
||||
"""Interactive picker for configuring effects pipeline."""
|
||||
import effects_plugins
|
||||
from engine.effects import get_effect_chain, get_registry
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
registry = get_registry()
|
||||
chain = get_effect_chain()
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
|
||||
effects = list(registry.list_all().values())
|
||||
if not effects:
|
||||
return
|
||||
|
||||
selected = 0
|
||||
editing_intensity = False
|
||||
intensity_value = 1.0
|
||||
|
||||
def _draw_effects_picker():
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print("\033[1;1H", end="")
|
||||
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||
print()
|
||||
|
||||
for i, effect in enumerate(effects):
|
||||
prefix = " > " if i == selected else " "
|
||||
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||
if editing_intensity and i == selected:
|
||||
print(
|
||||
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||
)
|
||||
|
||||
print()
|
||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||
print(
|
||||
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||
)
|
||||
|
||||
def _read_effects_key():
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x03":
|
||||
return "interrupt"
|
||||
if ch in ("\r", "\n"):
|
||||
return "enter"
|
||||
if ch == " ":
|
||||
return "toggle"
|
||||
if ch == "q":
|
||||
return "quit"
|
||||
if ch == "+" or ch == "=":
|
||||
return "up"
|
||||
if ch == "-" or ch == "_":
|
||||
return "down"
|
||||
if ch == "\x1b":
|
||||
c1 = sys.stdin.read(1)
|
||||
if c1 != "[":
|
||||
return None
|
||||
c2 = sys.stdin.read(1)
|
||||
if c2 == "A":
|
||||
return "up"
|
||||
if c2 == "B":
|
||||
return "down"
|
||||
return None
|
||||
return None
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_effects_picker()
|
||||
key = _read_effects_key()
|
||||
|
||||
if key == "quit" or key == "enter":
|
||||
break
|
||||
elif key == "up" and editing_intensity:
|
||||
intensity_value = min(1.0, intensity_value + 0.1)
|
||||
effects[selected].config.intensity = intensity_value
|
||||
elif key == "down" and editing_intensity:
|
||||
intensity_value = max(0.0, intensity_value - 0.1)
|
||||
effects[selected].config.intensity = intensity_value
|
||||
elif key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
intensity_value = effects[selected].config.intensity
|
||||
elif key == "down":
|
||||
selected = min(len(effects) - 1, selected + 1)
|
||||
intensity_value = effects[selected].config.intensity
|
||||
elif key == "toggle":
|
||||
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
|
||||
def main():
|
||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||
|
||||
@@ -263,6 +367,7 @@ def main():
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
pick_effects_config()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
38
engine/effects/__init__.py
Normal file
38
engine/effects/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from engine.effects.chain import EffectChain
|
||||
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||
from engine.effects.legacy import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||
|
||||
|
||||
def get_effect_chain():
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EffectChain",
|
||||
"EffectRegistry",
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"PipelineConfig",
|
||||
"get_registry",
|
||||
"set_registry",
|
||||
"get_effect_chain",
|
||||
"handle_effects_command",
|
||||
"show_effects_menu",
|
||||
"fade_line",
|
||||
"firehose_line",
|
||||
"glitch_bar",
|
||||
"noise",
|
||||
"next_headline",
|
||||
"vis_trunc",
|
||||
]
|
||||
47
engine/effects/chain.py
Normal file
47
engine/effects/chain.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from engine.effects.registry import EffectRegistry
|
||||
from engine.effects.types import EffectContext
|
||||
|
||||
|
||||
class EffectChain:
|
||||
def __init__(self, registry: EffectRegistry):
|
||||
self._registry = registry
|
||||
self._order: list[str] = []
|
||||
|
||||
def set_order(self, names: list[str]) -> None:
|
||||
self._order = list(names)
|
||||
|
||||
def get_order(self) -> list[str]:
|
||||
return self._order.copy()
|
||||
|
||||
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||
if name not in self._registry.list_all():
|
||||
return False
|
||||
if position is None:
|
||||
self._order.append(name)
|
||||
else:
|
||||
self._order.insert(position, name)
|
||||
return True
|
||||
|
||||
def remove_effect(self, name: str) -> bool:
|
||||
if name in self._order:
|
||||
self._order.remove(name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reorder(self, new_order: list[str]) -> bool:
|
||||
all_plugins = set(self._registry.list_all().keys())
|
||||
if not all(name in all_plugins for name in new_order):
|
||||
return False
|
||||
self._order = list(new_order)
|
||||
return True
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
result = list(buf)
|
||||
for name in self._order:
|
||||
plugin = self._registry.get(name)
|
||||
if plugin and plugin.config.enabled:
|
||||
try:
|
||||
result = plugin.process(result, ctx)
|
||||
except Exception:
|
||||
plugin.config.enabled = False
|
||||
return result
|
||||
102
engine/effects/controller.py
Normal file
102
engine/effects/controller.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from engine.effects.registry import get_registry
|
||||
|
||||
|
||||
def _get_effect_chain():
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
|
||||
|
||||
def handle_effects_command(cmd: str) -> str:
|
||||
"""Handle /effects command from NTFY message.
|
||||
|
||||
Commands:
|
||||
/effects list - list all effects and their status
|
||||
/effects <name> on - enable an effect
|
||||
/effects <name> off - disable an effect
|
||||
/effects <name> intensity <0.0-1.0> - set intensity
|
||||
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||
"""
|
||||
parts = cmd.strip().split()
|
||||
if not parts or parts[0] != "/effects":
|
||||
return "Unknown command"
|
||||
|
||||
registry = get_registry()
|
||||
chain = _get_effect_chain()
|
||||
|
||||
if len(parts) == 1 or parts[1] == "list":
|
||||
result = ["Effects:"]
|
||||
for name, plugin in registry.list_all().items():
|
||||
status = "ON" if plugin.config.enabled else "OFF"
|
||||
intensity = plugin.config.intensity
|
||||
result.append(f" {name}: {status} (intensity={intensity})")
|
||||
if chain:
|
||||
result.append(f"Order: {chain.get_order()}")
|
||||
return "\n".join(result)
|
||||
|
||||
if len(parts) < 3:
|
||||
return "Usage: /effects <name> on|off|intensity <value>"
|
||||
|
||||
effect_name = parts[1]
|
||||
action = parts[2]
|
||||
|
||||
if effect_name not in registry.list_all():
|
||||
return f"Unknown effect: {effect_name}"
|
||||
|
||||
if action == "on":
|
||||
registry.enable(effect_name)
|
||||
return f"Enabled: {effect_name}"
|
||||
|
||||
if action == "off":
|
||||
registry.disable(effect_name)
|
||||
return f"Disabled: {effect_name}"
|
||||
|
||||
if action == "intensity" and len(parts) >= 4:
|
||||
try:
|
||||
value = float(parts[3])
|
||||
if not 0.0 <= value <= 1.0:
|
||||
return "Intensity must be between 0.0 and 1.0"
|
||||
plugin = registry.get(effect_name)
|
||||
if plugin:
|
||||
plugin.config.intensity = value
|
||||
return f"Set {effect_name} intensity to {value}"
|
||||
except ValueError:
|
||||
return "Invalid intensity value"
|
||||
|
||||
if action == "reorder" and len(parts) >= 3:
|
||||
new_order = parts[2].split(",")
|
||||
if chain and chain.reorder(new_order):
|
||||
return f"Reordered pipeline: {new_order}"
|
||||
return "Failed to reorder pipeline"
|
||||
|
||||
return f"Unknown action: {action}"
|
||||
|
||||
|
||||
def show_effects_menu() -> str:
|
||||
"""Generate effects menu text for display."""
|
||||
registry = get_registry()
|
||||
chain = _get_effect_chain()
|
||||
|
||||
lines = [
|
||||
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||
"",
|
||||
"Effects:",
|
||||
]
|
||||
|
||||
for name, plugin in registry.list_all().items():
|
||||
status = "ON" if plugin.config.enabled else "OFF"
|
||||
intensity = plugin.config.intensity
|
||||
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||
|
||||
if chain:
|
||||
lines.append("")
|
||||
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Controls:")
|
||||
lines.append(" /effects <name> on|off")
|
||||
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||
lines.append(" /effects reorder name1,name2,...")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
59
engine/effects/registry.py
Normal file
59
engine/effects/registry.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from engine.effects.types import EffectConfig, EffectPlugin
|
||||
|
||||
|
||||
class EffectRegistry:
|
||||
def __init__(self):
|
||||
self._plugins: dict[str, EffectPlugin] = {}
|
||||
self._discovered: bool = False
|
||||
|
||||
def register(self, plugin: EffectPlugin) -> None:
|
||||
self._plugins[plugin.name] = plugin
|
||||
|
||||
def get(self, name: str) -> EffectPlugin | None:
|
||||
return self._plugins.get(name)
|
||||
|
||||
def list_all(self) -> dict[str, EffectPlugin]:
|
||||
return self._plugins.copy()
|
||||
|
||||
def list_enabled(self) -> list[EffectPlugin]:
|
||||
return [p for p in self._plugins.values() if p.config.enabled]
|
||||
|
||||
def enable(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.config.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.config.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.configure(config)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_enabled(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
return plugin.config.enabled if plugin else False
|
||||
|
||||
|
||||
_registry: EffectRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> EffectRegistry:
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = EffectRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
def set_registry(registry: EffectRegistry) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
39
engine/effects/types.py
Normal file
39
engine/effects/types.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int
|
||||
mic_excess: float
|
||||
grad_offset: float
|
||||
frame_number: int
|
||||
has_message: bool
|
||||
items: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectConfig:
|
||||
enabled: bool = True
|
||||
intensity: float = 1.0
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EffectPlugin:
|
||||
name: str
|
||||
config: EffectConfig
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
order: list[str] = field(default_factory=list)
|
||||
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||
@@ -10,6 +10,8 @@ from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectContext,
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
@@ -199,3 +201,60 @@ def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||
fline = firehose_line(items, w)
|
||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
return buf
|
||||
|
||||
|
||||
_effect_chain = None
|
||||
|
||||
|
||||
def init_effects() -> None:
|
||||
"""Initialize effect plugins and chain."""
|
||||
global _effect_chain
|
||||
from engine.effects import EffectChain, get_registry
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
_effect_chain = chain
|
||||
|
||||
|
||||
def process_effects(
|
||||
buf: list[str],
|
||||
w: int,
|
||||
h: int,
|
||||
scroll_cam: int,
|
||||
ticker_h: int,
|
||||
mic_excess: float,
|
||||
grad_offset: float,
|
||||
frame_number: int,
|
||||
has_message: bool,
|
||||
items: list,
|
||||
) -> list[str]:
|
||||
"""Process buffer through effect chain."""
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_h,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items,
|
||||
)
|
||||
return _effect_chain.process(buf, ctx)
|
||||
|
||||
|
||||
def get_effect_chain() -> EffectChain | None:
|
||||
"""Get the effect chain instance."""
|
||||
global _effect_chain
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
return _effect_chain
|
||||
|
||||
@@ -11,6 +11,7 @@ from engine import config
|
||||
from engine.frame import calculate_scroll_step
|
||||
from engine.layers import (
|
||||
apply_glitch,
|
||||
process_effects,
|
||||
render_firehose,
|
||||
render_message_overlay,
|
||||
render_ticker_zone,
|
||||
@@ -18,6 +19,8 @@ from engine.layers import (
|
||||
from engine.terminal import CLR
|
||||
from engine.viewport import th, tw
|
||||
|
||||
USE_EFFECT_CHAIN = True
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor):
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
@@ -42,6 +45,7 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
msg_cache = (None, None)
|
||||
frame_number = 0
|
||||
|
||||
while True:
|
||||
if queued >= config.HEADLINE_LIMIT and not active:
|
||||
@@ -93,10 +97,24 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
buf.extend(ticker_buf)
|
||||
|
||||
mic_excess = mic_monitor.excess
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
|
||||
firehose_buf = render_firehose(items, w, fh, h)
|
||||
buf.extend(firehose_buf)
|
||||
if USE_EFFECT_CHAIN:
|
||||
buf = process_effects(
|
||||
buf,
|
||||
w,
|
||||
h,
|
||||
scroll_cam,
|
||||
ticker_h,
|
||||
mic_excess,
|
||||
grad_offset,
|
||||
frame_number,
|
||||
msg is not None,
|
||||
items,
|
||||
)
|
||||
else:
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
firehose_buf = render_firehose(items, w, fh, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
if msg_overlay:
|
||||
buf.extend(msg_overlay)
|
||||
@@ -106,6 +124,7 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
frame_number += 1
|
||||
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
|
||||
Reference in New Issue
Block a user