Initial commit: video effects processing system
Add S-expression based video effects pipeline with modular effect definitions, constructs, and recipe files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
32
sexp_effects/__init__.py
Normal file
32
sexp_effects/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
S-Expression Effects System
|
||||
|
||||
Safe, shareable effects defined in S-expressions.
|
||||
"""
|
||||
|
||||
from .parser import parse, parse_file, Symbol, Keyword
|
||||
from .interpreter import (
|
||||
Interpreter,
|
||||
get_interpreter,
|
||||
load_effect,
|
||||
load_effects_dir,
|
||||
run_effect,
|
||||
list_effects,
|
||||
make_process_frame,
|
||||
)
|
||||
from .primitives import PRIMITIVES
|
||||
|
||||
__all__ = [
|
||||
'parse',
|
||||
'parse_file',
|
||||
'Symbol',
|
||||
'Keyword',
|
||||
'Interpreter',
|
||||
'get_interpreter',
|
||||
'load_effect',
|
||||
'load_effects_dir',
|
||||
'run_effect',
|
||||
'list_effects',
|
||||
'make_process_frame',
|
||||
'PRIMITIVES',
|
||||
]
|
||||
14
sexp_effects/effects/ascii_art.sexp
Normal file
14
sexp_effects/effects/ascii_art.sexp
Normal file
@@ -0,0 +1,14 @@
|
||||
;; 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)))
|
||||
(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)))
|
||||
28
sexp_effects/effects/ascii_zones.sexp
Normal file
28
sexp_effects/effects/ascii_zones.sexp
Normal file
@@ -0,0 +1,28 @@
|
||||
;; 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"))
|
||||
(let* ((sample (cell-sample frame char_size))
|
||||
(colors (nth sample 0))
|
||||
(luminances (nth sample 1))
|
||||
;; Start with simple chars as base
|
||||
(base-chars (luminance-to-chars luminances "simple" 1.2))
|
||||
;; Map each cell to appropriate alphabet based on brightness zone
|
||||
(zoned-chars (map-char-grid base-chars luminances
|
||||
(lambda (r c ch lum)
|
||||
(cond
|
||||
;; Bright zones: use block characters
|
||||
((> lum bright_threshold)
|
||||
(alphabet-char "blocks" (floor (/ (- lum bright_threshold) 15))))
|
||||
;; Dark zones: use simple sparse chars
|
||||
((< lum dark_threshold)
|
||||
(alphabet-char " .-" (floor (/ lum 30))))
|
||||
;; Mid zones: use standard ASCII
|
||||
(else
|
||||
(alphabet-char "standard" (floor (/ lum 4)))))))))
|
||||
(render-char-grid frame zoned-chars colors char_size color_mode (list 0 0 0))))
|
||||
48
sexp_effects/effects/blend.sexp
Normal file
48
sexp_effects/effects/blend.sexp
Normal file
@@ -0,0 +1,48 @@
|
||||
;; Blend effect - combines two video streams
|
||||
;; Multi-input effect: uses frame-a and frame-b
|
||||
;; Params:
|
||||
;; mode - blend mode (add, multiply, screen, overlay, difference, lighten, darken, alpha)
|
||||
;; opacity - blend amount (0-1)
|
||||
;; resize-mode - how to resize frame-b to match frame-a (fit, crop, stretch)
|
||||
;; priority - which dimension takes priority (width, height)
|
||||
;; 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)))
|
||||
(let [a frame-a
|
||||
a-w (width a)
|
||||
a-h (height a)
|
||||
b-raw frame-b
|
||||
b-w (width b-raw)
|
||||
b-h (height b-raw)
|
||||
;; Calculate scale based on resize mode and priority
|
||||
scale-w (/ a-w b-w)
|
||||
scale-h (/ a-h b-h)
|
||||
scale (if (= resize-mode "stretch")
|
||||
1 ;; Will use explicit dimensions
|
||||
(if (= resize-mode "crop")
|
||||
(max scale-w scale-h) ;; Scale to cover, then crop
|
||||
(if (= priority "width")
|
||||
scale-w
|
||||
scale-h)))
|
||||
;; For stretch, use target dimensions directly
|
||||
new-w (if (= resize-mode "stretch") a-w (round (* b-w scale)))
|
||||
new-h (if (= resize-mode "stretch") a-h (round (* b-h scale)))
|
||||
;; Resize b
|
||||
b-resized (resize b-raw new-w new-h "linear")
|
||||
;; Handle fit (pad) or crop to exact size
|
||||
b (if (= resize-mode "crop")
|
||||
;; Crop to center
|
||||
(let [cx (/ (- new-w a-w) 2)
|
||||
cy (/ (- new-h a-h) 2)]
|
||||
(crop b-resized cx cy a-w a-h))
|
||||
(if (and (= resize-mode "fit") (or (!= new-w a-w) (!= new-h a-h)))
|
||||
;; Pad to center
|
||||
(let [pad-x (/ (- a-w new-w) 2)
|
||||
pad-y (/ (- a-h new-h) 2)
|
||||
canvas (make-image a-w a-h pad-color)]
|
||||
(paste canvas b-resized pad-x pad-y))
|
||||
b-resized))]
|
||||
(if (= mode "alpha")
|
||||
(blend-images a b opacity)
|
||||
(blend-images a (blend-mode a b mode) opacity))))
|
||||
14
sexp_effects/effects/bloom.sexp
Normal file
14
sexp_effects/effects/bloom.sexp
Normal file
@@ -0,0 +1,14 @@
|
||||
;; 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))
|
||||
(let* ((bright (map-pixels frame
|
||||
(lambda (x y c)
|
||||
(if (> (luminance c) threshold)
|
||||
c
|
||||
(rgb 0 0 0)))))
|
||||
(blurred (blur bright radius)))
|
||||
(blend-mode frame blurred "add")))
|
||||
6
sexp_effects/effects/blur.sexp
Normal file
6
sexp_effects/effects/blur.sexp
Normal file
@@ -0,0 +1,6 @@
|
||||
;; Blur effect - gaussian blur
|
||||
;; @param radius int [1, 50] default 5
|
||||
|
||||
(define-effect blur
|
||||
((radius 5))
|
||||
(blur frame (max 1 radius)))
|
||||
7
sexp_effects/effects/brightness.sexp
Normal file
7
sexp_effects/effects/brightness.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; Brightness effect - adjusts overall brightness
|
||||
;; @param amount float [-255, 255] default 0
|
||||
;; Uses vectorized adjust primitive for fast processing
|
||||
|
||||
(define-effect brightness
|
||||
((amount 0))
|
||||
(adjust frame amount 1))
|
||||
8
sexp_effects/effects/color-adjust.sexp
Normal file
8
sexp_effects/effects/color-adjust.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; 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))
|
||||
(-> frame
|
||||
(adjust :brightness brightness :contrast contrast)
|
||||
(shift-hsv :s saturation)))
|
||||
11
sexp_effects/effects/color_cycle.sexp
Normal file
11
sexp_effects/effects/color_cycle.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Color Cycle effect - animated hue rotation
|
||||
;; @param speed float [0, 10] default 1
|
||||
|
||||
(define-effect color_cycle
|
||||
((speed 1))
|
||||
(let ((shift (* t speed 360)))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* ((hsv (rgb->hsv c))
|
||||
(new-h (mod (+ (first hsv) shift) 360)))
|
||||
(hsv->rgb (list new-h (nth hsv 1) (nth hsv 2))))))))
|
||||
7
sexp_effects/effects/contrast.sexp
Normal file
7
sexp_effects/effects/contrast.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; 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))
|
||||
(adjust frame 0 amount))
|
||||
28
sexp_effects/effects/crt.sexp
Normal file
28
sexp_effects/effects/crt.sexp
Normal file
@@ -0,0 +1,28 @@
|
||||
;; 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))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(cx (/ w 2))
|
||||
(cy (/ h 2))
|
||||
(max-dist (sqrt (+ (* cx cx) (* cy cy)))))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* (;; Scanline darkening
|
||||
(scanline-factor (if (= 0 (mod y line_spacing))
|
||||
(- 1 line_opacity)
|
||||
1))
|
||||
;; Vignette
|
||||
(dx (- x cx))
|
||||
(dy (- y cy))
|
||||
(dist (sqrt (+ (* dx dx) (* dy dy))))
|
||||
(vignette-factor (- 1 (* (/ dist max-dist) vignette_amount)))
|
||||
;; Combined
|
||||
(factor (* scanline-factor vignette-factor)))
|
||||
(rgb (* (red c) factor)
|
||||
(* (green c) factor)
|
||||
(* (blue c) factor)))))))
|
||||
13
sexp_effects/effects/datamosh.sexp
Normal file
13
sexp_effects/effects/datamosh.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; 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))
|
||||
;; Get previous frame from state, or use current frame if none
|
||||
(let ((prev (state-get "prev_frame" frame)))
|
||||
(begin
|
||||
(state-set "prev_frame" (copy frame))
|
||||
(datamosh frame prev block_size corruption max_offset color_corrupt))))
|
||||
17
sexp_effects/effects/echo.sexp
Normal file
17
sexp_effects/effects/echo.sexp
Normal file
@@ -0,0 +1,17 @@
|
||||
;; 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))
|
||||
(let* ((buffer (state-get 'buffer (list)))
|
||||
(new-buffer (take (cons frame buffer) (+ num_echoes 1))))
|
||||
(begin
|
||||
(state-set 'buffer new-buffer)
|
||||
;; Blend frames with decay
|
||||
(if (< (length new-buffer) 2)
|
||||
frame
|
||||
(let ((result (copy frame)))
|
||||
;; Simple blend of first two frames for now
|
||||
;; Full version would fold over all frames
|
||||
(blend-images frame (nth new-buffer 1) (* decay 0.5)))))))
|
||||
7
sexp_effects/effects/edge_detect.sexp
Normal file
7
sexp_effects/effects/edge_detect.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; 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))
|
||||
(edges frame low high))
|
||||
11
sexp_effects/effects/emboss.sexp
Normal file
11
sexp_effects/effects/emboss.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; 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))
|
||||
(let* ((kernel (list (list (- strength) (- strength) 0)
|
||||
(list (- strength) 1 strength)
|
||||
(list 0 strength strength)))
|
||||
(embossed (convolve frame kernel)))
|
||||
(blend-images embossed frame blend)))
|
||||
17
sexp_effects/effects/film_grain.sexp
Normal file
17
sexp_effects/effects/film_grain.sexp
Normal file
@@ -0,0 +1,17 @@
|
||||
;; 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))
|
||||
(let ((grain-amount (* intensity 50)))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(if colored
|
||||
(rgb (clamp (+ (red c) (gaussian 0 grain-amount)) 0 255)
|
||||
(clamp (+ (green c) (gaussian 0 grain-amount)) 0 255)
|
||||
(clamp (+ (blue c) (gaussian 0 grain-amount)) 0 255))
|
||||
(let ((n (gaussian 0 grain-amount)))
|
||||
(rgb (clamp (+ (red c) n) 0 255)
|
||||
(clamp (+ (green c) n) 0 255)
|
||||
(clamp (+ (blue c) n) 0 255))))))))
|
||||
14
sexp_effects/effects/fisheye.sexp
Normal file
14
sexp_effects/effects/fisheye.sexp
Normal file
@@ -0,0 +1,14 @@
|
||||
;; 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))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
(coords (fisheye-displace w h strength cx cy zoom_correct)))
|
||||
(remap frame (coords-x coords) (coords-y coords))))
|
||||
14
sexp_effects/effects/flip.sexp
Normal file
14
sexp_effects/effects/flip.sexp
Normal file
@@ -0,0 +1,14 @@
|
||||
;; Flip effect - flips image horizontally or vertically
|
||||
;; @param horizontal bool default true
|
||||
;; @param vertical bool default false
|
||||
|
||||
(define-effect flip
|
||||
((horizontal true) (vertical false))
|
||||
(let ((result frame))
|
||||
(if horizontal
|
||||
(set! result (flip-h result))
|
||||
nil)
|
||||
(if vertical
|
||||
(set! result (flip-v result))
|
||||
nil)
|
||||
result))
|
||||
5
sexp_effects/effects/grayscale.sexp
Normal file
5
sexp_effects/effects/grayscale.sexp
Normal file
@@ -0,0 +1,5 @@
|
||||
;; Grayscale effect - converts to grayscale
|
||||
;; Uses vectorized mix-gray primitive for fast processing
|
||||
|
||||
(define-effect grayscale ()
|
||||
(mix-gray frame 1))
|
||||
9
sexp_effects/effects/hue_shift.sexp
Normal file
9
sexp_effects/effects/hue_shift.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; 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))
|
||||
(let ((shift (+ degrees (* speed t))))
|
||||
(shift-hsv frame shift 1 1)))
|
||||
5
sexp_effects/effects/invert.sexp
Normal file
5
sexp_effects/effects/invert.sexp
Normal file
@@ -0,0 +1,5 @@
|
||||
;; Invert effect - inverts all colors
|
||||
;; Uses vectorized invert-img primitive for fast processing
|
||||
|
||||
(define-effect invert ()
|
||||
(invert-img frame))
|
||||
18
sexp_effects/effects/kaleidoscope.sexp
Normal file
18
sexp_effects/effects/kaleidoscope.sexp
Normal file
@@ -0,0 +1,18 @@
|
||||
;; 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))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
;; Total rotation including time-based animation
|
||||
(total_rot (+ rotation (* rotation_speed (or _time 0))))
|
||||
(coords (kaleidoscope-displace w h segments total_rot cx cy zoom)))
|
||||
(remap frame (coords-x coords) (coords-y coords))))
|
||||
21
sexp_effects/effects/layer.sexp
Normal file
21
sexp_effects/effects/layer.sexp
Normal file
@@ -0,0 +1,21 @@
|
||||
;; Layer effect - composite frame-b over frame-a at position
|
||||
;; Multi-input effect: uses frame-a (background) and frame-b (overlay)
|
||||
;; Params: x, y (position), opacity (0-1), mode (blend mode)
|
||||
|
||||
(define-effect layer
|
||||
((x 0) (y 0) (opacity 1.0) (mode "alpha"))
|
||||
(let [bg (copy frame-a)
|
||||
fg frame-b
|
||||
;; Resize fg if needed to fit
|
||||
fg-w (width fg)
|
||||
fg-h (height fg)]
|
||||
(if (= opacity 1.0)
|
||||
;; Simple paste
|
||||
(paste bg fg x y)
|
||||
;; Blend with opacity
|
||||
(let [blended (if (= mode "alpha")
|
||||
(blend-images (crop bg x y fg-w fg-h) fg opacity)
|
||||
(blend-images (crop bg x y fg-w fg-h)
|
||||
(blend-mode (crop bg x y fg-w fg-h) fg mode)
|
||||
opacity))]
|
||||
(paste bg blended x y)))))
|
||||
31
sexp_effects/effects/mirror.sexp
Normal file
31
sexp_effects/effects/mirror.sexp
Normal file
@@ -0,0 +1,31 @@
|
||||
;; Mirror effect - mirrors half of image
|
||||
;; @param mode string default "left_right"
|
||||
|
||||
(define-effect mirror
|
||||
((mode "left_right"))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(hw (floor (/ w 2)))
|
||||
(hh (floor (/ h 2))))
|
||||
(cond
|
||||
((= mode "left_right")
|
||||
(let ((left (crop frame 0 0 hw h))
|
||||
(result (copy frame)))
|
||||
(paste result (flip-h left) hw 0)))
|
||||
|
||||
((= mode "right_left")
|
||||
(let ((right (crop frame hw 0 hw h))
|
||||
(result (copy frame)))
|
||||
(paste result (flip-h right) 0 0)))
|
||||
|
||||
((= mode "top_bottom")
|
||||
(let ((top (crop frame 0 0 w hh))
|
||||
(result (copy frame)))
|
||||
(paste result (flip-v top) 0 hh)))
|
||||
|
||||
((= mode "bottom_top")
|
||||
(let ((bottom (crop frame 0 hh w hh))
|
||||
(result (copy frame)))
|
||||
(paste result (flip-v bottom) 0 0)))
|
||||
|
||||
(else frame))))
|
||||
22
sexp_effects/effects/neon_glow.sexp
Normal file
22
sexp_effects/effects/neon_glow.sexp
Normal file
@@ -0,0 +1,22 @@
|
||||
;; 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))
|
||||
(let* ((edge-img (edges frame edge_low edge_high))
|
||||
(glow (blur edge-img glow_radius))
|
||||
;; Intensify the glow
|
||||
(bright-glow (map-pixels glow
|
||||
(lambda (x y c)
|
||||
(rgb (clamp (* (red c) glow_intensity) 0 255)
|
||||
(clamp (* (green c) glow_intensity) 0 255)
|
||||
(clamp (* (blue c) glow_intensity) 0 255))))))
|
||||
(blend-mode (blend-images frame (make-image (width frame) (height frame) (list 0 0 0))
|
||||
(- 1 background))
|
||||
bright-glow
|
||||
"screen")))
|
||||
7
sexp_effects/effects/noise.sexp
Normal file
7
sexp_effects/effects/noise.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; 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))
|
||||
(add-noise frame amount))
|
||||
22
sexp_effects/effects/outline.sexp
Normal file
22
sexp_effects/effects/outline.sexp
Normal file
@@ -0,0 +1,22 @@
|
||||
;; 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"))
|
||||
(let* ((edge-img (edges frame (/ threshold 2) threshold))
|
||||
(dilated (if (> thickness 1)
|
||||
(dilate edge-img thickness)
|
||||
edge-img))
|
||||
(base (cond
|
||||
((= fill_mode "original") (copy frame))
|
||||
((= fill_mode "white") (make-image (width frame) (height frame) (list 255 255 255)))
|
||||
(else (make-image (width frame) (height frame) (list 0 0 0))))))
|
||||
(map-pixels base
|
||||
(lambda (x y c)
|
||||
(let ((edge-val (luminance (pixel dilated x y))))
|
||||
(if (> edge-val 128)
|
||||
color
|
||||
c))))))
|
||||
11
sexp_effects/effects/pixelate.sexp
Normal file
11
sexp_effects/effects/pixelate.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Pixelate effect - creates blocky pixels
|
||||
;; @param block_size int [2, 64] default 8
|
||||
|
||||
(define-effect pixelate
|
||||
((block_size 8))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(small-w (max 1 (floor (/ w block_size))))
|
||||
(small-h (max 1 (floor (/ h block_size))))
|
||||
(small (resize frame small-w small-h "area")))
|
||||
(resize small w h "nearest")))
|
||||
10
sexp_effects/effects/pixelsort.sexp
Normal file
10
sexp_effects/effects/pixelsort.sexp
Normal file
@@ -0,0 +1,10 @@
|
||||
;; 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))
|
||||
(pixelsort frame sort_by threshold_low threshold_high angle reverse))
|
||||
11
sexp_effects/effects/posterize.sexp
Normal file
11
sexp_effects/effects/posterize.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Posterize effect - reduces color levels
|
||||
;; @param levels int [2, 32] default 8
|
||||
|
||||
(define-effect posterize
|
||||
((levels 8))
|
||||
(let ((step (floor (/ 256 levels))))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(rgb (* (floor (/ (red c) step)) step)
|
||||
(* (floor (/ (green c) step)) step)
|
||||
(* (floor (/ (blue c) step)) step))))))
|
||||
7
sexp_effects/effects/resize-frame.sexp
Normal file
7
sexp_effects/effects/resize-frame.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; 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"))
|
||||
(resize frame target-w target-h mode))
|
||||
12
sexp_effects/effects/rgb_split.sexp
Normal file
12
sexp_effects/effects/rgb_split.sexp
Normal file
@@ -0,0 +1,12 @@
|
||||
;; 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))
|
||||
(let* ((r (channel frame 0))
|
||||
(g (channel frame 1))
|
||||
(b (channel frame 2))
|
||||
(r-shifted (translate (merge-channels r r r) offset_x offset_y))
|
||||
(b-shifted (translate (merge-channels b b b) (- offset_x) (- offset_y))))
|
||||
(merge-channels (channel r-shifted 0) g (channel b-shifted 0))))
|
||||
17
sexp_effects/effects/ripple.sexp
Normal file
17
sexp_effects/effects/ripple.sexp
Normal file
@@ -0,0 +1,17 @@
|
||||
;; 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))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
(phase (* (or _time 0) speed 2 pi))
|
||||
(coords (ripple-displace w h frequency amplitude cx cy decay phase)))
|
||||
(remap frame (coords-x coords) (coords-y coords))))
|
||||
8
sexp_effects/effects/rotate.sexp
Normal file
8
sexp_effects/effects/rotate.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; 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))
|
||||
(let ((total-angle (+ angle (* speed t))))
|
||||
(rotate-img frame total-angle)))
|
||||
7
sexp_effects/effects/saturation.sexp
Normal file
7
sexp_effects/effects/saturation.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; 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))
|
||||
(shift-hsv frame 0 amount 1))
|
||||
13
sexp_effects/effects/scanlines.sexp
Normal file
13
sexp_effects/effects/scanlines.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; 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))
|
||||
(map-rows frame
|
||||
(lambda (y row)
|
||||
(let* ((sine-shift (* amplitude (sin (/ (* y 6.28) (max 1 frequency)))))
|
||||
(rand-shift (random (- amplitude) amplitude))
|
||||
(shift (floor (lerp sine-shift rand-shift randomness))))
|
||||
(roll row shift 0)))))
|
||||
8
sexp_effects/effects/sepia.sexp
Normal file
8
sexp_effects/effects/sepia.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Sepia effect - applies sepia tone
|
||||
;; Classic warm vintage look
|
||||
|
||||
(define-effect sepia ()
|
||||
(color-matrix frame
|
||||
(list (list 0.393 0.769 0.189)
|
||||
(list 0.349 0.686 0.168)
|
||||
(list 0.272 0.534 0.131))))
|
||||
9
sexp_effects/effects/sharpen.sexp
Normal file
9
sexp_effects/effects/sharpen.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Sharpen effect - sharpens edges
|
||||
;; @param amount float [0, 5] default 1
|
||||
|
||||
(define-effect sharpen
|
||||
((amount 1))
|
||||
(let ((kernel (list (list 0 (- amount) 0)
|
||||
(list (- amount) (+ 1 (* 4 amount)) (- amount))
|
||||
(list 0 (- amount) 0))))
|
||||
(convolve frame kernel)))
|
||||
14
sexp_effects/effects/strobe.sexp
Normal file
14
sexp_effects/effects/strobe.sexp
Normal file
@@ -0,0 +1,14 @@
|
||||
;; Strobe effect - holds frames for choppy look
|
||||
;; @param frame_rate float [1, 60] default 12
|
||||
|
||||
(define-effect strobe
|
||||
((frame_rate 12))
|
||||
(let* ((held (state-get 'held nil))
|
||||
(held-until (state-get 'held-until 0))
|
||||
(frame-duration (/ 1 frame_rate)))
|
||||
(if (or (= held nil) (>= t held-until))
|
||||
(begin
|
||||
(state-set 'held (copy frame))
|
||||
(state-set 'held-until (+ t frame-duration))
|
||||
frame)
|
||||
held)))
|
||||
15
sexp_effects/effects/swirl.sexp
Normal file
15
sexp_effects/effects/swirl.sexp
Normal file
@@ -0,0 +1,15 @@
|
||||
;; 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"))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
(coords (swirl-displace w h strength radius cx cy falloff)))
|
||||
(remap frame (coords-x coords) (coords-y coords))))
|
||||
13
sexp_effects/effects/threshold.sexp
Normal file
13
sexp_effects/effects/threshold.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; 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))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* ((lum (luminance c))
|
||||
(above (if invert (< lum level) (> lum level))))
|
||||
(if above
|
||||
(rgb 255 255 255)
|
||||
(rgb 0 0 0))))))
|
||||
27
sexp_effects/effects/tile_grid.sexp
Normal file
27
sexp_effects/effects/tile_grid.sexp
Normal file
@@ -0,0 +1,27 @@
|
||||
;; 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))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(tile-w (floor (/ (- w (* gap (- cols 1))) cols)))
|
||||
(tile-h (floor (/ (- h (* gap (- rows 1))) rows)))
|
||||
(tile (resize frame tile-w tile-h "area"))
|
||||
(result (make-image w h (list 0 0 0))))
|
||||
(begin
|
||||
;; Manually place tiles using nested iteration
|
||||
;; This is a simplified version - full version would loop
|
||||
(paste result tile 0 0)
|
||||
(if (> cols 1)
|
||||
(paste result tile (+ tile-w gap) 0)
|
||||
nil)
|
||||
(if (> rows 1)
|
||||
(paste result tile 0 (+ tile-h gap))
|
||||
nil)
|
||||
(if (and (> cols 1) (> rows 1))
|
||||
(paste result tile (+ tile-w gap) (+ tile-h gap))
|
||||
nil)
|
||||
result)))
|
||||
18
sexp_effects/effects/trails.sexp
Normal file
18
sexp_effects/effects/trails.sexp
Normal file
@@ -0,0 +1,18 @@
|
||||
;; Trails effect - persistent motion trails
|
||||
;; @param persistence float [0, 0.99] default 0.8
|
||||
|
||||
(define-effect trails
|
||||
((persistence 0.8))
|
||||
(let* ((buffer (state-get 'buffer nil))
|
||||
(current frame))
|
||||
(if (= buffer nil)
|
||||
(begin
|
||||
(state-set 'buffer (copy frame))
|
||||
frame)
|
||||
(let* ((faded (blend-images buffer
|
||||
(make-image (width frame) (height frame) (list 0 0 0))
|
||||
(- 1 persistence)))
|
||||
(result (blend-mode faded current "lighten")))
|
||||
(begin
|
||||
(state-set 'buffer result)
|
||||
result)))))
|
||||
21
sexp_effects/effects/vignette.sexp
Normal file
21
sexp_effects/effects/vignette.sexp
Normal file
@@ -0,0 +1,21 @@
|
||||
;; 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))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
(cx (/ w 2))
|
||||
(cy (/ h 2))
|
||||
(max-dist (* (sqrt (+ (* cx cx) (* cy cy))) radius)))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* ((dx (- x cx))
|
||||
(dy (- y cy))
|
||||
(dist (sqrt (+ (* dx dx) (* dy dy))))
|
||||
(factor (- 1 (* (/ dist max-dist) strength)))
|
||||
(factor (clamp factor 0 1)))
|
||||
(rgb (* (red c) factor)
|
||||
(* (green c) factor)
|
||||
(* (blue c) factor)))))))
|
||||
20
sexp_effects/effects/wave.sexp
Normal file
20
sexp_effects/effects/wave.sexp
Normal file
@@ -0,0 +1,20 @@
|
||||
;; 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"))
|
||||
(let* ((w (width frame))
|
||||
(h (height frame))
|
||||
;; Use _time for animation phase
|
||||
(phase (* (or _time 0) speed 2 pi))
|
||||
;; Calculate frequency: waves per dimension
|
||||
(freq (/ (if (= direction "vertical") w h) wavelength))
|
||||
(axis (cond
|
||||
((= direction "horizontal") "x")
|
||||
((= direction "vertical") "y")
|
||||
(else "both")))
|
||||
(coords (wave-displace w h axis freq amplitude phase)))
|
||||
(remap frame (coords-x coords) (coords-y coords))))
|
||||
6
sexp_effects/effects/zoom.sexp
Normal file
6
sexp_effects/effects/zoom.sexp
Normal file
@@ -0,0 +1,6 @@
|
||||
;; Zoom effect - zooms in/out from center
|
||||
;; @param amount float [0.1, 5] default 1
|
||||
|
||||
(define-effect zoom
|
||||
((amount 1))
|
||||
(scale-img frame amount amount))
|
||||
537
sexp_effects/interpreter.py
Normal file
537
sexp_effects/interpreter.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
S-Expression Effect Interpreter
|
||||
|
||||
Interprets effect definitions written in S-expressions.
|
||||
Only allows safe primitives - no arbitrary code execution.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .parser import Symbol, Keyword, parse, parse_file
|
||||
from .primitives import PRIMITIVES, reset_rng
|
||||
|
||||
|
||||
class Environment:
|
||||
"""Lexical environment for variable bindings."""
|
||||
|
||||
def __init__(self, parent: 'Environment' = None):
|
||||
self.bindings: Dict[str, Any] = {}
|
||||
self.parent = parent
|
||||
|
||||
def get(self, name: str) -> Any:
|
||||
if name in self.bindings:
|
||||
return self.bindings[name]
|
||||
if self.parent:
|
||||
return self.parent.get(name)
|
||||
raise NameError(f"Undefined variable: {name}")
|
||||
|
||||
def set(self, name: str, value: Any):
|
||||
self.bindings[name] = value
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
if name in self.bindings:
|
||||
return True
|
||||
if self.parent:
|
||||
return self.parent.has(name)
|
||||
return False
|
||||
|
||||
|
||||
class Lambda:
|
||||
"""A user-defined function (lambda)."""
|
||||
|
||||
def __init__(self, params: List[str], body: Any, env: Environment):
|
||||
self.params = params
|
||||
self.body = body
|
||||
self.env = env # Closure environment
|
||||
|
||||
def __repr__(self):
|
||||
return f"<lambda ({' '.join(self.params)})>"
|
||||
|
||||
|
||||
class EffectDefinition:
|
||||
"""A parsed effect definition."""
|
||||
|
||||
def __init__(self, name: str, params: Dict[str, Any], body: Any):
|
||||
self.name = name
|
||||
self.params = params # {name: (type, default)}
|
||||
self.body = body
|
||||
|
||||
def __repr__(self):
|
||||
return f"<effect {self.name}>"
|
||||
|
||||
|
||||
class Interpreter:
|
||||
"""
|
||||
S-Expression interpreter for effects.
|
||||
|
||||
Provides a safe execution environment where only
|
||||
whitelisted primitives can be called.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Base environment with primitives
|
||||
self.global_env = Environment()
|
||||
|
||||
# Load primitives
|
||||
for name, fn in PRIMITIVES.items():
|
||||
self.global_env.set(name, fn)
|
||||
|
||||
# Special values
|
||||
self.global_env.set('true', True)
|
||||
self.global_env.set('false', False)
|
||||
self.global_env.set('nil', None)
|
||||
|
||||
# Loaded effect definitions
|
||||
self.effects: Dict[str, EffectDefinition] = {}
|
||||
|
||||
def eval(self, expr: Any, env: Environment = None) -> Any:
|
||||
"""Evaluate an S-expression."""
|
||||
if env is None:
|
||||
env = self.global_env
|
||||
|
||||
# Atoms
|
||||
if isinstance(expr, (int, float, str, bool)):
|
||||
return expr
|
||||
|
||||
if expr is None:
|
||||
return None
|
||||
|
||||
if isinstance(expr, Symbol):
|
||||
return env.get(expr.name)
|
||||
|
||||
if isinstance(expr, Keyword):
|
||||
return expr # Keywords evaluate to themselves
|
||||
|
||||
if isinstance(expr, np.ndarray):
|
||||
return expr # Images pass through
|
||||
|
||||
# Lists (function calls / special forms)
|
||||
if isinstance(expr, list):
|
||||
if not expr:
|
||||
return []
|
||||
|
||||
head = expr[0]
|
||||
|
||||
# Special forms
|
||||
if isinstance(head, Symbol):
|
||||
form = head.name
|
||||
|
||||
# Quote
|
||||
if form == 'quote':
|
||||
return expr[1]
|
||||
|
||||
# Define
|
||||
if form == 'define':
|
||||
name = expr[1]
|
||||
if isinstance(name, Symbol):
|
||||
value = self.eval(expr[2], env)
|
||||
self.global_env.set(name.name, value)
|
||||
return value
|
||||
else:
|
||||
raise SyntaxError(f"define requires symbol, got {name}")
|
||||
|
||||
# Define-effect
|
||||
if form == 'define-effect':
|
||||
return self._define_effect(expr, env)
|
||||
|
||||
# Lambda
|
||||
if form == 'lambda' or form == 'λ':
|
||||
params = [p.name if isinstance(p, Symbol) else p for p in expr[1]]
|
||||
body = expr[2]
|
||||
return Lambda(params, body, env)
|
||||
|
||||
# Let
|
||||
if form == 'let':
|
||||
return self._eval_let(expr, env)
|
||||
|
||||
# Let*
|
||||
if form == 'let*':
|
||||
return self._eval_let_star(expr, env)
|
||||
|
||||
# If
|
||||
if form == 'if':
|
||||
cond = self.eval(expr[1], env)
|
||||
if cond:
|
||||
return self.eval(expr[2], env)
|
||||
elif len(expr) > 3:
|
||||
return self.eval(expr[3], env)
|
||||
return None
|
||||
|
||||
# Cond
|
||||
if form == 'cond':
|
||||
return self._eval_cond(expr, env)
|
||||
|
||||
# And
|
||||
if form == 'and':
|
||||
result = True
|
||||
for e in expr[1:]:
|
||||
result = self.eval(e, env)
|
||||
if not result:
|
||||
return False
|
||||
return result
|
||||
|
||||
# Or
|
||||
if form == 'or':
|
||||
for e in expr[1:]:
|
||||
result = self.eval(e, env)
|
||||
if result:
|
||||
return result
|
||||
return False
|
||||
|
||||
# Not
|
||||
if form == 'not':
|
||||
return not self.eval(expr[1], env)
|
||||
|
||||
# Begin (sequence)
|
||||
if form == 'begin':
|
||||
result = None
|
||||
for e in expr[1:]:
|
||||
result = self.eval(e, env)
|
||||
return result
|
||||
|
||||
# Thread-first macro: (-> x (f a) (g b)) => (g (f x a) b)
|
||||
if form == '->':
|
||||
result = self.eval(expr[1], env)
|
||||
for form_expr in expr[2:]:
|
||||
if isinstance(form_expr, list):
|
||||
# Insert result as first arg: (f a b) => (f result a b)
|
||||
result = self.eval([form_expr[0], result] + form_expr[1:], env)
|
||||
else:
|
||||
# Just a symbol: f => (f result)
|
||||
result = self.eval([form_expr, result], env)
|
||||
return result
|
||||
|
||||
# Set! (mutation)
|
||||
if form == 'set!':
|
||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
||||
value = self.eval(expr[2], env)
|
||||
# Find and update in appropriate scope
|
||||
scope = env
|
||||
while scope:
|
||||
if name in scope.bindings:
|
||||
scope.bindings[name] = value
|
||||
return value
|
||||
scope = scope.parent
|
||||
raise NameError(f"Cannot set undefined variable: {name}")
|
||||
|
||||
# State-get / state-set (for effect state)
|
||||
if form == 'state-get':
|
||||
state = env.get('__state__')
|
||||
key = self.eval(expr[1], env)
|
||||
if isinstance(key, Symbol):
|
||||
key = key.name
|
||||
default = self.eval(expr[2], env) if len(expr) > 2 else None
|
||||
return state.get(key, default)
|
||||
|
||||
if form == 'state-set':
|
||||
state = env.get('__state__')
|
||||
key = self.eval(expr[1], env)
|
||||
if isinstance(key, Symbol):
|
||||
key = key.name
|
||||
value = self.eval(expr[2], env)
|
||||
state[key] = value
|
||||
return value
|
||||
|
||||
# Function call
|
||||
fn = self.eval(head, env)
|
||||
args = [self.eval(arg, env) for arg in expr[1:]]
|
||||
|
||||
# Handle keyword arguments
|
||||
pos_args = []
|
||||
kw_args = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if isinstance(args[i], Keyword):
|
||||
kw_args[args[i].name] = args[i + 1] if i + 1 < len(args) else None
|
||||
i += 2
|
||||
else:
|
||||
pos_args.append(args[i])
|
||||
i += 1
|
||||
|
||||
return self._apply(fn, pos_args, kw_args, env)
|
||||
|
||||
raise TypeError(f"Cannot evaluate: {expr}")
|
||||
|
||||
def _wrap_lambda(self, lam: 'Lambda') -> Callable:
|
||||
"""Wrap a Lambda in a Python callable for use by primitives."""
|
||||
def wrapper(*args):
|
||||
new_env = Environment(lam.env)
|
||||
for i, param in enumerate(lam.params):
|
||||
if i < len(args):
|
||||
new_env.set(param, args[i])
|
||||
else:
|
||||
new_env.set(param, None)
|
||||
return self.eval(lam.body, new_env)
|
||||
return wrapper
|
||||
|
||||
def _apply(self, fn: Any, args: List[Any], kwargs: Dict[str, Any], env: Environment) -> Any:
|
||||
"""Apply a function to arguments."""
|
||||
if isinstance(fn, Lambda):
|
||||
# User-defined function
|
||||
new_env = Environment(fn.env)
|
||||
for i, param in enumerate(fn.params):
|
||||
if i < len(args):
|
||||
new_env.set(param, args[i])
|
||||
else:
|
||||
new_env.set(param, None)
|
||||
return self.eval(fn.body, new_env)
|
||||
|
||||
elif callable(fn):
|
||||
# Wrap any Lambda arguments so primitives can call them
|
||||
wrapped_args = []
|
||||
for arg in args:
|
||||
if isinstance(arg, Lambda):
|
||||
wrapped_args.append(self._wrap_lambda(arg))
|
||||
else:
|
||||
wrapped_args.append(arg)
|
||||
|
||||
# Primitive function
|
||||
if kwargs:
|
||||
return fn(*wrapped_args, **kwargs)
|
||||
return fn(*wrapped_args)
|
||||
|
||||
else:
|
||||
raise TypeError(f"Cannot call: {fn}")
|
||||
|
||||
def _parse_bindings(self, bindings: list) -> list:
|
||||
"""Parse bindings in either Scheme or Clojure style.
|
||||
|
||||
Scheme: ((x 1) (y 2)) -> [(x, 1), (y, 2)]
|
||||
Clojure: [x 1 y 2] -> [(x, 1), (y, 2)]
|
||||
"""
|
||||
if not bindings:
|
||||
return []
|
||||
|
||||
# Check if Clojure style (flat list with symbols and values alternating)
|
||||
if isinstance(bindings[0], Symbol):
|
||||
# Clojure style: [x 1 y 2]
|
||||
pairs = []
|
||||
i = 0
|
||||
while i < len(bindings) - 1:
|
||||
name = bindings[i].name if isinstance(bindings[i], Symbol) else bindings[i]
|
||||
value = bindings[i + 1]
|
||||
pairs.append((name, value))
|
||||
i += 2
|
||||
return pairs
|
||||
else:
|
||||
# Scheme style: ((x 1) (y 2))
|
||||
pairs = []
|
||||
for binding in bindings:
|
||||
name = binding[0].name if isinstance(binding[0], Symbol) else binding[0]
|
||||
value = binding[1]
|
||||
pairs.append((name, value))
|
||||
return pairs
|
||||
|
||||
def _eval_let(self, expr: Any, env: Environment) -> Any:
|
||||
"""Evaluate let expression: (let ((x 1) (y 2)) body) or (let [x 1 y 2] body)
|
||||
|
||||
Note: Uses sequential binding (like Clojure let / Scheme let*) so each
|
||||
binding can reference previous bindings.
|
||||
"""
|
||||
bindings = expr[1]
|
||||
body = expr[2]
|
||||
|
||||
new_env = Environment(env)
|
||||
for name, value_expr in self._parse_bindings(bindings):
|
||||
value = self.eval(value_expr, new_env) # Sequential: can see previous bindings
|
||||
new_env.set(name, value)
|
||||
|
||||
return self.eval(body, new_env)
|
||||
|
||||
def _eval_let_star(self, expr: Any, env: Environment) -> Any:
|
||||
"""Evaluate let* expression: sequential bindings."""
|
||||
bindings = expr[1]
|
||||
body = expr[2]
|
||||
|
||||
new_env = Environment(env)
|
||||
for name, value_expr in self._parse_bindings(bindings):
|
||||
value = self.eval(value_expr, new_env) # Evaluate in current env
|
||||
new_env.set(name, value)
|
||||
|
||||
return self.eval(body, new_env)
|
||||
|
||||
def _eval_cond(self, expr: Any, env: Environment) -> Any:
|
||||
"""Evaluate cond expression."""
|
||||
for clause in expr[1:]:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name == 'else':
|
||||
return self.eval(clause[1], env)
|
||||
if self.eval(test, env):
|
||||
return self.eval(clause[1], env)
|
||||
return None
|
||||
|
||||
def _define_effect(self, expr: Any, env: Environment) -> EffectDefinition:
|
||||
"""
|
||||
Parse effect definition:
|
||||
(define-effect name
|
||||
((param1 default1) (param2 default2) ...)
|
||||
body)
|
||||
"""
|
||||
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
|
||||
|
||||
effect = EffectDefinition(name, params, body)
|
||||
self.effects[name] = effect
|
||||
return effect
|
||||
|
||||
def load_effect(self, path: str) -> EffectDefinition:
|
||||
"""Load an effect definition from a .sexp file."""
|
||||
expr = parse_file(path)
|
||||
|
||||
# Handle multiple top-level expressions
|
||||
if isinstance(expr, list) and expr and isinstance(expr[0], list):
|
||||
for e in expr:
|
||||
self.eval(e)
|
||||
else:
|
||||
self.eval(expr)
|
||||
|
||||
# Return the last defined effect
|
||||
if self.effects:
|
||||
return list(self.effects.values())[-1]
|
||||
return None
|
||||
|
||||
def run_effect(self, name: str, frame, params: Dict[str, Any],
|
||||
state: Dict[str, Any]) -> tuple:
|
||||
"""
|
||||
Run an effect on frame(s).
|
||||
|
||||
Args:
|
||||
name: Effect name
|
||||
frame: Input frame (H, W, 3) RGB uint8, or list of frames for multi-input
|
||||
params: Effect parameters (overrides defaults)
|
||||
state: Persistent state dict
|
||||
|
||||
Returns:
|
||||
(output_frame, new_state)
|
||||
"""
|
||||
if name not in self.effects:
|
||||
raise ValueError(f"Unknown effect: {name}")
|
||||
|
||||
effect = self.effects[name]
|
||||
|
||||
# Create environment for this run
|
||||
env = Environment(self.global_env)
|
||||
|
||||
# Bind frame(s) - support both single frame and list of frames
|
||||
if isinstance(frame, list):
|
||||
# Multi-input effect
|
||||
frames = frame
|
||||
env.set('frame', frames[0] if frames else None) # Backwards compat
|
||||
env.set('inputs', frames)
|
||||
# Named frame bindings
|
||||
for i, f in enumerate(frames):
|
||||
env.set(f'frame-{chr(ord("a") + i)}', f) # frame-a, frame-b, etc.
|
||||
else:
|
||||
# Single-input effect
|
||||
env.set('frame', frame)
|
||||
|
||||
# Bind state
|
||||
if state is None:
|
||||
state = {}
|
||||
env.set('__state__', state)
|
||||
|
||||
# Bind parameters (defaults + overrides)
|
||||
for pname, pdefault in effect.params.items():
|
||||
value = params.get(pname)
|
||||
if value is None:
|
||||
# Evaluate default if it's an expression (list)
|
||||
if isinstance(pdefault, list):
|
||||
value = self.eval(pdefault, env)
|
||||
else:
|
||||
value = pdefault
|
||||
env.set(pname, value)
|
||||
|
||||
# Reset RNG with seed if provided
|
||||
seed = params.get('seed', 42)
|
||||
reset_rng(int(seed))
|
||||
|
||||
# Bind time if provided
|
||||
time_val = params.get('_time', 0)
|
||||
env.set('t', time_val)
|
||||
env.set('_time', time_val)
|
||||
|
||||
# Evaluate body
|
||||
result = self.eval(effect.body, env)
|
||||
|
||||
# Ensure result is an image
|
||||
if not isinstance(result, np.ndarray):
|
||||
result = frame
|
||||
|
||||
return result, state
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience Functions
|
||||
# =============================================================================
|
||||
|
||||
_interpreter = None
|
||||
|
||||
|
||||
def get_interpreter() -> Interpreter:
|
||||
"""Get or create the global interpreter."""
|
||||
global _interpreter
|
||||
if _interpreter is None:
|
||||
_interpreter = Interpreter()
|
||||
return _interpreter
|
||||
|
||||
|
||||
def load_effect(path: str) -> EffectDefinition:
|
||||
"""Load an effect from a .sexp file."""
|
||||
return get_interpreter().load_effect(path)
|
||||
|
||||
|
||||
def load_effects_dir(directory: str):
|
||||
"""Load all .sexp effects from a directory."""
|
||||
interp = get_interpreter()
|
||||
dir_path = Path(directory)
|
||||
for path in dir_path.glob('*.sexp'):
|
||||
try:
|
||||
interp.load_effect(str(path))
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load {path}: {e}")
|
||||
|
||||
|
||||
def run_effect(name: str, frame: np.ndarray, params: Dict[str, Any],
|
||||
state: Dict[str, Any] = None) -> tuple:
|
||||
"""Run an effect."""
|
||||
return get_interpreter().run_effect(name, frame, params, state or {})
|
||||
|
||||
|
||||
def list_effects() -> List[str]:
|
||||
"""List loaded effect names."""
|
||||
return list(get_interpreter().effects.keys())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Adapter for existing effect system
|
||||
# =============================================================================
|
||||
|
||||
def make_process_frame(effect_path: str) -> Callable:
|
||||
"""
|
||||
Create a process_frame function from a .sexp effect.
|
||||
|
||||
This allows S-expression effects to be used with the existing
|
||||
effect system.
|
||||
"""
|
||||
interp = get_interpreter()
|
||||
interp.load_effect(effect_path)
|
||||
effect_name = Path(effect_path).stem
|
||||
|
||||
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
||||
return interp.run_effect(effect_name, frame, params, state)
|
||||
|
||||
return process_frame
|
||||
168
sexp_effects/parser.py
Normal file
168
sexp_effects/parser.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
S-Expression Parser
|
||||
|
||||
Parses S-expressions into Python data structures:
|
||||
- Lists become Python lists
|
||||
- Symbols become Symbol objects
|
||||
- Numbers become int/float
|
||||
- Strings become str
|
||||
- Keywords (:foo) become Keyword objects
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Union
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Symbol:
|
||||
"""A symbol (identifier) in the S-expression."""
|
||||
name: str
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Keyword:
|
||||
"""A keyword like :foo in the S-expression."""
|
||||
name: str
|
||||
|
||||
def __repr__(self):
|
||||
return f":{self.name}"
|
||||
|
||||
|
||||
# Token patterns
|
||||
TOKEN_PATTERNS = [
|
||||
(r'\s+', None), # Whitespace (skip)
|
||||
(r';[^\n]*', None), # Comments (skip)
|
||||
(r'\(', 'LPAREN'),
|
||||
(r'\)', 'RPAREN'),
|
||||
(r'\[', 'LBRACKET'),
|
||||
(r'\]', 'RBRACKET'),
|
||||
(r"'", 'QUOTE'),
|
||||
(r'"([^"\\]|\\.)*"', 'STRING'),
|
||||
(r':[a-zA-Z_][a-zA-Z0-9_\-]*', 'KEYWORD'),
|
||||
(r'-?[0-9]+\.[0-9]+', 'FLOAT'),
|
||||
(r'-?[0-9]+', 'INT'),
|
||||
(r'#t|#f|true|false', 'BOOL'),
|
||||
(r'[a-zA-Z_+\-*/<>=!?][a-zA-Z0-9_+\-*/<>=!?]*', 'SYMBOL'),
|
||||
]
|
||||
|
||||
TOKEN_REGEX = '|'.join(f'(?P<{name}>{pattern})' if name else f'(?:{pattern})'
|
||||
for pattern, name in TOKEN_PATTERNS)
|
||||
|
||||
|
||||
def tokenize(source: str) -> List[tuple]:
|
||||
"""Tokenize S-expression source code."""
|
||||
tokens = []
|
||||
for match in re.finditer(TOKEN_REGEX, source):
|
||||
kind = match.lastgroup
|
||||
value = match.group()
|
||||
if kind:
|
||||
tokens.append((kind, value))
|
||||
return tokens
|
||||
|
||||
|
||||
def parse(source: str) -> Any:
|
||||
"""Parse S-expression source into Python data structures."""
|
||||
tokens = tokenize(source)
|
||||
pos = [0] # Use list for mutability in nested function
|
||||
|
||||
def parse_expr():
|
||||
if pos[0] >= len(tokens):
|
||||
raise SyntaxError("Unexpected end of input")
|
||||
|
||||
kind, value = tokens[pos[0]]
|
||||
|
||||
if kind == 'LPAREN':
|
||||
pos[0] += 1
|
||||
items = []
|
||||
while pos[0] < len(tokens) and tokens[pos[0]][0] != 'RPAREN':
|
||||
items.append(parse_expr())
|
||||
if pos[0] >= len(tokens):
|
||||
raise SyntaxError("Missing closing parenthesis")
|
||||
pos[0] += 1 # Skip RPAREN
|
||||
return items
|
||||
|
||||
if kind == 'LBRACKET':
|
||||
pos[0] += 1
|
||||
items = []
|
||||
while pos[0] < len(tokens) and tokens[pos[0]][0] != 'RBRACKET':
|
||||
items.append(parse_expr())
|
||||
if pos[0] >= len(tokens):
|
||||
raise SyntaxError("Missing closing bracket")
|
||||
pos[0] += 1 # Skip RBRACKET
|
||||
return items
|
||||
|
||||
elif kind == 'RPAREN':
|
||||
raise SyntaxError("Unexpected closing parenthesis")
|
||||
|
||||
elif kind == 'QUOTE':
|
||||
pos[0] += 1
|
||||
return [Symbol('quote'), parse_expr()]
|
||||
|
||||
elif kind == 'STRING':
|
||||
pos[0] += 1
|
||||
# Remove quotes and unescape
|
||||
return value[1:-1].replace('\\"', '"').replace('\\n', '\n')
|
||||
|
||||
elif kind == 'INT':
|
||||
pos[0] += 1
|
||||
return int(value)
|
||||
|
||||
elif kind == 'FLOAT':
|
||||
pos[0] += 1
|
||||
return float(value)
|
||||
|
||||
elif kind == 'BOOL':
|
||||
pos[0] += 1
|
||||
return value in ('#t', 'true')
|
||||
|
||||
elif kind == 'KEYWORD':
|
||||
pos[0] += 1
|
||||
return Keyword(value[1:]) # Remove leading :
|
||||
|
||||
elif kind == 'SYMBOL':
|
||||
pos[0] += 1
|
||||
return Symbol(value)
|
||||
|
||||
else:
|
||||
raise SyntaxError(f"Unknown token: {kind} {value}")
|
||||
|
||||
result = parse_expr()
|
||||
|
||||
# Check for multiple top-level expressions
|
||||
if pos[0] < len(tokens):
|
||||
# Allow multiple top-level expressions, return as list
|
||||
results = [result]
|
||||
while pos[0] < len(tokens):
|
||||
results.append(parse_expr())
|
||||
return results
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_file(path: str) -> Any:
|
||||
"""Parse an S-expression file."""
|
||||
with open(path, 'r') as f:
|
||||
return parse(f.read())
|
||||
|
||||
|
||||
# Convenience for pretty-printing
|
||||
def to_sexp(obj: Any) -> str:
|
||||
"""Convert Python object back to S-expression string."""
|
||||
if isinstance(obj, list):
|
||||
return '(' + ' '.join(to_sexp(x) for x in obj) + ')'
|
||||
elif isinstance(obj, Symbol):
|
||||
return obj.name
|
||||
elif isinstance(obj, Keyword):
|
||||
return f':{obj.name}'
|
||||
elif isinstance(obj, str):
|
||||
return f'"{obj}"'
|
||||
elif isinstance(obj, bool):
|
||||
return '#t' if obj else '#f'
|
||||
elif isinstance(obj, (int, float)):
|
||||
return str(obj)
|
||||
else:
|
||||
return repr(obj)
|
||||
1867
sexp_effects/primitives.py
Normal file
1867
sexp_effects/primitives.py
Normal file
File diff suppressed because it is too large
Load Diff
173
sexp_effects/test_interpreter.py
Normal file
173
sexp_effects/test_interpreter.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test the S-expression effect interpreter.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sexp_effects import (
|
||||
get_interpreter,
|
||||
load_effects_dir,
|
||||
run_effect,
|
||||
list_effects,
|
||||
parse,
|
||||
)
|
||||
|
||||
|
||||
def test_parser():
|
||||
"""Test S-expression parser."""
|
||||
print("Testing parser...")
|
||||
|
||||
# Simple expressions
|
||||
assert parse("42") == 42
|
||||
assert parse("3.14") == 3.14
|
||||
assert parse('"hello"') == "hello"
|
||||
assert parse("true") == True
|
||||
|
||||
# Lists
|
||||
assert parse("(+ 1 2)")[0].name == "+"
|
||||
assert parse("(+ 1 2)")[1] == 1
|
||||
|
||||
# Nested
|
||||
expr = parse("(define x (+ 1 2))")
|
||||
assert expr[0].name == "define"
|
||||
|
||||
print(" Parser OK")
|
||||
|
||||
|
||||
def test_interpreter_basics():
|
||||
"""Test basic interpreter operations."""
|
||||
print("Testing interpreter basics...")
|
||||
|
||||
interp = get_interpreter()
|
||||
|
||||
# Math
|
||||
assert interp.eval(parse("(+ 1 2)")) == 3
|
||||
assert interp.eval(parse("(* 3 4)")) == 12
|
||||
assert interp.eval(parse("(- 10 3)")) == 7
|
||||
|
||||
# Comparison
|
||||
assert interp.eval(parse("(< 1 2)")) == True
|
||||
assert interp.eval(parse("(> 1 2)")) == False
|
||||
|
||||
# Let binding
|
||||
assert interp.eval(parse("(let ((x 5)) x)")) == 5
|
||||
assert interp.eval(parse("(let ((x 5) (y 3)) (+ x y))")) == 8
|
||||
|
||||
# Lambda
|
||||
result = interp.eval(parse("((lambda (x) (* x 2)) 5)"))
|
||||
assert result == 10
|
||||
|
||||
# If
|
||||
assert interp.eval(parse("(if true 1 2)")) == 1
|
||||
assert interp.eval(parse("(if false 1 2)")) == 2
|
||||
|
||||
print(" Interpreter basics OK")
|
||||
|
||||
|
||||
def test_primitives():
|
||||
"""Test image primitives."""
|
||||
print("Testing primitives...")
|
||||
|
||||
interp = get_interpreter()
|
||||
|
||||
# Create test image
|
||||
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||
img[50, 50] = [255, 128, 64]
|
||||
|
||||
interp.global_env.set('test_img', img)
|
||||
|
||||
# Width/height
|
||||
assert interp.eval(parse("(width test_img)")) == 100
|
||||
assert interp.eval(parse("(height test_img)")) == 100
|
||||
|
||||
# Pixel
|
||||
pixel = interp.eval(parse("(pixel test_img 50 50)"))
|
||||
assert pixel == [255, 128, 64]
|
||||
|
||||
# RGB
|
||||
color = interp.eval(parse("(rgb 100 150 200)"))
|
||||
assert color == [100, 150, 200]
|
||||
|
||||
# Luminance
|
||||
lum = interp.eval(parse("(luminance (rgb 100 100 100))"))
|
||||
assert abs(lum - 100) < 1
|
||||
|
||||
print(" Primitives OK")
|
||||
|
||||
|
||||
def test_effect_loading():
|
||||
"""Test loading effects from .sexp files."""
|
||||
print("Testing effect loading...")
|
||||
|
||||
# Load all effects
|
||||
effects_dir = Path(__file__).parent / "effects"
|
||||
load_effects_dir(str(effects_dir))
|
||||
|
||||
effects = list_effects()
|
||||
print(f" Loaded {len(effects)} effects: {', '.join(sorted(effects))}")
|
||||
|
||||
assert len(effects) > 0
|
||||
print(" Effect loading OK")
|
||||
|
||||
|
||||
def test_effect_execution():
|
||||
"""Test running effects on images."""
|
||||
print("Testing effect execution...")
|
||||
|
||||
# Create test image
|
||||
img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
|
||||
|
||||
# Load effects
|
||||
effects_dir = Path(__file__).parent / "effects"
|
||||
load_effects_dir(str(effects_dir))
|
||||
|
||||
# Test each effect
|
||||
effects = list_effects()
|
||||
passed = 0
|
||||
failed = []
|
||||
|
||||
for name in sorted(effects):
|
||||
try:
|
||||
result, state = run_effect(name, img.copy(), {'_time': 0.5}, {})
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == img.shape
|
||||
passed += 1
|
||||
print(f" {name}: OK")
|
||||
except Exception as e:
|
||||
failed.append((name, str(e)))
|
||||
print(f" {name}: FAILED - {e}")
|
||||
|
||||
print(f" Passed: {passed}/{len(effects)}")
|
||||
if failed:
|
||||
print(f" Failed: {[f[0] for f in failed]}")
|
||||
|
||||
return passed, failed
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("S-Expression Effect Interpreter Tests")
|
||||
print("=" * 60)
|
||||
|
||||
test_parser()
|
||||
test_interpreter_basics()
|
||||
test_primitives()
|
||||
test_effect_loading()
|
||||
passed, failed = test_effect_execution()
|
||||
|
||||
print("=" * 60)
|
||||
if not failed:
|
||||
print("All tests passed!")
|
||||
else:
|
||||
print(f"Tests completed with {len(failed)} failures")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user