Add composable ASCII art with per-cell effects and explicit effect loading
Implements ascii_fx_zone effect that allows applying arbitrary sexp effects to each character cell via cell_effect lambdas. Each cell is rendered as a small image that effects can operate on. Key changes: - New ascii_fx_zone effect with cell_effect parameter for per-cell transforms - Zone context (row, col, lum, sat, hue, etc.) available in cell_effect lambdas - Effects are now loaded explicitly from recipe declarations, not auto-loaded - Added effects_registry to plan for explicit effect dependency tracking - Updated effect definition syntax across all sexp effects - New run_staged.py for executing staged recipes - Example recipes demonstrating alternating rotation and blur/rgb_split patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,10 @@ class Interpreter:
|
||||
state[key] = value
|
||||
return value
|
||||
|
||||
# ascii-fx-zone special form - delays evaluation of expression parameters
|
||||
if form == 'ascii-fx-zone':
|
||||
return self._eval_ascii_fx_zone(expr, env)
|
||||
|
||||
# Function call
|
||||
fn = self.eval(head, env)
|
||||
args = [self.eval(arg, env) for arg in expr[1:]]
|
||||
@@ -362,32 +366,272 @@ class Interpreter:
|
||||
return self.eval(clause[1], env)
|
||||
return None
|
||||
|
||||
def _eval_ascii_fx_zone(self, expr: Any, env: Environment) -> Any:
|
||||
"""
|
||||
Evaluate ascii-fx-zone special form.
|
||||
|
||||
Syntax:
|
||||
(ascii-fx-zone frame
|
||||
:cols 80
|
||||
:alphabet "standard"
|
||||
:color_mode "color"
|
||||
:background "black"
|
||||
:contrast 1.5
|
||||
:char_hue <expr> ;; NOT evaluated - passed to primitive
|
||||
:char_saturation <expr>
|
||||
:char_brightness <expr>
|
||||
:char_scale <expr>
|
||||
:char_rotation <expr>
|
||||
:char_jitter <expr>)
|
||||
|
||||
The expression parameters (:char_hue, etc.) are NOT pre-evaluated.
|
||||
They are passed as raw S-expressions to the primitive which
|
||||
evaluates them per-zone with zone context variables injected.
|
||||
"""
|
||||
from .primitives import prim_ascii_fx_zone
|
||||
|
||||
# Expression parameter names that should NOT be evaluated
|
||||
expr_params = {'char_hue', 'char_saturation', 'char_brightness',
|
||||
'char_scale', 'char_rotation', 'char_jitter', 'cell_effect'}
|
||||
|
||||
# Parse arguments
|
||||
frame = self.eval(expr[1], env) # First arg is always the frame
|
||||
|
||||
# Defaults
|
||||
cols = 80
|
||||
char_size = None # If set, overrides cols
|
||||
alphabet = "standard"
|
||||
color_mode = "color"
|
||||
background = "black"
|
||||
contrast = 1.5
|
||||
char_hue = None
|
||||
char_saturation = None
|
||||
char_brightness = None
|
||||
char_scale = None
|
||||
char_rotation = None
|
||||
char_jitter = None
|
||||
cell_effect = None # Lambda for arbitrary per-cell effects
|
||||
# Convenience params for staged recipes
|
||||
energy = None
|
||||
rotation_scale = 0
|
||||
# Extra params to pass to zone dict for lambdas
|
||||
extra_params = {}
|
||||
|
||||
# Parse keyword arguments
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword):
|
||||
if i + 1 >= len(expr):
|
||||
break
|
||||
value_expr = expr[i + 1]
|
||||
kw_name = item.name
|
||||
|
||||
if kw_name in expr_params:
|
||||
# Resolve symbol references but don't evaluate expressions
|
||||
# This handles the case where effect definition passes a param like :char_hue char_hue
|
||||
resolved = value_expr
|
||||
if isinstance(value_expr, Symbol):
|
||||
try:
|
||||
resolved = env.get(value_expr.name)
|
||||
except NameError:
|
||||
resolved = value_expr # Keep as symbol if not found
|
||||
|
||||
if kw_name == 'char_hue':
|
||||
char_hue = resolved
|
||||
elif kw_name == 'char_saturation':
|
||||
char_saturation = resolved
|
||||
elif kw_name == 'char_brightness':
|
||||
char_brightness = resolved
|
||||
elif kw_name == 'char_scale':
|
||||
char_scale = resolved
|
||||
elif kw_name == 'char_rotation':
|
||||
char_rotation = resolved
|
||||
elif kw_name == 'char_jitter':
|
||||
char_jitter = resolved
|
||||
elif kw_name == 'cell_effect':
|
||||
cell_effect = resolved
|
||||
else:
|
||||
# Evaluate normally
|
||||
value = self.eval(value_expr, env)
|
||||
if kw_name == 'cols':
|
||||
cols = int(value)
|
||||
elif kw_name == 'char_size':
|
||||
# Handle nil/None values
|
||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
||||
char_size = None
|
||||
else:
|
||||
char_size = int(value)
|
||||
elif kw_name == 'alphabet':
|
||||
alphabet = str(value)
|
||||
elif kw_name == 'color_mode':
|
||||
color_mode = str(value)
|
||||
elif kw_name == 'background':
|
||||
background = str(value)
|
||||
elif kw_name == 'contrast':
|
||||
contrast = float(value)
|
||||
elif kw_name == 'energy':
|
||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
||||
energy = None
|
||||
else:
|
||||
energy = float(value)
|
||||
extra_params['energy'] = energy
|
||||
elif kw_name == 'rotation_scale':
|
||||
rotation_scale = float(value)
|
||||
extra_params['rotation_scale'] = rotation_scale
|
||||
else:
|
||||
# Store any other params for lambdas to access
|
||||
extra_params[kw_name] = value
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# If energy and rotation_scale provided, build rotation expression
|
||||
# rotation = energy * rotation_scale * position_factor
|
||||
# position_factor: bottom-left=0, top-right=3
|
||||
# Formula: 1.5 * (zone-col-norm + (1 - zone-row-norm))
|
||||
if energy is not None and rotation_scale > 0:
|
||||
# Build expression as S-expression list that will be evaluated per-zone
|
||||
# (* (* energy rotation_scale) (* 1.5 (+ zone-col-norm (- 1 zone-row-norm))))
|
||||
energy_times_scale = energy * rotation_scale
|
||||
# The position part uses zone variables, so we build it as an expression
|
||||
char_rotation = [
|
||||
Symbol('*'),
|
||||
energy_times_scale,
|
||||
[Symbol('*'), 1.5,
|
||||
[Symbol('+'), Symbol('zone-col-norm'),
|
||||
[Symbol('-'), 1, Symbol('zone-row-norm')]]]
|
||||
]
|
||||
|
||||
# Pull any extra params from environment that aren't standard params
|
||||
# These are typically passed from recipes for use in cell_effect lambdas
|
||||
standard_params = {
|
||||
'cols', 'char_size', 'alphabet', 'color_mode', 'background', 'contrast',
|
||||
'char_hue', 'char_saturation', 'char_brightness', 'char_scale',
|
||||
'char_rotation', 'char_jitter', 'cell_effect', 'energy', 'rotation_scale',
|
||||
'frame', 't', '_time', '__state__', '__interp__', 'true', 'false', 'nil'
|
||||
}
|
||||
# Check environment for extra bindings
|
||||
current_env = env
|
||||
while current_env is not None:
|
||||
for k, v in current_env.bindings.items():
|
||||
if k not in standard_params and k not in extra_params and not callable(v):
|
||||
# Add non-standard, non-callable bindings to extra_params
|
||||
if isinstance(v, (int, float, str, bool)) or v is None:
|
||||
extra_params[k] = v
|
||||
current_env = current_env.parent
|
||||
|
||||
# Call the primitive with interpreter and env for expression evaluation
|
||||
return prim_ascii_fx_zone(
|
||||
frame, cols, char_size, alphabet, color_mode, background, contrast,
|
||||
char_hue, char_saturation, char_brightness,
|
||||
char_scale, char_rotation, char_jitter,
|
||||
self, env, extra_params, cell_effect
|
||||
)
|
||||
|
||||
def _define_effect(self, expr: Any, env: Environment) -> EffectDefinition:
|
||||
"""
|
||||
Parse effect definition:
|
||||
(define-effect name
|
||||
((param1 default1) (param2 default2) ...)
|
||||
body)
|
||||
Parse effect definition.
|
||||
|
||||
Required syntax:
|
||||
(define-effect name
|
||||
:params (
|
||||
(param1 :type int :default 8 :desc "description")
|
||||
)
|
||||
body)
|
||||
|
||||
Effects MUST use :params syntax. Legacy ((param default) ...) is not supported.
|
||||
"""
|
||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
||||
params_list = expr[2] if len(expr) > 2 else []
|
||||
body = expr[3] if len(expr) > 3 else expr[2]
|
||||
|
||||
# Parse parameters
|
||||
params = {}
|
||||
if isinstance(params_list, list):
|
||||
for p in params_list:
|
||||
if isinstance(p, list) and len(p) >= 2:
|
||||
pname = p[0].name if isinstance(p[0], Symbol) else p[0]
|
||||
pdefault = p[1]
|
||||
params[pname] = pdefault
|
||||
elif isinstance(p, Symbol):
|
||||
params[p.name] = None
|
||||
body = None
|
||||
found_params = False
|
||||
|
||||
# Parse :params and body
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword) and item.name == "params":
|
||||
# :params syntax
|
||||
if i + 1 >= len(expr):
|
||||
raise SyntaxError(f"Effect '{name}': Missing params list after :params keyword")
|
||||
params_list = expr[i + 1]
|
||||
params = self._parse_params_block(params_list)
|
||||
found_params = True
|
||||
i += 2
|
||||
elif isinstance(item, Keyword):
|
||||
# Skip other keywords (like :desc)
|
||||
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 SyntaxError(
|
||||
f"Effect '{name}': Legacy parameter syntax ((name default) ...) is not supported. "
|
||||
f"Use :params block instead."
|
||||
)
|
||||
body = item
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if body is None:
|
||||
raise SyntaxError(f"Effect '{name}': No body found")
|
||||
|
||||
if not found_params:
|
||||
raise SyntaxError(
|
||||
f"Effect '{name}': Missing :params block. "
|
||||
f"For effects with no parameters, use empty :params ()"
|
||||
)
|
||||
|
||||
effect = EffectDefinition(name, params, body)
|
||||
self.effects[name] = effect
|
||||
return effect
|
||||
|
||||
def _parse_params_block(self, params_list: list) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse :params block syntax:
|
||||
(
|
||||
(param_name :type int :default 8 :range [4 32] :desc "description")
|
||||
)
|
||||
"""
|
||||
params = {}
|
||||
for param_def in params_list:
|
||||
if not isinstance(param_def, list) or len(param_def) < 1:
|
||||
continue
|
||||
|
||||
# First element is the parameter name
|
||||
first = param_def[0]
|
||||
if isinstance(first, Symbol):
|
||||
param_name = first.name
|
||||
elif isinstance(first, str):
|
||||
param_name = first
|
||||
else:
|
||||
continue
|
||||
|
||||
# Parse keyword arguments
|
||||
default = None
|
||||
i = 1
|
||||
while i < len(param_def):
|
||||
item = param_def[i]
|
||||
if isinstance(item, Keyword):
|
||||
if i + 1 >= len(param_def):
|
||||
break
|
||||
kw_value = param_def[i + 1]
|
||||
|
||||
if item.name == "default":
|
||||
default = kw_value
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
params[param_name] = default
|
||||
|
||||
return params
|
||||
|
||||
def load_effect(self, path: str) -> EffectDefinition:
|
||||
"""Load an effect definition from a .sexp file."""
|
||||
expr = parse_file(path)
|
||||
@@ -444,6 +688,16 @@ class Interpreter:
|
||||
state = {}
|
||||
env.set('__state__', state)
|
||||
|
||||
# Validate that all provided params are known (except internal params)
|
||||
# Extra params are allowed and will be passed through to cell_effect lambdas
|
||||
known_params = set(effect.params.keys())
|
||||
internal_params = {'_time', 'seed', '_binding', 'effect', 'cid', 'hash', 'effect_path'}
|
||||
extra_effect_params = {} # Unknown params passed through for cell_effect lambdas
|
||||
for k in params.keys():
|
||||
if k not in known_params and k not in internal_params:
|
||||
# Allow unknown params - they'll be passed to cell_effect lambdas via zone dict
|
||||
extra_effect_params[k] = params[k]
|
||||
|
||||
# Bind parameters (defaults + overrides)
|
||||
for pname, pdefault in effect.params.items():
|
||||
value = params.get(pname)
|
||||
@@ -455,6 +709,10 @@ class Interpreter:
|
||||
value = pdefault
|
||||
env.set(pname, value)
|
||||
|
||||
# Bind extra params (unknown params passed through for cell_effect lambdas)
|
||||
for k, v in extra_effect_params.items():
|
||||
env.set(k, v)
|
||||
|
||||
# Reset RNG with seed if provided
|
||||
seed = params.get('seed', 42)
|
||||
reset_rng(int(seed))
|
||||
@@ -473,6 +731,41 @@ class Interpreter:
|
||||
|
||||
return result, state
|
||||
|
||||
def eval_with_zone(self, expr, env: Environment, zone) -> Any:
|
||||
"""
|
||||
Evaluate expression with zone-* variables injected.
|
||||
|
||||
Args:
|
||||
expr: Expression to evaluate (S-expression)
|
||||
env: Parent environment with bound values
|
||||
zone: ZoneContext object with cell data
|
||||
|
||||
Zone variables injected:
|
||||
zone-row, zone-col: Grid position (integers)
|
||||
zone-row-norm, zone-col-norm: Normalized position (0-1)
|
||||
zone-lum: Cell luminance (0-1)
|
||||
zone-sat: Cell saturation (0-1)
|
||||
zone-hue: Cell hue (0-360)
|
||||
zone-r, zone-g, zone-b: RGB components (0-1)
|
||||
|
||||
Returns:
|
||||
Evaluated result (typically a number)
|
||||
"""
|
||||
# Create child environment with zone variables
|
||||
zone_env = Environment(env)
|
||||
zone_env.set('zone-row', zone.row)
|
||||
zone_env.set('zone-col', zone.col)
|
||||
zone_env.set('zone-row-norm', zone.row_norm)
|
||||
zone_env.set('zone-col-norm', zone.col_norm)
|
||||
zone_env.set('zone-lum', zone.luminance)
|
||||
zone_env.set('zone-sat', zone.saturation)
|
||||
zone_env.set('zone-hue', zone.hue)
|
||||
zone_env.set('zone-r', zone.r)
|
||||
zone_env.set('zone-g', zone.g)
|
||||
zone_env.set('zone-b', zone.b)
|
||||
|
||||
return self.eval(expr, zone_env)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience Functions
|
||||
|
||||
Reference in New Issue
Block a user