diff --git a/constructs/beat-alternate.sexp b/constructs/beat-alternate.sexp index ecac687..255cdb5 100644 --- a/constructs/beat-alternate.sexp +++ b/constructs/beat-alternate.sexp @@ -6,8 +6,10 @@ ;; (def segments (beat-alternate beats-data (list video-a video-b))) (define-construct beat-alternate - "Alternate between sources on each beat" - (analysis sources) + :params ( + (analysis :type any :desc "Analysis data with :times") + (sources :type any :desc "List of source nodes to alternate between") + ) ;; Body: map over time pairs, return segment descriptors (let [times (get analysis :times) pairs (zip-pairs (cons 0 times)) diff --git a/constructs/cycle-effects-preset.sexp b/constructs/cycle-effects-preset.sexp index 7ef680b..b9bcacb 100644 --- a/constructs/cycle-effects-preset.sexp +++ b/constructs/cycle-effects-preset.sexp @@ -8,8 +8,13 @@ ;; Binding specs {:bind "analyzer" :range [min max]} are resolved to actual bindings (define-construct cycle-effects-preset - "Cycle through effects from a data preset, with automatic binding resolution" - () + :params ( + (preset :type any :desc "List of effect preset definitions") + (videos :type any :desc "List of video source nodes") + (video_infos :type any :desc "List of video info analysis results") + (beats :type any :desc "Beat analysis data with :times") + (beats_per_segment :type int :default 4 :desc "Number of beats per segment") + ) (let [num-effects (len preset) num-videos (len videos) ;; Extract durations from video-info analysis results diff --git a/constructs/slice-every-n.sexp b/constructs/slice-every-n.sexp index 90cfd95..2e4fff3 100644 --- a/constructs/slice-every-n.sexp +++ b/constructs/slice-every-n.sexp @@ -12,9 +12,12 @@ ;; Groups every N analysis times into one segment, calling reducer once per group (define-construct slice-every-n - "Group every N analysis beats into segments" - (analysis n) - ;; 'init' and 'reducer' come from keyword args + :params ( + (analysis :type any :desc "Analysis data with :times") + (n :type int :default 4 :desc "Number of beats per segment") + (init :type any :default 0 :desc "Initial accumulator value") + (reducer :type any :desc "Reducer function (fn [acc i start end] ...)") + ) ;; Reducer receives: (acc, i, start, end) where start/end are audio beat times ;; Reducer returns: {:source src :effects fx :acc new-acc} ;; Optionally include :start/:end to override (e.g., for wrapping/randomizing) diff --git a/constructs/slice-on.sexp b/constructs/slice-on.sexp index 29a40e9..a4c29aa 100644 --- a/constructs/slice-on.sexp +++ b/constructs/slice-on.sexp @@ -15,9 +15,11 @@ ;; - :reducer as 'reducer' (the reducer lambda) (define-construct slice-on - "Iterate over analysis times, calling reducer for each slice" - (analysis) - ;; 'init' and 'reducer' come from keyword args + :params ( + (analysis :type any :desc "Analysis data with :times") + (init :type any :default 0 :desc "Initial accumulator value") + (reducer :type any :desc "Reducer function (fn [acc i start end] ...)") + ) ;; Get times from analysis data (let [times (get analysis :times) pairs (zip-pairs (cons 0 times))] diff --git a/effects/ascii_alternating_fx.sexp b/effects/ascii_alternating_fx.sexp new file mode 100644 index 0000000..d1a4278 --- /dev/null +++ b/effects/ascii_alternating_fx.sexp @@ -0,0 +1,69 @@ +;; ASCII with Alternating Effects - Checkerboard of blur and RGB split +;; +;; Demonstrates using existing sexp effects within cell_effect lambdas. +;; Even cells get blur, odd cells get RGB split - creating a checkerboard pattern. + +(recipe "ascii_alternating_fx" + :version "1.0" + :description "ASCII art with alternating blur and RGB split effects per cell" + :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) + + :params ( + (cols :type int :default 40 :range [20 100] + :desc "Number of character columns") + (blur_amount :type float :default 3 :range [1 10] + :desc "Blur radius for blur cells") + (rgb_offset :type int :default 3 :range [1 10] + :desc "RGB split offset for split cells") + ) + + ;; Registry + (effect ascii_fx_zone :path "../sexp_effects/effects/ascii_fx_zone.sexp") + (analyzer energy :path "../../artdag-analyzers/energy/analyzer.py") + + ;; Source files + (def video (source :path "../monday.webm")) + (def audio (source :path "../dizzy.mp3")) + + ;; Stage 1: Analysis + (stage :analyze + :outputs [energy-data] + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def energy-data (-> audio-clip (analyze energy)))) + + ;; Stage 2: Process - apply effect with alternating cell effects + (stage :process + :requires [:analyze] + :inputs [energy-data] + :outputs [result audio-clip] + (def clip (-> video (segment :start 0 :duration 10))) + (def audio-clip (-> audio (segment :start 60 :duration 10))) + + ;; Apply effect with cell_effect lambda + ;; Checkerboard: (row + col) even = blur, odd = rgb_split + (def result (-> clip + (effect ascii_fx_zone + :cols cols + :char_size (bind energy-data values :range [12 24]) + :color_mode "color" + :background "black" + ;; Pass params to zone dict + :energy (bind energy-data values :range [0 1]) + :blur_amount blur_amount + :rgb_offset rgb_offset + ;; Cell effect: alternate between blur and rgb_split + ;; Uses existing sexp effects - each cell is just a small frame + :cell_effect (lambda [cell zone] + (if (= (mod (+ (get zone "row") (get zone "col")) 2) 0) + ;; Even cells: blur scaled by energy + (blur cell (* (get zone "blur_amount") (get zone "energy"))) + ;; Odd cells: rgb split scaled by energy + (rgb_split cell + (* (get zone "rgb_offset") (get zone "energy")) + 0))))))) + + ;; Stage 3: Output + (stage :output + :requires [:process] + :inputs [result audio-clip] + (mux result audio-clip))) diff --git a/effects/ascii_alternating_rotate.sexp b/effects/ascii_alternating_rotate.sexp new file mode 100644 index 0000000..619ae00 --- /dev/null +++ b/effects/ascii_alternating_rotate.sexp @@ -0,0 +1,64 @@ +;; ASCII with Alternating Rotation Directions +;; +;; Checkerboard pattern: even cells rotate clockwise, odd cells rotate counter-clockwise +;; Rotation amount scaled by energy and position (more at top-right) + +(recipe "ascii_alternating_rotate" + :version "1.0" + :description "ASCII art with alternating rotation directions per cell" + :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) + + :params ( + (cols :type int :default 50 :range [20 100] + :desc "Number of character columns") + (rotation_scale :type float :default 60 :range [0 180] + :desc "Max rotation in degrees") + ) + + ;; Registry + (effect ascii_fx_zone :path "../sexp_effects/effects/ascii_fx_zone.sexp") + ;; Effects used in cell_effect lambda + (effect rotate :path "../sexp_effects/effects/rotate.sexp") + (analyzer energy :path "../../artdag-analyzers/energy/analyzer.py") + + ;; Source files + (def video (source :path "../monday.webm")) + (def audio (source :path "../dizzy.mp3")) + + ;; Stage 1: Analysis + (stage :analyze + :outputs [energy-data] + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def energy-data (-> audio-clip (analyze energy)))) + + ;; Stage 2: Process + (stage :process + :requires [:analyze] + :inputs [energy-data] + :outputs [result audio-clip] + (def clip (-> video (segment :start 0 :duration 10))) + (def audio-clip (-> audio (segment :start 60 :duration 10))) + + (def result (-> clip + (effect ascii_fx_zone + :cols cols + :char_size (bind energy-data values :range [10 20]) + :color_mode "color" + :background "black" + :energy (bind energy-data values :range [0 1]) + :rotation_scale rotation_scale + ;; Alternating rotation: even cells clockwise, odd cells counter-clockwise + ;; Scaled by energy * position (more at top-right) + :cell_effect (lambda [cell zone] + (rotate cell + (* (if (= (mod (+ (get zone "row") (get zone "col")) 2) 0) 1 -1) + (* (get zone "energy") + (get zone "rotation_scale") + (* 1.5 (+ (get zone "col-norm") + (- 1 (get zone "row-norm")))))))))))) + + ;; Stage 3: Output + (stage :output + :requires [:process] + :inputs [result audio-clip] + (mux result audio-clip))) diff --git a/effects/ascii_art_fx_staged.sexp b/effects/ascii_art_fx_staged.sexp new file mode 100644 index 0000000..75e507d --- /dev/null +++ b/effects/ascii_art_fx_staged.sexp @@ -0,0 +1,89 @@ +;; ASCII art FX effect with staged execution and per-character effects +;; +;; Run with --list-params to see all available parameters: +;; python3 run_staged.py effects/ascii_art_fx_staged.sexp --list-params + +(recipe "ascii_art_fx_staged" + :version "1.0" + :description "ASCII art FX with per-character effects" + :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) + + :params ( + ;; Colors + (color_mode :type string :default "color" + :desc "Character color: color, mono, invert, or any color name/hex") + (background_color :type string :default "black" + :desc "Background color name or hex value") + (invert_colors :type int :default 0 :range [0 1] + :desc "Swap foreground and background colors") + + ;; Character sizing + (char_size :type int :default 12 :range [4 32] + :desc "Base character cell size in pixels") + + ;; Per-character effects + (char_jitter :type float :default 0 :range [0 20] + :desc "Position jitter amount in pixels") + (char_scale :type float :default 1.0 :range [0.5 2.0] + :desc "Character scale factor") + (char_rotation :type float :default 0 :range [0 180] + :desc "Rotation amount in degrees") + (char_hue_shift :type float :default 0 :range [0 360] + :desc "Hue shift in degrees") + + ;; Modulation sources + (jitter_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y random center_dist] + :desc "What drives jitter modulation") + (scale_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y random center_dist] + :desc "What drives scale modulation") + (rotation_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y random center_dist] + :desc "What drives rotation modulation") + (hue_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y random center_dist] + :desc "What drives hue shift modulation") + ) + + ;; Registry + (effect ascii_art_fx :path "../sexp_effects/effects/ascii_art_fx.sexp") + (analyzer energy :path "../../artdag-analyzers/energy/analyzer.py") + + ;; Source files (not parameterized for now) + (def video (source :path "../monday.webm")) + (def audio (source :path "../dizzy.mp3")) + + ;; Stage 1: Analysis + (stage :analyze + :outputs [energy-data] + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def energy-data (-> audio-clip (analyze energy)))) + + ;; Stage 2: Process - apply effect + (stage :process + :requires [:analyze] + :inputs [energy-data] + :outputs [result audio-clip] + (def clip (-> video (segment :start 0 :duration 10))) + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def result (-> clip + (effect ascii_art_fx + :char_size (bind energy-data values :range [8 24]) + :color_mode color_mode + :background_color background_color + :invert_colors invert_colors + :char_jitter char_jitter + :char_scale char_scale + :char_rotation char_rotation + :char_hue_shift char_hue_shift + :jitter_source jitter_source + :scale_source scale_source + :rotation_source rotation_source + :hue_source hue_source)))) + + ;; Stage 3: Output + (stage :output + :requires [:process] + :inputs [result audio-clip] + (mux result audio-clip))) diff --git a/effects/ascii_art_staged.sexp b/effects/ascii_art_staged.sexp new file mode 100644 index 0000000..0a0dbb1 --- /dev/null +++ b/effects/ascii_art_staged.sexp @@ -0,0 +1,59 @@ +;; ASCII art effect with staged execution +;; +;; Stages: +;; :analyze - Run energy analysis on audio (cacheable) +;; :process - Segment media and apply effect +;; :output - Mux video with audio +;; +;; Usage: python3 run_staged.py effects/ascii_art_staged.sexp +;; +;; Parameters: +;; color_mode: coloring mode ("color", "green", "white", default: "color") +;; char_size is bound to energy (wobbles with overall loudness) + +(recipe "ascii_art_staged" + :version "1.0" + :description "ASCII art effect with staged execution" + :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) + + ;; Registry: effects and analyzers + (effect ascii_art :path "../sexp_effects/effects/ascii_art.sexp") + (analyzer energy :path "../../artdag-analyzers/energy/analyzer.py") + + ;; Pre-stage definitions (available to all stages) + (def color_mode "color") + (def background_color "black") + (def invert_colors 0) ;; 0=false, 1=true + (def video (source :path "../monday.webm")) + (def audio (source :path "../dizzy.mp3")) + + ;; Stage 1: Analysis - extract energy from audio + ;; This stage is expensive but cacheable - rerun with same input skips this + (stage :analyze + :outputs [energy-data] + ;; Audio from 60s where it's louder + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def energy-data (-> audio-clip (analyze energy)))) + + ;; Stage 2: Process - apply ASCII art effect with energy binding + (stage :process + :requires [:analyze] + :inputs [energy-data] + :outputs [result audio-clip] + ;; Video segment + (def clip (-> video (segment :start 0 :duration 10))) + ;; Audio clip for muxing (same segment as analysis) + (def audio-clip (-> audio (segment :start 60 :duration 10))) + ;; Apply effect with char_size bound to energy + (def result (-> clip + (effect ascii_art + :char_size (bind energy-data values :range [2 32]) + :color_mode color_mode + :background_color background_color + :invert_colors invert_colors)))) + + ;; Stage 3: Output - combine video and audio + (stage :output + :requires [:process] + :inputs [result audio-clip] + (mux result audio-clip))) diff --git a/effects/ascii_cell_effect_staged.sexp b/effects/ascii_cell_effect_staged.sexp new file mode 100644 index 0000000..b165c57 --- /dev/null +++ b/effects/ascii_cell_effect_staged.sexp @@ -0,0 +1,64 @@ +;; ASCII Cell Effect - Demonstrates arbitrary per-cell effects via lambda +;; +;; Each character cell is a mini-frame that can have any effects applied. +;; The lambda receives the cell image and zone context (including bound analysis data). + +(recipe "ascii_cell_effect_staged" + :version "1.0" + :description "ASCII art with lambda-driven per-cell effects" + :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) + + :params ( + (cols :type int :default 60 :range [20 200] + :desc "Number of character columns") + (rotation_scale :type float :default 45 :range [0 90] + :desc "Max rotation in degrees at top-right corner") + ) + + ;; Registry + (effect ascii_fx_zone :path "../sexp_effects/effects/ascii_fx_zone.sexp") + (analyzer energy :path "../../artdag-analyzers/energy/analyzer.py") + + ;; Source files + (def video (source :path "../monday.webm")) + (def audio (source :path "../dizzy.mp3")) + + ;; Stage 1: Analysis + (stage :analyze + :outputs [energy-data] + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def energy-data (-> audio-clip (analyze energy)))) + + ;; Stage 2: Process - apply effect with cell_effect lambda + (stage :process + :requires [:analyze] + :inputs [energy-data] + :outputs [result audio-clip] + (def clip (-> video (segment :start 0 :duration 10))) + (def audio-clip (-> audio (segment :start 60 :duration 10))) + + ;; Apply effect with cell_effect lambda + ;; The lambda receives (cell zone) where: + ;; cell = the rendered character as a small image + ;; zone = dict with row, col, lum, sat, hue, energy, rotation_scale, etc. + (def result (-> clip + (effect ascii_fx_zone + :cols cols + :char_size (bind energy-data values :range [10 20]) + :color_mode "color" + :background "black" + ;; Pass bound values so they're available in zone dict + :energy (bind energy-data values :range [0 1]) + :rotation_scale rotation_scale + ;; Cell effect lambda: rotate each cell based on energy * position + :cell_effect (lambda [cell zone] + (rotate-cell cell + (* (* (get zone "energy") (get zone "rotation_scale")) + (* 1.5 (+ (get zone "col-norm") + (- 1 (get zone "row-norm"))))))))))) + + ;; Stage 3: Output + (stage :output + :requires [:process] + :inputs [result audio-clip] + (mux result audio-clip))) diff --git a/effects/ascii_fx_zone_staged.sexp b/effects/ascii_fx_zone_staged.sexp new file mode 100644 index 0000000..25c4254 --- /dev/null +++ b/effects/ascii_fx_zone_staged.sexp @@ -0,0 +1,66 @@ +;; ASCII FX Zone effect with per-zone expression-driven effects +;; +;; Uses energy analysis to drive rotation based on position: +;; - Bottom-left = 0 rotation +;; - Top-right = max rotation (scaled by energy) + +(recipe "ascii_fx_zone_staged" + :version "1.0" + :description "ASCII art with per-zone expression-driven effects" + :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) + + :params ( + (cols :type int :default 80 :range [20 200] + :desc "Number of character columns") + (color_mode :type string :default "color" + :desc "Character color: color, mono, invert, or any color name/hex") + (background :type string :default "black" + :desc "Background color name or hex value") + (rotation_scale :type float :default 30 :range [0 90] + :desc "Max rotation in degrees at top-right corner") + ) + + ;; Registry + (effect ascii_fx_zone :path "../sexp_effects/effects/ascii_fx_zone.sexp") + (analyzer energy :path "../../artdag-analyzers/energy/analyzer.py") + + ;; Source files + (def video (source :path "../monday.webm")) + (def audio (source :path "../dizzy.mp3")) + + ;; Stage 1: Analysis + (stage :analyze + :outputs [energy-data] + (def audio-clip (-> audio (segment :start 60 :duration 10))) + (def energy-data (-> audio-clip (analyze energy)))) + + ;; Stage 2: Process - apply effect with zone expressions + (stage :process + :requires [:analyze] + :inputs [energy-data] + :outputs [result audio-clip] + (def clip (-> video (segment :start 0 :duration 10))) + (def audio-clip (-> audio (segment :start 60 :duration 10))) + + ;; Apply effect with lambdas + ;; Lambda receives zone dict: {row, col, row-norm, col-norm, lum, sat, hue, r, g, b, char} + ;; Plus any extra params like energy, rotation_scale + (def result (-> clip + (effect ascii_fx_zone + :char_size (bind energy-data values :range [8 24]) + :color_mode color_mode + :background background + ;; Pass energy as extra param so lambda can access it via zone dict + :energy (bind energy-data values :range [0 1]) + :rotation_scale rotation_scale + ;; Rotation: energy * scale * position (bottom-left=0, top-right=3) + :char_rotation (lambda [z] + (* (* (get z "energy") (get z "rotation_scale")) + (* 1.5 (+ (get z "col-norm") + (- 1 (get z "row-norm")))))))))) + + ;; Stage 3: Output + (stage :output + :requires [:process] + :inputs [result audio-clip] + (mux result audio-clip))) diff --git a/execute.py b/execute.py index 212d770..cc03a7d 100644 --- a/execute.py +++ b/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) diff --git a/run_staged.py b/run_staged.py new file mode 100644 index 0000000..b96823a --- /dev/null +++ b/run_staged.py @@ -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 = [-p = ...]") + 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() diff --git a/sexp_effects/effects/ascii_art.sexp b/sexp_effects/effects/ascii_art.sexp index 91f811e..a2237e1 100644 --- a/sexp_effects/effects/ascii_art.sexp +++ b/sexp_effects/effects/ascii_art.sexp @@ -1,14 +1,16 @@ ;; ASCII Art effect - converts image to ASCII characters -;; @param char_size int [4, 32] default 8 -;; @param alphabet string default "standard" -;; @param color_mode string default "color" -;; @param contrast float [1, 3] default 1.5 -;; @param background list default (0 0 0) (define-effect ascii_art - ((char_size 8) (alphabet "standard") (color_mode "color") (contrast 1.5) (background (list 0 0 0))) + :params ( + (char_size :type int :default 8 :range [4 32]) + (alphabet :type string :default "standard") + (color_mode :type string :default "color" :desc ""color", "mono", "invert", or any color name/hex") + (background_color :type string :default "black" :desc "background color name/hex") + (invert_colors :type int :default 0 :desc "swap foreground and background colors") + (contrast :type float :default 1.5 :range [1 3]) + ) (let* ((sample (cell-sample frame char_size)) (colors (nth sample 0)) (luminances (nth sample 1)) (chars (luminance-to-chars luminances alphabet contrast))) - (render-char-grid frame chars colors char_size color_mode background))) + (render-char-grid frame chars colors char_size color_mode background_color invert_colors))) diff --git a/sexp_effects/effects/ascii_art_fx.sexp b/sexp_effects/effects/ascii_art_fx.sexp new file mode 100644 index 0000000..724d37f --- /dev/null +++ b/sexp_effects/effects/ascii_art_fx.sexp @@ -0,0 +1,51 @@ +;; ASCII Art FX - converts image to ASCII characters with per-character effects + +(define-effect ascii_art_fx + :params ( + ;; Basic parameters + (char_size :type int :default 8 :range [4 32] + :desc "Size of each character cell in pixels") + (alphabet :type string :default "standard" + :desc "Character set to use") + (color_mode :type string :default "color" + :choices [color mono invert] + :desc "Color mode: color, mono, invert, or any color name/hex") + (background_color :type string :default "black" + :desc "Background color name or hex value") + (invert_colors :type int :default 0 :range [0 1] + :desc "Swap foreground and background colors (0/1)") + (contrast :type float :default 1.5 :range [1 3] + :desc "Character selection contrast") + + ;; Per-character effects + (char_jitter :type float :default 0 :range [0 20] + :desc "Position jitter amount in pixels") + (char_scale :type float :default 1.0 :range [0.5 2.0] + :desc "Character scale factor") + (char_rotation :type float :default 0 :range [0 180] + :desc "Rotation amount in degrees") + (char_hue_shift :type float :default 0 :range [0 360] + :desc "Hue shift in degrees") + + ;; Modulation sources + (jitter_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist] + :desc "What drives jitter modulation") + (scale_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist] + :desc "What drives scale modulation") + (rotation_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist] + :desc "What drives rotation modulation") + (hue_source :type string :default "none" + :choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist] + :desc "What drives hue shift modulation") + ) + (let* ((sample (cell-sample frame char_size)) + (colors (nth sample 0)) + (luminances (nth sample 1)) + (chars (luminance-to-chars luminances alphabet contrast))) + (render-char-grid-fx frame chars colors luminances char_size + color_mode background_color invert_colors + char_jitter char_scale char_rotation char_hue_shift + jitter_source scale_source rotation_source hue_source))) diff --git a/sexp_effects/effects/ascii_fx_zone.sexp b/sexp_effects/effects/ascii_fx_zone.sexp new file mode 100644 index 0000000..915470a --- /dev/null +++ b/sexp_effects/effects/ascii_fx_zone.sexp @@ -0,0 +1,99 @@ +;; Composable ASCII Art with Per-Zone Expression-Driven Effects +;; +;; Two modes of operation: +;; +;; 1. EXPRESSION MODE: Use zone-* variables in expression parameters +;; Zone variables available: +;; 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) +;; +;; Example: +;; (ascii-fx-zone frame +;; :cols 80 +;; :char_hue (* zone-lum 180) +;; :char_rotation (* zone-col-norm 30)) +;; +;; 2. CELL EFFECT MODE: Pass a lambda to apply arbitrary effects per-cell +;; The lambda receives (cell-image zone-dict) and returns modified cell. +;; Zone dict contains: row, col, row-norm, col-norm, lum, sat, hue, r, g, b, +;; char, color, cell_size, plus any bound analysis values. +;; +;; Any loaded sexp effect can be called on cells - each cell is just a small frame: +;; (blur cell radius) - Gaussian blur +;; (rotate cell angle) - Rotate by angle degrees +;; (brightness cell factor) - Adjust brightness +;; (contrast cell factor) - Adjust contrast +;; (saturation cell factor) - Adjust saturation +;; (hue_shift cell degrees) - Shift hue +;; (rgb_split cell offset_x offset_y) - RGB channel split +;; (invert cell) - Invert colors +;; (pixelate cell block_size) - Pixelate +;; (wave cell amplitude freq) - Wave distortion +;; ... and any other loaded effect +;; +;; Example: +;; (ascii-fx-zone frame +;; :cols 60 +;; :cell_effect (lambda [cell zone] +;; (blur (rotate cell (* (get zone "energy") 45)) +;; (if (> (get zone "lum") 0.5) 3 0)))) + +(define-effect ascii_fx_zone + :params ( + (cols :type int :default 80 :range [20 200] + :desc "Number of character columns") + (char_size :type int :default nil :range [4 32] + :desc "Character cell size in pixels (overrides cols if set)") + (alphabet :type string :default "standard" + :desc "Character set: standard, blocks, simple, digits, or custom string") + (color_mode :type string :default "color" + :desc "Color mode: color, mono, invert, or any color name/hex") + (background :type string :default "black" + :desc "Background color name or hex value") + (contrast :type float :default 1.5 :range [0.5 3.0] + :desc "Contrast for character selection") + (char_hue :type any :default nil + :desc "Hue shift expression (evaluated per-zone with zone-* vars)") + (char_saturation :type any :default nil + :desc "Saturation multiplier expression (1.0 = unchanged)") + (char_brightness :type any :default nil + :desc "Brightness multiplier expression (1.0 = unchanged)") + (char_scale :type any :default nil + :desc "Character scale expression (1.0 = normal size)") + (char_rotation :type any :default nil + :desc "Character rotation expression (degrees)") + (char_jitter :type any :default nil + :desc "Position jitter expression (pixels)") + (cell_effect :type any :default nil + :desc "Lambda (cell zone) -> cell for arbitrary per-cell effects") + ;; Convenience params for staged recipes (avoids compile-time expression issues) + (energy :type float :default nil + :desc "Energy multiplier (0-1) from audio analysis bind") + (rotation_scale :type float :default 0 + :desc "Max rotation at top-right when energy=1 (degrees)") + ) + ;; The ascii-fx-zone special form handles expression params + ;; If energy + rotation_scale provided, it builds: energy * scale * position_factor + ;; where position_factor = 0 at bottom-left, 3 at top-right + ;; If cell_effect provided, each character is rendered to a cell image, + ;; passed to the lambda, and the result composited back + (ascii-fx-zone frame + :cols cols + :char_size char_size + :alphabet alphabet + :color_mode color_mode + :background background + :contrast contrast + :char_hue char_hue + :char_saturation char_saturation + :char_brightness char_brightness + :char_scale char_scale + :char_rotation char_rotation + :char_jitter char_jitter + :cell_effect cell_effect + :energy energy + :rotation_scale rotation_scale)) diff --git a/sexp_effects/effects/ascii_zones.sexp b/sexp_effects/effects/ascii_zones.sexp index 12173d9..245900f 100644 --- a/sexp_effects/effects/ascii_zones.sexp +++ b/sexp_effects/effects/ascii_zones.sexp @@ -1,12 +1,13 @@ ;; ASCII Zones effect - different character sets for different brightness zones ;; Dark areas use simple chars, mid uses standard, bright uses blocks -;; @param char_size int [4, 32] default 8 -;; @param dark_threshold int [0, 128] default 80 -;; @param bright_threshold int [128, 255] default 180 -;; @param color_mode string default "color" (define-effect ascii_zones - ((char_size 8) (dark_threshold 80) (bright_threshold 180) (color_mode "color")) + :params ( + (char_size :type int :default 8 :range [4 32]) + (dark_threshold :type int :default 80 :range [0 128]) + (bright_threshold :type int :default 180 :range [128 255]) + (color_mode :type string :default "color") + ) (let* ((sample (cell-sample frame char_size)) (colors (nth sample 0)) (luminances (nth sample 1)) diff --git a/sexp_effects/effects/blend.sexp b/sexp_effects/effects/blend.sexp index bd0cb58..b8f6850 100644 --- a/sexp_effects/effects/blend.sexp +++ b/sexp_effects/effects/blend.sexp @@ -8,7 +8,13 @@ ;; pad-color - color for padding in fit mode [r g b] (define-effect blend - ((mode "overlay") (opacity 0.5) (resize-mode "fit") (priority "width") (pad-color (list 0 0 0))) + :params ( + (mode :type string :default "overlay") + (opacity :type float :default 0.5) + (priority :type string :default "width") + (list :type string :default 0 0 0) + ) + ) (let [a frame-a a-w (width a) a-h (height a) @@ -45,4 +51,4 @@ b-resized))] (if (= mode "alpha") (blend-images a b opacity) - (blend-images a (blend-mode a b mode) opacity)))) + (blend-images a (blend-mode a b mode) opacity))) diff --git a/sexp_effects/effects/bloom.sexp b/sexp_effects/effects/bloom.sexp index f6ed31b..5a4b020 100644 --- a/sexp_effects/effects/bloom.sexp +++ b/sexp_effects/effects/bloom.sexp @@ -1,10 +1,11 @@ ;; Bloom effect - glow on bright areas -;; @param intensity float [0, 2] default 0.5 -;; @param threshold int [0, 255] default 200 -;; @param radius int [1, 50] default 15 (define-effect bloom - ((intensity 0.5) (threshold 200) (radius 15)) + :params ( + (intensity :type float :default 0.5 :range [0 2]) + (threshold :type int :default 200 :range [0 255]) + (radius :type int :default 15 :range [1 50]) + ) (let* ((bright (map-pixels frame (lambda (x y c) (if (> (luminance c) threshold) diff --git a/sexp_effects/effects/blur.sexp b/sexp_effects/effects/blur.sexp index ad2ad69..7171fa5 100644 --- a/sexp_effects/effects/blur.sexp +++ b/sexp_effects/effects/blur.sexp @@ -1,6 +1,7 @@ ;; Blur effect - gaussian blur -;; @param radius int [1, 50] default 5 (define-effect blur - ((radius 5)) + :params ( + (radius :type int :default 5 :range [1 50]) + ) (blur frame (max 1 radius))) diff --git a/sexp_effects/effects/brightness.sexp b/sexp_effects/effects/brightness.sexp index 63a203c..d094c11 100644 --- a/sexp_effects/effects/brightness.sexp +++ b/sexp_effects/effects/brightness.sexp @@ -1,7 +1,8 @@ ;; Brightness effect - adjusts overall brightness -;; @param amount float [-255, 255] default 0 ;; Uses vectorized adjust primitive for fast processing (define-effect brightness - ((amount 0)) + :params ( + (amount :type int :default 0 :range [-255 255]) + ) (adjust frame amount 1)) diff --git a/sexp_effects/effects/color-adjust.sexp b/sexp_effects/effects/color-adjust.sexp index 672a9b8..3f598b3 100644 --- a/sexp_effects/effects/color-adjust.sexp +++ b/sexp_effects/effects/color-adjust.sexp @@ -1,8 +1,11 @@ ;; Color adjustment effect - replaces TRANSFORM node -;; Params: brightness (-255 to 255), contrast (0 to 3+), saturation (0 to 2+) (define-effect color-adjust - ((brightness 0) (contrast 1) (saturation 1)) + :params ( + (brightness :type int :default 0 :range [-255 255] :desc "Brightness adjustment") + (contrast :type float :default 1 :range [0 3] :desc "Contrast multiplier") + (saturation :type float :default 1 :range [0 2] :desc "Saturation multiplier") + ) (-> frame (adjust :brightness brightness :contrast contrast) (shift-hsv :s saturation))) diff --git a/sexp_effects/effects/color_cycle.sexp b/sexp_effects/effects/color_cycle.sexp index 5ee9bbe..f581ac0 100644 --- a/sexp_effects/effects/color_cycle.sexp +++ b/sexp_effects/effects/color_cycle.sexp @@ -1,8 +1,9 @@ ;; Color Cycle effect - animated hue rotation -;; @param speed float [0, 10] default 1 (define-effect color_cycle - ((speed 1)) + :params ( + (speed :type int :default 1 :range [0 10]) + ) (let ((shift (* t speed 360))) (map-pixels frame (lambda (x y c) diff --git a/sexp_effects/effects/contrast.sexp b/sexp_effects/effects/contrast.sexp index dd3e809..ce2e960 100644 --- a/sexp_effects/effects/contrast.sexp +++ b/sexp_effects/effects/contrast.sexp @@ -1,7 +1,8 @@ ;; Contrast effect - adjusts image contrast -;; @param amount float [0.5, 3] default 1 ;; Uses vectorized adjust primitive for fast processing (define-effect contrast - ((amount 1)) + :params ( + (amount :type int :default 1 :range [0.5 3]) + ) (adjust frame 0 amount)) diff --git a/sexp_effects/effects/crt.sexp b/sexp_effects/effects/crt.sexp index 17c6229..861d8a8 100644 --- a/sexp_effects/effects/crt.sexp +++ b/sexp_effects/effects/crt.sexp @@ -1,10 +1,11 @@ ;; CRT effect - old monitor simulation -;; @param line_spacing int [1, 10] default 2 -;; @param line_opacity float [0, 1] default 0.3 -;; @param vignette float [0, 1] default 0.2 (define-effect crt - ((line_spacing 2) (line_opacity 0.3) (vignette_amount 0.2)) + :params ( + (line_spacing :type int :default 2 :range [1 10]) + (line_opacity :type float :default 0.3 :range [0 1]) + (vignette_amount :type float :default 0.2) + ) (let* ((w (width frame)) (h (height frame)) (cx (/ w 2)) diff --git a/sexp_effects/effects/datamosh.sexp b/sexp_effects/effects/datamosh.sexp index f185061..60cec66 100644 --- a/sexp_effects/effects/datamosh.sexp +++ b/sexp_effects/effects/datamosh.sexp @@ -1,11 +1,12 @@ ;; Datamosh effect - glitch block corruption -;; @param block_size int [8, 128] default 32 -;; @param corruption float [0, 1] default 0.3 -;; @param max_offset int [0, 200] default 50 -;; @param color_corrupt bool default true (define-effect datamosh - ((block_size 32) (corruption 0.3) (max_offset 50) (color_corrupt true)) + :params ( + (block_size :type int :default 32 :range [8 128]) + (corruption :type float :default 0.3 :range [0 1]) + (max_offset :type int :default 50 :range [0 200]) + (color_corrupt :type bool :default true) + ) ;; Get previous frame from state, or use current frame if none (let ((prev (state-get "prev_frame" frame))) (begin diff --git a/sexp_effects/effects/echo.sexp b/sexp_effects/effects/echo.sexp index d7e74da..0528ab2 100644 --- a/sexp_effects/effects/echo.sexp +++ b/sexp_effects/effects/echo.sexp @@ -1,9 +1,10 @@ ;; Echo effect - motion trails using frame buffer -;; @param num_echoes int [1, 20] default 4 -;; @param decay float [0, 1] default 0.5 (define-effect echo - ((num_echoes 4) (decay 0.5)) + :params ( + (num_echoes :type int :default 4 :range [1 20]) + (decay :type float :default 0.5 :range [0 1]) + ) (let* ((buffer (state-get 'buffer (list))) (new-buffer (take (cons frame buffer) (+ num_echoes 1)))) (begin diff --git a/sexp_effects/effects/edge_detect.sexp b/sexp_effects/effects/edge_detect.sexp index 9312afa..aacc350 100644 --- a/sexp_effects/effects/edge_detect.sexp +++ b/sexp_effects/effects/edge_detect.sexp @@ -1,7 +1,8 @@ ;; Edge detection effect - highlights edges -;; @param low int [10, 100] default 50 -;; @param high int [50, 300] default 150 (define-effect edge_detect - ((low 50) (high 150)) + :params ( + (low :type int :default 50 :range [10 100]) + (high :type int :default 150 :range [50 300]) + ) (edges frame low high)) diff --git a/sexp_effects/effects/emboss.sexp b/sexp_effects/effects/emboss.sexp index b0db5fd..2305c24 100644 --- a/sexp_effects/effects/emboss.sexp +++ b/sexp_effects/effects/emboss.sexp @@ -1,9 +1,10 @@ ;; Emboss effect - creates raised/3D appearance -;; @param strength float [0.5, 3] default 1 -;; @param blend float [0, 1] default 0.3 (define-effect emboss - ((strength 1) (blend 0.3)) + :params ( + (strength :type int :default 1 :range [0.5 3]) + (blend :type float :default 0.3 :range [0 1]) + ) (let* ((kernel (list (list (- strength) (- strength) 0) (list (- strength) 1 strength) (list 0 strength strength))) diff --git a/sexp_effects/effects/film_grain.sexp b/sexp_effects/effects/film_grain.sexp index affcd9c..1f7b38b 100644 --- a/sexp_effects/effects/film_grain.sexp +++ b/sexp_effects/effects/film_grain.sexp @@ -1,9 +1,10 @@ ;; Film Grain effect - adds film grain texture -;; @param intensity float [0, 1] default 0.2 -;; @param colored bool default false (define-effect film_grain - ((intensity 0.2) (colored false)) + :params ( + (intensity :type float :default 0.2 :range [0 1]) + (colored :type bool :default false) + ) (let ((grain-amount (* intensity 50))) (map-pixels frame (lambda (x y c) diff --git a/sexp_effects/effects/fisheye.sexp b/sexp_effects/effects/fisheye.sexp index 698eb48..d31935d 100644 --- a/sexp_effects/effects/fisheye.sexp +++ b/sexp_effects/effects/fisheye.sexp @@ -1,11 +1,12 @@ ;; Fisheye effect - barrel/pincushion lens distortion -;; @param strength float [-1, 1] default 0.3 -;; @param center_x float [0, 1] default 0.5 -;; @param center_y float [0, 1] default 0.5 -;; @param zoom_correct bool default true (define-effect fisheye - ((strength 0.3) (center_x 0.5) (center_y 0.5) (zoom_correct true)) + :params ( + (strength :type float :default 0.3 :range [-1 1]) + (center_x :type float :default 0.5 :range [0 1]) + (center_y :type float :default 0.5 :range [0 1]) + (zoom_correct :type bool :default true) + ) (let* ((w (width frame)) (h (height frame)) (cx (* w center_x)) diff --git a/sexp_effects/effects/flip.sexp b/sexp_effects/effects/flip.sexp index 72afc9b..c527113 100644 --- a/sexp_effects/effects/flip.sexp +++ b/sexp_effects/effects/flip.sexp @@ -1,9 +1,10 @@ ;; Flip effect - flips image horizontally or vertically -;; @param horizontal bool default true -;; @param vertical bool default false (define-effect flip - ((horizontal true) (vertical false)) + :params ( + (horizontal :type bool :default true) + (vertical :type bool :default false) + ) (let ((result frame)) (if horizontal (set! result (flip-h result)) diff --git a/sexp_effects/effects/grayscale.sexp b/sexp_effects/effects/grayscale.sexp index b25dcc3..48c59ee 100644 --- a/sexp_effects/effects/grayscale.sexp +++ b/sexp_effects/effects/grayscale.sexp @@ -1,5 +1,6 @@ ;; Grayscale effect - converts to grayscale ;; Uses vectorized mix-gray primitive for fast processing -(define-effect grayscale () +(define-effect grayscale + :params () (mix-gray frame 1)) diff --git a/sexp_effects/effects/hue_shift.sexp b/sexp_effects/effects/hue_shift.sexp index 8d3e19c..f9f014b 100644 --- a/sexp_effects/effects/hue_shift.sexp +++ b/sexp_effects/effects/hue_shift.sexp @@ -1,9 +1,10 @@ ;; Hue shift effect - rotates hue values -;; @param degrees float [0, 360] default 0 -;; @param speed float default 0 - rotation per second ;; Uses vectorized shift-hsv primitive for fast processing (define-effect hue_shift - ((degrees 0) (speed 0)) + :params ( + (degrees :type int :default 0 :range [0 360]) + (speed :type int :default 0 :desc "rotation per second") + ) (let ((shift (+ degrees (* speed t)))) (shift-hsv frame shift 1 1))) diff --git a/sexp_effects/effects/invert.sexp b/sexp_effects/effects/invert.sexp index e5917fd..fdda9e5 100644 --- a/sexp_effects/effects/invert.sexp +++ b/sexp_effects/effects/invert.sexp @@ -1,5 +1,6 @@ ;; Invert effect - inverts all colors ;; Uses vectorized invert-img primitive for fast processing -(define-effect invert () +(define-effect invert + :params () (invert-img frame)) diff --git a/sexp_effects/effects/kaleidoscope.sexp b/sexp_effects/effects/kaleidoscope.sexp index e487d00..8a79937 100644 --- a/sexp_effects/effects/kaleidoscope.sexp +++ b/sexp_effects/effects/kaleidoscope.sexp @@ -1,13 +1,14 @@ ;; Kaleidoscope effect - mandala-like symmetry patterns -;; @param segments int [3, 16] default 6 -;; @param rotation float [0, 360] default 0 -;; @param rotation_speed float [-180, 180] default 0 -;; @param center_x float [0, 1] default 0.5 -;; @param center_y float [0, 1] default 0.5 -;; @param zoom float [0.5, 3] default 1 (define-effect kaleidoscope - ((segments 6) (rotation 0) (rotation_speed 0) (center_x 0.5) (center_y 0.5) (zoom 1)) + :params ( + (segments :type int :default 6 :range [3 16]) + (rotation :type int :default 0 :range [0 360]) + (rotation_speed :type int :default 0 :range [-180 180]) + (center_x :type float :default 0.5 :range [0 1]) + (center_y :type float :default 0.5 :range [0 1]) + (zoom :type int :default 1 :range [0.5 3]) + ) (let* ((w (width frame)) (h (height frame)) (cx (* w center_x)) diff --git a/sexp_effects/effects/layer.sexp b/sexp_effects/effects/layer.sexp index 90154fb..9915407 100644 --- a/sexp_effects/effects/layer.sexp +++ b/sexp_effects/effects/layer.sexp @@ -3,7 +3,12 @@ ;; Params: x, y (position), opacity (0-1), mode (blend mode) (define-effect layer - ((x 0) (y 0) (opacity 1.0) (mode "alpha")) + :params ( + (x :type int :default 0) + (y :type int :default 0) + (opacity :type float :default 1.0) + (mode :type string :default "alpha") + ) (let [bg (copy frame-a) fg frame-b ;; Resize fg if needed to fit diff --git a/sexp_effects/effects/mirror.sexp b/sexp_effects/effects/mirror.sexp index 0bcfce3..f1f4c5a 100644 --- a/sexp_effects/effects/mirror.sexp +++ b/sexp_effects/effects/mirror.sexp @@ -1,8 +1,9 @@ ;; Mirror effect - mirrors half of image -;; @param mode string default "left_right" (define-effect mirror - ((mode "left_right")) + :params ( + (mode :type string :default "left_right") + ) (let* ((w (width frame)) (h (height frame)) (hw (floor (/ w 2))) diff --git a/sexp_effects/effects/neon_glow.sexp b/sexp_effects/effects/neon_glow.sexp index 77de9fc..26e5462 100644 --- a/sexp_effects/effects/neon_glow.sexp +++ b/sexp_effects/effects/neon_glow.sexp @@ -1,13 +1,13 @@ ;; Neon Glow effect - glowing edge effect -;; @param edge_low int [10, 200] default 50 -;; @param edge_high int [50, 300] default 150 -;; @param glow_radius int [1, 50] default 15 -;; @param glow_intensity float [0.5, 5] default 2 -;; @param background float [0, 1] default 0.3 (define-effect neon_glow - ((edge_low 50) (edge_high 150) (glow_radius 15) - (glow_intensity 2) (background 0.3)) + :params ( + (edge_low :type int :default 50 :range [10 200]) + (edge_high :type int :default 150 :range [50 300]) + (glow_radius :type int :default 15 :range [1 50]) + (glow_intensity :type int :default 2 :range [0.5 5]) + (background :type float :default 0.3 :range [0 1]) + ) (let* ((edge-img (edges frame edge_low edge_high)) (glow (blur edge-img glow_radius)) ;; Intensify the glow diff --git a/sexp_effects/effects/noise.sexp b/sexp_effects/effects/noise.sexp index 34b363d..4da8298 100644 --- a/sexp_effects/effects/noise.sexp +++ b/sexp_effects/effects/noise.sexp @@ -1,7 +1,8 @@ ;; Noise effect - adds random noise -;; @param amount float [0, 100] default 20 ;; Uses vectorized add-noise primitive for fast processing (define-effect noise - ((amount 20)) + :params ( + (amount :type int :default 20 :range [0 100]) + ) (add-noise frame amount)) diff --git a/sexp_effects/effects/outline.sexp b/sexp_effects/effects/outline.sexp index e34a85c..b9127e0 100644 --- a/sexp_effects/effects/outline.sexp +++ b/sexp_effects/effects/outline.sexp @@ -1,11 +1,12 @@ ;; Outline effect - shows only edges -;; @param thickness int [1, 10] default 2 -;; @param threshold int [20, 300] default 100 -;; @param color list default (0 0 0) -;; @param fill_mode string default "original" (define-effect outline - ((thickness 2) (threshold 100) (color (list 0 0 0)) (fill_mode "original")) + :params ( + (thickness :type int :default 2 :range [1 10]) + (threshold :type int :default 100 :range [20 300]) + (color :type list :default (list 0 0 0) + ) + (fill_mode "original")) (let* ((edge-img (edges frame (/ threshold 2) threshold)) (dilated (if (> thickness 1) (dilate edge-img thickness) diff --git a/sexp_effects/effects/pixelate.sexp b/sexp_effects/effects/pixelate.sexp index 0f215ad..0abacdf 100644 --- a/sexp_effects/effects/pixelate.sexp +++ b/sexp_effects/effects/pixelate.sexp @@ -1,8 +1,9 @@ ;; Pixelate effect - creates blocky pixels -;; @param block_size int [2, 64] default 8 (define-effect pixelate - ((block_size 8)) + :params ( + (block_size :type int :default 8 :range [2 64]) + ) (let* ((w (width frame)) (h (height frame)) (small-w (max 1 (floor (/ w block_size)))) diff --git a/sexp_effects/effects/pixelsort.sexp b/sexp_effects/effects/pixelsort.sexp index b13c539..155ac13 100644 --- a/sexp_effects/effects/pixelsort.sexp +++ b/sexp_effects/effects/pixelsort.sexp @@ -1,10 +1,11 @@ ;; Pixelsort effect - glitch art pixel sorting -;; @param sort_by string default "lightness" -;; @param threshold_low float [0, 255] default 50 -;; @param threshold_high float [0, 255] default 200 -;; @param angle float [0, 180] default 0 -;; @param reverse bool default false (define-effect pixelsort - ((sort_by "lightness") (threshold_low 50) (threshold_high 200) (angle 0) (reverse false)) + :params ( + (sort_by :type string :default "lightness") + (threshold_low :type int :default 50 :range [0 255]) + (threshold_high :type int :default 200 :range [0 255]) + (angle :type int :default 0 :range [0 180]) + (reverse :type bool :default false) + ) (pixelsort frame sort_by threshold_low threshold_high angle reverse)) diff --git a/sexp_effects/effects/posterize.sexp b/sexp_effects/effects/posterize.sexp index b82f084..1063e80 100644 --- a/sexp_effects/effects/posterize.sexp +++ b/sexp_effects/effects/posterize.sexp @@ -1,8 +1,9 @@ ;; Posterize effect - reduces color levels -;; @param levels int [2, 32] default 8 (define-effect posterize - ((levels 8)) + :params ( + (levels :type int :default 8 :range [2 32]) + ) (let ((step (floor (/ 256 levels)))) (map-pixels frame (lambda (x y c) diff --git a/sexp_effects/effects/resize-frame.sexp b/sexp_effects/effects/resize-frame.sexp index 2c0b868..a337865 100644 --- a/sexp_effects/effects/resize-frame.sexp +++ b/sexp_effects/effects/resize-frame.sexp @@ -1,7 +1,10 @@ ;; Resize effect - replaces RESIZE node -;; Params: width, height, mode (linear, nearest, area) ;; Note: uses target-w/target-h to avoid conflict with width/height primitives (define-effect resize-frame - ((target-w 640) (target-h 480) (mode "linear")) + :params ( + (target-w :type int :default 640 :desc "Target width in pixels") + (target-h :type int :default 480 :desc "Target height in pixels") + (mode :type string :default "linear" :choices [linear nearest area] :desc "Interpolation mode") + ) (resize frame target-w target-h mode)) diff --git a/sexp_effects/effects/rgb_split.sexp b/sexp_effects/effects/rgb_split.sexp index 57c789e..4582701 100644 --- a/sexp_effects/effects/rgb_split.sexp +++ b/sexp_effects/effects/rgb_split.sexp @@ -1,9 +1,10 @@ ;; RGB Split effect - chromatic aberration -;; @param offset_x float [-50, 50] default 10 -;; @param offset_y float [-50, 50] default 0 (define-effect rgb_split - ((offset_x 10) (offset_y 0)) + :params ( + (offset_x :type int :default 10 :range [-50 50]) + (offset_y :type int :default 0 :range [-50 50]) + ) (let* ((r (channel frame 0)) (g (channel frame 1)) (b (channel frame 2)) diff --git a/sexp_effects/effects/ripple.sexp b/sexp_effects/effects/ripple.sexp index 6a9b433..aac278f 100644 --- a/sexp_effects/effects/ripple.sexp +++ b/sexp_effects/effects/ripple.sexp @@ -1,13 +1,14 @@ ;; Ripple effect - radial wave distortion from center -;; @param frequency float [1, 20] default 5 -;; @param amplitude float [0, 50] default 10 -;; @param center_x float [0, 1] default 0.5 -;; @param center_y float [0, 1] default 0.5 -;; @param decay float [0, 5] default 1 -;; @param speed float [0, 10] default 1 (define-effect ripple - ((frequency 5) (amplitude 10) (center_x 0.5) (center_y 0.5) (decay 1) (speed 1)) + :params ( + (frequency :type int :default 5 :range [1 20]) + (amplitude :type int :default 10 :range [0 50]) + (center_x :type float :default 0.5 :range [0 1]) + (center_y :type float :default 0.5 :range [0 1]) + (decay :type int :default 1 :range [0 5]) + (speed :type int :default 1 :range [0 10]) + ) (let* ((w (width frame)) (h (height frame)) (cx (* w center_x)) diff --git a/sexp_effects/effects/rotate.sexp b/sexp_effects/effects/rotate.sexp index ad2a1c0..8923296 100644 --- a/sexp_effects/effects/rotate.sexp +++ b/sexp_effects/effects/rotate.sexp @@ -1,8 +1,9 @@ ;; Rotate effect - rotates image -;; @param angle float [-360, 360] default 0 -;; @param speed float default 0 - rotation per second (define-effect rotate - ((angle 0) (speed 0)) + :params ( + (angle :type int :default 0 :range [-360 360]) + (speed :type int :default 0 :desc "rotation per second") + ) (let ((total-angle (+ angle (* speed t)))) (rotate-img frame total-angle))) diff --git a/sexp_effects/effects/saturation.sexp b/sexp_effects/effects/saturation.sexp index 8af6121..5abf27b 100644 --- a/sexp_effects/effects/saturation.sexp +++ b/sexp_effects/effects/saturation.sexp @@ -1,7 +1,8 @@ ;; Saturation effect - adjusts color saturation -;; @param amount float [0, 3] default 1 ;; Uses vectorized shift-hsv primitive for fast processing (define-effect saturation - ((amount 1)) + :params ( + (amount :type int :default 1 :range [0 3]) + ) (shift-hsv frame 0 amount 1)) diff --git a/sexp_effects/effects/scanlines.sexp b/sexp_effects/effects/scanlines.sexp index e10705d..f46a8c3 100644 --- a/sexp_effects/effects/scanlines.sexp +++ b/sexp_effects/effects/scanlines.sexp @@ -1,10 +1,11 @@ ;; Scanlines effect - VHS-style horizontal line shifting -;; @param amplitude float [0, 100] default 10 -;; @param frequency float [1, 100] default 10 -;; @param randomness float [0, 1] default 0.5 (define-effect scanlines - ((amplitude 10) (frequency 10) (randomness 0.5)) + :params ( + (amplitude :type int :default 10 :range [0 100]) + (frequency :type int :default 10 :range [1 100]) + (randomness :type float :default 0.5 :range [0 1]) + ) (map-rows frame (lambda (y row) (let* ((sine-shift (* amplitude (sin (/ (* y 6.28) (max 1 frequency))))) diff --git a/sexp_effects/effects/sepia.sexp b/sexp_effects/effects/sepia.sexp index 2bf0ba2..2fd6666 100644 --- a/sexp_effects/effects/sepia.sexp +++ b/sexp_effects/effects/sepia.sexp @@ -1,7 +1,8 @@ ;; Sepia effect - applies sepia tone ;; Classic warm vintage look -(define-effect sepia () +(define-effect sepia + :params () (color-matrix frame (list (list 0.393 0.769 0.189) (list 0.349 0.686 0.168) diff --git a/sexp_effects/effects/sharpen.sexp b/sexp_effects/effects/sharpen.sexp index 192a2f9..81dc72e 100644 --- a/sexp_effects/effects/sharpen.sexp +++ b/sexp_effects/effects/sharpen.sexp @@ -1,8 +1,9 @@ ;; Sharpen effect - sharpens edges -;; @param amount float [0, 5] default 1 (define-effect sharpen - ((amount 1)) + :params ( + (amount :type int :default 1 :range [0 5]) + ) (let ((kernel (list (list 0 (- amount) 0) (list (- amount) (+ 1 (* 4 amount)) (- amount)) (list 0 (- amount) 0)))) diff --git a/sexp_effects/effects/strobe.sexp b/sexp_effects/effects/strobe.sexp index b05e347..0825d3c 100644 --- a/sexp_effects/effects/strobe.sexp +++ b/sexp_effects/effects/strobe.sexp @@ -1,8 +1,9 @@ ;; Strobe effect - holds frames for choppy look -;; @param frame_rate float [1, 60] default 12 (define-effect strobe - ((frame_rate 12)) + :params ( + (frame_rate :type int :default 12 :range [1 60]) + ) (let* ((held (state-get 'held nil)) (held-until (state-get 'held-until 0)) (frame-duration (/ 1 frame_rate))) diff --git a/sexp_effects/effects/swirl.sexp b/sexp_effects/effects/swirl.sexp index c841a2a..254f92c 100644 --- a/sexp_effects/effects/swirl.sexp +++ b/sexp_effects/effects/swirl.sexp @@ -1,12 +1,13 @@ ;; Swirl effect - spiral vortex distortion -;; @param strength float [-10, 10] default 1 -;; @param radius float [0.1, 2] default 0.5 -;; @param center_x float [0, 1] default 0.5 -;; @param center_y float [0, 1] default 0.5 -;; @param falloff string default "quadratic" (define-effect swirl - ((strength 1) (radius 0.5) (center_x 0.5) (center_y 0.5) (falloff "quadratic")) + :params ( + (strength :type int :default 1 :range [-10 10]) + (radius :type float :default 0.5 :range [0.1 2]) + (center_x :type float :default 0.5 :range [0 1]) + (center_y :type float :default 0.5 :range [0 1]) + (falloff :type string :default "quadratic") + ) (let* ((w (width frame)) (h (height frame)) (cx (* w center_x)) diff --git a/sexp_effects/effects/threshold.sexp b/sexp_effects/effects/threshold.sexp index 3f4c943..4f8f115 100644 --- a/sexp_effects/effects/threshold.sexp +++ b/sexp_effects/effects/threshold.sexp @@ -1,9 +1,10 @@ ;; Threshold effect - converts to black and white -;; @param level int [0, 255] default 128 -;; @param invert bool default false (define-effect threshold - ((level 128) (invert false)) + :params ( + (level :type int :default 128 :range [0 255]) + (invert :type bool :default false) + ) (map-pixels frame (lambda (x y c) (let* ((lum (luminance c)) diff --git a/sexp_effects/effects/tile_grid.sexp b/sexp_effects/effects/tile_grid.sexp index e473164..95ea769 100644 --- a/sexp_effects/effects/tile_grid.sexp +++ b/sexp_effects/effects/tile_grid.sexp @@ -1,10 +1,11 @@ ;; Tile Grid effect - tiles image in grid -;; @param rows int [1, 10] default 2 -;; @param cols int [1, 10] default 2 -;; @param gap int [0, 50] default 0 (define-effect tile_grid - ((rows 2) (cols 2) (gap 0)) + :params ( + (rows :type int :default 2 :range [1 10]) + (cols :type int :default 2 :range [1 10]) + (gap :type int :default 0 :range [0 50]) + ) (let* ((w (width frame)) (h (height frame)) (tile-w (floor (/ (- w (* gap (- cols 1))) cols))) diff --git a/sexp_effects/effects/trails.sexp b/sexp_effects/effects/trails.sexp index 6ece7ba..b0752e7 100644 --- a/sexp_effects/effects/trails.sexp +++ b/sexp_effects/effects/trails.sexp @@ -1,8 +1,9 @@ ;; Trails effect - persistent motion trails -;; @param persistence float [0, 0.99] default 0.8 (define-effect trails - ((persistence 0.8)) + :params ( + (persistence :type float :default 0.8 :range [0 0.99]) + ) (let* ((buffer (state-get 'buffer nil)) (current frame)) (if (= buffer nil) diff --git a/sexp_effects/effects/vignette.sexp b/sexp_effects/effects/vignette.sexp index dc73c5c..6c1cd02 100644 --- a/sexp_effects/effects/vignette.sexp +++ b/sexp_effects/effects/vignette.sexp @@ -1,9 +1,10 @@ ;; Vignette effect - darkens corners -;; @param strength float [0, 1] default 0.5 -;; @param radius float [0.5, 2] default 1 (define-effect vignette - ((strength 0.5) (radius 1)) + :params ( + (strength :type float :default 0.5 :range [0 1]) + (radius :type int :default 1 :range [0.5 2]) + ) (let* ((w (width frame)) (h (height frame)) (cx (/ w 2)) diff --git a/sexp_effects/effects/wave.sexp b/sexp_effects/effects/wave.sexp index b6be14f..98246e2 100644 --- a/sexp_effects/effects/wave.sexp +++ b/sexp_effects/effects/wave.sexp @@ -1,11 +1,12 @@ ;; Wave effect - sine wave displacement distortion -;; @param amplitude float [0, 100] default 10 -;; @param wavelength float [10, 500] default 50 -;; @param speed float [0, 10] default 1 -;; @param direction string default "horizontal" (define-effect wave - ((amplitude 10) (wavelength 50) (speed 1) (direction "horizontal")) + :params ( + (amplitude :type int :default 10 :range [0 100]) + (wavelength :type int :default 50 :range [10 500]) + (speed :type int :default 1 :range [0 10]) + (direction :type string :default "horizontal") + ) (let* ((w (width frame)) (h (height frame)) ;; Use _time for animation phase diff --git a/sexp_effects/effects/zoom.sexp b/sexp_effects/effects/zoom.sexp index 77c4974..a3d8902 100644 --- a/sexp_effects/effects/zoom.sexp +++ b/sexp_effects/effects/zoom.sexp @@ -1,6 +1,7 @@ ;; Zoom effect - zooms in/out from center -;; @param amount float [0.1, 5] default 1 (define-effect zoom - ((amount 1)) + :params ( + (amount :type int :default 1 :range [0.1 5]) + ) (scale-img frame amount amount)) diff --git a/sexp_effects/interpreter.py b/sexp_effects/interpreter.py index 46b4663..eaa9f41 100644 --- a/sexp_effects/interpreter.py +++ b/sexp_effects/interpreter.py @@ -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 ;; NOT evaluated - passed to primitive + :char_saturation + :char_brightness + :char_scale + :char_rotation + :char_jitter ) + + 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 diff --git a/sexp_effects/primitives.py b/sexp_effects/primitives.py index 0bafa4d..64cbff8 100644 --- a/sexp_effects/primitives.py +++ b/sexp_effects/primitives.py @@ -8,9 +8,25 @@ All primitives operate only on image data - no filesystem, network, etc. import numpy as np import cv2 from typing import Any, Callable, Dict, List, Tuple, Optional +from dataclasses import dataclass import math +@dataclass +class ZoneContext: + """Context for a single cell/zone in ASCII art grid.""" + row: int + col: int + row_norm: float # Normalized row position 0-1 + col_norm: float # Normalized col position 0-1 + luminance: float # Cell luminance 0-1 + saturation: float # Cell saturation 0-1 + hue: float # Cell hue 0-360 + r: float # Red component 0-1 + g: float # Green component 0-1 + b: float # Blue component 0-1 + + class DeterministicRNG: """Seeded RNG for reproducible effects.""" @@ -37,6 +53,232 @@ def reset_rng(seed: int): _rng = DeterministicRNG(seed) +# ============================================================================= +# Color Names (FFmpeg/X11 compatible) +# ============================================================================= + +NAMED_COLORS = { + # Basic colors + "black": (0, 0, 0), + "white": (255, 255, 255), + "red": (255, 0, 0), + "green": (0, 128, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "cyan": (0, 255, 255), + "magenta": (255, 0, 255), + + # Grays + "gray": (128, 128, 128), + "grey": (128, 128, 128), + "darkgray": (169, 169, 169), + "darkgrey": (169, 169, 169), + "lightgray": (211, 211, 211), + "lightgrey": (211, 211, 211), + "dimgray": (105, 105, 105), + "dimgrey": (105, 105, 105), + "silver": (192, 192, 192), + + # Reds + "darkred": (139, 0, 0), + "firebrick": (178, 34, 34), + "crimson": (220, 20, 60), + "indianred": (205, 92, 92), + "lightcoral": (240, 128, 128), + "salmon": (250, 128, 114), + "darksalmon": (233, 150, 122), + "lightsalmon": (255, 160, 122), + "tomato": (255, 99, 71), + "orangered": (255, 69, 0), + "coral": (255, 127, 80), + + # Oranges + "orange": (255, 165, 0), + "darkorange": (255, 140, 0), + + # Yellows + "gold": (255, 215, 0), + "lightyellow": (255, 255, 224), + "lemonchiffon": (255, 250, 205), + "papayawhip": (255, 239, 213), + "moccasin": (255, 228, 181), + "peachpuff": (255, 218, 185), + "palegoldenrod": (238, 232, 170), + "khaki": (240, 230, 140), + "darkkhaki": (189, 183, 107), + + # Greens + "lime": (0, 255, 0), + "limegreen": (50, 205, 50), + "forestgreen": (34, 139, 34), + "darkgreen": (0, 100, 0), + "seagreen": (46, 139, 87), + "mediumseagreen": (60, 179, 113), + "springgreen": (0, 255, 127), + "mediumspringgreen": (0, 250, 154), + "lightgreen": (144, 238, 144), + "palegreen": (152, 251, 152), + "darkseagreen": (143, 188, 143), + "greenyellow": (173, 255, 47), + "chartreuse": (127, 255, 0), + "lawngreen": (124, 252, 0), + "olivedrab": (107, 142, 35), + "olive": (128, 128, 0), + "darkolivegreen": (85, 107, 47), + "yellowgreen": (154, 205, 50), + + # Cyans/Teals + "aqua": (0, 255, 255), + "teal": (0, 128, 128), + "darkcyan": (0, 139, 139), + "lightcyan": (224, 255, 255), + "aquamarine": (127, 255, 212), + "mediumaquamarine": (102, 205, 170), + "paleturquoise": (175, 238, 238), + "turquoise": (64, 224, 208), + "mediumturquoise": (72, 209, 204), + "darkturquoise": (0, 206, 209), + "cadetblue": (95, 158, 160), + + # Blues + "navy": (0, 0, 128), + "darkblue": (0, 0, 139), + "mediumblue": (0, 0, 205), + "royalblue": (65, 105, 225), + "cornflowerblue": (100, 149, 237), + "steelblue": (70, 130, 180), + "dodgerblue": (30, 144, 255), + "deepskyblue": (0, 191, 255), + "lightskyblue": (135, 206, 250), + "skyblue": (135, 206, 235), + "lightsteelblue": (176, 196, 222), + "lightblue": (173, 216, 230), + "powderblue": (176, 224, 230), + "slateblue": (106, 90, 205), + "mediumslateblue": (123, 104, 238), + "darkslateblue": (72, 61, 139), + "midnightblue": (25, 25, 112), + + # Purples/Violets + "purple": (128, 0, 128), + "darkmagenta": (139, 0, 139), + "darkviolet": (148, 0, 211), + "blueviolet": (138, 43, 226), + "darkorchid": (153, 50, 204), + "mediumorchid": (186, 85, 211), + "orchid": (218, 112, 214), + "violet": (238, 130, 238), + "plum": (221, 160, 221), + "thistle": (216, 191, 216), + "lavender": (230, 230, 250), + "indigo": (75, 0, 130), + "mediumpurple": (147, 112, 219), + "fuchsia": (255, 0, 255), + "hotpink": (255, 105, 180), + "deeppink": (255, 20, 147), + "mediumvioletred": (199, 21, 133), + "palevioletred": (219, 112, 147), + + # Pinks + "pink": (255, 192, 203), + "lightpink": (255, 182, 193), + "mistyrose": (255, 228, 225), + + # Browns + "brown": (165, 42, 42), + "maroon": (128, 0, 0), + "saddlebrown": (139, 69, 19), + "sienna": (160, 82, 45), + "chocolate": (210, 105, 30), + "peru": (205, 133, 63), + "sandybrown": (244, 164, 96), + "burlywood": (222, 184, 135), + "tan": (210, 180, 140), + "rosybrown": (188, 143, 143), + "goldenrod": (218, 165, 32), + "darkgoldenrod": (184, 134, 11), + + # Whites + "snow": (255, 250, 250), + "honeydew": (240, 255, 240), + "mintcream": (245, 255, 250), + "azure": (240, 255, 255), + "aliceblue": (240, 248, 255), + "ghostwhite": (248, 248, 255), + "whitesmoke": (245, 245, 245), + "seashell": (255, 245, 238), + "beige": (245, 245, 220), + "oldlace": (253, 245, 230), + "floralwhite": (255, 250, 240), + "ivory": (255, 255, 240), + "antiquewhite": (250, 235, 215), + "linen": (250, 240, 230), + "lavenderblush": (255, 240, 245), + "wheat": (245, 222, 179), + "cornsilk": (255, 248, 220), + "blanchedalmond": (255, 235, 205), + "bisque": (255, 228, 196), + "navajowhite": (255, 222, 173), + + # Special + "transparent": (0, 0, 0), # Note: no alpha support, just black +} + + +def parse_color(color_spec: str) -> Optional[Tuple[int, int, int]]: + """ + Parse a color specification into RGB tuple. + + Supports: + - Named colors: "red", "green", "lime", "navy", etc. + - Hex colors: "#FF0000", "#f00", "0xFF0000" + - Special modes: "color", "mono", "invert" return None (handled separately) + + Returns: + RGB tuple (r, g, b) or None for special modes + """ + if color_spec is None: + return None + + color_spec = str(color_spec).strip().lower() + + # Special modes handled elsewhere + if color_spec in ("color", "mono", "invert"): + return None + + # Check named colors + if color_spec in NAMED_COLORS: + return NAMED_COLORS[color_spec] + + # Handle hex colors + hex_str = None + if color_spec.startswith("#"): + hex_str = color_spec[1:] + elif color_spec.startswith("0x"): + hex_str = color_spec[2:] + elif all(c in "0123456789abcdef" for c in color_spec) and len(color_spec) in (3, 6): + hex_str = color_spec + + if hex_str: + try: + if len(hex_str) == 3: + # Short form: #RGB -> #RRGGBB + r = int(hex_str[0] * 2, 16) + g = int(hex_str[1] * 2, 16) + b = int(hex_str[2] * 2, 16) + return (r, g, b) + elif len(hex_str) == 6: + r = int(hex_str[0:2], 16) + g = int(hex_str[2:4], 16) + b = int(hex_str[4:6], 16) + return (r, g, b) + except ValueError: + pass + + # Unknown color - default to None (will use original colors) + return None + + # ============================================================================= # Image Primitives # ============================================================================= @@ -1271,6 +1513,97 @@ def prim_cell_sample(img: np.ndarray, cell_size: int) -> Tuple[np.ndarray, np.nd return (colors, luminances) +def cell_sample_extended(img: np.ndarray, cell_size: int) -> Tuple[np.ndarray, np.ndarray, List[List[ZoneContext]]]: + """ + Sample image into cell grid, returning colors, luminances, and full zone contexts. + + Args: + img: source image (RGB) + cell_size: size of each cell in pixels + + Returns: (colors, luminances, zone_contexts) tuple + - colors: (rows, cols, 3) array of average RGB per cell + - luminances: (rows, cols) array of average brightness 0-255 + - zone_contexts: 2D list of ZoneContext objects with full cell data + """ + cell_size = max(1, int(cell_size)) + h, w = img.shape[:2] + rows = h // cell_size + cols = w // cell_size + + if rows < 1 or cols < 1: + return (np.zeros((1, 1, 3), dtype=np.uint8), + np.zeros((1, 1), dtype=np.float32), + [[ZoneContext(0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)]]) + + # Crop to grid + grid_h, grid_w = rows * cell_size, cols * cell_size + cropped = img[:grid_h, :grid_w] + + # Reshape and average + reshaped = cropped.reshape(rows, cell_size, cols, cell_size, 3) + colors = reshaped.mean(axis=(1, 3)).astype(np.uint8) + + # Compute luminance (0-255) + luminances = (0.299 * colors[:, :, 0] + + 0.587 * colors[:, :, 1] + + 0.114 * colors[:, :, 2]).astype(np.float32) + + # Normalize colors to 0-1 for HSV/saturation calculations + colors_float = colors.astype(np.float32) / 255.0 + + # Compute HSV values for each cell + max_c = colors_float.max(axis=2) + min_c = colors_float.min(axis=2) + diff = max_c - min_c + + # Saturation + saturation = np.where(max_c > 0, diff / max_c, 0) + + # Hue (0-360) + hue = np.zeros((rows, cols), dtype=np.float32) + # Avoid division by zero + mask = diff > 0 + r, g, b = colors_float[:, :, 0], colors_float[:, :, 1], colors_float[:, :, 2] + + # Red is max + red_max = mask & (max_c == r) + hue[red_max] = 60 * (((g[red_max] - b[red_max]) / diff[red_max]) % 6) + + # Green is max + green_max = mask & (max_c == g) + hue[green_max] = 60 * ((b[green_max] - r[green_max]) / diff[green_max] + 2) + + # Blue is max + blue_max = mask & (max_c == b) + hue[blue_max] = 60 * ((r[blue_max] - g[blue_max]) / diff[blue_max] + 4) + + # Ensure hue is in 0-360 range + hue = hue % 360 + + # Build zone contexts + zone_contexts = [] + for row in range(rows): + row_contexts = [] + for col in range(cols): + ctx = ZoneContext( + row=row, + col=col, + row_norm=row / max(1, rows - 1) if rows > 1 else 0.5, + col_norm=col / max(1, cols - 1) if cols > 1 else 0.5, + luminance=luminances[row, col] / 255.0, # Normalize to 0-1 + saturation=float(saturation[row, col]), + hue=float(hue[row, col]), + r=float(colors_float[row, col, 0]), + g=float(colors_float[row, col, 1]), + b=float(colors_float[row, col, 2]), + ) + row_contexts.append(ctx) + zone_contexts.append(row_contexts) + + return (colors, luminances, zone_contexts) + + def prim_luminance_to_chars(luminances: np.ndarray, alphabet: str, contrast: float = 1.0) -> List[List[str]]: """ Map luminance values to characters from alphabet. @@ -1309,7 +1642,8 @@ def prim_luminance_to_chars(luminances: np.ndarray, alphabet: str, contrast: flo def prim_render_char_grid(img: np.ndarray, chars: List[List[str]], colors: np.ndarray, cell_size: int, color_mode: str = "color", - background: List[int] = None) -> np.ndarray: + background_color: str = "black", + invert_colors: bool = False) -> np.ndarray: """ Render a grid of characters onto an image. @@ -1318,11 +1652,29 @@ def prim_render_char_grid(img: np.ndarray, chars: List[List[str]], colors: np.nd chars: 2D list of single characters colors: (rows, cols, 3) array of colors per cell cell_size: size of each cell - color_mode: "color", "mono", or "invert" - background: RGB background color + color_mode: "color" (original colors), "mono" (white), "invert", + or any color name/hex value ("green", "lime", "#00ff00") + background_color: background color name/hex ("black", "navy", "#001100") + invert_colors: if True, swap foreground and background colors Returns: rendered image """ + # Parse color_mode - may be a named color or hex value + fg_color = parse_color(color_mode) + + # Parse background_color + if isinstance(background_color, (list, tuple)): + # Legacy: accept RGB list + bg_color = tuple(int(c) for c in background_color[:3]) + else: + bg_color = parse_color(background_color) + if bg_color is None: + bg_color = (0, 0, 0) # Default to black + + # Handle invert_colors - swap fg and bg + if invert_colors and fg_color is not None: + fg_color, bg_color = bg_color, fg_color + cell_size = max(1, int(cell_size)) if not chars or not chars[0]: @@ -1332,10 +1684,7 @@ def prim_render_char_grid(img: np.ndarray, chars: List[List[str]], colors: np.nd cols = len(chars[0]) h, w = rows * cell_size, cols * cell_size - # Default background - if background is None: - background = [0, 0, 0] - bg = list(background)[:3] + bg = list(bg_color) result = np.full((h, w, 3), bg, dtype=np.uint8) @@ -1376,7 +1725,10 @@ def prim_render_char_grid(img: np.ndarray, chars: List[List[str]], colors: np.nd if char_mask is None: continue - if color_mode == "mono": + if fg_color is not None: + # Use fixed color (named color or hex value) + color = np.array(fg_color, dtype=np.uint8) + elif color_mode == "mono": color = np.array([255, 255, 255], dtype=np.uint8) elif color_mode == "invert": result[y1:y1+cell_size, x1:x1+cell_size] = colors[r, c] @@ -1399,6 +1751,785 @@ def prim_render_char_grid(img: np.ndarray, chars: List[List[str]], colors: np.nd return result +def prim_render_char_grid_fx(img: np.ndarray, chars: List[List[str]], colors: np.ndarray, + luminances: np.ndarray, cell_size: int, + color_mode: str = "color", + background_color: str = "black", + invert_colors: bool = False, + char_jitter: float = 0.0, + char_scale: float = 1.0, + char_rotation: float = 0.0, + char_hue_shift: float = 0.0, + jitter_source: str = "none", + scale_source: str = "none", + rotation_source: str = "none", + hue_source: str = "none") -> np.ndarray: + """ + Render a grid of characters with per-character effects. + + Args: + img: source image (for dimensions) + chars: 2D list of single characters + colors: (rows, cols, 3) array of colors per cell + luminances: (rows, cols) array of luminance values (0-255) + cell_size: size of each cell + color_mode: "color", "mono", "invert", or any color name/hex + background_color: background color name/hex + invert_colors: if True, swap foreground and background colors + char_jitter: base jitter amount in pixels + char_scale: base scale factor (1.0 = normal) + char_rotation: base rotation in degrees + char_hue_shift: base hue shift in degrees (0-360) + jitter_source: source for jitter modulation ("none", "luminance", "position", "random") + scale_source: source for scale modulation + rotation_source: source for rotation modulation + hue_source: source for hue modulation + + Per-character effect sources: + "none" - use base value only + "luminance" - modulate by cell luminance (0-1) + "inv_luminance" - modulate by inverse luminance (dark = high) + "saturation" - modulate by cell color saturation + "position_x" - modulate by horizontal position (0-1) + "position_y" - modulate by vertical position (0-1) + "position_diag" - modulate by diagonal position + "random" - random per-cell value (deterministic from position) + "center_dist" - distance from center (0=center, 1=corner) + + Returns: rendered image + """ + # Parse colors + fg_color = parse_color(color_mode) + + if isinstance(background_color, (list, tuple)): + bg_color = tuple(int(c) for c in background_color[:3]) + else: + bg_color = parse_color(background_color) + if bg_color is None: + bg_color = (0, 0, 0) + + if invert_colors and fg_color is not None: + fg_color, bg_color = bg_color, fg_color + + cell_size = max(1, int(cell_size)) + + if not chars or not chars[0]: + return img.copy() + + rows = len(chars) + cols = len(chars[0]) + h, w = rows * cell_size, cols * cell_size + + bg = list(bg_color) + result = np.full((h, w, 3), bg, dtype=np.uint8) + + # Normalize luminances to 0-1 + lum_normalized = luminances.astype(np.float32) / 255.0 + + # Compute saturation from colors + colors_float = colors.astype(np.float32) / 255.0 + max_c = colors_float.max(axis=2) + min_c = colors_float.min(axis=2) + saturation = np.where(max_c > 0, (max_c - min_c) / max_c, 0) + + # Helper to get modulation value for a cell + def get_mod_value(source: str, r: int, c: int) -> float: + if source == "none": + return 1.0 + elif source == "luminance": + return lum_normalized[r, c] + elif source == "inv_luminance": + return 1.0 - lum_normalized[r, c] + elif source == "saturation": + return saturation[r, c] + elif source == "position_x": + return c / max(1, cols - 1) if cols > 1 else 0.5 + elif source == "position_y": + return r / max(1, rows - 1) if rows > 1 else 0.5 + elif source == "position_diag": + px = c / max(1, cols - 1) if cols > 1 else 0.5 + py = r / max(1, rows - 1) if rows > 1 else 0.5 + return (px + py) / 2.0 + elif source == "random": + # Deterministic random based on position + seed = (r * 1000 + c) % 10000 + return ((seed * 9301 + 49297) % 233280) / 233280.0 + elif source == "center_dist": + cx, cy = (cols - 1) / 2.0, (rows - 1) / 2.0 + dx = (c - cx) / max(1, cx) if cx > 0 else 0 + dy = (r - cy) / max(1, cy) if cy > 0 else 0 + return min(1.0, math.sqrt(dx*dx + dy*dy)) + else: + return 1.0 + + # Build character atlas at base size + font = cv2.FONT_HERSHEY_SIMPLEX + base_font_scale = cell_size / 20.0 + thickness = max(1, int(cell_size / 10)) + + unique_chars = set() + for row in chars: + for ch in row: + unique_chars.add(ch) + + # For rotation/scale, we need to render characters larger then transform + max_scale = max(1.0, char_scale * 1.5) # Allow headroom for scaling + atlas_size = int(cell_size * max_scale * 1.5) + + atlas = {} + for char in unique_chars: + if char and char != ' ': + try: + char_img = np.zeros((atlas_size, atlas_size), dtype=np.uint8) + scaled_font = base_font_scale * max_scale + (text_w, text_h), _ = cv2.getTextSize(char, font, scaled_font, thickness) + text_x = max(0, (atlas_size - text_w) // 2) + text_y = (atlas_size + text_h) // 2 + cv2.putText(char_img, char, (text_x, text_y), font, scaled_font, 255, thickness, cv2.LINE_AA) + atlas[char] = char_img + except: + atlas[char] = None + else: + atlas[char] = None + + # Render characters with effects + for r in range(rows): + for c in range(cols): + char = chars[r][c] + if not char or char == ' ': + continue + + char_img = atlas.get(char) + if char_img is None: + continue + + # Get per-cell modulation values + jitter_mod = get_mod_value(jitter_source, r, c) + scale_mod = get_mod_value(scale_source, r, c) + rot_mod = get_mod_value(rotation_source, r, c) + hue_mod = get_mod_value(hue_source, r, c) + + # Compute effective values + eff_jitter = char_jitter * jitter_mod + eff_scale = char_scale * (0.5 + 0.5 * scale_mod) if scale_source != "none" else char_scale + eff_rotation = char_rotation * (rot_mod * 2 - 1) # -1 to 1 range + eff_hue_shift = char_hue_shift * hue_mod + + # Apply transformations + transformed = char_img.copy() + + # Rotation + if abs(eff_rotation) > 0.5: + center = (atlas_size // 2, atlas_size // 2) + rot_matrix = cv2.getRotationMatrix2D(center, eff_rotation, 1.0) + transformed = cv2.warpAffine(transformed, rot_matrix, (atlas_size, atlas_size)) + + # Scale - resize to target size + target_size = max(1, int(cell_size * eff_scale)) + if target_size != atlas_size: + transformed = cv2.resize(transformed, (target_size, target_size), interpolation=cv2.INTER_LINEAR) + + # Compute position with jitter + base_y = r * cell_size + base_x = c * cell_size + + if eff_jitter > 0: + # Deterministic jitter based on position + jx = ((r * 7 + c * 13) % 100) / 100.0 - 0.5 + jy = ((r * 11 + c * 17) % 100) / 100.0 - 0.5 + base_x += int(jx * eff_jitter * 2) + base_y += int(jy * eff_jitter * 2) + + # Center the character in the cell + offset = (target_size - cell_size) // 2 + y1 = base_y - offset + x1 = base_x - offset + + # Determine color + if fg_color is not None: + color = np.array(fg_color, dtype=np.uint8) + elif color_mode == "mono": + color = np.array([255, 255, 255], dtype=np.uint8) + elif color_mode == "invert": + # Fill cell with source color first + cy1 = max(0, r * cell_size) + cy2 = min(h, (r + 1) * cell_size) + cx1 = max(0, c * cell_size) + cx2 = min(w, (c + 1) * cell_size) + result[cy1:cy2, cx1:cx2] = colors[r, c] + color = np.array([0, 0, 0], dtype=np.uint8) + else: # color mode + color = colors[r, c].copy() + + # Apply hue shift + if abs(eff_hue_shift) > 0.5 and color_mode not in ("mono", "invert") and fg_color is None: + # Convert to HSV, shift hue, convert back + color_hsv = cv2.cvtColor(color.reshape(1, 1, 3), cv2.COLOR_RGB2HSV) + # Cast to int to avoid uint8 overflow, then back to uint8 + new_hue = (int(color_hsv[0, 0, 0]) + int(eff_hue_shift * 180 / 360)) % 180 + color_hsv[0, 0, 0] = np.uint8(new_hue) + color = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2RGB).flatten() + + # Blit character to result + mask = transformed > 0 + th, tw = transformed.shape[:2] + + for dy in range(th): + for dx in range(tw): + py = y1 + dy + px = x1 + dx + if 0 <= py < h and 0 <= px < w and mask[dy, dx]: + result[py, px] = color + + # Resize to match original if needed + orig_h, orig_w = img.shape[:2] + if result.shape[0] != orig_h or result.shape[1] != orig_w: + padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8) + copy_h = min(h, orig_h) + copy_w = min(w, orig_w) + padded[:copy_h, :copy_w] = result[:copy_h, :copy_w] + result = padded + + return result + + +def _render_with_cell_effect( + frame: np.ndarray, + chars: List[List[str]], + colors: np.ndarray, + luminances: np.ndarray, + zone_contexts: List[List['ZoneContext']], + cell_size: int, + bg_color: tuple, + fg_color: tuple, + color_mode: str, + cell_effect, # Lambda or callable: (cell_image, zone_dict) -> cell_image + extra_params: dict, + interp, + env, + result: np.ndarray, +) -> np.ndarray: + """ + Render ASCII art using a cell_effect lambda for arbitrary per-cell transforms. + + Each character is rendered to a cell image, the cell_effect is called with + (cell_image, zone_dict), and the returned cell is composited into result. + + This allows arbitrary effects (rotate, blur, etc.) to be applied per-character. + """ + grid_rows = len(chars) + grid_cols = len(chars[0]) if chars else 0 + out_h, out_w = result.shape[:2] + + # Build character atlas (cell-sized colored characters on transparent bg) + font = cv2.FONT_HERSHEY_SIMPLEX + font_scale = cell_size / 20.0 + thickness = max(1, int(cell_size / 10)) + + # Helper to render a single character cell + def render_char_cell(char: str, color: np.ndarray) -> np.ndarray: + """Render a character onto a cell-sized RGB image.""" + cell = np.full((cell_size, cell_size, 3), bg_color, dtype=np.uint8) + if not char or char == ' ': + return cell + + try: + (text_w, text_h), _ = cv2.getTextSize(char, font, font_scale, thickness) + text_x = max(0, (cell_size - text_w) // 2) + text_y = (cell_size + text_h) // 2 + + # Render character in white on mask, then apply color + mask = np.zeros((cell_size, cell_size), dtype=np.uint8) + cv2.putText(mask, char, (text_x, text_y), font, font_scale, 255, thickness, cv2.LINE_AA) + + # Apply color where mask is set + for ch in range(3): + cell[:, :, ch] = np.where(mask > 0, color[ch], bg_color[ch]) + except: + pass + + return cell + + # Helper to evaluate cell_effect (handles artdag Lambda objects) + def eval_cell_effect(cell_img: np.ndarray, zone_dict: dict) -> np.ndarray: + """Call cell_effect with (cell_image, zone_dict), handle Lambda objects.""" + if callable(cell_effect): + return cell_effect(cell_img, zone_dict) + + # Check if it's an artdag Lambda object + try: + from artdag.sexp.parser import Lambda as ArtdagLambda + from artdag.sexp.evaluator import evaluate as artdag_evaluate + if isinstance(cell_effect, ArtdagLambda): + # Build env with closure values + eval_env = dict(cell_effect.closure) if cell_effect.closure else {} + # Bind lambda parameters + if len(cell_effect.params) >= 2: + eval_env[cell_effect.params[0]] = cell_img + eval_env[cell_effect.params[1]] = zone_dict + elif len(cell_effect.params) == 1: + # Single param gets zone_dict with cell as 'cell' key + zone_dict['cell'] = cell_img + eval_env[cell_effect.params[0]] = zone_dict + + # Add primitives to eval env + eval_env.update(PRIMITIVES) + + # Add effect runner - allows calling any loaded sexp effect on a cell + # Usage: (apply-effect "effect_name" cell {"param" value ...}) + # Or: (apply-effect "effect_name" cell) for defaults + def apply_effect_fn(effect_name, frame, params=None): + """Run a loaded sexp effect on a frame (cell).""" + if interp and hasattr(interp, 'run_effect'): + if params is None: + params = {} + result, _ = interp.run_effect(effect_name, frame, params, {}) + return result + return frame + eval_env['apply-effect'] = apply_effect_fn + + # Also inject loaded effects directly as callable functions + # These wrappers take positional args in common order for each effect + # Usage: (blur cell 5) or (rotate cell 45) etc. + if interp and hasattr(interp, 'effects'): + for effect_name in interp.effects: + # Create a wrapper that calls run_effect with positional-to-named mapping + def make_effect_fn(name): + def effect_fn(frame, *args): + # Map common positional args to named params + params = {} + if name == 'blur' and len(args) >= 1: + params['radius'] = args[0] + elif name == 'rotate' and len(args) >= 1: + params['angle'] = args[0] + elif name == 'brightness' and len(args) >= 1: + params['factor'] = args[0] + elif name == 'contrast' and len(args) >= 1: + params['factor'] = args[0] + elif name == 'saturation' and len(args) >= 1: + params['factor'] = args[0] + elif name == 'hue_shift' and len(args) >= 1: + params['degrees'] = args[0] + elif name == 'rgb_split' and len(args) >= 1: + params['offset_x'] = args[0] + if len(args) >= 2: + params['offset_y'] = args[1] + elif name == 'pixelate' and len(args) >= 1: + params['block_size'] = args[0] + elif name == 'wave' and len(args) >= 1: + params['amplitude'] = args[0] + if len(args) >= 2: + params['frequency'] = args[1] + elif name == 'noise' and len(args) >= 1: + params['amount'] = args[0] + elif name == 'posterize' and len(args) >= 1: + params['levels'] = args[0] + elif name == 'threshold' and len(args) >= 1: + params['level'] = args[0] + elif name == 'sharpen' and len(args) >= 1: + params['amount'] = args[0] + elif len(args) == 1 and isinstance(args[0], dict): + # Accept dict as single arg + params = args[0] + result, _ = interp.run_effect(name, frame, params, {}) + return result + return effect_fn + eval_env[effect_name] = make_effect_fn(effect_name) + + result = artdag_evaluate(cell_effect.body, eval_env) + if isinstance(result, np.ndarray): + return result + return cell_img + except ImportError: + pass + + # Fallback: return cell unchanged + return cell_img + + # Render each cell + for r in range(grid_rows): + for c in range(grid_cols): + char = chars[r][c] + zone = zone_contexts[r][c] + + # Determine character color + if fg_color is not None: + color = np.array(fg_color, dtype=np.uint8) + elif color_mode == "mono": + color = np.array([255, 255, 255], dtype=np.uint8) + elif color_mode == "invert": + color = np.array([0, 0, 0], dtype=np.uint8) + else: + color = colors[r, c].copy() + + # Render character to cell image + cell_img = render_char_cell(char, color) + + # Build zone dict + zone_dict = { + 'row': zone.row, + 'col': zone.col, + 'row-norm': zone.row_norm, + 'col-norm': zone.col_norm, + 'lum': zone.luminance, + 'sat': zone.saturation, + 'hue': zone.hue, + 'r': zone.r, + 'g': zone.g, + 'b': zone.b, + 'char': char, + 'color': color.tolist(), + 'cell_size': cell_size, + } + # Add extra params (energy, rotation_scale, etc.) + if extra_params: + zone_dict.update(extra_params) + + # Call cell_effect + modified_cell = eval_cell_effect(cell_img, zone_dict) + + # Ensure result is valid + if modified_cell is None or not isinstance(modified_cell, np.ndarray): + modified_cell = cell_img + if modified_cell.shape[:2] != (cell_size, cell_size): + # Resize if cell size changed + modified_cell = cv2.resize(modified_cell, (cell_size, cell_size)) + if len(modified_cell.shape) == 2: + # Convert grayscale to RGB + modified_cell = cv2.cvtColor(modified_cell, cv2.COLOR_GRAY2RGB) + + # Composite into result + y1 = r * cell_size + x1 = c * cell_size + y2 = min(y1 + cell_size, out_h) + x2 = min(x1 + cell_size, out_w) + ch = y2 - y1 + cw = x2 - x1 + result[y1:y2, x1:x2] = modified_cell[:ch, :cw] + + # Resize to match original frame if needed + orig_h, orig_w = frame.shape[:2] + if result.shape[0] != orig_h or result.shape[1] != orig_w: + bg = list(bg_color) + padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8) + copy_h = min(out_h, orig_h) + copy_w = min(out_w, orig_w) + padded[:copy_h, :copy_w] = result[:copy_h, :copy_w] + result = padded + + return result + + +def prim_ascii_fx_zone( + frame: np.ndarray, + cols: int, + char_size_override: int, # If set, overrides cols-based calculation + alphabet: str, + color_mode: str, + background: str, + contrast: float, + char_hue_expr, # Expression, literal, or None + char_sat_expr, # Expression, literal, or None + char_bright_expr, # Expression, literal, or None + char_scale_expr, # Expression, literal, or None + char_rotation_expr, # Expression, literal, or None + char_jitter_expr, # Expression, literal, or None + interp, # Interpreter for expression evaluation + env, # Environment with bound values + extra_params=None, # Extra params to include in zone dict for lambdas + cell_effect=None, # Lambda (cell_image, zone_dict) -> cell_image for arbitrary cell effects +) -> np.ndarray: + """ + Render ASCII art with per-zone expression-driven transforms. + + Args: + frame: Source image (H, W, 3) RGB uint8 + cols: Number of character columns + char_size_override: If set, use this cell size instead of cols-based + alphabet: Character set name or literal string + color_mode: "color", "mono", "invert", or color name/hex + background: Background color name or hex + contrast: Contrast boost for character selection + char_hue_expr: Expression for hue shift (evaluated per zone) + char_sat_expr: Expression for saturation adjustment (evaluated per zone) + char_bright_expr: Expression for brightness adjustment (evaluated per zone) + char_scale_expr: Expression for scale factor (evaluated per zone) + char_rotation_expr: Expression for rotation degrees (evaluated per zone) + char_jitter_expr: Expression for position jitter (evaluated per zone) + interp: Interpreter instance for expression evaluation + env: Environment with bound variables + cell_effect: Optional lambda that receives (cell_image, zone_dict) and returns + a modified cell_image. When provided, each character is rendered + to a cell image, passed to this lambda, and the result composited. + This allows arbitrary effects to be applied per-character. + + Zone variables available in expressions: + 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: Rendered image + """ + h, w = frame.shape[:2] + # Use char_size if provided, otherwise calculate from cols + if char_size_override is not None: + cell_size = max(4, int(char_size_override)) + else: + cell_size = max(4, w // cols) + + # Get zone data using extended sampling + colors, luminances, zone_contexts = cell_sample_extended(frame, cell_size) + + # Convert luminances to characters + chars = prim_luminance_to_chars(luminances, alphabet, contrast) + + grid_rows = len(chars) + grid_cols = len(chars[0]) if chars else 0 + + # Parse colors + fg_color = parse_color(color_mode) + if isinstance(background, (list, tuple)): + bg_color = tuple(int(c) for c in background[:3]) + else: + bg_color = parse_color(background) + if bg_color is None: + bg_color = (0, 0, 0) + + # Arrays for per-zone transform values + hue_shifts = np.zeros((grid_rows, grid_cols), dtype=np.float32) + saturations = np.ones((grid_rows, grid_cols), dtype=np.float32) + brightness = np.ones((grid_rows, grid_cols), dtype=np.float32) + scales = np.ones((grid_rows, grid_cols), dtype=np.float32) + rotations = np.zeros((grid_rows, grid_cols), dtype=np.float32) + jitters = np.zeros((grid_rows, grid_cols), dtype=np.float32) + + # Helper to evaluate expression or return literal value + def eval_expr(expr, zone, char): + if expr is None: + return None + if isinstance(expr, (int, float)): + return expr + + # Build zone dict for lambda calls + zone_dict = { + 'row': zone.row, + 'col': zone.col, + 'row-norm': zone.row_norm, + 'col-norm': zone.col_norm, + 'lum': zone.luminance, + 'sat': zone.saturation, + 'hue': zone.hue, + 'r': zone.r, + 'g': zone.g, + 'b': zone.b, + 'char': char, + } + # Add extra params (energy, rotation_scale, etc.) for lambdas to access + if extra_params: + zone_dict.update(extra_params) + + # Check if it's a Python callable + if callable(expr): + return expr(zone_dict) + + # Check if it's an artdag Lambda object + try: + from artdag.sexp.parser import Lambda as ArtdagLambda + from artdag.sexp.evaluator import evaluate as artdag_evaluate + if isinstance(expr, ArtdagLambda): + # Build env with zone dict and any closure values + eval_env = dict(expr.closure) if expr.closure else {} + # Bind the lambda parameter to zone_dict + if expr.params: + eval_env[expr.params[0]] = zone_dict + return artdag_evaluate(expr.body, eval_env) + except ImportError: + pass + + # It's an expression - evaluate with zone context (sexp_effects style) + return interp.eval_with_zone(expr, env, zone) + + # Evaluate expressions for each zone + for r in range(grid_rows): + for c in range(grid_cols): + zone = zone_contexts[r][c] + char = chars[r][c] + + val = eval_expr(char_hue_expr, zone, char) + if val is not None: + hue_shifts[r, c] = float(val) + + val = eval_expr(char_sat_expr, zone, char) + if val is not None: + saturations[r, c] = float(val) + + val = eval_expr(char_bright_expr, zone, char) + if val is not None: + brightness[r, c] = float(val) + + val = eval_expr(char_scale_expr, zone, char) + if val is not None: + scales[r, c] = float(val) + + val = eval_expr(char_rotation_expr, zone, char) + if val is not None: + rotations[r, c] = float(val) + + val = eval_expr(char_jitter_expr, zone, char) + if val is not None: + jitters[r, c] = float(val) + + # Now render with computed transform arrays + out_h, out_w = grid_rows * cell_size, grid_cols * cell_size + bg = list(bg_color) + result = np.full((out_h, out_w, 3), bg, dtype=np.uint8) + + # If cell_effect is provided, use the cell-mapper rendering path + if cell_effect is not None: + return _render_with_cell_effect( + frame, chars, colors, luminances, zone_contexts, + cell_size, bg_color, fg_color, color_mode, + cell_effect, extra_params, interp, env, result + ) + + # Build character atlas + font = cv2.FONT_HERSHEY_SIMPLEX + base_font_scale = cell_size / 20.0 + thickness = max(1, int(cell_size / 10)) + + unique_chars = set() + for row in chars: + for ch in row: + unique_chars.add(ch) + + # For rotation/scale, render characters larger then transform + max_scale = max(1.0, np.max(scales) * 1.5) + atlas_size = int(cell_size * max_scale * 1.5) + + atlas = {} + for char in unique_chars: + if char and char != ' ': + try: + char_img = np.zeros((atlas_size, atlas_size), dtype=np.uint8) + scaled_font = base_font_scale * max_scale + (text_w, text_h), _ = cv2.getTextSize(char, font, scaled_font, thickness) + text_x = max(0, (atlas_size - text_w) // 2) + text_y = (atlas_size + text_h) // 2 + cv2.putText(char_img, char, (text_x, text_y), font, scaled_font, 255, thickness, cv2.LINE_AA) + atlas[char] = char_img + except: + atlas[char] = None + else: + atlas[char] = None + + # Render characters with per-zone effects + for r in range(grid_rows): + for c in range(grid_cols): + char = chars[r][c] + if not char or char == ' ': + continue + + char_img = atlas.get(char) + if char_img is None: + continue + + # Get per-cell values + eff_scale = scales[r, c] + eff_rotation = rotations[r, c] + eff_jitter = jitters[r, c] + eff_hue_shift = hue_shifts[r, c] + eff_brightness = brightness[r, c] + eff_saturation = saturations[r, c] + + # Apply transformations to character + transformed = char_img.copy() + + # Rotation + if abs(eff_rotation) > 0.5: + center = (atlas_size // 2, atlas_size // 2) + rot_matrix = cv2.getRotationMatrix2D(center, eff_rotation, 1.0) + transformed = cv2.warpAffine(transformed, rot_matrix, (atlas_size, atlas_size)) + + # Scale - resize to target size + target_size = max(1, int(cell_size * eff_scale)) + if target_size != atlas_size: + transformed = cv2.resize(transformed, (target_size, target_size), interpolation=cv2.INTER_LINEAR) + + # Compute position with jitter + base_y = r * cell_size + base_x = c * cell_size + + if eff_jitter > 0: + # Deterministic jitter based on position + jx = ((r * 7 + c * 13) % 100) / 100.0 - 0.5 + jy = ((r * 11 + c * 17) % 100) / 100.0 - 0.5 + base_x += int(jx * eff_jitter * 2) + base_y += int(jy * eff_jitter * 2) + + # Center the character in the cell + offset = (target_size - cell_size) // 2 + y1 = base_y - offset + x1 = base_x - offset + + # Determine color + if fg_color is not None: + color = np.array(fg_color, dtype=np.uint8) + elif color_mode == "mono": + color = np.array([255, 255, 255], dtype=np.uint8) + elif color_mode == "invert": + cy1 = max(0, r * cell_size) + cy2 = min(out_h, (r + 1) * cell_size) + cx1 = max(0, c * cell_size) + cx2 = min(out_w, (c + 1) * cell_size) + result[cy1:cy2, cx1:cx2] = colors[r, c] + color = np.array([0, 0, 0], dtype=np.uint8) + else: # color mode - use source colors + color = colors[r, c].copy() + + # Apply hue shift + if abs(eff_hue_shift) > 0.5 and color_mode not in ("mono", "invert") and fg_color is None: + color_hsv = cv2.cvtColor(color.reshape(1, 1, 3), cv2.COLOR_RGB2HSV) + new_hue = (int(color_hsv[0, 0, 0]) + int(eff_hue_shift * 180 / 360)) % 180 + color_hsv[0, 0, 0] = np.uint8(new_hue) + color = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2RGB).flatten() + + # Apply saturation adjustment + if abs(eff_saturation - 1.0) > 0.01 and color_mode not in ("mono", "invert") and fg_color is None: + color_hsv = cv2.cvtColor(color.reshape(1, 1, 3), cv2.COLOR_RGB2HSV) + new_sat = np.clip(int(color_hsv[0, 0, 1] * eff_saturation), 0, 255) + color_hsv[0, 0, 1] = np.uint8(new_sat) + color = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2RGB).flatten() + + # Apply brightness adjustment + if abs(eff_brightness - 1.0) > 0.01: + color = np.clip(color.astype(np.float32) * eff_brightness, 0, 255).astype(np.uint8) + + # Blit character to result + mask = transformed > 0 + th, tw = transformed.shape[:2] + + for dy in range(th): + for dx in range(tw): + py = y1 + dy + px = x1 + dx + if 0 <= py < out_h and 0 <= px < out_w and mask[dy, dx]: + result[py, px] = color + + # Resize to match original if needed + orig_h, orig_w = frame.shape[:2] + if result.shape[0] != orig_h or result.shape[1] != orig_w: + padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8) + copy_h = min(out_h, orig_h) + copy_w = min(out_w, orig_w) + padded[:copy_h, :copy_w] = result[:copy_h, :copy_w] + result = padded + + return result + + def prim_make_char_grid(rows: int, cols: int, fill_char: str = " ") -> List[List[str]]: """Create a character grid filled with a character.""" return [[fill_char for _ in range(cols)] for _ in range(rows)] @@ -1850,8 +2981,11 @@ PRIMITIVES = { # Character/ASCII art 'cell-sample': prim_cell_sample, + 'cell-sample-extended': cell_sample_extended, 'luminance-to-chars': prim_luminance_to_chars, 'render-char-grid': prim_render_char_grid, + 'render-char-grid-fx': prim_render_char_grid_fx, + 'ascii-fx-zone': prim_ascii_fx_zone, 'make-char-grid': prim_make_char_grid, 'set-char': prim_set_char, 'get-char': prim_get_char, @@ -1864,4 +2998,5 @@ PRIMITIVES = { # Glitch art 'pixelsort': prim_pixelsort, 'datamosh': prim_datamosh, + } diff --git a/sexp_effects/test_interpreter.py b/sexp_effects/test_interpreter.py index 7f9d2ef..550b21a 100644 --- a/sexp_effects/test_interpreter.py +++ b/sexp_effects/test_interpreter.py @@ -150,6 +150,68 @@ def test_effect_execution(): return passed, failed +def test_ascii_fx_zone(): + """Test ascii_fx_zone effect with zone expressions.""" + print("Testing ascii_fx_zone...") + + interp = get_interpreter() + + # Load the effect + effects_dir = Path(__file__).parent / "effects" + load_effects_dir(str(effects_dir)) + + # Create gradient test frame + frame = np.zeros((120, 160, 3), dtype=np.uint8) + for x in range(160): + frame[:, x] = int(x / 160 * 255) + frame = np.stack([frame[:,:,0]]*3, axis=2) + + # Test 1: Basic without expressions + result, _ = run_effect('ascii_fx_zone', frame, {'cols': 20}, {}) + assert result.shape == frame.shape + print(" Basic run: OK") + + # Test 2: With zone-lum expression + expr = parse('(* zone-lum 180)') + result, _ = run_effect('ascii_fx_zone', frame, { + 'cols': 20, + 'char_hue': expr + }, {}) + assert result.shape == frame.shape + print(" Zone-lum expression: OK") + + # Test 3: With multiple expressions + scale_expr = parse('(+ 0.5 (* zone-lum 0.5))') + rot_expr = parse('(* zone-row-norm 30)') + result, _ = run_effect('ascii_fx_zone', frame, { + 'cols': 20, + 'char_scale': scale_expr, + 'char_rotation': rot_expr + }, {}) + assert result.shape == frame.shape + print(" Multiple expressions: OK") + + # Test 4: With numeric literals + result, _ = run_effect('ascii_fx_zone', frame, { + 'cols': 20, + 'char_hue': 90, + 'char_scale': 1.2 + }, {}) + assert result.shape == frame.shape + print(" Numeric literals: OK") + + # Test 5: Zone position expressions + col_expr = parse('(* zone-col-norm 360)') + result, _ = run_effect('ascii_fx_zone', frame, { + 'cols': 20, + 'char_hue': col_expr + }, {}) + assert result.shape == frame.shape + print(" Zone position expression: OK") + + print(" ascii_fx_zone OK") + + def main(): print("=" * 60) print("S-Expression Effect Interpreter Tests") @@ -159,6 +221,7 @@ def main(): test_interpreter_basics() test_primitives() test_effect_loading() + test_ascii_fx_zone() passed, failed = test_effect_execution() print("=" * 60)