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:
17
sideline/preset_packs/__init__.py
Normal file
17
sideline/preset_packs/__init__.py
Normal 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",
|
||||
]
|
||||
211
sideline/preset_packs/encoder.py
Normal file
211
sideline/preset_packs/encoder.py
Normal 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)
|
||||
194
sideline/preset_packs/manager.py
Normal file
194
sideline/preset_packs/manager.py
Normal 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}")
|
||||
127
sideline/preset_packs/pack_format.py
Normal file
127
sideline/preset_packs/pack_format.py
Normal 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", [])],
|
||||
)
|
||||
Reference in New Issue
Block a user