Add composable ASCII art with per-cell effects and explicit effect loading
Implements ascii_fx_zone effect that allows applying arbitrary sexp effects to each character cell via cell_effect lambdas. Each cell is rendered as a small image that effects can operate on. Key changes: - New ascii_fx_zone effect with cell_effect parameter for per-cell transforms - Zone context (row, col, lum, sat, hue, etc.) available in cell_effect lambdas - Effects are now loaded explicitly from recipe declarations, not auto-loaded - Added effects_registry to plan for explicit effect dependency tracking - Updated effect definition syntax across all sexp effects - New run_staged.py for executing staged recipes - Example recipes demonstrating alternating rotation and blur/rgb_split patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))]
|
||||
|
||||
69
effects/ascii_alternating_fx.sexp
Normal file
69
effects/ascii_alternating_fx.sexp
Normal file
@@ -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)))
|
||||
64
effects/ascii_alternating_rotate.sexp
Normal file
64
effects/ascii_alternating_rotate.sexp
Normal file
@@ -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)))
|
||||
89
effects/ascii_art_fx_staged.sexp
Normal file
89
effects/ascii_art_fx_staged.sexp
Normal file
@@ -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)))
|
||||
59
effects/ascii_art_staged.sexp
Normal file
59
effects/ascii_art_staged.sexp
Normal file
@@ -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)))
|
||||
64
effects/ascii_cell_effect_staged.sexp
Normal file
64
effects/ascii_cell_effect_staged.sexp
Normal file
@@ -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)))
|
||||
66
effects/ascii_fx_zone_staged.sexp
Normal file
66
effects/ascii_fx_zone_staged.sexp
Normal file
@@ -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)))
|
||||
55
execute.py
55
execute.py
@@ -131,6 +131,9 @@ def sexp_to_plan(sexp) -> dict:
|
||||
elif item[0].name == "analysis":
|
||||
# Parse analysis data
|
||||
plan["analysis"] = parse_analysis_sexp(item)
|
||||
elif item[0].name == "effects-registry":
|
||||
# Parse effects registry
|
||||
plan["effects_registry"] = parse_effects_registry_sexp(item)
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
@@ -159,6 +162,27 @@ def parse_analysis_sexp(sexp) -> dict:
|
||||
return analysis
|
||||
|
||||
|
||||
def parse_effects_registry_sexp(sexp) -> dict:
|
||||
"""Parse effects-registry S-expression: (effects-registry (rotate :path "...") (blur :path "..."))"""
|
||||
registry = {}
|
||||
for item in sexp[1:]: # Skip 'effects-registry' symbol
|
||||
if isinstance(item, list) and item and isinstance(item[0], Symbol):
|
||||
name = item[0].name
|
||||
data = {}
|
||||
j = 1
|
||||
while j < len(item):
|
||||
if isinstance(item[j], Keyword):
|
||||
key = item[j].name
|
||||
j += 1
|
||||
if j < len(item):
|
||||
data[key] = item[j]
|
||||
j += 1
|
||||
else:
|
||||
j += 1
|
||||
registry[name] = data
|
||||
return registry
|
||||
|
||||
|
||||
def parse_bind_sexp(sexp) -> dict:
|
||||
"""Parse a bind S-expression: (bind analysis-ref :range [min max] :offset 60 :transform sqrt)"""
|
||||
if not isinstance(sexp, list) or len(sexp) < 2:
|
||||
@@ -324,9 +348,22 @@ def get_encoding(recipe_encoding: dict, step_config: dict) -> dict:
|
||||
class SexpEffectModule:
|
||||
"""Wrapper for S-expression effects to provide process_frame interface."""
|
||||
|
||||
def __init__(self, effect_path: Path):
|
||||
def __init__(self, effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None):
|
||||
from sexp_effects import get_interpreter
|
||||
self.interp = get_interpreter()
|
||||
|
||||
# Load only explicitly declared effects from the recipe's registry
|
||||
# No auto-loading from directory - everything must be explicit
|
||||
if effects_registry:
|
||||
base_dir = recipe_dir or effect_path.parent.parent # Resolve relative paths
|
||||
for effect_name, effect_info in effects_registry.items():
|
||||
effect_rel_path = effect_info.get("path")
|
||||
if effect_rel_path:
|
||||
full_path = (base_dir / effect_rel_path).resolve()
|
||||
if full_path.exists() and effect_name not in self.interp.effects:
|
||||
self.interp.load_effect(str(full_path))
|
||||
|
||||
# Load the specific effect if not already loaded
|
||||
self.interp.load_effect(str(effect_path))
|
||||
self.effect_name = effect_path.stem
|
||||
|
||||
@@ -334,10 +371,10 @@ class SexpEffectModule:
|
||||
return self.interp.run_effect(self.effect_name, frame, params, state or {})
|
||||
|
||||
|
||||
def load_effect(effect_path: Path):
|
||||
def load_effect(effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None):
|
||||
"""Load an effect module from a local path (.py or .sexp)."""
|
||||
if effect_path.suffix == ".sexp":
|
||||
return SexpEffectModule(effect_path)
|
||||
return SexpEffectModule(effect_path, effects_registry, recipe_dir)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("effect", effect_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
@@ -939,6 +976,11 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
||||
if analysis_data:
|
||||
print(f"Analysis tracks: {list(analysis_data.keys())}", file=sys.stderr)
|
||||
|
||||
# Get effects registry for loading explicitly declared effects
|
||||
effects_registry = plan.get("effects_registry", {})
|
||||
if effects_registry:
|
||||
print(f"Effects registry: {list(effects_registry.keys())}", file=sys.stderr)
|
||||
|
||||
# Execute steps
|
||||
results = {} # step_id -> output_path
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="artdag_exec_"))
|
||||
@@ -1082,7 +1124,7 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
||||
|
||||
if effect_path:
|
||||
full_path = recipe_dir / effect_path
|
||||
effect_module = load_effect(full_path)
|
||||
effect_module = load_effect(full_path, effects_registry, recipe_dir)
|
||||
params = {k: v for k, v in config.items()
|
||||
if k not in ("effect", "effect_path", "cid", "encoding", "multi_input")}
|
||||
print(f" Effect: {effect_name}", file=sys.stderr)
|
||||
@@ -1209,6 +1251,9 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
||||
if not filter_chain_raw:
|
||||
raise ValueError("COMPOUND step has empty filter_chain")
|
||||
|
||||
# Get effects registry for loading explicitly declared effects
|
||||
effects_registry = config.get("effects_registry", {})
|
||||
|
||||
# Convert filter_chain items from S-expression lists to dicts
|
||||
# and clean nil Symbols from configs
|
||||
filter_chain = []
|
||||
@@ -1375,7 +1420,7 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
||||
|
||||
if effect_path:
|
||||
full_path = recipe_dir / effect_path
|
||||
effect_module = load_effect(full_path)
|
||||
effect_module = load_effect(full_path, effects_registry, recipe_dir)
|
||||
params = {k: v for k, v in effect_config.items()
|
||||
if k not in ("effect", "effect_path", "cid", "encoding", "type")}
|
||||
print(f" COMPOUND [{i+1}/{len(effects)}]: {effect_name} (Python)", file=sys.stderr)
|
||||
|
||||
335
run_staged.py
Normal file
335
run_staged.py
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run a staged recipe through analyze -> plan -> execute pipeline.
|
||||
|
||||
This script demonstrates stage-level caching: analysis stages can be
|
||||
skipped on re-run if the inputs haven't changed.
|
||||
|
||||
Usage:
|
||||
python3 run_staged.py recipe.sexp [-o output.mp4]
|
||||
python3 run_staged.py effects/ascii_art_staged.sexp -o ascii_out.mp4
|
||||
|
||||
The script:
|
||||
1. Compiles the recipe and extracts stage information
|
||||
2. For each stage in topological order:
|
||||
- Check stage cache (skip if hit)
|
||||
- Run stage (analyze, plan, execute)
|
||||
- Cache stage outputs
|
||||
3. Produce final output
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# Add artdag to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "artdag"))
|
||||
|
||||
from artdag.sexp import compile_string, parse
|
||||
from artdag.sexp.parser import Symbol, Keyword
|
||||
from artdag.sexp.planner import create_plan
|
||||
|
||||
|
||||
def run_staged_recipe(
|
||||
recipe_path: Path,
|
||||
output_path: Optional[Path] = None,
|
||||
cache_dir: Optional[Path] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
verbose: bool = True,
|
||||
) -> Path:
|
||||
"""
|
||||
Run a staged recipe with stage-level caching.
|
||||
|
||||
Args:
|
||||
recipe_path: Path to the .sexp recipe file
|
||||
output_path: Optional output file path
|
||||
cache_dir: Optional cache directory for stage results
|
||||
params: Optional parameter overrides
|
||||
verbose: Print progress information
|
||||
|
||||
Returns:
|
||||
Path to the final output file
|
||||
"""
|
||||
recipe_text = recipe_path.read_text()
|
||||
recipe_dir = recipe_path.parent
|
||||
|
||||
# Set up cache directory
|
||||
if cache_dir is None:
|
||||
cache_dir = recipe_dir / ".stage_cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def log(msg: str):
|
||||
if verbose:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
# Compile recipe
|
||||
log(f"Compiling: {recipe_path}")
|
||||
compiled = compile_string(recipe_text, params)
|
||||
log(f"Recipe: {compiled.name} v{compiled.version}")
|
||||
log(f"Nodes: {len(compiled.nodes)}")
|
||||
|
||||
# Check for stages
|
||||
if not compiled.stages:
|
||||
log("No stages found - running as regular recipe")
|
||||
return _run_non_staged(compiled, recipe_dir, output_path, verbose)
|
||||
|
||||
log(f"\nStages: {len(compiled.stages)}")
|
||||
log(f"Stage order: {compiled.stage_order}")
|
||||
|
||||
# Display stage info
|
||||
for stage in compiled.stages:
|
||||
log(f"\n Stage: {stage.name}")
|
||||
log(f" Requires: {stage.requires or '(none)'}")
|
||||
log(f" Inputs: {stage.inputs or '(none)'}")
|
||||
log(f" Outputs: {stage.outputs}")
|
||||
|
||||
# Create plan with analysis
|
||||
log("\n--- Planning ---")
|
||||
analysis_data = {}
|
||||
|
||||
def on_analysis(node_id: str, results: dict):
|
||||
analysis_data[node_id] = results
|
||||
times = results.get("times", [])
|
||||
log(f" Analysis complete: {node_id[:16]}... ({len(times)} times)")
|
||||
|
||||
plan = create_plan(
|
||||
compiled,
|
||||
inputs={},
|
||||
recipe_dir=recipe_dir,
|
||||
on_analysis=on_analysis,
|
||||
)
|
||||
|
||||
log(f"\nPlan ID: {plan.plan_id[:16]}...")
|
||||
log(f"Steps: {len(plan.steps)}")
|
||||
|
||||
# Execute the plan using execute.py logic
|
||||
log("\n--- Execution ---")
|
||||
from execute import execute_plan
|
||||
|
||||
plan_dict = {
|
||||
"plan_id": plan.plan_id,
|
||||
"recipe_id": compiled.name,
|
||||
"recipe_hash": plan.recipe_hash,
|
||||
"encoding": compiled.encoding,
|
||||
"output_step_id": plan.output_step_id,
|
||||
"analysis": analysis_data,
|
||||
"effects_registry": plan.effects_registry,
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
for step in plan.steps:
|
||||
step_dict = {
|
||||
"step_id": step.step_id,
|
||||
"node_type": step.node_type,
|
||||
"config": step.config,
|
||||
"inputs": step.inputs,
|
||||
"level": step.level,
|
||||
"cache_id": step.cache_id,
|
||||
}
|
||||
# Tag with stage info if present
|
||||
if step.stage:
|
||||
step_dict["stage"] = step.stage
|
||||
step_dict["stage_cache_id"] = step.stage_cache_id
|
||||
plan_dict["steps"].append(step_dict)
|
||||
|
||||
# Execute
|
||||
result_path = execute_plan(
|
||||
plan_path=None,
|
||||
output_path=output_path,
|
||||
recipe_dir=recipe_dir,
|
||||
plan_data=plan_dict,
|
||||
external_analysis=analysis_data,
|
||||
)
|
||||
|
||||
log(f"\n--- Complete ---")
|
||||
log(f"Output: {result_path}")
|
||||
|
||||
return result_path
|
||||
|
||||
|
||||
def _run_non_staged(compiled, recipe_dir: Path, output_path: Optional[Path], verbose: bool) -> Path:
|
||||
"""Run a non-staged recipe using the standard pipeline."""
|
||||
from execute import execute_plan
|
||||
from plan import plan_recipe
|
||||
|
||||
# This is a fallback for recipes without stages
|
||||
# Just run through regular plan -> execute
|
||||
raise NotImplementedError("Non-staged recipes should use plan.py | execute.py")
|
||||
|
||||
|
||||
def list_params(recipe_path: Path):
|
||||
"""List available parameters for a recipe and its effects."""
|
||||
from artdag.sexp import parse
|
||||
from artdag.sexp.parser import Symbol, Keyword
|
||||
from artdag.sexp.compiler import _parse_params
|
||||
from artdag.sexp.effect_loader import load_sexp_effect_file
|
||||
|
||||
recipe_text = recipe_path.read_text()
|
||||
sexp = parse(recipe_text)
|
||||
|
||||
if isinstance(sexp, list) and len(sexp) == 1:
|
||||
sexp = sexp[0]
|
||||
|
||||
# Find recipe name
|
||||
recipe_name = sexp[1] if len(sexp) > 1 and isinstance(sexp[1], str) else recipe_path.stem
|
||||
|
||||
# Find :params block and effect declarations
|
||||
recipe_params = []
|
||||
effect_declarations = {} # name -> path
|
||||
|
||||
i = 2
|
||||
while i < len(sexp):
|
||||
item = sexp[i]
|
||||
if isinstance(item, Keyword) and item.name == "params":
|
||||
if i + 1 < len(sexp):
|
||||
recipe_params = _parse_params(sexp[i + 1])
|
||||
i += 2
|
||||
elif isinstance(item, list) and item:
|
||||
# Check for effect declaration: (effect name :path "...")
|
||||
if isinstance(item[0], Symbol) and item[0].name == "effect":
|
||||
if len(item) >= 2:
|
||||
effect_name = item[1].name if isinstance(item[1], Symbol) else item[1]
|
||||
# Find :path
|
||||
j = 2
|
||||
while j < len(item):
|
||||
if isinstance(item[j], Keyword) and item[j].name == "path":
|
||||
if j + 1 < len(item):
|
||||
effect_declarations[effect_name] = item[j + 1]
|
||||
break
|
||||
j += 1
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Load effect params
|
||||
effect_params = {} # effect_name -> list of ParamDef
|
||||
recipe_dir = recipe_path.parent
|
||||
|
||||
for effect_name, effect_rel_path in effect_declarations.items():
|
||||
effect_path = recipe_dir / effect_rel_path
|
||||
if effect_path.exists() and effect_path.suffix == ".sexp":
|
||||
try:
|
||||
_, _, _, param_defs = load_sexp_effect_file(effect_path)
|
||||
if param_defs:
|
||||
effect_params[effect_name] = param_defs
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load params from effect {effect_name}: {e}", file=sys.stderr)
|
||||
|
||||
# Print results
|
||||
def print_params(params, header_prefix=""):
|
||||
print(f"{header_prefix}{'Name':<20} {'Type':<8} {'Default':<12} {'Range/Choices':<20} Description")
|
||||
print(f"{header_prefix}{'-' * 88}")
|
||||
for p in params:
|
||||
range_str = ""
|
||||
if p.range_min is not None and p.range_max is not None:
|
||||
range_str = f"[{p.range_min}, {p.range_max}]"
|
||||
elif p.choices:
|
||||
range_str = ", ".join(p.choices[:3])
|
||||
if len(p.choices) > 3:
|
||||
range_str += "..."
|
||||
|
||||
default_str = str(p.default) if p.default is not None else "-"
|
||||
if len(default_str) > 10:
|
||||
default_str = default_str[:9] + "…"
|
||||
|
||||
print(f"{header_prefix}{p.name:<20} {p.param_type:<8} {default_str:<12} {range_str:<20} {p.description}")
|
||||
|
||||
if recipe_params:
|
||||
print(f"\nRecipe parameters for '{recipe_name}':\n")
|
||||
print_params(recipe_params)
|
||||
else:
|
||||
print(f"\nRecipe '{recipe_name}' has no declared parameters.")
|
||||
|
||||
if effect_params:
|
||||
for effect_name, params in effect_params.items():
|
||||
print(f"\n\nEffect '{effect_name}' parameters:\n")
|
||||
print_params(params)
|
||||
|
||||
if not recipe_params and not effect_params:
|
||||
print("\nParameters can be declared using :params block:")
|
||||
print("""
|
||||
:params (
|
||||
(color_mode :type string :default "color" :desc "Character color")
|
||||
(char_size :type int :default 12 :range [4 32] :desc "Cell size")
|
||||
)
|
||||
""")
|
||||
return
|
||||
|
||||
print("\n\nUsage:")
|
||||
print(f" python3 run_staged.py {recipe_path} -p <name>=<value> [-p <name>=<value> ...]")
|
||||
print(f"\nExample:")
|
||||
all_params = recipe_params + [p for params in effect_params.values() for p in params]
|
||||
if all_params:
|
||||
p = all_params[0]
|
||||
example_val = p.default if p.default else ("value" if p.param_type == "string" else "1")
|
||||
print(f" python3 run_staged.py {recipe_path} -p {p.name}={example_val}")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run a staged recipe with stage-level caching",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python3 run_staged.py effects/ascii_art_fx_staged.sexp --list-params
|
||||
python3 run_staged.py effects/ascii_art_fx_staged.sexp -o output.mp4
|
||||
python3 run_staged.py recipe.sexp -p color_mode=lime -p char_jitter=5
|
||||
"""
|
||||
)
|
||||
parser.add_argument("recipe", type=Path, help="Recipe file (.sexp)")
|
||||
parser.add_argument("-o", "--output", type=Path, help="Output file path")
|
||||
parser.add_argument("-c", "--cache", type=Path, help="Stage cache directory")
|
||||
parser.add_argument("-p", "--param", action="append", dest="params",
|
||||
metavar="KEY=VALUE", help="Set recipe parameter")
|
||||
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress progress output")
|
||||
parser.add_argument("--list-params", action="store_true", help="List available parameters and exit")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.recipe.exists():
|
||||
print(f"Recipe not found: {args.recipe}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# List params mode
|
||||
if args.list_params:
|
||||
list_params(args.recipe)
|
||||
sys.exit(0)
|
||||
|
||||
# Parse parameters
|
||||
params = {}
|
||||
if args.params:
|
||||
for param_str in args.params:
|
||||
if "=" not in param_str:
|
||||
print(f"Invalid parameter format: {param_str}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
key, value = param_str.split("=", 1)
|
||||
# Try to parse as number
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
pass # Keep as string
|
||||
params[key] = value
|
||||
|
||||
result = run_staged_recipe(
|
||||
recipe_path=args.recipe,
|
||||
output_path=args.output,
|
||||
cache_dir=args.cache,
|
||||
params=params if params else None,
|
||||
verbose=not args.quiet,
|
||||
)
|
||||
|
||||
# Print final output path
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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)))
|
||||
|
||||
51
sexp_effects/effects/ascii_art_fx.sexp
Normal file
51
sexp_effects/effects/ascii_art_fx.sexp
Normal file
@@ -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)))
|
||||
99
sexp_effects/effects/ascii_fx_zone.sexp
Normal file
99
sexp_effects/effects/ascii_fx_zone.sexp
Normal file
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -234,6 +234,10 @@ class Interpreter:
|
||||
state[key] = value
|
||||
return value
|
||||
|
||||
# ascii-fx-zone special form - delays evaluation of expression parameters
|
||||
if form == 'ascii-fx-zone':
|
||||
return self._eval_ascii_fx_zone(expr, env)
|
||||
|
||||
# Function call
|
||||
fn = self.eval(head, env)
|
||||
args = [self.eval(arg, env) for arg in expr[1:]]
|
||||
@@ -362,32 +366,272 @@ class Interpreter:
|
||||
return self.eval(clause[1], env)
|
||||
return None
|
||||
|
||||
def _eval_ascii_fx_zone(self, expr: Any, env: Environment) -> Any:
|
||||
"""
|
||||
Evaluate ascii-fx-zone special form.
|
||||
|
||||
Syntax:
|
||||
(ascii-fx-zone frame
|
||||
:cols 80
|
||||
:alphabet "standard"
|
||||
:color_mode "color"
|
||||
:background "black"
|
||||
:contrast 1.5
|
||||
:char_hue <expr> ;; NOT evaluated - passed to primitive
|
||||
:char_saturation <expr>
|
||||
:char_brightness <expr>
|
||||
:char_scale <expr>
|
||||
:char_rotation <expr>
|
||||
:char_jitter <expr>)
|
||||
|
||||
The expression parameters (:char_hue, etc.) are NOT pre-evaluated.
|
||||
They are passed as raw S-expressions to the primitive which
|
||||
evaluates them per-zone with zone context variables injected.
|
||||
"""
|
||||
from .primitives import prim_ascii_fx_zone
|
||||
|
||||
# Expression parameter names that should NOT be evaluated
|
||||
expr_params = {'char_hue', 'char_saturation', 'char_brightness',
|
||||
'char_scale', 'char_rotation', 'char_jitter', 'cell_effect'}
|
||||
|
||||
# Parse arguments
|
||||
frame = self.eval(expr[1], env) # First arg is always the frame
|
||||
|
||||
# Defaults
|
||||
cols = 80
|
||||
char_size = None # If set, overrides cols
|
||||
alphabet = "standard"
|
||||
color_mode = "color"
|
||||
background = "black"
|
||||
contrast = 1.5
|
||||
char_hue = None
|
||||
char_saturation = None
|
||||
char_brightness = None
|
||||
char_scale = None
|
||||
char_rotation = None
|
||||
char_jitter = None
|
||||
cell_effect = None # Lambda for arbitrary per-cell effects
|
||||
# Convenience params for staged recipes
|
||||
energy = None
|
||||
rotation_scale = 0
|
||||
# Extra params to pass to zone dict for lambdas
|
||||
extra_params = {}
|
||||
|
||||
# Parse keyword arguments
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword):
|
||||
if i + 1 >= len(expr):
|
||||
break
|
||||
value_expr = expr[i + 1]
|
||||
kw_name = item.name
|
||||
|
||||
if kw_name in expr_params:
|
||||
# Resolve symbol references but don't evaluate expressions
|
||||
# This handles the case where effect definition passes a param like :char_hue char_hue
|
||||
resolved = value_expr
|
||||
if isinstance(value_expr, Symbol):
|
||||
try:
|
||||
resolved = env.get(value_expr.name)
|
||||
except NameError:
|
||||
resolved = value_expr # Keep as symbol if not found
|
||||
|
||||
if kw_name == 'char_hue':
|
||||
char_hue = resolved
|
||||
elif kw_name == 'char_saturation':
|
||||
char_saturation = resolved
|
||||
elif kw_name == 'char_brightness':
|
||||
char_brightness = resolved
|
||||
elif kw_name == 'char_scale':
|
||||
char_scale = resolved
|
||||
elif kw_name == 'char_rotation':
|
||||
char_rotation = resolved
|
||||
elif kw_name == 'char_jitter':
|
||||
char_jitter = resolved
|
||||
elif kw_name == 'cell_effect':
|
||||
cell_effect = resolved
|
||||
else:
|
||||
# Evaluate normally
|
||||
value = self.eval(value_expr, env)
|
||||
if kw_name == 'cols':
|
||||
cols = int(value)
|
||||
elif kw_name == 'char_size':
|
||||
# Handle nil/None values
|
||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
||||
char_size = None
|
||||
else:
|
||||
char_size = int(value)
|
||||
elif kw_name == 'alphabet':
|
||||
alphabet = str(value)
|
||||
elif kw_name == 'color_mode':
|
||||
color_mode = str(value)
|
||||
elif kw_name == 'background':
|
||||
background = str(value)
|
||||
elif kw_name == 'contrast':
|
||||
contrast = float(value)
|
||||
elif kw_name == 'energy':
|
||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
||||
energy = None
|
||||
else:
|
||||
energy = float(value)
|
||||
extra_params['energy'] = energy
|
||||
elif kw_name == 'rotation_scale':
|
||||
rotation_scale = float(value)
|
||||
extra_params['rotation_scale'] = rotation_scale
|
||||
else:
|
||||
# Store any other params for lambdas to access
|
||||
extra_params[kw_name] = value
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# If energy and rotation_scale provided, build rotation expression
|
||||
# rotation = energy * rotation_scale * position_factor
|
||||
# position_factor: bottom-left=0, top-right=3
|
||||
# Formula: 1.5 * (zone-col-norm + (1 - zone-row-norm))
|
||||
if energy is not None and rotation_scale > 0:
|
||||
# Build expression as S-expression list that will be evaluated per-zone
|
||||
# (* (* energy rotation_scale) (* 1.5 (+ zone-col-norm (- 1 zone-row-norm))))
|
||||
energy_times_scale = energy * rotation_scale
|
||||
# The position part uses zone variables, so we build it as an expression
|
||||
char_rotation = [
|
||||
Symbol('*'),
|
||||
energy_times_scale,
|
||||
[Symbol('*'), 1.5,
|
||||
[Symbol('+'), Symbol('zone-col-norm'),
|
||||
[Symbol('-'), 1, Symbol('zone-row-norm')]]]
|
||||
]
|
||||
|
||||
# Pull any extra params from environment that aren't standard params
|
||||
# These are typically passed from recipes for use in cell_effect lambdas
|
||||
standard_params = {
|
||||
'cols', 'char_size', 'alphabet', 'color_mode', 'background', 'contrast',
|
||||
'char_hue', 'char_saturation', 'char_brightness', 'char_scale',
|
||||
'char_rotation', 'char_jitter', 'cell_effect', 'energy', 'rotation_scale',
|
||||
'frame', 't', '_time', '__state__', '__interp__', 'true', 'false', 'nil'
|
||||
}
|
||||
# Check environment for extra bindings
|
||||
current_env = env
|
||||
while current_env is not None:
|
||||
for k, v in current_env.bindings.items():
|
||||
if k not in standard_params and k not in extra_params and not callable(v):
|
||||
# Add non-standard, non-callable bindings to extra_params
|
||||
if isinstance(v, (int, float, str, bool)) or v is None:
|
||||
extra_params[k] = v
|
||||
current_env = current_env.parent
|
||||
|
||||
# Call the primitive with interpreter and env for expression evaluation
|
||||
return prim_ascii_fx_zone(
|
||||
frame, cols, char_size, alphabet, color_mode, background, contrast,
|
||||
char_hue, char_saturation, char_brightness,
|
||||
char_scale, char_rotation, char_jitter,
|
||||
self, env, extra_params, cell_effect
|
||||
)
|
||||
|
||||
def _define_effect(self, expr: Any, env: Environment) -> EffectDefinition:
|
||||
"""
|
||||
Parse effect definition:
|
||||
(define-effect name
|
||||
((param1 default1) (param2 default2) ...)
|
||||
body)
|
||||
Parse effect definition.
|
||||
|
||||
Required syntax:
|
||||
(define-effect name
|
||||
:params (
|
||||
(param1 :type int :default 8 :desc "description")
|
||||
)
|
||||
body)
|
||||
|
||||
Effects MUST use :params syntax. Legacy ((param default) ...) is not supported.
|
||||
"""
|
||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
||||
params_list = expr[2] if len(expr) > 2 else []
|
||||
body = expr[3] if len(expr) > 3 else expr[2]
|
||||
|
||||
# Parse parameters
|
||||
params = {}
|
||||
if isinstance(params_list, list):
|
||||
for p in params_list:
|
||||
if isinstance(p, list) and len(p) >= 2:
|
||||
pname = p[0].name if isinstance(p[0], Symbol) else p[0]
|
||||
pdefault = p[1]
|
||||
params[pname] = pdefault
|
||||
elif isinstance(p, Symbol):
|
||||
params[p.name] = None
|
||||
body = None
|
||||
found_params = False
|
||||
|
||||
# Parse :params and body
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword) and item.name == "params":
|
||||
# :params syntax
|
||||
if i + 1 >= len(expr):
|
||||
raise SyntaxError(f"Effect '{name}': Missing params list after :params keyword")
|
||||
params_list = expr[i + 1]
|
||||
params = self._parse_params_block(params_list)
|
||||
found_params = True
|
||||
i += 2
|
||||
elif isinstance(item, Keyword):
|
||||
# Skip other keywords (like :desc)
|
||||
i += 2
|
||||
elif body is None:
|
||||
# First non-keyword item is the body
|
||||
if isinstance(item, list) and item:
|
||||
first_elem = item[0]
|
||||
# Check for legacy syntax and reject it
|
||||
if isinstance(first_elem, list) and len(first_elem) >= 2:
|
||||
raise SyntaxError(
|
||||
f"Effect '{name}': Legacy parameter syntax ((name default) ...) is not supported. "
|
||||
f"Use :params block instead."
|
||||
)
|
||||
body = item
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if body is None:
|
||||
raise SyntaxError(f"Effect '{name}': No body found")
|
||||
|
||||
if not found_params:
|
||||
raise SyntaxError(
|
||||
f"Effect '{name}': Missing :params block. "
|
||||
f"For effects with no parameters, use empty :params ()"
|
||||
)
|
||||
|
||||
effect = EffectDefinition(name, params, body)
|
||||
self.effects[name] = effect
|
||||
return effect
|
||||
|
||||
def _parse_params_block(self, params_list: list) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse :params block syntax:
|
||||
(
|
||||
(param_name :type int :default 8 :range [4 32] :desc "description")
|
||||
)
|
||||
"""
|
||||
params = {}
|
||||
for param_def in params_list:
|
||||
if not isinstance(param_def, list) or len(param_def) < 1:
|
||||
continue
|
||||
|
||||
# First element is the parameter name
|
||||
first = param_def[0]
|
||||
if isinstance(first, Symbol):
|
||||
param_name = first.name
|
||||
elif isinstance(first, str):
|
||||
param_name = first
|
||||
else:
|
||||
continue
|
||||
|
||||
# Parse keyword arguments
|
||||
default = None
|
||||
i = 1
|
||||
while i < len(param_def):
|
||||
item = param_def[i]
|
||||
if isinstance(item, Keyword):
|
||||
if i + 1 >= len(param_def):
|
||||
break
|
||||
kw_value = param_def[i + 1]
|
||||
|
||||
if item.name == "default":
|
||||
default = kw_value
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
params[param_name] = default
|
||||
|
||||
return params
|
||||
|
||||
def load_effect(self, path: str) -> EffectDefinition:
|
||||
"""Load an effect definition from a .sexp file."""
|
||||
expr = parse_file(path)
|
||||
@@ -444,6 +688,16 @@ class Interpreter:
|
||||
state = {}
|
||||
env.set('__state__', state)
|
||||
|
||||
# Validate that all provided params are known (except internal params)
|
||||
# Extra params are allowed and will be passed through to cell_effect lambdas
|
||||
known_params = set(effect.params.keys())
|
||||
internal_params = {'_time', 'seed', '_binding', 'effect', 'cid', 'hash', 'effect_path'}
|
||||
extra_effect_params = {} # Unknown params passed through for cell_effect lambdas
|
||||
for k in params.keys():
|
||||
if k not in known_params and k not in internal_params:
|
||||
# Allow unknown params - they'll be passed to cell_effect lambdas via zone dict
|
||||
extra_effect_params[k] = params[k]
|
||||
|
||||
# Bind parameters (defaults + overrides)
|
||||
for pname, pdefault in effect.params.items():
|
||||
value = params.get(pname)
|
||||
@@ -455,6 +709,10 @@ class Interpreter:
|
||||
value = pdefault
|
||||
env.set(pname, value)
|
||||
|
||||
# Bind extra params (unknown params passed through for cell_effect lambdas)
|
||||
for k, v in extra_effect_params.items():
|
||||
env.set(k, v)
|
||||
|
||||
# Reset RNG with seed if provided
|
||||
seed = params.get('seed', 42)
|
||||
reset_rng(int(seed))
|
||||
@@ -473,6 +731,41 @@ class Interpreter:
|
||||
|
||||
return result, state
|
||||
|
||||
def eval_with_zone(self, expr, env: Environment, zone) -> Any:
|
||||
"""
|
||||
Evaluate expression with zone-* variables injected.
|
||||
|
||||
Args:
|
||||
expr: Expression to evaluate (S-expression)
|
||||
env: Parent environment with bound values
|
||||
zone: ZoneContext object with cell data
|
||||
|
||||
Zone variables injected:
|
||||
zone-row, zone-col: Grid position (integers)
|
||||
zone-row-norm, zone-col-norm: Normalized position (0-1)
|
||||
zone-lum: Cell luminance (0-1)
|
||||
zone-sat: Cell saturation (0-1)
|
||||
zone-hue: Cell hue (0-360)
|
||||
zone-r, zone-g, zone-b: RGB components (0-1)
|
||||
|
||||
Returns:
|
||||
Evaluated result (typically a number)
|
||||
"""
|
||||
# Create child environment with zone variables
|
||||
zone_env = Environment(env)
|
||||
zone_env.set('zone-row', zone.row)
|
||||
zone_env.set('zone-col', zone.col)
|
||||
zone_env.set('zone-row-norm', zone.row_norm)
|
||||
zone_env.set('zone-col-norm', zone.col_norm)
|
||||
zone_env.set('zone-lum', zone.luminance)
|
||||
zone_env.set('zone-sat', zone.saturation)
|
||||
zone_env.set('zone-hue', zone.hue)
|
||||
zone_env.set('zone-r', zone.r)
|
||||
zone_env.set('zone-g', zone.g)
|
||||
zone_env.set('zone-b', zone.b)
|
||||
|
||||
return self.eval(expr, zone_env)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience Functions
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user