338 lines
11 KiB
Python
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)
|