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:
55
execute.py
55
execute.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user