feat: Implement Sideline plugin system with consistent terminology

This commit implements the Sideline/Mainline split with a clean plugin architecture:

## Core Changes

### Sideline Framework (New Directory)
- Created  directory containing the reusable pipeline framework
- Moved pipeline core, controllers, adapters, and registry to
- Moved display system to
- Moved effects system to
- Created plugin system with security and compatibility management in
- Created preset pack system with ASCII art encoding in
- Added default font (Corptic) to
- Added terminal ANSI constants to

### Mainline Application (Updated)
- Created  for Mainline stage component registration
- Updated  to register Mainline stages at startup
- Updated  as a compatibility shim re-exporting from sideline

### Terminology Consistency
- : Base class for all pipeline components (sources, effects, displays, cameras)
- : Base class for distributable plugin packages (was )
- : Base class for visual effects (was )
- Backward compatibility aliases maintained for existing code

## Key Features
- Plugin discovery via entry points and explicit registration
- Security permissions system for plugins
- Compatibility management with semantic version constraints
- Preset pack system for distributable configurations
- Default font bundled with Sideline (Corptic.otf)

## Testing
- Updated tests to register Mainline stages before discovery
- All StageRegistry tests passing

Note: This is a major refactoring that separates the framework (Sideline) from the application (Mainline), enabling Sideline to be used by other applications.
This commit is contained in:
2026-03-23 20:42:33 -07:00
parent 2d28e92594
commit e4b143ff36
58 changed files with 10163 additions and 50 deletions

View File

@@ -0,0 +1,194 @@
"""
Preset pack manager for loading, validating, and managing preset packs.
"""
import logging
import os
from typing import Dict, List, Optional
import tomli
from sideline.preset_packs.pack_format import PresetPack, PresetPackMetadata
from sideline.preset_packs.encoder import PresetPackEncoder
from sideline.plugins.compatibility import CompatibilityManager
logger = logging.getLogger(__name__)
class PresetPackManager:
"""Manages preset pack loading and validation."""
def __init__(self, pack_dir: Optional[str] = None):
"""Initialize preset pack manager.
Args:
pack_dir: Directory to search for preset packs
"""
self.pack_dir = pack_dir or os.path.expanduser("~/.config/sideline/packs")
self._packs: Dict[str, PresetPack] = {}
def load_pack(self, pack_path: str) -> Optional[PresetPack]:
"""Load a preset pack from a file.
Args:
pack_path: Path to the preset pack file (.tpack or .toml)
Returns:
Loaded PresetPack or None if failed
"""
try:
with open(pack_path, "rb") as f:
# Try loading as TOML first
if pack_path.endswith(".toml"):
data = tomli.load(f)
pack = PresetPack.from_dict(data)
elif pack_path.endswith(".tpack"):
# Load ASCII art encoded pack
content = f.read().decode("utf-8")
pack = self._load_ascii_pack(content)
else:
logger.error(f"Unknown file format: {pack_path}")
return None
# Validate compatibility
if not CompatibilityManager.validate_compatibility(
pack.metadata.sideline_version
):
error = CompatibilityManager.get_compatibility_error(
pack.metadata.sideline_version
)
logger.warning(f"Pack {pack.metadata.name} incompatible: {error}")
return None
# Store pack
self._packs[pack.metadata.name] = pack
logger.info(
f"Loaded preset pack: {pack.metadata.name} v{pack.metadata.version}"
)
return pack
except Exception as e:
logger.error(f"Failed to load preset pack {pack_path}: {e}")
return None
def _load_ascii_pack(self, content: str) -> PresetPack:
"""Load pack from ASCII art encoded content."""
# Extract TOML from ASCII art
toml_data = PresetPackEncoder.decode_toml(content)
# Parse TOML
import tomli
data = tomli.loads(toml_data)
return PresetPack.from_dict(data)
def load_directory(self, directory: Optional[str] = None) -> List[PresetPack]:
"""Load all preset packs from a directory.
Args:
directory: Directory to search (defaults to pack_dir)
Returns:
List of loaded PresetPack objects
"""
directory = directory or self.pack_dir
if not os.path.exists(directory):
logger.warning(f"Preset pack directory does not exist: {directory}")
return []
loaded = []
for filename in os.listdir(directory):
if filename.endswith((".toml", ".tpack")):
path = os.path.join(directory, filename)
pack = self.load_pack(path)
if pack:
loaded.append(pack)
return loaded
def save_pack(
self, pack: PresetPack, output_path: str, format: str = "toml"
) -> bool:
"""Save a preset pack to a file.
Args:
pack: PresetPack to save
output_path: Path to save the pack
format: Output format ("toml" or "tpack")
Returns:
True if successful, False otherwise
"""
try:
if format == "toml":
import tomli_w
with open(output_path, "w") as f:
tomli_w.dump(pack.to_dict(), f)
elif format == "tpack":
# Encode as ASCII art
toml_data = self._pack_to_toml(pack)
ascii_art = PresetPackEncoder.encode_toml(toml_data, pack.metadata.name)
with open(output_path, "w") as f:
f.write(ascii_art)
else:
logger.error(f"Unknown format: {format}")
return False
logger.info(f"Saved preset pack: {output_path}")
return True
except Exception as e:
logger.error(f"Failed to save preset pack: {e}")
return False
def _pack_to_toml(self, pack: PresetPack) -> str:
"""Convert PresetPack to TOML string."""
import tomli_w
return tomli_w.dumps(pack.to_dict())
def get_pack(self, name: str) -> Optional[PresetPack]:
"""Get a loaded preset pack by name."""
return self._packs.get(name)
def list_packs(self) -> List[str]:
"""List all loaded preset pack names."""
return list(self._packs.keys())
def register_pack_plugins(self, pack: PresetPack):
"""Register all plugins from a preset pack.
Args:
pack: PresetPack containing plugins
"""
from sideline.pipeline import StageRegistry
for plugin_entry in pack.plugins:
try:
# Decode plugin code
code = PresetPackEncoder.decode_plugin_code(plugin_entry.encoded_code)
# Execute plugin code to get the class
local_ns = {}
exec(code, local_ns)
# Find the plugin class (first class defined)
plugin_class = None
for obj in local_ns.values():
if isinstance(obj, type) and hasattr(obj, "metadata"):
plugin_class = obj
break
if plugin_class:
# Register the plugin
StageRegistry.register(plugin_entry.category, plugin_class)
logger.info(f"Registered plugin: {plugin_entry.name}")
else:
logger.warning(f"No plugin class found in {plugin_entry.name}")
except Exception as e:
logger.error(f"Failed to register plugin {plugin_entry.name}: {e}")