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

@@ -131,6 +131,9 @@ def sexp_to_plan(sexp) -> dict:
elif item[0].name == "analysis":
# Parse analysis data
plan["analysis"] = parse_analysis_sexp(item)
elif item[0].name == "effects-registry":
# Parse effects registry
plan["effects_registry"] = parse_effects_registry_sexp(item)
i += 1
else:
i += 1
@@ -159,6 +162,27 @@ def parse_analysis_sexp(sexp) -> dict:
return analysis
def parse_effects_registry_sexp(sexp) -> dict:
"""Parse effects-registry S-expression: (effects-registry (rotate :path "...") (blur :path "..."))"""
registry = {}
for item in sexp[1:]: # Skip 'effects-registry' symbol
if isinstance(item, list) and item and isinstance(item[0], Symbol):
name = item[0].name
data = {}
j = 1
while j < len(item):
if isinstance(item[j], Keyword):
key = item[j].name
j += 1
if j < len(item):
data[key] = item[j]
j += 1
else:
j += 1
registry[name] = data
return registry
def parse_bind_sexp(sexp) -> dict:
"""Parse a bind S-expression: (bind analysis-ref :range [min max] :offset 60 :transform sqrt)"""
if not isinstance(sexp, list) or len(sexp) < 2:
@@ -324,9 +348,22 @@ def get_encoding(recipe_encoding: dict, step_config: dict) -> dict:
class SexpEffectModule:
"""Wrapper for S-expression effects to provide process_frame interface."""
def __init__(self, effect_path: Path):
def __init__(self, effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None):
from sexp_effects import get_interpreter
self.interp = get_interpreter()
# Load only explicitly declared effects from the recipe's registry
# No auto-loading from directory - everything must be explicit
if effects_registry:
base_dir = recipe_dir or effect_path.parent.parent # Resolve relative paths
for effect_name, effect_info in effects_registry.items():
effect_rel_path = effect_info.get("path")
if effect_rel_path:
full_path = (base_dir / effect_rel_path).resolve()
if full_path.exists() and effect_name not in self.interp.effects:
self.interp.load_effect(str(full_path))
# Load the specific effect if not already loaded
self.interp.load_effect(str(effect_path))
self.effect_name = effect_path.stem
@@ -334,10 +371,10 @@ class SexpEffectModule:
return self.interp.run_effect(self.effect_name, frame, params, state or {})
def load_effect(effect_path: Path):
def load_effect(effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None):
"""Load an effect module from a local path (.py or .sexp)."""
if effect_path.suffix == ".sexp":
return SexpEffectModule(effect_path)
return SexpEffectModule(effect_path, effects_registry, recipe_dir)
spec = importlib.util.spec_from_file_location("effect", effect_path)
module = importlib.util.module_from_spec(spec)
@@ -939,6 +976,11 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
if analysis_data:
print(f"Analysis tracks: {list(analysis_data.keys())}", file=sys.stderr)
# Get effects registry for loading explicitly declared effects
effects_registry = plan.get("effects_registry", {})
if effects_registry:
print(f"Effects registry: {list(effects_registry.keys())}", file=sys.stderr)
# Execute steps
results = {} # step_id -> output_path
work_dir = Path(tempfile.mkdtemp(prefix="artdag_exec_"))
@@ -1082,7 +1124,7 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
if effect_path:
full_path = recipe_dir / effect_path
effect_module = load_effect(full_path)
effect_module = load_effect(full_path, effects_registry, recipe_dir)
params = {k: v for k, v in config.items()
if k not in ("effect", "effect_path", "cid", "encoding", "multi_input")}
print(f" Effect: {effect_name}", file=sys.stderr)
@@ -1209,6 +1251,9 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
if not filter_chain_raw:
raise ValueError("COMPOUND step has empty filter_chain")
# Get effects registry for loading explicitly declared effects
effects_registry = config.get("effects_registry", {})
# Convert filter_chain items from S-expression lists to dicts
# and clean nil Symbols from configs
filter_chain = []
@@ -1375,7 +1420,7 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
if effect_path:
full_path = recipe_dir / effect_path
effect_module = load_effect(full_path)
effect_module = load_effect(full_path, effects_registry, recipe_dir)
params = {k: v for k, v in effect_config.items()
if k not in ("effect", "effect_path", "cid", "encoding", "type")}
print(f" COMPOUND [{i+1}/{len(effects)}]: {effect_name} (Python)", file=sys.stderr)