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,17 @@
"""
Preset pack system for Sideline.
Allows bundling plugins, presets, and configurations into distributable packs
with ASCII art encoding for fun and version control friendly storage.
"""
from sideline.preset_packs.pack_format import PresetPack, PresetPackMetadata
from sideline.preset_packs.manager import PresetPackManager
from sideline.preset_packs.encoder import PresetPackEncoder
__all__ = [
"PresetPack",
"PresetPackMetadata",
"PresetPackManager",
"PresetPackEncoder",
]

View File

@@ -0,0 +1,211 @@
"""
Preset pack encoder with ASCII art compression.
Compresses plugin code and encodes it as ASCII art for fun and version control.
"""
import base64
import zlib
import textwrap
from typing import Tuple
class PresetPackEncoder:
"""Encodes and decodes preset packs with ASCII art compression."""
# ASCII art frame characters
FRAME_TOP_LEFT = ""
FRAME_TOP_RIGHT = ""
FRAME_BOTTOM_LEFT = ""
FRAME_BOTTOM_RIGHT = ""
FRAME_HORIZONTAL = ""
FRAME_VERTICAL = ""
# Data block characters (for visual representation)
DATA_CHARS = " ░▒▓█"
@classmethod
def encode_plugin_code(cls, code: str, name: str = "plugin") -> str:
"""Encode plugin code as ASCII art.
Args:
code: Python source code to encode
name: Plugin name for metadata
Returns:
ASCII art encoded plugin code
"""
# Compress the code
compressed = zlib.compress(code.encode("utf-8"))
# Encode as base64
b64 = base64.b64encode(compressed).decode("ascii")
# Wrap in ASCII art frame
return cls._wrap_in_ascii_art(b64, name)
@classmethod
def decode_plugin_code(cls, ascii_art: str) -> str:
"""Decode ASCII art to plugin code.
Args:
ascii_art: ASCII art encoded plugin code
Returns:
Decoded Python source code
"""
# Extract base64 from ASCII art
b64 = cls._extract_from_ascii_art(ascii_art)
# Decode base64
compressed = base64.b64decode(b64)
# Decompress
code = zlib.decompress(compressed).decode("utf-8")
return code
@classmethod
def _wrap_in_ascii_art(cls, data: str, name: str) -> str:
"""Wrap data in ASCII art frame."""
# Calculate frame width
max_line_length = 60
lines = textwrap.wrap(data, max_line_length)
# Find longest line for frame width
longest_line = max(len(line) for line in lines) if lines else 0
frame_width = longest_line + 4 # 2 padding + 2 borders
# Build ASCII art
result = []
# Top border
result.append(
cls.FRAME_TOP_LEFT
+ cls.FRAME_HORIZONTAL * (frame_width - 2)
+ cls.FRAME_TOP_RIGHT
)
# Plugin name header
name_line = f" {name} "
name_padding = frame_width - 2 - len(name_line)
left_pad = name_padding // 2
right_pad = name_padding - left_pad
result.append(
cls.FRAME_VERTICAL
+ " " * left_pad
+ name_line
+ " " * right_pad
+ cls.FRAME_VERTICAL
)
# Separator line
result.append(
cls.FRAME_VERTICAL
+ cls.FRAME_HORIZONTAL * (frame_width - 2)
+ cls.FRAME_VERTICAL
)
# Data lines
for line in lines:
padding = frame_width - 2 - len(line)
result.append(
cls.FRAME_VERTICAL + line + " " * padding + cls.FRAME_VERTICAL
)
# Bottom border
result.append(
cls.FRAME_BOTTOM_LEFT
+ cls.FRAME_HORIZONTAL * (frame_width - 2)
+ cls.FRAME_BOTTOM_RIGHT
)
return "\n".join(result)
@classmethod
def _extract_from_ascii_art(cls, ascii_art: str) -> str:
"""Extract base64 data from ASCII art frame."""
lines = ascii_art.strip().split("\n")
# Skip top and bottom borders, header, and separator
data_lines = lines[3:-1]
# Extract data from between frame characters
extracted = []
for line in data_lines:
if len(line) > 2:
# Remove frame characters and extract content
content = line[1:-1].rstrip()
extracted.append(content)
return "".join(extracted)
@classmethod
def encode_toml(cls, toml_data: str, name: str = "pack") -> str:
"""Encode TOML data as ASCII art.
Args:
toml_data: TOML configuration data
name: Pack name
Returns:
ASCII art encoded TOML
"""
# Compress
compressed = zlib.compress(toml_data.encode("utf-8"))
# Encode as base64
b64 = base64.b64encode(compressed).decode("ascii")
# Create visual representation using data characters
visual_data = cls._data_to_visual(b64)
return cls._wrap_in_ascii_art(visual_data, name)
@classmethod
def decode_toml(cls, ascii_art: str) -> str:
"""Decode ASCII art to TOML data.
Args:
ascii_art: ASCII art encoded TOML
Returns:
Decoded TOML data
"""
# Extract base64 from ASCII art
b64 = cls._extract_from_ascii_art(ascii_art)
# Decode base64
compressed = base64.b64decode(b64)
# Decompress
toml_data = zlib.decompress(compressed).decode("utf-8")
return toml_data
@classmethod
def _data_to_visual(cls, data: str) -> str:
"""Convert base64 data to visual representation.
This creates a fun visual pattern based on the data.
"""
# Simple mapping: each character to a data block character
# This is purely for visual appeal
visual = ""
for i, char in enumerate(data):
# Use character code to select visual block
idx = ord(char) % len(cls.DATA_CHARS)
visual += cls.DATA_CHARS[idx]
# Add line breaks for visual appeal
if (i + 1) % 60 == 0:
visual += "\n"
return visual
@classmethod
def get_visual_representation(cls, data: str) -> str:
"""Get a visual representation of data for display."""
compressed = zlib.compress(data.encode("utf-8"))
b64 = base64.b64encode(compressed).decode("ascii")
return cls._data_to_visual(b64)

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}")

