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:
gilesb
2026-01-19 21:58:05 +00:00
parent 406cc7c0c7
commit 6ceaa37ab6
62 changed files with 2687 additions and 185 deletions

View File

@@ -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