Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
337
artdag/sexp/effect_loader.py
Normal file
337
artdag/sexp/effect_loader.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user