View File

@@ -0,0 +1,127 @@
"""
Preset pack format definition.
Defines the structure of preset packs and their TOML-based configuration.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass
class PresetPackMetadata:
"""Metadata for a preset pack."""
name: str
version: str
author: str
description: str
sideline_version: str # Compatible Sideline version
created: Optional[str] = None # ISO 8601 timestamp
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict:
"""Convert to dictionary for TOML serialization."""
return {
"name": self.name,
"version": self.version,
"author": self.author,
"description": self.description,
"sideline_version": self.sideline_version,
"created": self.created,
"tags": self.tags,
}
@classmethod
def from_dict(cls, data: Dict) -> "PresetPackMetadata":
"""Create from dictionary."""
return cls(
name=data["name"],
version=data["version"],
author=data["author"],
description=data["description"],
sideline_version=data["sideline_version"],
created=data.get("created"),
tags=data.get("tags", []),
)
@dataclass
class PluginEntry:
"""Entry for a plugin in the preset pack."""
name: str
category: str # source, effect, display, camera
encoded_code: str # ASCII art encoded plugin code
permissions: List[str] = field(default_factory=list)
capabilities: List[str] = field(default_factory=list)
def to_dict(self) -> Dict:
"""Convert to dictionary for TOML serialization."""
return {
"name": self.name,
"category": self.category,
"code": self.encoded_code,
"permissions": self.permissions,
"capabilities": self.capabilities,
}
@classmethod
def from_dict(cls, data: Dict) -> "PluginEntry":
"""Create from dictionary."""
return cls(
name=data["name"],
category=data["category"],
encoded_code=data["code"],
permissions=data.get("permissions", []),
capabilities=data.get("capabilities", []),
)
@dataclass
class PresetEntry:
"""Entry for a preset in the preset pack."""
name: str
config: Dict # Preset configuration (TOML-compatible)
def to_dict(self) -> Dict:
"""Convert to dictionary for TOML serialization."""
return {
"name": self.name,
"config": self.config,
}
@classmethod
def from_dict(cls, data: Dict) -> "PresetEntry":
"""Create from dictionary."""
return cls(
name=data["name"],
config=data["config"],
)
@dataclass
class PresetPack:
"""Complete preset pack with metadata, plugins, and presets."""
metadata: PresetPackMetadata
plugins: List[PluginEntry] = field(default_factory=list)
presets: List[PresetEntry] = field(default_factory=list)
def to_dict(self) -> Dict:
"""Convert to dictionary for TOML serialization."""
return {
"pack": self.metadata.to_dict(),
"plugins": [p.to_dict() for p in self.plugins],
"presets": [p.to_dict() for p in self.presets],
}
@classmethod
def from_dict(cls, data: Dict) -> "PresetPack":
"""Create from dictionary."""
return cls(
metadata=PresetPackMetadata.from_dict(data["pack"]),
plugins=[PluginEntry.from_dict(p) for p in data.get("plugins", [])],
presets=[PresetEntry.from_dict(p) for p in data.get("presets", [])],
)