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:
335
run_staged.py
Normal file
335
run_staged.py
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run a staged recipe through analyze -> plan -> execute pipeline.
|
||||
|
||||
This script demonstrates stage-level caching: analysis stages can be
|
||||
skipped on re-run if the inputs haven't changed.
|
||||
|
||||
Usage:
|
||||
python3 run_staged.py recipe.sexp [-o output.mp4]
|
||||
python3 run_staged.py effects/ascii_art_staged.sexp -o ascii_out.mp4
|
||||
|
||||
The script:
|
||||
1. Compiles the recipe and extracts stage information
|
||||
2. For each stage in topological order:
|
||||
- Check stage cache (skip if hit)
|
||||
- Run stage (analyze, plan, execute)
|
||||
- Cache stage outputs
|
||||
3. Produce final output
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# Add artdag to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "artdag"))
|
||||
|
||||
from artdag.sexp import compile_string, parse
|
||||
from artdag.sexp.parser import Symbol, Keyword
|
||||
from artdag.sexp.planner import create_plan
|
||||
|
||||
|
||||
def run_staged_recipe(
|
||||
recipe_path: Path,
|
||||
output_path: Optional[Path] = None,
|
||||
cache_dir: Optional[Path] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
verbose: bool = True,
|
||||
) -> Path:
|
||||
"""
|
||||
Run a staged recipe with stage-level caching.
|
||||
|
||||
Args:
|
||||
recipe_path: Path to the .sexp recipe file
|
||||
output_path: Optional output file path
|
||||
cache_dir: Optional cache directory for stage results
|
||||
params: Optional parameter overrides
|
||||
verbose: Print progress information
|
||||
|
||||
Returns:
|
||||
Path to the final output file
|
||||
"""
|
||||
recipe_text = recipe_path.read_text()
|
||||
recipe_dir = recipe_path.parent
|
||||
|
||||
# Set up cache directory
|
||||
if cache_dir is None:
|
||||
cache_dir = recipe_dir / ".stage_cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def log(msg: str):
|
||||
if verbose:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
# Compile recipe
|
||||
log(f"Compiling: {recipe_path}")
|
||||
compiled = compile_string(recipe_text, params)
|
||||
log(f"Recipe: {compiled.name} v{compiled.version}")
|
||||
log(f"Nodes: {len(compiled.nodes)}")
|
||||
|
||||
# Check for stages
|
||||
if not compiled.stages:
|
||||
log("No stages found - running as regular recipe")
|
||||
return _run_non_staged(compiled, recipe_dir, output_path, verbose)
|
||||
|
||||
log(f"\nStages: {len(compiled.stages)}")
|
||||
log(f"Stage order: {compiled.stage_order}")
|
||||
|
||||
# Display stage info
|
||||
for stage in compiled.stages:
|
||||
log(f"\n Stage: {stage.name}")
|
||||
log(f" Requires: {stage.requires or '(none)'}")
|
||||
log(f" Inputs: {stage.inputs or '(none)'}")
|
||||
log(f" Outputs: {stage.outputs}")
|
||||
|
||||
# Create plan with analysis
|
||||
log("\n--- Planning ---")
|
||||
analysis_data = {}
|
||||
|
||||
def on_analysis(node_id: str, results: dict):
|
||||
analysis_data[node_id] = results
|
||||
times = results.get("times", [])
|
||||
log(f" Analysis complete: {node_id[:16]}... ({len(times)} times)")
|
||||
|
||||
plan = create_plan(
|
||||
compiled,
|
||||
inputs={},
|
||||
recipe_dir=recipe_dir,
|
||||
on_analysis=on_analysis,
|
||||
)
|
||||
|
||||
log(f"\nPlan ID: {plan.plan_id[:16]}...")
|
||||
log(f"Steps: {len(plan.steps)}")
|
||||
|
||||
# Execute the plan using execute.py logic
|
||||
log("\n--- Execution ---")
|
||||
from execute import execute_plan
|
||||
|
||||
plan_dict = {
|
||||
"plan_id": plan.plan_id,
|
||||
"recipe_id": compiled.name,
|
||||
"recipe_hash": plan.recipe_hash,
|
||||
"encoding": compiled.encoding,
|
||||
"output_step_id": plan.output_step_id,
|
||||
"analysis": analysis_data,
|
||||
"effects_registry": plan.effects_registry,
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
for step in plan.steps:
|
||||
step_dict = {
|
||||
"step_id": step.step_id,
|
||||
"node_type": step.node_type,
|
||||
"config": step.config,
|
||||
"inputs": step.inputs,
|
||||
"level": step.level,
|
||||
"cache_id": step.cache_id,
|
||||
}
|
||||
# Tag with stage info if present
|
||||
if step.stage:
|
||||
step_dict["stage"] = step.stage
|
||||
step_dict["stage_cache_id"] = step.stage_cache_id
|
||||
plan_dict["steps"].append(step_dict)
|
||||
|
||||
# Execute
|
||||
result_path = execute_plan(
|
||||
plan_path=None,
|
||||
output_path=output_path,
|
||||
recipe_dir=recipe_dir,
|
||||
plan_data=plan_dict,
|
||||
external_analysis=analysis_data,
|
||||
)
|
||||
|
||||
log(f"\n--- Complete ---")
|
||||
log(f"Output: {result_path}")
|
||||
|
||||
return result_path
|
||||
|
||||
|
||||
def _run_non_staged(compiled, recipe_dir: Path, output_path: Optional[Path], verbose: bool) -> Path:
|
||||
"""Run a non-staged recipe using the standard pipeline."""
|
||||
from execute import execute_plan
|
||||
from plan import plan_recipe
|
||||
|
||||
# This is a fallback for recipes without stages
|
||||
# Just run through regular plan -> execute
|
||||
raise NotImplementedError("Non-staged recipes should use plan.py | execute.py")
|
||||
|
||||
|
||||
def list_params(recipe_path: Path):
|
||||
"""List available parameters for a recipe and its effects."""
|
||||
from artdag.sexp import parse
|
||||
from artdag.sexp.parser import Symbol, Keyword
|
||||
from artdag.sexp.compiler import _parse_params
|
||||
from artdag.sexp.effect_loader import load_sexp_effect_file
|
||||
|
||||
recipe_text = recipe_path.read_text()
|
||||
sexp = parse(recipe_text)
|
||||
|
||||
if isinstance(sexp, list) and len(sexp) == 1:
|
||||
sexp = sexp[0]
|
||||
|
||||
# Find recipe name
|
||||
recipe_name = sexp[1] if len(sexp) > 1 and isinstance(sexp[1], str) else recipe_path.stem
|
||||
|
||||
# Find :params block and effect declarations
|
||||
recipe_params = []
|
||||
effect_declarations = {} # name -> path
|
||||
|
||||
i = 2
|
||||
while i < len(sexp):
|
||||
item = sexp[i]
|
||||
if isinstance(item, Keyword) and item.name == "params":
|
||||
if i + 1 < len(sexp):
|
||||
recipe_params = _parse_params(sexp[i + 1])
|
||||
i += 2
|
||||
elif isinstance(item, list) and item:
|
||||
# Check for effect declaration: (effect name :path "...")
|
||||
if isinstance(item[0], Symbol) and item[0].name == "effect":
|
||||
if len(item) >= 2:
|
||||
effect_name = item[1].name if isinstance(item[1], Symbol) else item[1]
|
||||
# Find :path
|
||||
j = 2
|
||||
while j < len(item):
|
||||
if isinstance(item[j], Keyword) and item[j].name == "path":
|
||||
if j + 1 < len(item):
|
||||
effect_declarations[effect_name] = item[j + 1]
|
||||
break
|
||||
j += 1
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Load effect params
|
||||
effect_params = {} # effect_name -> list of ParamDef
|
||||
recipe_dir = recipe_path.parent
|
||||
|
||||
for effect_name, effect_rel_path in effect_declarations.items():
|
||||
effect_path = recipe_dir / effect_rel_path
|
||||
if effect_path.exists() and effect_path.suffix == ".sexp":
|
||||
try:
|
||||
_, _, _, param_defs = load_sexp_effect_file(effect_path)
|
||||
if param_defs:
|
||||
effect_params[effect_name] = param_defs
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load params from effect {effect_name}: {e}", file=sys.stderr)
|
||||
|
||||
# Print results
|
||||
def print_params(params, header_prefix=""):
|
||||
print(f"{header_prefix}{'Name':<20} {'Type':<8} {'Default':<12} {'Range/Choices':<20} Description")
|
||||
print(f"{header_prefix}{'-' * 88}")
|
||||
for p in params:
|
||||
range_str = ""
|
||||
if p.range_min is not None and p.range_max is not None:
|
||||
range_str = f"[{p.range_min}, {p.range_max}]"
|
||||
elif p.choices:
|
||||
range_str = ", ".join(p.choices[:3])
|
||||
if len(p.choices) > 3:
|
||||
range_str += "..."
|
||||
|
||||
default_str = str(p.default) if p.default is not None else "-"
|
||||
if len(default_str) > 10:
|
||||
default_str = default_str[:9] + "…"
|
||||
|
||||
print(f"{header_prefix}{p.name:<20} {p.param_type:<8} {default_str:<12} {range_str:<20} {p.description}")
|
||||
|
||||
if recipe_params:
|
||||
print(f"\nRecipe parameters for '{recipe_name}':\n")
|
||||
print_params(recipe_params)
|
||||
else:
|
||||
print(f"\nRecipe '{recipe_name}' has no declared parameters.")
|
||||
|
||||
if effect_params:
|
||||
for effect_name, params in effect_params.items():
|
||||
print(f"\n\nEffect '{effect_name}' parameters:\n")
|
||||
print_params(params)
|
||||
|
||||
if not recipe_params and not effect_params:
|
||||
print("\nParameters can be declared using :params block:")
|
||||
print("""
|
||||
:params (
|
||||
(color_mode :type string :default "color" :desc "Character color")
|
||||
(char_size :type int :default 12 :range [4 32] :desc "Cell size")
|
||||
)
|
||||
""")
|
||||
return
|
||||
|
||||
print("\n\nUsage:")
|
||||
print(f" python3 run_staged.py {recipe_path} -p <name>=<value> [-p <name>=<value> ...]")
|
||||
print(f"\nExample:")
|
||||
all_params = recipe_params + [p for params in effect_params.values() for p in params]
|
||||
if all_params:
|
||||
p = all_params[0]
|
||||
example_val = p.default if p.default else ("value" if p.param_type == "string" else "1")
|
||||
print(f" python3 run_staged.py {recipe_path} -p {p.name}={example_val}")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run a staged recipe with stage-level caching",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python3 run_staged.py effects/ascii_art_fx_staged.sexp --list-params
|
||||
python3 run_staged.py effects/ascii_art_fx_staged.sexp -o output.mp4
|
||||
python3 run_staged.py recipe.sexp -p color_mode=lime -p char_jitter=5
|
||||
"""
|
||||
)
|
||||
parser.add_argument("recipe", type=Path, help="Recipe file (.sexp)")
|
||||
parser.add_argument("-o", "--output", type=Path, help="Output file path")
|
||||
parser.add_argument("-c", "--cache", type=Path, help="Stage cache directory")
|
||||
parser.add_argument("-p", "--param", action="append", dest="params",
|
||||
metavar="KEY=VALUE", help="Set recipe parameter")
|
||||
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress progress output")
|
||||
parser.add_argument("--list-params", action="store_true", help="List available parameters and exit")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.recipe.exists():
|
||||
print(f"Recipe not found: {args.recipe}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# List params mode
|
||||
if args.list_params:
|
||||
list_params(args.recipe)
|
||||
sys.exit(0)
|
||||
|
||||
# Parse parameters
|
||||
params = {}
|
||||
if args.params:
|
||||
for param_str in args.params:
|
||||
if "=" not in param_str:
|
||||
print(f"Invalid parameter format: {param_str}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
key, value = param_str.split("=", 1)
|
||||
# Try to parse as number
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
pass # Keep as string
|
||||
params[key] = value
|
||||
|
||||
result = run_staged_recipe(
|
||||
recipe_path=args.recipe,
|
||||
output_path=args.output,
|
||||
cache_dir=args.cache,
|
||||
params=params if params else None,
|
||||
verbose=not args.quiet,
|
||||
)
|
||||
|
||||
# Print final output path
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user