Files
mono/artdag/sexp/effect_loader.py
giles cc2dcbddd4 Squashed 'core/' content from commit 4957443
git-subtree-dir: core
git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
2026-02-24 23:09:39 +00:00

338 lines
11 KiB
Python

"""
Sexp effect loader.
Loads sexp effect definitions (define-effect forms) and creates
frame processors that evaluate the sexp body with primitives.
Effects must use :params syntax:
(define-effect name
:params (
(param1 :type int :default 8 :range [4 32] :desc "description")
(param2 :type string :default "value" :desc "description")
)
body)
For effects with no parameters, use empty :params ():
(define-effect name
:params ()
body)
Unknown parameters passed to effects will raise an error.
Usage:
loader = SexpEffectLoader()
effect_fn = loader.load_effect_file(Path("effects/ascii_art.sexp"))
output = effect_fn(input_path, output_path, config)
"""
import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import numpy as np
from .parser import parse_all, Symbol, Keyword
from .evaluator import evaluate
from .primitives import PRIMITIVES
from .compiler import ParamDef, _parse_params, CompileError
logger = logging.getLogger(__name__)
def _parse_define_effect(sexp) -> tuple:
"""
Parse a define-effect form.
Required syntax:
(define-effect name
:params (
(param1 :type int :default 8 :range [4 32] :desc "description")
)
body)
Effects MUST use :params syntax. Legacy ((param default) ...) syntax is not supported.
Returns (name, params_with_defaults, param_defs, body)
where param_defs is a list of ParamDef objects
"""
if not isinstance(sexp, list) or len(sexp) < 3:
raise ValueError(f"Invalid define-effect form: {sexp}")
head = sexp[0]
if not (isinstance(head, Symbol) and head.name == "define-effect"):
raise ValueError(f"Expected define-effect, got {head}")
name = sexp[1]
if isinstance(name, Symbol):
name = name.name
params_with_defaults = {}
param_defs: List[ParamDef] = []
body = None
found_params = False
# Parse :params and body
i = 2
while i < len(sexp):
item = sexp[i]
if isinstance(item, Keyword) and item.name == "params":
# :params syntax
if i + 1 >= len(sexp):
raise ValueError(f"Effect '{name}': Missing params list after :params keyword")
try:
param_defs = _parse_params(sexp[i + 1])
# Build params_with_defaults from ParamDef objects
for pd in param_defs:
params_with_defaults[pd.name] = pd.default
except CompileError as e:
raise ValueError(f"Effect '{name}': Error parsing :params: {e}")
found_params = True
i += 2
elif isinstance(item, Keyword):
# Skip other keywords we don't recognize
i += 2
elif body is None:
# First non-keyword item is the body
if isinstance(item, list) and item:
first_elem = item[0]
# Check for legacy syntax and reject it
if isinstance(first_elem, list) and len(first_elem) >= 2:
raise ValueError(
f"Effect '{name}': Legacy parameter syntax ((name default) ...) is not supported. "
f"Use :params block instead:\n"
f" :params (\n"
f" (param_name :type int :default 0 :desc \"description\")\n"
f" )"
)
body = item
i += 1
else:
i += 1
if body is None:
raise ValueError(f"Effect '{name}': No body found")
if not found_params:
raise ValueError(
f"Effect '{name}': Missing :params block. Effects must declare parameters.\n"
f"For effects with no parameters, use empty :params ():\n"
f" (define-effect {name}\n"
f" :params ()\n"
f" body)"
)
return name, params_with_defaults, param_defs, body
def _create_process_frame(
effect_name: str,
params_with_defaults: Dict[str, Any],
param_defs: List[ParamDef],
body: Any,
) -> Callable:
"""
Create a process_frame function that evaluates the sexp body.
The function signature is: (frame, params, state) -> (frame, state)
"""
import math
def process_frame(frame: np.ndarray, params: Dict[str, Any], state: Any):
"""Evaluate sexp effect body on a frame."""
# Build environment with primitives
env = dict(PRIMITIVES)
# Add math functions
env["floor"] = lambda x: int(math.floor(x))
env["ceil"] = lambda x: int(math.ceil(x))
env["round"] = lambda x: int(round(x))
env["abs"] = abs
env["min"] = min
env["max"] = max
env["sqrt"] = math.sqrt
env["sin"] = math.sin
env["cos"] = math.cos
# Add list operations
env["list"] = lambda *args: tuple(args)
env["nth"] = lambda coll, i: coll[int(i)] if coll else None
# Bind frame
env["frame"] = frame
# Validate that all provided params are known
known_params = set(params_with_defaults.keys())
for k in params.keys():
if k not in known_params:
raise ValueError(
f"Effect '{effect_name}': Unknown parameter '{k}'. "
f"Valid parameters are: {', '.join(sorted(known_params)) if known_params else '(none)'}"
)
# Bind parameters (defaults + overrides from config)
for param_name, default in params_with_defaults.items():
# Use config value if provided, otherwise default
if param_name in params:
env[param_name] = params[param_name]
elif default is not None:
env[param_name] = default
# Evaluate the body
try:
result = evaluate(body, env)
if isinstance(result, np.ndarray):
return result, state
else:
logger.warning(f"Effect {effect_name} returned {type(result)}, expected ndarray")
return frame, state
except Exception as e:
logger.error(f"Error evaluating effect {effect_name}: {e}")
raise
return process_frame
def load_sexp_effect(source: str, base_path: Optional[Path] = None) -> tuple:
"""
Load a sexp effect from source code.
Args:
source: Sexp source code
base_path: Base path for resolving relative imports
Returns:
(effect_name, process_frame_fn, params_with_defaults, param_defs)
where param_defs is a list of ParamDef objects for introspection
"""
exprs = parse_all(source)
# Find define-effect form
define_effect = None
if isinstance(exprs, list):
for expr in exprs:
if isinstance(expr, list) and expr and isinstance(expr[0], Symbol):
if expr[0].name == "define-effect":
define_effect = expr
break
elif isinstance(exprs, list) and exprs and isinstance(exprs[0], Symbol):
if exprs[0].name == "define-effect":
define_effect = exprs
if not define_effect:
raise ValueError("No define-effect form found in sexp effect")
name, params_with_defaults, param_defs, body = _parse_define_effect(define_effect)
process_frame = _create_process_frame(name, params_with_defaults, param_defs, body)
return name, process_frame, params_with_defaults, param_defs
def load_sexp_effect_file(path: Path) -> tuple:
"""
Load a sexp effect from file.
Returns:
(effect_name, process_frame_fn, params_with_defaults, param_defs)
where param_defs is a list of ParamDef objects for introspection
"""
source = path.read_text()
return load_sexp_effect(source, base_path=path.parent)
class SexpEffectLoader:
"""
Loader for sexp effect definitions.
Creates effect functions compatible with the EffectExecutor.
"""
def __init__(self, recipe_dir: Optional[Path] = None):
"""
Initialize loader.
Args:
recipe_dir: Base directory for resolving relative effect paths
"""
self.recipe_dir = recipe_dir or Path.cwd()
# Cache loaded effects with their param_defs for introspection
self._loaded_effects: Dict[str, tuple] = {}
def load_effect_path(self, effect_path: str) -> Callable:
"""
Load a sexp effect from a relative path.
Args:
effect_path: Relative path to effect .sexp file
Returns:
Effect function (input_path, output_path, config) -> output_path
"""
from ..effects.frame_processor import process_video
full_path = self.recipe_dir / effect_path
if not full_path.exists():
raise FileNotFoundError(f"Sexp effect not found: {full_path}")
name, process_frame_fn, params_defaults, param_defs = load_sexp_effect_file(full_path)
logger.info(f"Loaded sexp effect: {name} from {effect_path}")
# Cache for introspection
self._loaded_effects[effect_path] = (name, params_defaults, param_defs)
def effect_fn(input_path: Path, output_path: Path, config: Dict[str, Any]) -> Path:
"""Run sexp effect via frame processor."""
# Extract params (excluding internal keys)
params = dict(params_defaults) # Start with defaults
for k, v in config.items():
if k not in ("effect", "cid", "hash", "effect_path", "_binding"):
params[k] = v
# Get bindings if present
bindings = {}
for key, value in config.items():
if isinstance(value, dict) and value.get("_resolved_values"):
bindings[key] = value["_resolved_values"]
output_path.parent.mkdir(parents=True, exist_ok=True)
actual_output = output_path.with_suffix(".mp4")
process_video(
input_path=input_path,
output_path=actual_output,
process_frame=process_frame_fn,
params=params,
bindings=bindings,
)
logger.info(f"Processed sexp effect '{name}' from {effect_path}")
return actual_output
return effect_fn
def get_effect_params(self, effect_path: str) -> List[ParamDef]:
"""
Get parameter definitions for an effect.
Args:
effect_path: Relative path to effect .sexp file
Returns:
List of ParamDef objects describing the effect's parameters
"""
if effect_path not in self._loaded_effects:
# Load the effect to get its params
full_path = self.recipe_dir / effect_path
if not full_path.exists():
raise FileNotFoundError(f"Sexp effect not found: {full_path}")
name, _, params_defaults, param_defs = load_sexp_effect_file(full_path)
self._loaded_effects[effect_path] = (name, params_defaults, param_defs)
return self._loaded_effects[effect_path][2]
def get_sexp_effect_loader(recipe_dir: Optional[Path] = None) -> SexpEffectLoader:
"""Get a sexp effect loader instance."""
return SexpEffectLoader(recipe_dir)