Import L1 (celery) as l1/

This commit is contained in:
giles
2026-02-24 23:07:19 +00:00
225 changed files with 57298 additions and 0 deletions

View 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',
]

View File

@@ -0,0 +1,206 @@
;; Derived Operations
;;
;; These are built from true primitives using S-expressions.
;; Load with: (require "derived")
;; =============================================================================
;; Math Helpers (derivable from where + basic ops)
;; =============================================================================
;; Absolute value
(define (abs x) (where (< x 0) (- x) x))
;; Minimum of two values
(define (min2 a b) (where (< a b) a b))
;; Maximum of two values
(define (max2 a b) (where (> a b) a b))
;; Clamp x to range [lo, hi]
(define (clamp x lo hi) (max2 lo (min2 hi x)))
;; Square of x
(define (sq x) (* x x))
;; Linear interpolation: a*(1-t) + b*t
(define (lerp a b t) (+ (* a (- 1 t)) (* b t)))
;; Smooth interpolation between edges
(define (smoothstep edge0 edge1 x)
(let ((t (clamp (/ (- x edge0) (- edge1 edge0)) 0 1)))
(* t (* t (- 3 (* 2 t))))))
;; =============================================================================
;; Channel Shortcuts (derivable from channel primitive)
;; =============================================================================
;; Extract red channel as xector
(define (red frame) (channel frame 0))
;; Extract green channel as xector
(define (green frame) (channel frame 1))
;; Extract blue channel as xector
(define (blue frame) (channel frame 2))
;; Convert to grayscale xector (ITU-R BT.601)
(define (gray frame)
(+ (* (red frame) 0.299)
(* (green frame) 0.587)
(* (blue frame) 0.114)))
;; Alias for gray
(define (luminance frame) (gray frame))
;; =============================================================================
;; Coordinate Generators (derivable from iota + repeat/tile)
;; =============================================================================
;; X coordinate for each pixel [0, width)
(define (x-coords frame) (tile (iota (width frame)) (height frame)))
;; Y coordinate for each pixel [0, height)
(define (y-coords frame) (repeat (iota (height frame)) (width frame)))
;; Normalized X coordinate [0, 1]
(define (x-norm frame) (/ (x-coords frame) (max2 1 (- (width frame) 1))))
;; Normalized Y coordinate [0, 1]
(define (y-norm frame) (/ (y-coords frame) (max2 1 (- (height frame) 1))))
;; Distance from frame center for each pixel
(define (dist-from-center frame)
(let* ((cx (/ (width frame) 2))
(cy (/ (height frame) 2))
(dx (- (x-coords frame) cx))
(dy (- (y-coords frame) cy)))
(sqrt (+ (sq dx) (sq dy)))))
;; Normalized distance from center [0, ~1]
(define (dist-norm frame)
(let ((d (dist-from-center frame)))
(/ d (max2 1 (βmax d)))))
;; =============================================================================
;; Cell/Grid Operations (derivable from floor + basic math)
;; =============================================================================
;; Cell row index for each pixel
(define (cell-row frame cell-size) (floor (/ (y-coords frame) cell-size)))
;; Cell column index for each pixel
(define (cell-col frame cell-size) (floor (/ (x-coords frame) cell-size)))
;; Number of cell rows
(define (num-rows frame cell-size) (floor (/ (height frame) cell-size)))
;; Number of cell columns
(define (num-cols frame cell-size) (floor (/ (width frame) cell-size)))
;; Flat cell index for each pixel
(define (cell-indices frame cell-size)
(+ (* (cell-row frame cell-size) (num-cols frame cell-size))
(cell-col frame cell-size)))
;; Total number of cells
(define (num-cells frame cell-size)
(* (num-rows frame cell-size) (num-cols frame cell-size)))
;; X position within cell [0, cell-size)
(define (local-x frame cell-size) (mod (x-coords frame) cell-size))
;; Y position within cell [0, cell-size)
(define (local-y frame cell-size) (mod (y-coords frame) cell-size))
;; Normalized X within cell [0, 1]
(define (local-x-norm frame cell-size)
(/ (local-x frame cell-size) (max2 1 (- cell-size 1))))
;; Normalized Y within cell [0, 1]
(define (local-y-norm frame cell-size)
(/ (local-y frame cell-size) (max2 1 (- cell-size 1))))
;; =============================================================================
;; Fill Operations (derivable from iota)
;; =============================================================================
;; Xector of n zeros
(define (zeros n) (* (iota n) 0))
;; Xector of n ones
(define (ones n) (+ (zeros n) 1))
;; Xector of n copies of val
(define (fill val n) (+ (zeros n) val))
;; Xector of zeros matching x's length
(define (zeros-like x) (* x 0))
;; Xector of ones matching x's length
(define (ones-like x) (+ (zeros-like x) 1))
;; =============================================================================
;; Pooling (derivable from group-reduce)
;; =============================================================================
;; Pool a channel by cell index
(define (pool-channel chan cell-idx num-cells)
(group-reduce chan cell-idx num-cells "mean"))
;; Pool red channel to cells
(define (pool-red frame cell-size)
(pool-channel (red frame)
(cell-indices frame cell-size)
(num-cells frame cell-size)))
;; Pool green channel to cells
(define (pool-green frame cell-size)
(pool-channel (green frame)
(cell-indices frame cell-size)
(num-cells frame cell-size)))
;; Pool blue channel to cells
(define (pool-blue frame cell-size)
(pool-channel (blue frame)
(cell-indices frame cell-size)
(num-cells frame cell-size)))
;; Pool grayscale to cells
(define (pool-gray frame cell-size)
(pool-channel (gray frame)
(cell-indices frame cell-size)
(num-cells frame cell-size)))
;; =============================================================================
;; Blending (derivable from math)
;; =============================================================================
;; Additive blend
(define (blend-add a b) (clamp (+ a b) 0 255))
;; Multiply blend (normalized)
(define (blend-multiply a b) (* (/ a 255) b))
;; Screen blend
(define (blend-screen a b) (- 255 (* (/ (- 255 a) 255) (- 255 b))))
;; Overlay blend
(define (blend-overlay a b)
(where (< a 128)
(* 2 (/ (* a b) 255))
(- 255 (* 2 (/ (* (- 255 a) (- 255 b)) 255)))))
;; =============================================================================
;; Simple Effects (derivable from primitives)
;; =============================================================================
;; Invert a channel (255 - c)
(define (invert-channel c) (- 255 c))
;; Binary threshold
(define (threshold-channel c thresh) (where (> c thresh) 255 0))
;; Reduce to n levels
(define (posterize-channel c levels)
(let ((step (/ 255 (- levels 1))))
(* (round (/ c step)) step)))

View File

@@ -0,0 +1,17 @@
;; ASCII Art effect - converts image to ASCII characters
(require-primitives "ascii")
(define-effect ascii_art
: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_color invert_colors)))

View File

@@ -0,0 +1,52 @@
;; ASCII Art FX - converts image to ASCII characters with per-character effects
(require-primitives "ascii")
(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)))

View File

@@ -0,0 +1,102 @@
;; Composable ASCII Art with Per-Zone Expression-Driven Effects
;; Requires ascii primitive library for the ascii-fx-zone primitive
(require-primitives "ascii")
;; 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))

View File

@@ -0,0 +1,30 @@
;; ASCII Zones effect - different character sets for different brightness zones
;; Dark areas use simple chars, mid uses standard, bright uses blocks
(require-primitives "ascii")
(define-effect ascii_zones
: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))
;; 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))))

View File

@@ -0,0 +1,31 @@
;; Blend effect - combines two video frames
;; Streaming-compatible: frame is background, overlay is second frame
;; Usage: (blend background overlay :opacity 0.5 :mode "alpha")
;;
;; Params:
;; mode - blend mode (add, multiply, screen, overlay, difference, lighten, darken, alpha)
;; opacity - blend amount (0-1)
(require-primitives "image" "blending" "core")
(define-effect blend
:params (
(overlay :type frame :default nil)
(mode :type string :default "alpha")
(opacity :type float :default 0.5)
)
(if (core:is-nil overlay)
frame
(let [a frame
b overlay
a-h (image:height a)
a-w (image:width a)
b-h (image:height b)
b-w (image:width b)
;; Resize b to match a if needed
b-sized (if (and (= a-w b-w) (= a-h b-h))
b
(image:resize b a-w a-h "linear"))]
(if (= mode "alpha")
(blending:blend-images a b-sized opacity)
(blending:blend-images a (blending:blend-mode a b-sized mode) opacity)))))

View File

@@ -0,0 +1,58 @@
;; N-way weighted blend effect
;; Streaming-compatible: pass inputs as a list of frames
;; Usage: (blend_multi :inputs [(read a) (read b) (read c)] :weights [0.3 0.4 0.3])
;;
;; Parameters:
;; inputs - list of N frames to blend
;; weights - list of N floats, one per input (resolved per-frame)
;; mode - blend mode applied when folding each frame in:
;; "alpha" — pure weighted average (default)
;; "multiply" — darken by multiplication
;; "screen" — lighten (inverse multiply)
;; "overlay" — contrast-boosting midtone blend
;; "soft-light" — gentle dodge/burn
;; "hard-light" — strong dodge/burn
;; "color-dodge" — brightens towards white
;; "color-burn" — darkens towards black
;; "difference" — absolute pixel difference
;; "exclusion" — softer difference
;; "add" — additive (clamped)
;; "subtract" — subtractive (clamped)
;; "darken" — per-pixel minimum
;; "lighten" — per-pixel maximum
;; resize_mode - how to match frame dimensions (fit, crop, stretch)
;;
;; Uses a left-fold over inputs[1..N-1]. At each step the running
;; opacity is: w[i] / (w[0] + w[1] + ... + w[i])
;; which produces the correct normalised weighted result.
(require-primitives "image" "blending")
(define-effect blend_multi
:params (
(inputs :type list :default [])
(weights :type list :default [])
(mode :type string :default "alpha")
(resize_mode :type string :default "fit")
)
(let [n (len inputs)
;; Target dimensions from first frame
target-w (image:width (nth inputs 0))
target-h (image:height (nth inputs 0))
;; Fold over indices 1..n-1
;; Accumulator is (list blended-frame running-weight-sum)
seed (list (nth inputs 0) (nth weights 0))
result (reduce (range 1 n) seed
(lambda (pair i)
(let [acc (nth pair 0)
running (nth pair 1)
w (nth weights i)
new-running (+ running w)
opacity (/ w (max new-running 0.001))
f (image:resize (nth inputs i) target-w target-h "linear")
;; Apply blend mode then mix with opacity
blended (if (= mode "alpha")
(blending:blend-images acc f opacity)
(blending:blend-images acc (blending:blend-mode acc f mode) opacity))]
(list blended new-running))))]
(nth result 0)))

View File

@@ -0,0 +1,16 @@
;; Bloom effect - glow on bright areas
(require-primitives "image" "blending")
(define-effect bloom
: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)
c
(rgb 0 0 0)))))
(blurred (image:blur bright radius)))
(blending:blend-mode frame blurred "add")))

View File

@@ -0,0 +1,8 @@
;; Blur effect - gaussian blur
(require-primitives "image")
(define-effect blur
:params (
(radius :type int :default 5 :range [1 50])
)
(image:blur frame (max 1 radius)))

View File

@@ -0,0 +1,9 @@
;; Brightness effect - adjusts overall brightness
;; Uses vectorized adjust primitive for fast processing
(require-primitives "color_ops")
(define-effect brightness
:params (
(amount :type int :default 0 :range [-255 255])
)
(color_ops:adjust-brightness frame amount))

View File

@@ -0,0 +1,65 @@
;; Cell Pattern effect - custom patterns within cells
;;
;; Demonstrates building arbitrary per-cell visuals from primitives.
;; Uses local coordinates within cells to draw patterns scaled by luminance.
(require-primitives "xector")
(define-effect cell_pattern
:params (
(cell-size :type int :default 16 :range [8 48] :desc "Cell size")
(pattern :type string :default "diagonal" :desc "Pattern: diagonal, cross, ring")
)
(let* (
;; Pool to get cell colors
(pooled (pool-frame frame cell-size))
(cell-r (nth pooled 0))
(cell-g (nth pooled 1))
(cell-b (nth pooled 2))
(cell-lum (α/ (nth pooled 3) 255))
;; Cell indices for each pixel
(cell-idx (cell-indices frame cell-size))
;; Look up cell values for each pixel
(pix-r (gather cell-r cell-idx))
(pix-g (gather cell-g cell-idx))
(pix-b (gather cell-b cell-idx))
(pix-lum (gather cell-lum cell-idx))
;; Local position within cell [0, 1]
(lx (local-x-norm frame cell-size))
(ly (local-y-norm frame cell-size))
;; Pattern mask based on pattern type
(mask
(cond
;; Diagonal lines - thickness based on luminance
((= pattern "diagonal")
(let* ((diag (αmod (α+ lx ly) 0.25))
(thickness (α* pix-lum 0.125)))
(α< diag thickness)))
;; Cross pattern
((= pattern "cross")
(let* ((cx (αabs (α- lx 0.5)))
(cy (αabs (α- ly 0.5)))
(thickness (α* pix-lum 0.25)))
(αor (α< cx thickness) (α< cy thickness))))
;; Ring pattern
((= pattern "ring")
(let* ((dx (α- lx 0.5))
(dy (α- ly 0.5))
(dist (αsqrt (α+ (α² dx) (α² dy))))
(target (α* pix-lum 0.4))
(thickness 0.05))
(α< (αabs (α- dist target)) thickness)))
;; Default: solid
(else (α> pix-lum 0)))))
;; Apply mask: show cell color where mask is true, black elsewhere
(rgb (where mask pix-r 0)
(where mask pix-g 0)
(where mask pix-b 0))))

View File

@@ -0,0 +1,13 @@
;; Color adjustment effect - replaces TRANSFORM node
(require-primitives "color_ops")
(define-effect color-adjust
: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
(color_ops:adjust-brightness brightness)
(color_ops:adjust-contrast contrast)
(color_ops:adjust-saturation saturation)))

View File

@@ -0,0 +1,13 @@
;; Color Cycle effect - animated hue rotation
(require-primitives "color_ops")
(define-effect color_cycle
:params (
(speed :type int :default 1 :range [0 10])
)
(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))))))))

View File

@@ -0,0 +1,9 @@
;; Contrast effect - adjusts image contrast
;; Uses vectorized adjust primitive for fast processing
(require-primitives "color_ops")
(define-effect contrast
:params (
(amount :type int :default 1 :range [0.5 3])
)
(color_ops:adjust-contrast frame amount))

View File

@@ -0,0 +1,30 @@
;; CRT effect - old monitor simulation
(require-primitives "image")
(define-effect crt
: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 (image:width frame))
(h (image: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)))))))

View File

@@ -0,0 +1,14 @@
;; Datamosh effect - glitch block corruption
(define-effect datamosh
: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
(state-set "prev_frame" (copy frame))
(datamosh frame prev block_size corruption max_offset color_corrupt))))

View File

@@ -0,0 +1,19 @@
;; Echo effect - motion trails using frame buffer
(require-primitives "blending")
(define-effect echo
: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
(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
(blending:blend-images frame (nth new-buffer 1) (* decay 0.5)))))))

View File

@@ -0,0 +1,9 @@
;; Edge detection effect - highlights edges
(require-primitives "image")
(define-effect edge_detect
:params (
(low :type int :default 50 :range [10 100])
(high :type int :default 150 :range [50 300])
)
(image:edge-detect frame low high))

View File

@@ -0,0 +1,13 @@
;; Emboss effect - creates raised/3D appearance
(require-primitives "blending")
(define-effect emboss
: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)))
(embossed (convolve frame kernel)))
(blending:blend-images embossed frame blend)))

View File

@@ -0,0 +1,19 @@
;; Film Grain effect - adds film grain texture
(require-primitives "core")
(define-effect film_grain
: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)
(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))))))))

View File

@@ -0,0 +1,16 @@
;; Fisheye effect - barrel/pincushion lens distortion
(require-primitives "geometry" "image")
(define-effect fisheye
: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 (image:width frame))
(h (image:height frame))
(cx (* w center_x))
(cy (* h center_y))
(coords (geometry:fisheye-coords w h strength cx cy zoom_correct)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -0,0 +1,16 @@
;; Flip effect - flips image horizontally or vertically
(require-primitives "geometry")
(define-effect flip
:params (
(horizontal :type bool :default true)
(vertical :type bool :default false)
)
(let ((result frame))
(if horizontal
(set! result (geometry:flip-img result "horizontal"))
nil)
(if vertical
(set! result (geometry:flip-img result "vertical"))
nil)
result))

View File

@@ -0,0 +1,7 @@
;; Grayscale effect - converts to grayscale
;; Uses vectorized mix-gray primitive for fast processing
(require-primitives "image")
(define-effect grayscale
:params ()
(image:grayscale frame))

View File

@@ -0,0 +1,49 @@
;; Halftone/dot effect - built from primitive xector operations
;;
;; Uses:
;; pool-frame - downsample to cell luminances
;; cell-indices - which cell each pixel belongs to
;; gather - look up cell value for each pixel
;; local-x/y-norm - position within cell [0,1]
;; where - conditional per-pixel
(require-primitives "xector")
(define-effect halftone
:params (
(cell-size :type int :default 12 :range [4 32] :desc "Size of halftone cells")
(dot-scale :type float :default 0.9 :range [0.1 1.0] :desc "Max dot radius")
(invert :type bool :default false :desc "Invert (white dots on black)")
)
(let* (
;; Pool frame to get luminance per cell
(pooled (pool-frame frame cell-size))
(cell-lum (nth pooled 3)) ; luminance is 4th element
;; For each output pixel, get its cell index
(cell-idx (cell-indices frame cell-size))
;; Get cell luminance for each pixel
(pixel-lum (α/ (gather cell-lum cell-idx) 255))
;; Position within cell, normalized to [-0.5, 0.5]
(lx (α- (local-x-norm frame cell-size) 0.5))
(ly (α- (local-y-norm frame cell-size) 0.5))
;; Distance from cell center (0 at center, ~0.7 at corners)
(dist (αsqrt (α+ (α² lx) (α² ly))))
;; Radius based on luminance (brighter = bigger dot)
(radius (α* (if invert (α- 1 pixel-lum) pixel-lum)
(α* dot-scale 0.5)))
;; Is this pixel inside the dot?
(inside (α< dist radius))
;; Output color
(fg (if invert 255 0))
(bg (if invert 0 255))
(out (where inside fg bg)))
;; Grayscale output
(rgb out out out)))

View File

@@ -0,0 +1,12 @@
;; Hue shift effect - rotates hue values
;; Uses vectorized shift-hsv primitive for fast processing
(require-primitives "color_ops")
(define-effect hue_shift
:params (
(degrees :type int :default 0 :range [0 360])
(speed :type int :default 0 :desc "rotation per second")
)
(let ((shift (+ degrees (* speed t))))
(color_ops:shift-hsv frame shift 1 1)))

View File

@@ -0,0 +1,9 @@
;; Invert effect - inverts all colors
;; Uses vectorized invert-img primitive for fast processing
;; amount param: 0 = no invert, 1 = full invert (threshold at 0.5)
(require-primitives "color_ops")
(define-effect invert
:params ((amount :type float :default 1 :range [0 1]))
(if (> amount 0.5) (color_ops:invert-img frame) frame))

View File

@@ -0,0 +1,20 @@
;; Kaleidoscope effect - mandala-like symmetry patterns
(require-primitives "geometry" "image")
(define-effect kaleidoscope
: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 (image:width frame))
(h (image: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 (geometry:kaleidoscope-coords w h segments total_rot cx cy zoom)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -0,0 +1,36 @@
;; Layer effect - composite overlay over background at position
;; Streaming-compatible: frame is background, overlay is foreground
;; Usage: (layer background overlay :x 10 :y 20 :opacity 0.8)
;;
;; Params:
;; overlay - frame to composite on top
;; x, y - position to place overlay
;; opacity - blend amount (0-1)
;; mode - blend mode (alpha, multiply, screen, etc.)
(require-primitives "image" "blending" "core")
(define-effect layer
:params (
(overlay :type frame :default nil)
(x :type int :default 0)
(y :type int :default 0)
(opacity :type float :default 1.0)
(mode :type string :default "alpha")
)
(if (core:is-nil overlay)
frame
(let [bg (copy frame)
fg overlay
fg-w (image:width fg)
fg-h (image:height fg)]
(if (= opacity 1.0)
;; Simple paste
(paste bg fg x y)
;; Blend with opacity
(let [blended (if (= mode "alpha")
(blending:blend-images (image:crop bg x y fg-w fg-h) fg opacity)
(blending:blend-images (image:crop bg x y fg-w fg-h)
(blending:blend-mode (image:crop bg x y fg-w fg-h) fg mode)
opacity))]
(paste bg blended x y))))))

View File

@@ -0,0 +1,33 @@
;; Mirror effect - mirrors half of image
(require-primitives "geometry" "image")
(define-effect mirror
:params (
(mode :type string :default "left_right")
)
(let* ((w (image:width frame))
(h (image:height frame))
(hw (floor (/ w 2)))
(hh (floor (/ h 2))))
(cond
((= mode "left_right")
(let ((left (image:crop frame 0 0 hw h))
(result (copy frame)))
(paste result (geometry:flip-img left "horizontal") hw 0)))
((= mode "right_left")
(let ((right (image:crop frame hw 0 hw h))
(result (copy frame)))
(paste result (geometry:flip-img right "horizontal") 0 0)))
((= mode "top_bottom")
(let ((top (image:crop frame 0 0 w hh))
(result (copy frame)))
(paste result (geometry:flip-img top "vertical") 0 hh)))
((= mode "bottom_top")
(let ((bottom (image:crop frame 0 hh w hh))
(result (copy frame)))
(paste result (geometry:flip-img bottom "vertical") 0 0)))
(else frame))))

View File

@@ -0,0 +1,30 @@
;; Mosaic effect - built from primitive xector operations
;;
;; Uses:
;; pool-frame - downsample to cell averages
;; cell-indices - which cell each pixel belongs to
;; gather - look up cell value for each pixel
(require-primitives "xector")
(define-effect mosaic
:params (
(cell-size :type int :default 16 :range [4 64] :desc "Size of mosaic cells")
)
(let* (
;; Pool frame to get average color per cell (returns r,g,b,lum xectors)
(pooled (pool-frame frame cell-size))
(cell-r (nth pooled 0))
(cell-g (nth pooled 1))
(cell-b (nth pooled 2))
;; For each output pixel, get its cell index
(cell-idx (cell-indices frame cell-size))
;; Gather: look up cell color for each pixel
(out-r (gather cell-r cell-idx))
(out-g (gather cell-g cell-idx))
(out-b (gather cell-b cell-idx)))
;; Reconstruct frame
(rgb out-r out-g out-b)))

View File

@@ -0,0 +1,23 @@
;; Neon Glow effect - glowing edge effect
(require-primitives "image" "blending")
(define-effect neon_glow
: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 (image:edge-detect frame edge_low edge_high))
(glow (image: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))))))
(blending:blend-mode (blending:blend-images frame (make-image (image:width frame) (image:height frame) (list 0 0 0))
(- 1 background))
bright-glow
"screen")))

View File

@@ -0,0 +1,8 @@
;; Noise effect - adds random noise
;; Uses vectorized add-noise primitive for fast processing
(define-effect noise
:params (
(amount :type int :default 20 :range [0 100])
)
(add-noise frame amount))

View File

@@ -0,0 +1,24 @@
;; Outline effect - shows only edges
(require-primitives "image")
(define-effect outline
: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 :type string :default "original")
)
(let* ((edge-img (image:edge-detect 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 (image:width frame) (image:height frame) (list 255 255 255)))
(else (make-image (image:width frame) (image: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))))))

View File

@@ -0,0 +1,13 @@
;; Pixelate effect - creates blocky pixels
(require-primitives "image")
(define-effect pixelate
:params (
(block_size :type int :default 8 :range [2 64])
)
(let* ((w (image:width frame))
(h (image:height frame))
(small-w (max 1 (floor (/ w block_size))))
(small-h (max 1 (floor (/ h block_size))))
(small (image:resize frame small-w small-h "area")))
(image:resize small w h "nearest")))

View File

@@ -0,0 +1,11 @@
;; Pixelsort effect - glitch art pixel sorting
(define-effect pixelsort
: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))

View File

@@ -0,0 +1,8 @@
;; Posterize effect - reduces color levels
(require-primitives "color_ops")
(define-effect posterize
:params (
(levels :type int :default 8 :range [2 32])
)
(color_ops:posterize frame levels))

View File

@@ -0,0 +1,11 @@
;; Resize effect - replaces RESIZE node
;; Note: uses target-w/target-h to avoid conflict with width/height primitives
(require-primitives "image")
(define-effect resize-frame
: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")
)
(image:resize frame target-w target-h mode))

View File

@@ -0,0 +1,13 @@
;; RGB Split effect - chromatic aberration
(define-effect rgb_split
: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))
(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))))

View File

@@ -0,0 +1,19 @@
;; Ripple effect - radial wave distortion from center
(require-primitives "geometry" "image" "math")
(define-effect ripple
: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 (image:width frame))
(h (image:height frame))
(cx (* w center_x))
(cy (* h center_y))
(phase (* (or t 0) speed 2 pi))
(coords (geometry:ripple-displace w h frequency amplitude cx cy decay phase)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -0,0 +1,11 @@
;; Rotate effect - rotates image
(require-primitives "geometry")
(define-effect rotate
:params (
(angle :type int :default 0 :range [-360 360])
(speed :type int :default 0 :desc "rotation per second")
)
(let ((total-angle (+ angle (* speed t))))
(geometry:rotate-img frame total-angle)))

View File

@@ -0,0 +1,9 @@
;; Saturation effect - adjusts color saturation
;; Uses vectorized shift-hsv primitive for fast processing
(require-primitives "color_ops")
(define-effect saturation
:params (
(amount :type int :default 1 :range [0 3])
)
(color_ops:adjust-saturation frame amount))

View File

@@ -0,0 +1,15 @@
;; Scanlines effect - VHS-style horizontal line shifting
(require-primitives "core")
(define-effect scanlines
: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)))))
(rand-shift (core:rand-range (- amplitude) amplitude))
(shift (floor (lerp sine-shift rand-shift randomness))))
(roll row shift 0)))))

View File

@@ -0,0 +1,7 @@
;; Sepia effect - applies sepia tone
;; Classic warm vintage look
(require-primitives "color_ops")
(define-effect sepia
:params ()
(color_ops:sepia frame))

View File

@@ -0,0 +1,8 @@
;; Sharpen effect - sharpens edges
(require-primitives "image")
(define-effect sharpen
:params (
(amount :type int :default 1 :range [0 5])
)
(image:sharpen frame amount))

View File

@@ -0,0 +1,16 @@
;; Strobe effect - holds frames for choppy look
(require-primitives "core")
(define-effect strobe
: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)))
(if (or (core:is-nil held) (>= t held-until))
(begin
(state-set "held" (copy frame))
(state-set "held-until" (+ t frame-duration))
frame)
held)))

View File

@@ -0,0 +1,17 @@
;; Swirl effect - spiral vortex distortion
(require-primitives "geometry" "image")
(define-effect swirl
: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 (image:width frame))
(h (image:height frame))
(cx (* w center_x))
(cy (* h center_y))
(coords (geometry:swirl-coords w h strength radius cx cy falloff)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -0,0 +1,9 @@
;; Threshold effect - converts to black and white
(require-primitives "color_ops")
(define-effect threshold
:params (
(level :type int :default 128 :range [0 255])
(invert :type bool :default false)
)
(color_ops:threshold frame level invert))

View File

@@ -0,0 +1,29 @@
;; Tile Grid effect - tiles image in grid
(require-primitives "geometry" "image")
(define-effect tile_grid
: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 (image:width frame))
(h (image:height frame))
(tile-w (floor (/ (- w (* gap (- cols 1))) cols)))
(tile-h (floor (/ (- h (* gap (- rows 1))) rows)))
(tile (image: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)))

View File

@@ -0,0 +1,20 @@
;; Trails effect - persistent motion trails
(require-primitives "image" "blending")
(define-effect trails
:params (
(persistence :type float :default 0.8 :range [0 0.99])
)
(let* ((buffer (state-get "buffer" nil))
(current frame))
(if (= buffer nil)
(begin
(state-set "buffer" (copy frame))
frame)
(let* ((faded (blending:blend-images buffer
(make-image (image:width frame) (image:height frame) (list 0 0 0))
(- 1 persistence)))
(result (blending:blend-mode faded current "lighten")))
(begin
(state-set "buffer" result)
result)))))

View File

@@ -0,0 +1,23 @@
;; Vignette effect - darkens corners
(require-primitives "image")
(define-effect vignette
:params (
(strength :type float :default 0.5 :range [0 1])
(radius :type int :default 1 :range [0.5 2])
)
(let* ((w (image:width frame))
(h (image: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)))))))

View File

@@ -0,0 +1,22 @@
;; Wave effect - sine wave displacement distortion
(require-primitives "geometry" "image")
(define-effect wave
: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 (image:width frame))
(h (image: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 (geometry:wave-coords w h axis freq amplitude phase)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -0,0 +1,44 @@
;; Feathered blend - blend two same-size frames with distance-based falloff
;; Center shows overlay, edges show background, with smooth transition
(require-primitives "xector")
(define-effect xector_feathered_blend
:params (
(inner-radius :type float :default 0.3 :range [0 1] :desc "Radius where overlay is 100% (fraction of size)")
(fade-width :type float :default 0.2 :range [0 0.5] :desc "Width of fade region (fraction of size)")
(overlay :type frame :default nil :desc "Frame to blend in center")
)
(let* (
;; Get normalized distance from center (0 at center, ~1 at corners)
(dist (dist-from-center frame))
(max-dist (βmax dist))
(dist-norm (α/ dist max-dist))
;; Calculate blend factor:
;; - 1.0 when dist-norm < inner-radius (fully overlay)
;; - 0.0 when dist-norm > inner-radius + fade-width (fully background)
;; - linear ramp between
(t (α/ (α- dist-norm inner-radius) fade-width))
(blend (α- 1 (αclamp t 0 1)))
(inv-blend (α- 1 blend))
;; Background channels
(bg-r (red frame))
(bg-g (green frame))
(bg-b (blue frame)))
(if (nil? overlay)
;; No overlay - visualize the blend mask
(let ((vis (α* blend 255)))
(rgb vis vis vis))
;; Blend overlay with background using the mask
(let* ((ov-r (red overlay))
(ov-g (green overlay))
(ov-b (blue overlay))
;; lerp: bg * (1-blend) + overlay * blend
(r-out (α+ (α* bg-r inv-blend) (α* ov-r blend)))
(g-out (α+ (α* bg-g inv-blend) (α* ov-g blend)))
(b-out (α+ (α* bg-b inv-blend) (α* ov-b blend))))
(rgb r-out g-out b-out)))))

View File

@@ -0,0 +1,34 @@
;; Film grain effect using xector operations
;; Demonstrates random xectors and mixing scalar/xector math
(require-primitives "xector")
(define-effect xector_grain
:params (
(intensity :type float :default 0.2 :range [0 1] :desc "Grain intensity")
(colored :type bool :default false :desc "Use colored grain")
)
(let* (
;; Extract channels
(r (red frame))
(g (green frame))
(b (blue frame))
;; Generate noise xector(s)
;; randn-x generates normal distribution noise
(grain-amount (* intensity 50)))
(if colored
;; Colored grain: different noise per channel
(let* ((nr (randn-x frame 0 grain-amount))
(ng (randn-x frame 0 grain-amount))
(nb (randn-x frame 0 grain-amount)))
(rgb (αclamp (α+ r nr) 0 255)
(αclamp (α+ g ng) 0 255)
(αclamp (α+ b nb) 0 255)))
;; Monochrome grain: same noise for all channels
(let ((n (randn-x frame 0 grain-amount)))
(rgb (αclamp (α+ r n) 0 255)
(αclamp (α+ g n) 0 255)
(αclamp (α+ b n) 0 255))))))

View File

@@ -0,0 +1,57 @@
;; Inset blend - fade a smaller frame into a larger background
;; Uses distance-based alpha for smooth transition (no hard edges)
(require-primitives "xector")
(define-effect xector_inset_blend
:params (
(x :type int :default 0 :desc "X position of inset")
(y :type int :default 0 :desc "Y position of inset")
(fade-width :type int :default 50 :desc "Width of fade region in pixels")
(overlay :type frame :default nil :desc "The smaller frame to inset")
)
(let* (
;; Get dimensions
(bg-h (first (list (nth (list (red frame)) 0)))) ;; TODO: need image:height
(bg-w bg-h) ;; placeholder
;; For now, create a simple centered circular blend
;; Distance from center of overlay position
(cx (+ x (/ (- bg-w (* 2 x)) 2)))
(cy (+ y (/ (- bg-h (* 2 y)) 2)))
;; Get coordinates as xectors
(px (x-coords frame))
(py (y-coords frame))
;; Distance from center
(dx (α- px cx))
(dy (α- py cy))
(dist (αsqrt (α+ (α* dx dx) (α* dy dy))))
;; Inner radius (fully overlay) and outer radius (fully background)
(inner-r (- (/ bg-w 2) x fade-width))
(outer-r (- (/ bg-w 2) x))
;; Blend factor: 1.0 inside inner-r, 0.0 outside outer-r, linear between
(t (α/ (α- dist inner-r) fade-width))
(blend (α- 1 (αclamp t 0 1)))
;; Extract channels from both frames
(bg-r (red frame))
(bg-g (green frame))
(bg-b (blue frame)))
;; If overlay provided, blend it
(if overlay
(let* ((ov-r (red overlay))
(ov-g (green overlay))
(ov-b (blue overlay))
;; Linear blend: result = bg * (1-blend) + overlay * blend
(r-out (α+ (α* bg-r (α- 1 blend)) (α* ov-r blend)))
(g-out (α+ (α* bg-g (α- 1 blend)) (α* ov-g blend)))
(b-out (α+ (α* bg-b (α- 1 blend)) (α* ov-b blend))))
(rgb r-out g-out b-out))
;; No overlay - just show the blend mask for debugging
(let ((mask-vis (α* blend 255)))
(rgb mask-vis mask-vis mask-vis)))))

View File

@@ -0,0 +1,27 @@
;; Threshold effect using xector operations
;; Demonstrates where (conditional select) and β (reduction) for normalization
(require-primitives "xector")
(define-effect xector_threshold
:params (
(threshold :type float :default 0.5 :range [0 1] :desc "Brightness threshold (0-1)")
(invert :type bool :default false :desc "Invert the threshold")
)
(let* (
;; Get grayscale luminance as xector
(luma (gray frame))
;; Normalize to 0-1 range
(luma-norm (α/ luma 255))
;; Create boolean mask: pixels above threshold
(mask (if invert
(α< luma-norm threshold)
(α>= luma-norm threshold)))
;; Use where to select: white (255) if above threshold, black (0) if below
(out (where mask 255 0)))
;; Output as grayscale (same value for R, G, B)
(rgb out out out)))

View File

@@ -0,0 +1,36 @@
;; Vignette effect using xector operations
;; Demonstrates α (element-wise) and β (reduction) patterns
(require-primitives "xector")
(define-effect xector_vignette
:params (
(strength :type float :default 0.5 :range [0 1])
(radius :type float :default 1.0 :range [0.5 2])
)
(let* (
;; Get normalized distance from center for each pixel
(dist (dist-from-center frame))
;; Calculate max distance (corner distance)
(max-dist (* (βmax dist) radius))
;; Calculate brightness factor per pixel: 1 - (dist/max-dist * strength)
;; Using explicit α operators
(factor (α- 1 (α* (α/ dist max-dist) strength)))
;; Clamp factor to [0, 1]
(factor (αclamp factor 0 1))
;; Extract channels as xectors
(r (red frame))
(g (green frame))
(b (blue frame))
;; Apply factor to each channel (implicit element-wise via Xector operators)
(r-out (* r factor))
(g-out (* g factor))
(b-out (* b factor)))
;; Combine back to frame
(rgb r-out g-out b-out)))

View File

@@ -0,0 +1,8 @@
;; Zoom effect - zooms in/out from center
(require-primitives "geometry")
(define-effect zoom
:params (
(amount :type int :default 1 :range [0.1 5])
)
(geometry:scale-img frame amount amount))

File diff suppressed because it is too large Load Diff

396
l1/sexp_effects/parser.py Normal file
View File

@@ -0,0 +1,396 @@
"""
S-expression parser for ArtDAG recipes and plans.
Supports:
- Lists: (a b c)
- Symbols: foo, bar-baz, ->
- Keywords: :key
- Strings: "hello world"
- Numbers: 42, 3.14, -1.5
- Comments: ; to end of line
- Vectors: [a b c] (syntactic sugar for lists)
- Maps: {:key1 val1 :key2 val2} (parsed as Python dicts)
"""
from dataclasses import dataclass
from typing import Any, Dict, List, Union
import re
@dataclass
class Symbol:
"""An unquoted symbol/identifier."""
name: str
def __repr__(self):
return f"Symbol({self.name!r})"
def __eq__(self, other):
if isinstance(other, Symbol):
return self.name == other.name
if isinstance(other, str):
return self.name == other
return False
def __hash__(self):
return hash(self.name)
@dataclass
class Keyword:
"""A keyword starting with colon."""
name: str
def __repr__(self):
return f"Keyword({self.name!r})"
def __eq__(self, other):
if isinstance(other, Keyword):
return self.name == other.name
return False
def __hash__(self):
return hash((':' , self.name))
class ParseError(Exception):
"""Error during S-expression parsing."""
def __init__(self, message: str, position: int = 0, line: int = 1, col: int = 1):
self.position = position
self.line = line
self.col = col
super().__init__(f"{message} at line {line}, column {col}")
class Tokenizer:
"""Tokenize S-expression text into tokens."""
# Token patterns
WHITESPACE = re.compile(r'\s+')
COMMENT = re.compile(r';[^\n]*')
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
NUMBER = re.compile(r'-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?')
KEYWORD = re.compile(r':[a-zA-Z_][a-zA-Z0-9_-]*')
# Symbol pattern includes Greek letters α (alpha) and β (beta) for xector operations
SYMBOL = re.compile(r'[a-zA-Z_*+\-><=/!?αβ²λ][a-zA-Z0-9_*+\-><=/!?.:αβ²λ]*')
def __init__(self, text: str):
self.text = text
self.pos = 0
self.line = 1
self.col = 1
def _advance(self, count: int = 1):
"""Advance position, tracking line/column."""
for _ in range(count):
if self.pos < len(self.text):
if self.text[self.pos] == '\n':
self.line += 1
self.col = 1
else:
self.col += 1
self.pos += 1
def _skip_whitespace_and_comments(self):
"""Skip whitespace and comments."""
while self.pos < len(self.text):
# Whitespace
match = self.WHITESPACE.match(self.text, self.pos)
if match:
self._advance(match.end() - self.pos)
continue
# Comments
match = self.COMMENT.match(self.text, self.pos)
if match:
self._advance(match.end() - self.pos)
continue
break
def peek(self) -> str | None:
"""Peek at current character."""
self._skip_whitespace_and_comments()
if self.pos >= len(self.text):
return None
return self.text[self.pos]
def next_token(self) -> Any:
"""Get the next token."""
self._skip_whitespace_and_comments()
if self.pos >= len(self.text):
return None
char = self.text[self.pos]
start_line, start_col = self.line, self.col
# Single-character tokens (parens, brackets, braces)
if char in '()[]{}':
self._advance()
return char
# String
if char == '"':
match = self.STRING.match(self.text, self.pos)
if not match:
raise ParseError("Unterminated string", self.pos, self.line, self.col)
self._advance(match.end() - self.pos)
# Parse escape sequences
content = match.group()[1:-1]
content = content.replace('\\n', '\n')
content = content.replace('\\t', '\t')
content = content.replace('\\"', '"')
content = content.replace('\\\\', '\\')
return content
# Keyword
if char == ':':
match = self.KEYWORD.match(self.text, self.pos)
if match:
self._advance(match.end() - self.pos)
return Keyword(match.group()[1:]) # Strip leading colon
raise ParseError(f"Invalid keyword", self.pos, self.line, self.col)
# Number (must check before symbol due to - prefix)
if char.isdigit() or (char == '-' and self.pos + 1 < len(self.text) and
(self.text[self.pos + 1].isdigit() or self.text[self.pos + 1] == '.')):
match = self.NUMBER.match(self.text, self.pos)
if match:
self._advance(match.end() - self.pos)
num_str = match.group()
if '.' in num_str or 'e' in num_str or 'E' in num_str:
return float(num_str)
return int(num_str)
# Symbol
match = self.SYMBOL.match(self.text, self.pos)
if match:
self._advance(match.end() - self.pos)
return Symbol(match.group())
raise ParseError(f"Unexpected character: {char!r}", self.pos, self.line, self.col)
def parse(text: str) -> Any:
"""
Parse an S-expression string into Python data structures.
Returns:
Parsed S-expression as nested Python structures:
- Lists become Python lists
- Symbols become Symbol objects
- Keywords become Keyword objects
- Strings become Python strings
- Numbers become int/float
Example:
>>> parse('(recipe "test" :version "1.0")')
[Symbol('recipe'), 'test', Keyword('version'), '1.0']
"""
tokenizer = Tokenizer(text)
result = _parse_expr(tokenizer)
# Check for trailing content
if tokenizer.peek() is not None:
raise ParseError("Unexpected content after expression",
tokenizer.pos, tokenizer.line, tokenizer.col)
return result
def parse_all(text: str) -> List[Any]:
"""
Parse multiple S-expressions from a string.
Returns list of parsed expressions.
"""
tokenizer = Tokenizer(text)
results = []
while tokenizer.peek() is not None:
results.append(_parse_expr(tokenizer))
return results
def _parse_expr(tokenizer: Tokenizer) -> Any:
"""Parse a single expression."""
token = tokenizer.next_token()
if token is None:
raise ParseError("Unexpected end of input", tokenizer.pos, tokenizer.line, tokenizer.col)
# List
if token == '(':
return _parse_list(tokenizer, ')')
# Vector (sugar for list)
if token == '[':
return _parse_list(tokenizer, ']')
# Map/dict: {:key1 val1 :key2 val2}
if token == '{':
return _parse_map(tokenizer)
# Unexpected closers
if isinstance(token, str) and token in ')]}':
raise ParseError(f"Unexpected {token!r}", tokenizer.pos, tokenizer.line, tokenizer.col)
# Atom
return token
def _parse_list(tokenizer: Tokenizer, closer: str) -> List[Any]:
"""Parse a list until the closing delimiter."""
items = []
while True:
char = tokenizer.peek()
if char is None:
raise ParseError(f"Unterminated list, expected {closer!r}",
tokenizer.pos, tokenizer.line, tokenizer.col)
if char == closer:
tokenizer.next_token() # Consume closer
return items
items.append(_parse_expr(tokenizer))
def _parse_map(tokenizer: Tokenizer) -> Dict[str, Any]:
"""Parse a map/dict: {:key1 val1 :key2 val2} -> {"key1": val1, "key2": val2}."""
result = {}
while True:
char = tokenizer.peek()
if char is None:
raise ParseError("Unterminated map, expected '}'",
tokenizer.pos, tokenizer.line, tokenizer.col)
if char == '}':
tokenizer.next_token() # Consume closer
return result
# Parse key (should be a keyword like :key)
key_token = _parse_expr(tokenizer)
if isinstance(key_token, Keyword):
key = key_token.name
elif isinstance(key_token, str):
key = key_token
else:
raise ParseError(f"Map key must be keyword or string, got {type(key_token).__name__}",
tokenizer.pos, tokenizer.line, tokenizer.col)
# Parse value
value = _parse_expr(tokenizer)
result[key] = value
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
"""
Serialize a Python data structure back to S-expression format.
Args:
expr: The expression to serialize
indent: Current indentation level (for pretty printing)
pretty: Whether to use pretty printing with newlines
Returns:
S-expression string
"""
if isinstance(expr, list):
if not expr:
return "()"
if pretty:
return _serialize_pretty(expr, indent)
else:
items = [serialize(item, indent, False) for item in expr]
return "(" + " ".join(items) + ")"
if isinstance(expr, Symbol):
return expr.name
if isinstance(expr, Keyword):
return f":{expr.name}"
if isinstance(expr, str):
# Escape special characters
escaped = expr.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t')
return f'"{escaped}"'
if isinstance(expr, bool):
return "true" if expr else "false"
if isinstance(expr, (int, float)):
return str(expr)
if expr is None:
return "nil"
if isinstance(expr, dict):
# Serialize dict as property list: {:key1 val1 :key2 val2}
items = []
for k, v in expr.items():
items.append(f":{k}")
items.append(serialize(v, indent, pretty))
return "{" + " ".join(items) + "}"
raise ValueError(f"Cannot serialize {type(expr).__name__}: {expr!r}")
def _serialize_pretty(expr: List, indent: int) -> str:
"""Pretty-print a list expression with smart formatting."""
if not expr:
return "()"
prefix = " " * indent
inner_prefix = " " * (indent + 1)
# Check if this is a simple list that fits on one line
simple = serialize(expr, indent, False)
if len(simple) < 60 and '\n' not in simple:
return simple
# Start building multiline output
head = serialize(expr[0], indent + 1, False)
parts = [f"({head}"]
i = 1
while i < len(expr):
item = expr[i]
# Group keyword-value pairs on same line
if isinstance(item, Keyword) and i + 1 < len(expr):
key = serialize(item, 0, False)
val = serialize(expr[i + 1], indent + 1, False)
# If value is short, put on same line
if len(val) < 50 and '\n' not in val:
parts.append(f"{inner_prefix}{key} {val}")
else:
# Value is complex, serialize it pretty
val_pretty = serialize(expr[i + 1], indent + 1, True)
parts.append(f"{inner_prefix}{key} {val_pretty}")
i += 2
else:
# Regular item
item_str = serialize(item, indent + 1, True)
parts.append(f"{inner_prefix}{item_str}")
i += 1
return "\n".join(parts) + ")"
def parse_file(path: str) -> Any:
"""Parse an S-expression file (supports multiple top-level expressions)."""
with open(path, 'r') as f:
return parse_all(f.read())
def to_sexp(obj: Any) -> str:
"""Convert Python object back to S-expression string (alias for serialize)."""
return serialize(obj)

View File

@@ -0,0 +1,102 @@
"""
Primitive Libraries System
Provides modular loading of primitives. Core primitives are always available,
additional primitive libraries can be loaded on-demand with scoped availability.
Usage in sexp:
;; Load at recipe level - available throughout
(primitives math :path "primitive_libs/math.py")
;; Or use with-primitives for scoped access
(with-primitives "image"
(blur frame 3)) ;; blur only available inside
;; Nested scopes work
(with-primitives "math"
(with-primitives "color"
(hue-shift frame (* (sin t) 30))))
Library file format (primitive_libs/math.py):
import math
def prim_sin(x): return math.sin(x)
def prim_cos(x): return math.cos(x)
PRIMITIVES = {
'sin': prim_sin,
'cos': prim_cos,
}
"""
import importlib.util
from pathlib import Path
from typing import Dict, Callable, Any, Optional
# Cache of loaded primitive libraries
_library_cache: Dict[str, Dict[str, Any]] = {}
# Core primitives - always available, cannot be overridden
CORE_PRIMITIVES: Dict[str, Any] = {}
def register_core_primitive(name: str, fn: Callable):
"""Register a core primitive that's always available."""
CORE_PRIMITIVES[name] = fn
def load_primitive_library(name: str, path: Optional[str] = None) -> Dict[str, Any]:
"""
Load a primitive library by name or path.
Args:
name: Library name (e.g., "math", "image", "color")
path: Optional explicit path to library file
Returns:
Dict of primitive name -> function
"""
# Check cache first
cache_key = path or name
if cache_key in _library_cache:
return _library_cache[cache_key]
# Find library file
if path:
lib_path = Path(path)
else:
# Look in standard locations
lib_dir = Path(__file__).parent
lib_path = lib_dir / f"{name}.py"
if not lib_path.exists():
raise ValueError(f"Primitive library '{name}' not found at {lib_path}")
if not lib_path.exists():
raise ValueError(f"Primitive library file not found: {lib_path}")
# Load the module
spec = importlib.util.spec_from_file_location(f"prim_lib_{name}", lib_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Get PRIMITIVES dict from module
if not hasattr(module, 'PRIMITIVES'):
raise ValueError(f"Primitive library '{name}' missing PRIMITIVES dict")
primitives = module.PRIMITIVES
# Cache and return
_library_cache[cache_key] = primitives
return primitives
def get_library_names() -> list:
"""Get names of available primitive libraries."""
lib_dir = Path(__file__).parent
return [p.stem for p in lib_dir.glob("*.py") if p.stem != "__init__"]
def clear_cache():
"""Clear the library cache (useful for testing)."""
_library_cache.clear()

View File

@@ -0,0 +1,196 @@
"""
Array Primitives Library
Vectorized operations on numpy arrays for coordinate transformations.
"""
import numpy as np
# Arithmetic
def prim_arr_add(a, b):
return np.add(a, b)
def prim_arr_sub(a, b):
return np.subtract(a, b)
def prim_arr_mul(a, b):
return np.multiply(a, b)
def prim_arr_div(a, b):
return np.divide(a, b)
def prim_arr_mod(a, b):
return np.mod(a, b)
def prim_arr_neg(a):
return np.negative(a)
# Math functions
def prim_arr_sin(a):
return np.sin(a)
def prim_arr_cos(a):
return np.cos(a)
def prim_arr_tan(a):
return np.tan(a)
def prim_arr_sqrt(a):
return np.sqrt(np.maximum(a, 0))
def prim_arr_pow(a, b):
return np.power(a, b)
def prim_arr_abs(a):
return np.abs(a)
def prim_arr_exp(a):
return np.exp(a)
def prim_arr_log(a):
return np.log(np.maximum(a, 1e-10))
def prim_arr_atan2(y, x):
return np.arctan2(y, x)
# Comparison / selection
def prim_arr_min(a, b):
return np.minimum(a, b)
def prim_arr_max(a, b):
return np.maximum(a, b)
def prim_arr_clip(a, lo, hi):
return np.clip(a, lo, hi)
def prim_arr_where(cond, a, b):
return np.where(cond, a, b)
def prim_arr_floor(a):
return np.floor(a)
def prim_arr_ceil(a):
return np.ceil(a)
def prim_arr_round(a):
return np.round(a)
# Interpolation
def prim_arr_lerp(a, b, t):
return a + (b - a) * t
def prim_arr_smoothstep(edge0, edge1, x):
t = prim_arr_clip((x - edge0) / (edge1 - edge0), 0.0, 1.0)
return t * t * (3 - 2 * t)
# Creation
def prim_arr_zeros(shape):
return np.zeros(shape, dtype=np.float32)
def prim_arr_ones(shape):
return np.ones(shape, dtype=np.float32)
def prim_arr_full(shape, value):
return np.full(shape, value, dtype=np.float32)
def prim_arr_arange(start, stop, step=1):
return np.arange(start, stop, step, dtype=np.float32)
def prim_arr_linspace(start, stop, num):
return np.linspace(start, stop, num, dtype=np.float32)
def prim_arr_meshgrid(x, y):
return np.meshgrid(x, y)
# Coordinate transforms
def prim_polar_from_center(map_x, map_y, cx, cy):
"""Convert Cartesian to polar coordinates centered at (cx, cy)."""
dx = map_x - cx
dy = map_y - cy
r = np.sqrt(dx**2 + dy**2)
theta = np.arctan2(dy, dx)
return (r, theta)
def prim_cart_from_polar(r, theta, cx, cy):
"""Convert polar to Cartesian, adding center offset."""
x = r * np.cos(theta) + cx
y = r * np.sin(theta) + cy
return (x, y)
PRIMITIVES = {
# Arithmetic
'arr+': prim_arr_add,
'arr-': prim_arr_sub,
'arr*': prim_arr_mul,
'arr/': prim_arr_div,
'arr-mod': prim_arr_mod,
'arr-neg': prim_arr_neg,
# Math
'arr-sin': prim_arr_sin,
'arr-cos': prim_arr_cos,
'arr-tan': prim_arr_tan,
'arr-sqrt': prim_arr_sqrt,
'arr-pow': prim_arr_pow,
'arr-abs': prim_arr_abs,
'arr-exp': prim_arr_exp,
'arr-log': prim_arr_log,
'arr-atan2': prim_arr_atan2,
# Selection
'arr-min': prim_arr_min,
'arr-max': prim_arr_max,
'arr-clip': prim_arr_clip,
'arr-where': prim_arr_where,
'arr-floor': prim_arr_floor,
'arr-ceil': prim_arr_ceil,
'arr-round': prim_arr_round,
# Interpolation
'arr-lerp': prim_arr_lerp,
'arr-smoothstep': prim_arr_smoothstep,
# Creation
'arr-zeros': prim_arr_zeros,
'arr-ones': prim_arr_ones,
'arr-full': prim_arr_full,
'arr-arange': prim_arr_arange,
'arr-linspace': prim_arr_linspace,
'arr-meshgrid': prim_arr_meshgrid,
# Coordinates
'polar-from-center': prim_polar_from_center,
'cart-from-polar': prim_cart_from_polar,
}

View File

@@ -0,0 +1,388 @@
"""
ASCII Art Primitives Library
ASCII art rendering with per-zone expression evaluation and cell effects.
"""
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont
from typing import Any, Dict, List, Optional, Callable
import colorsys
# Character sets
CHAR_SETS = {
"standard": " .:-=+*#%@",
"blocks": " ░▒▓█",
"simple": " .:oO@",
"digits": "0123456789",
"binary": "01",
"ascii": " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@",
}
# Default font
_default_font = None
def _get_font(size: int):
"""Get monospace font at given size."""
global _default_font
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", size)
except:
return ImageFont.load_default()
def _parse_color(color_str: str) -> tuple:
"""Parse color string to RGB tuple."""
if color_str.startswith('#'):
hex_color = color_str[1:]
if len(hex_color) == 3:
hex_color = ''.join(c*2 for c in hex_color)
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
colors = {
'black': (0, 0, 0), 'white': (255, 255, 255),
'red': (255, 0, 0), 'green': (0, 255, 0), 'blue': (0, 0, 255),
'yellow': (255, 255, 0), 'cyan': (0, 255, 255), 'magenta': (255, 0, 255),
'gray': (128, 128, 128), 'grey': (128, 128, 128),
}
return colors.get(color_str.lower(), (0, 0, 0))
def _cell_sample(frame: np.ndarray, cell_size: int):
"""Sample frame into cells, returning colors and luminances.
Uses cv2.resize with INTER_AREA (pixel-area averaging) which is
~25x faster than numpy reshape+mean for block downsampling.
"""
h, w = frame.shape[:2]
rows = h // cell_size
cols = w // cell_size
# Crop to exact grid then block-average via cv2 area interpolation.
cropped = frame[:rows * cell_size, :cols * cell_size]
colors = cv2.resize(cropped, (cols, rows), interpolation=cv2.INTER_AREA)
luminances = ((0.299 * colors[:, :, 0] +
0.587 * colors[:, :, 1] +
0.114 * colors[:, :, 2]) / 255.0).astype(np.float32)
return colors, luminances
def _luminance_to_char(lum: float, alphabet: str, contrast: float) -> str:
"""Map luminance to character."""
chars = CHAR_SETS.get(alphabet, alphabet)
lum = ((lum - 0.5) * contrast + 0.5)
lum = max(0, min(1, lum))
idx = int(lum * (len(chars) - 1))
return chars[idx]
def _render_char_cell(char: str, cell_size: int, color: tuple, bg_color: tuple) -> np.ndarray:
"""Render a single character to a cell image."""
img = Image.new('RGB', (cell_size, cell_size), bg_color)
draw = ImageDraw.Draw(img)
font = _get_font(cell_size)
# Center the character
bbox = draw.textbbox((0, 0), char, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
x = (cell_size - text_w) // 2
y = (cell_size - text_h) // 2 - bbox[1]
draw.text((x, y), char, fill=color, font=font)
return np.array(img)
def prim_ascii_fx_zone(
frame: np.ndarray,
cols: int = 80,
char_size: int = None,
alphabet: str = "standard",
color_mode: str = "color",
background: str = "black",
contrast: float = 1.5,
char_hue = None,
char_saturation = None,
char_brightness = None,
char_scale = None,
char_rotation = None,
char_jitter = None,
cell_effect = None,
energy: float = None,
rotation_scale: float = 0,
_interp = None,
_env = None,
**extra_params
) -> np.ndarray:
"""
Render frame as ASCII art with per-zone effects.
Args:
frame: Input image
cols: Number of character columns
char_size: Cell size in pixels (overrides cols if set)
alphabet: Character set name or custom string
color_mode: "color", "mono", "invert", or color name
background: Background color name or hex
contrast: Contrast for character selection
char_hue/saturation/brightness/scale/rotation/jitter: Per-zone expressions
cell_effect: Lambda (cell, zone) -> cell for per-cell effects
energy: Energy value from audio analysis
rotation_scale: Max rotation degrees
_interp: Interpreter (auto-injected)
_env: Environment (auto-injected)
**extra_params: Additional params passed to zone dict
"""
h, w = frame.shape[:2]
# Calculate cell size
if char_size is None or char_size == 0:
cell_size = max(4, w // cols)
else:
cell_size = max(4, int(char_size))
# Sample cells
colors, luminances = _cell_sample(frame, cell_size)
rows, cols_actual = luminances.shape
# Parse background color
bg_color = _parse_color(background)
# Create output image
out_h = rows * cell_size
out_w = cols_actual * cell_size
output = np.full((out_h, out_w, 3), bg_color, dtype=np.uint8)
# Check if we have cell_effect
has_cell_effect = cell_effect is not None
# Process each cell
for r in range(rows):
for c in range(cols_actual):
lum = luminances[r, c]
cell_color = tuple(colors[r, c])
# Build zone context
zone = {
'row': r,
'col': c,
'row-norm': r / max(1, rows - 1),
'col-norm': c / max(1, cols_actual - 1),
'lum': float(lum),
'r': cell_color[0] / 255,
'g': cell_color[1] / 255,
'b': cell_color[2] / 255,
'cell_size': cell_size,
}
# Add HSV
r_f, g_f, b_f = cell_color[0]/255, cell_color[1]/255, cell_color[2]/255
hsv = colorsys.rgb_to_hsv(r_f, g_f, b_f)
zone['hue'] = hsv[0] * 360
zone['sat'] = hsv[1]
# Add energy and rotation_scale
if energy is not None:
zone['energy'] = energy
zone['rotation_scale'] = rotation_scale
# Add extra params
for k, v in extra_params.items():
if isinstance(v, (int, float, str, bool)) or v is None:
zone[k] = v
# Get character
char = _luminance_to_char(lum, alphabet, contrast)
zone['char'] = char
# Determine cell color based on mode
if color_mode == "mono":
render_color = (255, 255, 255)
elif color_mode == "invert":
render_color = tuple(255 - c for c in cell_color)
elif color_mode == "color":
render_color = cell_color
else:
render_color = _parse_color(color_mode)
zone['color'] = render_color
# Render character to cell
cell_img = _render_char_cell(char, cell_size, render_color, bg_color)
# Apply cell_effect if provided
if has_cell_effect and _interp is not None:
cell_img = _apply_cell_effect(cell_img, zone, cell_effect, _interp, _env, extra_params)
# Paste cell to output
y1, y2 = r * cell_size, (r + 1) * cell_size
x1, x2 = c * cell_size, (c + 1) * cell_size
output[y1:y2, x1:x2] = cell_img
# Resize to match input dimensions
if output.shape[:2] != frame.shape[:2]:
output = cv2.resize(output, (w, h), interpolation=cv2.INTER_LINEAR)
return output
def _apply_cell_effect(cell_img, zone, cell_effect, interp, env, extra_params):
"""Apply cell_effect lambda to a cell image.
cell_effect is a Lambda object with params and body.
We create a child environment with zone variables and cell,
then evaluate the lambda body.
"""
# Get Environment class from the interpreter's module
Environment = type(env)
# Create child environment with zone variables
cell_env = Environment(env)
# Bind zone variables
for k, v in zone.items():
cell_env.set(k, v)
# Also bind with zone- prefix for consistency
cell_env.set('zone-row', zone.get('row', 0))
cell_env.set('zone-col', zone.get('col', 0))
cell_env.set('zone-row-norm', zone.get('row-norm', 0))
cell_env.set('zone-col-norm', zone.get('col-norm', 0))
cell_env.set('zone-lum', zone.get('lum', 0))
cell_env.set('zone-sat', zone.get('sat', 0))
cell_env.set('zone-hue', zone.get('hue', 0))
cell_env.set('zone-r', zone.get('r', 0))
cell_env.set('zone-g', zone.get('g', 0))
cell_env.set('zone-b', zone.get('b', 0))
# Inject loaded effects as callable functions
if hasattr(interp, 'effects'):
for effect_name in interp.effects:
def make_effect_fn(name):
def effect_fn(frame, *args):
params = {}
if name == 'blur' and len(args) >= 1:
params['radius'] = args[0]
elif name == 'rotate' and len(args) >= 1:
params['angle'] = args[0]
elif name == 'brightness' and len(args) >= 1:
params['amount'] = args[0]
elif name == 'contrast' and len(args) >= 1:
params['amount'] = args[0]
elif name == 'saturation' and len(args) >= 1:
params['amount'] = args[0]
elif name == 'hue_shift' and len(args) >= 1:
params['degrees'] = args[0]
elif name == 'rgb_split' and len(args) >= 2:
params['offset_x'] = args[0]
params['offset_y'] = args[1]
elif name == 'pixelate' and len(args) >= 1:
params['size'] = args[0]
elif name == 'invert':
pass
result, _ = interp.run_effect(name, frame, params, {})
return result
return effect_fn
cell_env.set(effect_name, make_effect_fn(effect_name))
# Bind cell image and zone dict
cell_env.set('cell', cell_img)
cell_env.set('zone', zone)
# Evaluate the cell_effect lambda
# Lambda has params and body - we need to bind the params then evaluate
if hasattr(cell_effect, 'params') and hasattr(cell_effect, 'body'):
# Bind lambda parameters: (lambda [cell zone] body)
if len(cell_effect.params) >= 1:
cell_env.set(cell_effect.params[0], cell_img)
if len(cell_effect.params) >= 2:
cell_env.set(cell_effect.params[1], zone)
result = interp.eval(cell_effect.body, cell_env)
elif isinstance(cell_effect, list):
# Raw S-expression lambda like (lambda [cell zone] body) or (fn [cell zone] body)
# Check if it's a lambda expression
head = cell_effect[0] if cell_effect else None
head_name = head.name if head and hasattr(head, 'name') else str(head) if head else None
is_lambda = head_name in ('lambda', 'fn')
if is_lambda:
# (lambda [params...] body)
params = cell_effect[1] if len(cell_effect) > 1 else []
body = cell_effect[2] if len(cell_effect) > 2 else None
# Bind lambda parameters
if isinstance(params, list) and len(params) >= 1:
param_name = params[0].name if hasattr(params[0], 'name') else str(params[0])
cell_env.set(param_name, cell_img)
if isinstance(params, list) and len(params) >= 2:
param_name = params[1].name if hasattr(params[1], 'name') else str(params[1])
cell_env.set(param_name, zone)
result = interp.eval(body, cell_env) if body else cell_img
else:
# Some other expression - just evaluate it
result = interp.eval(cell_effect, cell_env)
elif callable(cell_effect):
# It's a callable
result = cell_effect(cell_img, zone)
else:
raise ValueError(f"cell_effect must be a Lambda, list, or callable, got {type(cell_effect)}")
if isinstance(result, np.ndarray) and result.shape == cell_img.shape:
return result
elif isinstance(result, np.ndarray):
# Shape mismatch - resize to fit
result = cv2.resize(result, (cell_img.shape[1], cell_img.shape[0]))
return result
raise ValueError(f"cell_effect must return an image array, got {type(result)}")
def _get_legacy_ascii_primitives():
"""Import ASCII primitives from legacy primitives module.
These are loaded lazily to avoid import issues during module loading.
By the time a primitive library is loaded, sexp_effects.primitives
is already in sys.modules (imported by sexp_effects.__init__).
"""
from sexp_effects.primitives import (
prim_cell_sample,
prim_luminance_to_chars,
prim_render_char_grid,
prim_render_char_grid_fx,
prim_alphabet_char,
prim_alphabet_length,
prim_map_char_grid,
prim_map_colors,
prim_make_char_grid,
prim_set_char,
prim_get_char,
prim_char_grid_dimensions,
cell_sample_extended,
)
return {
'cell-sample': prim_cell_sample,
'cell-sample-extended': cell_sample_extended,
'luminance-to-chars': prim_luminance_to_chars,
'render-char-grid': prim_render_char_grid,
'render-char-grid-fx': prim_render_char_grid_fx,
'alphabet-char': prim_alphabet_char,
'alphabet-length': prim_alphabet_length,
'map-char-grid': prim_map_char_grid,
'map-colors': prim_map_colors,
'make-char-grid': prim_make_char_grid,
'set-char': prim_set_char,
'get-char': prim_get_char,
'char-grid-dimensions': prim_char_grid_dimensions,
}
PRIMITIVES = {
'ascii-fx-zone': prim_ascii_fx_zone,
**_get_legacy_ascii_primitives(),
}

View File

@@ -0,0 +1,116 @@
"""
Blending Primitives Library
Image blending and compositing operations.
"""
import numpy as np
def prim_blend_images(a, b, alpha):
"""Blend two images: a * (1-alpha) + b * alpha."""
alpha = max(0.0, min(1.0, alpha))
return (a.astype(float) * (1 - alpha) + b.astype(float) * alpha).astype(np.uint8)
def prim_blend_mode(a, b, mode):
"""Blend using Photoshop-style blend modes."""
a = a.astype(float) / 255
b = b.astype(float) / 255
if mode == "multiply":
result = a * b
elif mode == "screen":
result = 1 - (1 - a) * (1 - b)
elif mode == "overlay":
mask = a < 0.5
result = np.where(mask, 2 * a * b, 1 - 2 * (1 - a) * (1 - b))
elif mode == "soft-light":
mask = b < 0.5
result = np.where(mask,
a - (1 - 2 * b) * a * (1 - a),
a + (2 * b - 1) * (np.sqrt(a) - a))
elif mode == "hard-light":
mask = b < 0.5
result = np.where(mask, 2 * a * b, 1 - 2 * (1 - a) * (1 - b))
elif mode == "color-dodge":
result = np.clip(a / (1 - b + 0.001), 0, 1)
elif mode == "color-burn":
result = 1 - np.clip((1 - a) / (b + 0.001), 0, 1)
elif mode == "difference":
result = np.abs(a - b)
elif mode == "exclusion":
result = a + b - 2 * a * b
elif mode == "add":
result = np.clip(a + b, 0, 1)
elif mode == "subtract":
result = np.clip(a - b, 0, 1)
elif mode == "darken":
result = np.minimum(a, b)
elif mode == "lighten":
result = np.maximum(a, b)
else:
# Default to normal (just return b)
result = b
return (result * 255).astype(np.uint8)
def prim_mask(img, mask_img):
"""Apply grayscale mask to image (white=opaque, black=transparent)."""
if len(mask_img.shape) == 3:
mask = mask_img[:, :, 0].astype(float) / 255
else:
mask = mask_img.astype(float) / 255
mask = mask[:, :, np.newaxis]
return (img.astype(float) * mask).astype(np.uint8)
def prim_alpha_composite(base, overlay, alpha_channel):
"""Composite overlay onto base using alpha channel."""
if len(alpha_channel.shape) == 3:
alpha = alpha_channel[:, :, 0].astype(float) / 255
else:
alpha = alpha_channel.astype(float) / 255
alpha = alpha[:, :, np.newaxis]
result = base.astype(float) * (1 - alpha) + overlay.astype(float) * alpha
return result.astype(np.uint8)
def prim_overlay(base, overlay, x, y, alpha=1.0):
"""Overlay image at position (x, y) with optional alpha."""
result = base.copy()
x, y = int(x), int(y)
oh, ow = overlay.shape[:2]
bh, bw = base.shape[:2]
# Clip to bounds
sx1 = max(0, -x)
sy1 = max(0, -y)
dx1 = max(0, x)
dy1 = max(0, y)
sx2 = min(ow, bw - x)
sy2 = min(oh, bh - y)
if sx2 > sx1 and sy2 > sy1:
src = overlay[sy1:sy2, sx1:sx2]
dst = result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)]
blended = (dst.astype(float) * (1 - alpha) + src.astype(float) * alpha)
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = blended.astype(np.uint8)
return result
PRIMITIVES = {
# Basic blending
'blend-images': prim_blend_images,
'blend-mode': prim_blend_mode,
# Masking
'mask': prim_mask,
'alpha-composite': prim_alpha_composite,
# Overlay
'overlay': prim_overlay,
}

View File

@@ -0,0 +1,220 @@
"""
GPU-Accelerated Blending Primitives Library
Uses CuPy for CUDA-accelerated image blending and compositing.
Keeps frames on GPU when STREAMING_GPU_PERSIST=1 for maximum performance.
"""
import os
import numpy as np
# Try to import CuPy for GPU acceleration
try:
import cupy as cp
GPU_AVAILABLE = True
print("[blending_gpu] CuPy GPU acceleration enabled")
except ImportError:
cp = np
GPU_AVAILABLE = False
print("[blending_gpu] CuPy not available, using CPU fallback")
# GPU persistence mode - keep frames on GPU between operations
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
if GPU_AVAILABLE and GPU_PERSIST:
print("[blending_gpu] GPU persistence enabled - frames stay on GPU")
def _to_gpu(img):
"""Move image to GPU if available."""
if GPU_AVAILABLE and not isinstance(img, cp.ndarray):
return cp.asarray(img)
return img
def _to_cpu(img):
"""Move image back to CPU (only if GPU_PERSIST is disabled)."""
if not GPU_PERSIST and GPU_AVAILABLE and isinstance(img, cp.ndarray):
return cp.asnumpy(img)
return img
def _get_xp(img):
"""Get the array module (numpy or cupy) for the given image."""
if GPU_AVAILABLE and isinstance(img, cp.ndarray):
return cp
return np
def prim_blend_images(a, b, alpha):
"""Blend two images: a * (1-alpha) + b * alpha."""
alpha = max(0.0, min(1.0, float(alpha)))
if GPU_AVAILABLE:
a_gpu = _to_gpu(a)
b_gpu = _to_gpu(b)
result = (a_gpu.astype(cp.float32) * (1 - alpha) + b_gpu.astype(cp.float32) * alpha).astype(cp.uint8)
return _to_cpu(result)
return (a.astype(float) * (1 - alpha) + b.astype(float) * alpha).astype(np.uint8)
def prim_blend_mode(a, b, mode):
"""Blend using Photoshop-style blend modes."""
if GPU_AVAILABLE:
a_gpu = _to_gpu(a).astype(cp.float32) / 255
b_gpu = _to_gpu(b).astype(cp.float32) / 255
xp = cp
else:
a_gpu = a.astype(float) / 255
b_gpu = b.astype(float) / 255
xp = np
if mode == "multiply":
result = a_gpu * b_gpu
elif mode == "screen":
result = 1 - (1 - a_gpu) * (1 - b_gpu)
elif mode == "overlay":
mask = a_gpu < 0.5
result = xp.where(mask, 2 * a_gpu * b_gpu, 1 - 2 * (1 - a_gpu) * (1 - b_gpu))
elif mode == "soft-light":
mask = b_gpu < 0.5
result = xp.where(mask,
a_gpu - (1 - 2 * b_gpu) * a_gpu * (1 - a_gpu),
a_gpu + (2 * b_gpu - 1) * (xp.sqrt(a_gpu) - a_gpu))
elif mode == "hard-light":
mask = b_gpu < 0.5
result = xp.where(mask, 2 * a_gpu * b_gpu, 1 - 2 * (1 - a_gpu) * (1 - b_gpu))
elif mode == "color-dodge":
result = xp.clip(a_gpu / (1 - b_gpu + 0.001), 0, 1)
elif mode == "color-burn":
result = 1 - xp.clip((1 - a_gpu) / (b_gpu + 0.001), 0, 1)
elif mode == "difference":
result = xp.abs(a_gpu - b_gpu)
elif mode == "exclusion":
result = a_gpu + b_gpu - 2 * a_gpu * b_gpu
elif mode == "add":
result = xp.clip(a_gpu + b_gpu, 0, 1)
elif mode == "subtract":
result = xp.clip(a_gpu - b_gpu, 0, 1)
elif mode == "darken":
result = xp.minimum(a_gpu, b_gpu)
elif mode == "lighten":
result = xp.maximum(a_gpu, b_gpu)
else:
# Default to normal (just return b)
result = b_gpu
result = (result * 255).astype(xp.uint8)
return _to_cpu(result)
def prim_mask(img, mask_img):
"""Apply grayscale mask to image (white=opaque, black=transparent)."""
if GPU_AVAILABLE:
img_gpu = _to_gpu(img)
mask_gpu = _to_gpu(mask_img)
if len(mask_gpu.shape) == 3:
mask = mask_gpu[:, :, 0].astype(cp.float32) / 255
else:
mask = mask_gpu.astype(cp.float32) / 255
mask = mask[:, :, cp.newaxis]
result = (img_gpu.astype(cp.float32) * mask).astype(cp.uint8)
return _to_cpu(result)
if len(mask_img.shape) == 3:
mask = mask_img[:, :, 0].astype(float) / 255
else:
mask = mask_img.astype(float) / 255
mask = mask[:, :, np.newaxis]
return (img.astype(float) * mask).astype(np.uint8)
def prim_alpha_composite(base, overlay, alpha_channel):
"""Composite overlay onto base using alpha channel."""
if GPU_AVAILABLE:
base_gpu = _to_gpu(base)
overlay_gpu = _to_gpu(overlay)
alpha_gpu = _to_gpu(alpha_channel)
if len(alpha_gpu.shape) == 3:
alpha = alpha_gpu[:, :, 0].astype(cp.float32) / 255
else:
alpha = alpha_gpu.astype(cp.float32) / 255
alpha = alpha[:, :, cp.newaxis]
result = base_gpu.astype(cp.float32) * (1 - alpha) + overlay_gpu.astype(cp.float32) * alpha
return _to_cpu(result.astype(cp.uint8))
if len(alpha_channel.shape) == 3:
alpha = alpha_channel[:, :, 0].astype(float) / 255
else:
alpha = alpha_channel.astype(float) / 255
alpha = alpha[:, :, np.newaxis]
result = base.astype(float) * (1 - alpha) + overlay.astype(float) * alpha
return result.astype(np.uint8)
def prim_overlay(base, overlay, x, y, alpha=1.0):
"""Overlay image at position (x, y) with optional alpha."""
if GPU_AVAILABLE:
base_gpu = _to_gpu(base)
overlay_gpu = _to_gpu(overlay)
result = base_gpu.copy()
x, y = int(x), int(y)
oh, ow = overlay_gpu.shape[:2]
bh, bw = base_gpu.shape[:2]
# Clip to bounds
sx1 = max(0, -x)
sy1 = max(0, -y)
dx1 = max(0, x)
dy1 = max(0, y)
sx2 = min(ow, bw - x)
sy2 = min(oh, bh - y)
if sx2 > sx1 and sy2 > sy1:
src = overlay_gpu[sy1:sy2, sx1:sx2]
dst = result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)]
blended = (dst.astype(cp.float32) * (1 - alpha) + src.astype(cp.float32) * alpha)
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = blended.astype(cp.uint8)
return _to_cpu(result)
result = base.copy()
x, y = int(x), int(y)
oh, ow = overlay.shape[:2]
bh, bw = base.shape[:2]
# Clip to bounds
sx1 = max(0, -x)
sy1 = max(0, -y)
dx1 = max(0, x)
dy1 = max(0, y)
sx2 = min(ow, bw - x)
sy2 = min(oh, bh - y)
if sx2 > sx1 and sy2 > sy1:
src = overlay[sy1:sy2, sx1:sx2]
dst = result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)]
blended = (dst.astype(float) * (1 - alpha) + src.astype(float) * alpha)
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = blended.astype(np.uint8)
return result
PRIMITIVES = {
# Basic blending
'blend-images': prim_blend_images,
'blend-mode': prim_blend_mode,
# Masking
'mask': prim_mask,
'alpha-composite': prim_alpha_composite,
# Overlay
'overlay': prim_overlay,
}

View File

@@ -0,0 +1,137 @@
"""
Color Primitives Library
Color manipulation: RGB, HSV, blending, luminance.
"""
import numpy as np
import colorsys
def prim_rgb(r, g, b):
"""Create RGB color as [r, g, b] (0-255)."""
return [int(max(0, min(255, r))),
int(max(0, min(255, g))),
int(max(0, min(255, b)))]
def prim_red(c):
return c[0]
def prim_green(c):
return c[1]
def prim_blue(c):
return c[2]
def prim_luminance(c):
"""Perceived luminance (0-1) using standard weights."""
return (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255
def prim_rgb_to_hsv(c):
"""Convert RGB [0-255] to HSV [h:0-360, s:0-1, v:0-1]."""
r, g, b = c[0] / 255, c[1] / 255, c[2] / 255
h, s, v = colorsys.rgb_to_hsv(r, g, b)
return [h * 360, s, v]
def prim_hsv_to_rgb(hsv):
"""Convert HSV [h:0-360, s:0-1, v:0-1] to RGB [0-255]."""
h, s, v = hsv[0] / 360, hsv[1], hsv[2]
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return [int(r * 255), int(g * 255), int(b * 255)]
def prim_rgb_to_hsl(c):
"""Convert RGB [0-255] to HSL [h:0-360, s:0-1, l:0-1]."""
r, g, b = c[0] / 255, c[1] / 255, c[2] / 255
h, l, s = colorsys.rgb_to_hls(r, g, b)
return [h * 360, s, l]
def prim_hsl_to_rgb(hsl):
"""Convert HSL [h:0-360, s:0-1, l:0-1] to RGB [0-255]."""
h, s, l = hsl[0] / 360, hsl[1], hsl[2]
r, g, b = colorsys.hls_to_rgb(h, l, s)
return [int(r * 255), int(g * 255), int(b * 255)]
def prim_blend_color(c1, c2, alpha):
"""Blend two colors: c1 * (1-alpha) + c2 * alpha."""
return [int(c1[i] * (1 - alpha) + c2[i] * alpha) for i in range(3)]
def prim_average_color(img):
"""Get average color of an image."""
mean = np.mean(img, axis=(0, 1))
return [int(mean[0]), int(mean[1]), int(mean[2])]
def prim_dominant_color(img, k=1):
"""Get dominant color using k-means (simplified: just average for now)."""
return prim_average_color(img)
def prim_invert_color(c):
"""Invert a color."""
return [255 - c[0], 255 - c[1], 255 - c[2]]
def prim_grayscale_color(c):
"""Convert color to grayscale."""
gray = int(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2])
return [gray, gray, gray]
def prim_saturate(c, amount):
"""Adjust saturation of color. amount=0 is grayscale, 1 is unchanged, >1 is more saturated."""
hsv = prim_rgb_to_hsv(c)
hsv[1] = max(0, min(1, hsv[1] * amount))
return prim_hsv_to_rgb(hsv)
def prim_brighten(c, amount):
"""Adjust brightness. amount=0 is black, 1 is unchanged, >1 is brighter."""
return [int(max(0, min(255, c[i] * amount))) for i in range(3)]
def prim_shift_hue(c, degrees):
"""Shift hue by degrees."""
hsv = prim_rgb_to_hsv(c)
hsv[0] = (hsv[0] + degrees) % 360
return prim_hsv_to_rgb(hsv)
PRIMITIVES = {
# Construction
'rgb': prim_rgb,
# Component access
'red': prim_red,
'green': prim_green,
'blue': prim_blue,
'luminance': prim_luminance,
# Color space conversion
'rgb->hsv': prim_rgb_to_hsv,
'hsv->rgb': prim_hsv_to_rgb,
'rgb->hsl': prim_rgb_to_hsl,
'hsl->rgb': prim_hsl_to_rgb,
# Blending
'blend-color': prim_blend_color,
# Analysis
'average-color': prim_average_color,
'dominant-color': prim_dominant_color,
# Manipulation
'invert-color': prim_invert_color,
'grayscale-color': prim_grayscale_color,
'saturate': prim_saturate,
'brighten': prim_brighten,
'shift-hue': prim_shift_hue,
}

View File

@@ -0,0 +1,109 @@
"""
Color Operations Primitives Library
Vectorized color adjustments: brightness, contrast, saturation, invert, HSV.
These operate on entire images for fast processing.
"""
import numpy as np
import cv2
def _to_numpy(img):
"""Convert GPU frames or CuPy arrays to numpy for CPU processing."""
# Handle GPUFrame objects
if hasattr(img, 'cpu'):
return img.cpu
# Handle CuPy arrays
if hasattr(img, 'get'):
return img.get()
return img
def prim_adjust(img, brightness=0, contrast=1):
"""Adjust brightness and contrast. Brightness: -255 to 255, Contrast: 0 to 3+."""
img = _to_numpy(img)
result = (img.astype(np.float32) - 128) * contrast + 128 + brightness
return np.clip(result, 0, 255).astype(np.uint8)
def prim_mix_gray(img_raw, amount):
"""Mix image with its grayscale version. 0=original, 1=grayscale."""
img = _to_numpy(img_raw)
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
gray_rgb = np.stack([gray, gray, gray], axis=-1)
result = img.astype(np.float32) * (1 - amount) + gray_rgb * amount
return np.clip(result, 0, 255).astype(np.uint8)
def prim_invert_img(img):
"""Invert all pixel values."""
img = _to_numpy(img)
return (255 - img).astype(np.uint8)
def prim_shift_hsv(img, h=0, s=1, v=1):
"""Shift HSV: h=degrees offset, s/v=multipliers."""
img = _to_numpy(img)
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
hsv[:, :, 0] = (hsv[:, :, 0] + h / 2) % 180
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * s, 0, 255)
hsv[:, :, 2] = np.clip(hsv[:, :, 2] * v, 0, 255)
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
def prim_add_noise(img, amount):
"""Add gaussian noise to image."""
img = _to_numpy(img)
noise = np.random.normal(0, amount, img.shape)
result = img.astype(np.float32) + noise
return np.clip(result, 0, 255).astype(np.uint8)
def prim_quantize(img, levels):
"""Reduce to N color levels per channel."""
img = _to_numpy(img)
levels = max(2, int(levels))
factor = 256 / levels
result = (img // factor) * factor + factor // 2
return np.clip(result, 0, 255).astype(np.uint8)
def prim_sepia(img, intensity=1.0):
"""Apply sepia tone effect."""
img = _to_numpy(img)
sepia_matrix = np.array([
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131]
])
sepia = np.dot(img, sepia_matrix.T)
result = img.astype(np.float32) * (1 - intensity) + sepia * intensity
return np.clip(result, 0, 255).astype(np.uint8)
def prim_grayscale(img):
"""Convert to grayscale (still RGB output)."""
img = _to_numpy(img)
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
return np.stack([gray, gray, gray], axis=-1).astype(np.uint8)
PRIMITIVES = {
# Brightness/Contrast
'adjust': prim_adjust,
# Saturation
'mix-gray': prim_mix_gray,
'grayscale': prim_grayscale,
# HSV manipulation
'shift-hsv': prim_shift_hsv,
# Inversion
'invert-img': prim_invert_img,
# Effects
'add-noise': prim_add_noise,
'quantize': prim_quantize,
'sepia': prim_sepia,
}

View File

@@ -0,0 +1,280 @@
"""
GPU-Accelerated Color Operations Library
Uses CuPy for CUDA-accelerated color transforms.
Performance Mode:
- Set STREAMING_GPU_PERSIST=1 to keep frames on GPU between operations
- This dramatically improves performance by avoiding CPU<->GPU transfers
"""
import os
import numpy as np
# Try to import CuPy for GPU acceleration
try:
import cupy as cp
GPU_AVAILABLE = True
print("[color_ops_gpu] CuPy GPU acceleration enabled")
except ImportError:
cp = np
GPU_AVAILABLE = False
print("[color_ops_gpu] CuPy not available, using CPU fallback")
# GPU persistence mode - keep frames on GPU between operations
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
if GPU_AVAILABLE and GPU_PERSIST:
print("[color_ops_gpu] GPU persistence enabled - frames stay on GPU")
def _to_gpu(img):
"""Move image to GPU if available."""
if GPU_AVAILABLE and not isinstance(img, cp.ndarray):
return cp.asarray(img)
return img
def _to_cpu(img):
"""Move image back to CPU (only if GPU_PERSIST is disabled)."""
if not GPU_PERSIST and GPU_AVAILABLE and isinstance(img, cp.ndarray):
return cp.asnumpy(img)
return img
def prim_invert(img):
"""Invert image colors."""
if GPU_AVAILABLE:
img_gpu = _to_gpu(img)
return _to_cpu(255 - img_gpu)
return 255 - img
def prim_grayscale(img):
"""Convert to grayscale."""
if img.ndim != 3:
return img
if GPU_AVAILABLE:
img_gpu = _to_gpu(img.astype(np.float32))
# Standard luminance weights
gray = 0.299 * img_gpu[:, :, 0] + 0.587 * img_gpu[:, :, 1] + 0.114 * img_gpu[:, :, 2]
gray = cp.clip(gray, 0, 255).astype(cp.uint8)
# Stack to 3 channels
result = cp.stack([gray, gray, gray], axis=2)
return _to_cpu(result)
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
gray = np.clip(gray, 0, 255).astype(np.uint8)
return np.stack([gray, gray, gray], axis=2)
def prim_brightness(img, factor=1.0):
"""Adjust brightness by factor."""
xp = cp if GPU_AVAILABLE else np
if GPU_AVAILABLE:
img_gpu = _to_gpu(img.astype(np.float32))
result = xp.clip(img_gpu * factor, 0, 255).astype(xp.uint8)
return _to_cpu(result)
return np.clip(img.astype(np.float32) * factor, 0, 255).astype(np.uint8)
def prim_contrast(img, factor=1.0):
"""Adjust contrast around midpoint."""
xp = cp if GPU_AVAILABLE else np
if GPU_AVAILABLE:
img_gpu = _to_gpu(img.astype(np.float32))
result = xp.clip((img_gpu - 128) * factor + 128, 0, 255).astype(xp.uint8)
return _to_cpu(result)
return np.clip((img.astype(np.float32) - 128) * factor + 128, 0, 255).astype(np.uint8)
# CUDA kernel for HSV hue shift
if GPU_AVAILABLE:
_hue_shift_kernel = cp.RawKernel(r'''
extern "C" __global__
void hue_shift(unsigned char* img, int width, int height, float shift) {
int x = blockDim.x * blockIdx.x + threadIdx.x;
int y = blockDim.y * blockIdx.y + threadIdx.y;
if (x >= width || y >= height) return;
int idx = (y * width + x) * 3;
// Get RGB
float r = img[idx] / 255.0f;
float g = img[idx + 1] / 255.0f;
float b = img[idx + 2] / 255.0f;
// RGB to HSV
float max_c = fmaxf(r, fmaxf(g, b));
float min_c = fminf(r, fminf(g, b));
float delta = max_c - min_c;
float h = 0.0f, s = 0.0f, v = max_c;
if (delta > 0.00001f) {
s = delta / max_c;
if (max_c == r) {
h = 60.0f * fmodf((g - b) / delta, 6.0f);
} else if (max_c == g) {
h = 60.0f * ((b - r) / delta + 2.0f);
} else {
h = 60.0f * ((r - g) / delta + 4.0f);
}
if (h < 0) h += 360.0f;
}
// Shift hue
h = fmodf(h + shift, 360.0f);
if (h < 0) h += 360.0f;
// HSV to RGB
float c = v * s;
float x_val = c * (1.0f - fabsf(fmodf(h / 60.0f, 2.0f) - 1.0f));
float m = v - c;
float r_out, g_out, b_out;
if (h < 60) {
r_out = c; g_out = x_val; b_out = 0;
} else if (h < 120) {
r_out = x_val; g_out = c; b_out = 0;
} else if (h < 180) {
r_out = 0; g_out = c; b_out = x_val;
} else if (h < 240) {
r_out = 0; g_out = x_val; b_out = c;
} else if (h < 300) {
r_out = x_val; g_out = 0; b_out = c;
} else {
r_out = c; g_out = 0; b_out = x_val;
}
img[idx] = (unsigned char)fminf(255.0f, (r_out + m) * 255.0f);
img[idx + 1] = (unsigned char)fminf(255.0f, (g_out + m) * 255.0f);
img[idx + 2] = (unsigned char)fminf(255.0f, (b_out + m) * 255.0f);
}
''', 'hue_shift')
def prim_hue_shift(img, shift=0.0):
"""Shift hue by degrees."""
if img.ndim != 3 or img.shape[2] != 3:
return img
if not GPU_AVAILABLE:
import cv2
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
hsv[:, :, 0] = (hsv[:, :, 0].astype(np.float32) + shift / 2) % 180
return cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)
h, w = img.shape[:2]
img_gpu = _to_gpu(img.astype(np.uint8)).copy()
block = (16, 16)
grid = ((w + block[0] - 1) // block[0], (h + block[1] - 1) // block[1])
_hue_shift_kernel(grid, block, (img_gpu, np.int32(w), np.int32(h), np.float32(shift)))
return _to_cpu(img_gpu)
def prim_saturate(img, factor=1.0):
"""Adjust saturation by factor."""
if img.ndim != 3:
return img
if not GPU_AVAILABLE:
import cv2
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * factor, 0, 255)
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
# GPU version - simple desaturation blend
img_gpu = _to_gpu(img.astype(np.float32))
gray = 0.299 * img_gpu[:, :, 0] + 0.587 * img_gpu[:, :, 1] + 0.114 * img_gpu[:, :, 2]
gray = gray[:, :, cp.newaxis]
if factor < 1.0:
# Desaturate: blend toward gray
result = img_gpu * factor + gray * (1 - factor)
else:
# Oversaturate: extrapolate away from gray
result = gray + (img_gpu - gray) * factor
result = cp.clip(result, 0, 255).astype(cp.uint8)
return _to_cpu(result)
def prim_blend(img1, img2, alpha=0.5):
"""Blend two images with alpha."""
xp = cp if GPU_AVAILABLE else np
if GPU_AVAILABLE:
img1_gpu = _to_gpu(img1.astype(np.float32))
img2_gpu = _to_gpu(img2.astype(np.float32))
result = img1_gpu * (1 - alpha) + img2_gpu * alpha
result = xp.clip(result, 0, 255).astype(xp.uint8)
return _to_cpu(result)
result = img1.astype(np.float32) * (1 - alpha) + img2.astype(np.float32) * alpha
return np.clip(result, 0, 255).astype(np.uint8)
def prim_add(img1, img2):
"""Add two images (clamped)."""
xp = cp if GPU_AVAILABLE else np
if GPU_AVAILABLE:
result = xp.clip(_to_gpu(img1).astype(np.int16) + _to_gpu(img2).astype(np.int16), 0, 255)
return _to_cpu(result.astype(xp.uint8))
return np.clip(img1.astype(np.int16) + img2.astype(np.int16), 0, 255).astype(np.uint8)
def prim_multiply(img1, img2):
"""Multiply two images (normalized)."""
xp = cp if GPU_AVAILABLE else np
if GPU_AVAILABLE:
result = (_to_gpu(img1).astype(np.float32) * _to_gpu(img2).astype(np.float32)) / 255.0
result = xp.clip(result, 0, 255).astype(xp.uint8)
return _to_cpu(result)
result = (img1.astype(np.float32) * img2.astype(np.float32)) / 255.0
return np.clip(result, 0, 255).astype(np.uint8)
def prim_screen(img1, img2):
"""Screen blend mode."""
xp = cp if GPU_AVAILABLE else np
if GPU_AVAILABLE:
i1 = _to_gpu(img1).astype(np.float32) / 255.0
i2 = _to_gpu(img2).astype(np.float32) / 255.0
result = 1.0 - (1.0 - i1) * (1.0 - i2)
result = xp.clip(result * 255, 0, 255).astype(xp.uint8)
return _to_cpu(result)
i1 = img1.astype(np.float32) / 255.0
i2 = img2.astype(np.float32) / 255.0
result = 1.0 - (1.0 - i1) * (1.0 - i2)
return np.clip(result * 255, 0, 255).astype(np.uint8)
# Import CPU primitives as fallbacks
def _get_cpu_primitives():
"""Get all primitives from CPU color_ops module as fallbacks."""
from sexp_effects.primitive_libs import color_ops
return color_ops.PRIMITIVES
# Export functions - start with CPU primitives, then override with GPU versions
PRIMITIVES = _get_cpu_primitives().copy()
# Override specific primitives with GPU-accelerated versions
PRIMITIVES.update({
'invert': prim_invert,
'grayscale': prim_grayscale,
'brightness': prim_brightness,
'contrast': prim_contrast,
'hue-shift': prim_hue_shift,
'saturate': prim_saturate,
'blend': prim_blend,
'add': prim_add,
'multiply': prim_multiply,
'screen': prim_screen,
})

View File

@@ -0,0 +1,294 @@
"""
Core Primitives - Always available, minimal essential set.
These are the primitives that form the foundation of the language.
They cannot be overridden by libraries.
"""
# Arithmetic
def prim_add(*args):
if len(args) == 0:
return 0
result = args[0]
for arg in args[1:]:
result = result + arg
return result
def prim_sub(a, b=None):
if b is None:
return -a
return a - b
def prim_mul(*args):
if len(args) == 0:
return 1
result = args[0]
for arg in args[1:]:
result = result * arg
return result
def prim_div(a, b):
return a / b
def prim_mod(a, b):
return a % b
def prim_abs(x):
return abs(x)
def prim_min(*args):
return min(args)
def prim_max(*args):
return max(args)
def prim_round(x):
import numpy as np
if hasattr(x, '_data'): # Xector
from .xector import Xector
return Xector(np.round(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.round(x)
return round(x)
def prim_floor(x):
import numpy as np
if hasattr(x, '_data'): # Xector
from .xector import Xector
return Xector(np.floor(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.floor(x)
import math
return math.floor(x)
def prim_ceil(x):
import numpy as np
if hasattr(x, '_data'): # Xector
from .xector import Xector
return Xector(np.ceil(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.ceil(x)
import math
return math.ceil(x)
# Comparison
def prim_lt(a, b):
return a < b
def prim_gt(a, b):
return a > b
def prim_le(a, b):
return a <= b
def prim_ge(a, b):
return a >= b
def prim_eq(a, b):
if isinstance(a, float) or isinstance(b, float):
return abs(a - b) < 1e-9
return a == b
def prim_ne(a, b):
return not prim_eq(a, b)
# Logic
def prim_not(x):
return not x
def prim_and(*args):
for a in args:
if not a:
return False
return True
def prim_or(*args):
for a in args:
if a:
return True
return False
# Basic data access
def prim_get(obj, key, default=None):
"""Get value from dict or list."""
if isinstance(obj, dict):
return obj.get(key, default)
elif isinstance(obj, (list, tuple)):
try:
return obj[int(key)]
except (IndexError, ValueError):
return default
return default
def prim_nth(seq, i):
i = int(i)
if 0 <= i < len(seq):
return seq[i]
return None
def prim_first(seq):
return seq[0] if seq else None
def prim_length(seq):
return len(seq)
def prim_list(*args):
return list(args)
# Type checking
def prim_is_number(x):
return isinstance(x, (int, float))
def prim_is_string(x):
return isinstance(x, str)
def prim_is_list(x):
return isinstance(x, (list, tuple))
def prim_is_dict(x):
return isinstance(x, dict)
def prim_is_nil(x):
return x is None
# Higher-order / iteration
def prim_reduce(seq, init, fn):
"""(reduce seq init fn) — fold left: fn(fn(fn(init, s0), s1), s2) ..."""
acc = init
for item in seq:
acc = fn(acc, item)
return acc
def prim_map(seq, fn):
"""(map seq fn) — apply fn to each element, return new list."""
return [fn(item) for item in seq]
def prim_range(*args):
"""(range end), (range start end), or (range start end step) — integer range."""
if len(args) == 1:
return list(range(int(args[0])))
elif len(args) == 2:
return list(range(int(args[0]), int(args[1])))
elif len(args) >= 3:
return list(range(int(args[0]), int(args[1]), int(args[2])))
return []
# Random
import random
_rng = random.Random()
def set_random_seed(seed):
"""Set the random seed for deterministic output."""
global _rng
_rng = random.Random(seed)
def prim_rand():
"""Return random float in [0, 1)."""
return _rng.random()
def prim_rand_int(lo, hi):
"""Return random integer in [lo, hi]."""
return _rng.randint(int(lo), int(hi))
def prim_rand_range(lo, hi):
"""Return random float in [lo, hi)."""
return lo + _rng.random() * (hi - lo)
def prim_map_range(val, from_lo, from_hi, to_lo, to_hi):
"""Map value from one range to another."""
if from_hi == from_lo:
return to_lo
t = (val - from_lo) / (from_hi - from_lo)
return to_lo + t * (to_hi - to_lo)
# Core primitives dict
PRIMITIVES = {
# Arithmetic
'+': prim_add,
'-': prim_sub,
'*': prim_mul,
'/': prim_div,
'mod': prim_mod,
'abs': prim_abs,
'min': prim_min,
'max': prim_max,
'round': prim_round,
'floor': prim_floor,
'ceil': prim_ceil,
# Comparison
'<': prim_lt,
'>': prim_gt,
'<=': prim_le,
'>=': prim_ge,
'=': prim_eq,
'!=': prim_ne,
# Logic
'not': prim_not,
'and': prim_and,
'or': prim_or,
# Data access
'get': prim_get,
'nth': prim_nth,
'first': prim_first,
'length': prim_length,
'len': prim_length,
'list': prim_list,
# Type predicates
'number?': prim_is_number,
'string?': prim_is_string,
'list?': prim_is_list,
'dict?': prim_is_dict,
'nil?': prim_is_nil,
'is-nil': prim_is_nil,
# Higher-order / iteration
'reduce': prim_reduce,
'fold': prim_reduce,
'map': prim_map,
'range': prim_range,
# Random
'rand': prim_rand,
'rand-int': prim_rand_int,
'rand-range': prim_rand_range,
'map-range': prim_map_range,
}

View File

@@ -0,0 +1,690 @@
"""
Drawing Primitives Library
Draw shapes, text, and characters on images with sophisticated text handling.
Text Features:
- Font loading from files or system fonts
- Text measurement and fitting
- Alignment (left/center/right, top/middle/bottom)
- Opacity for fade effects
- Multi-line text support
- Shadow and outline effects
"""
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont
import os
import glob as glob_module
from typing import Optional, Tuple, List, Union
# =============================================================================
# Font Management
# =============================================================================
# Font cache: (path, size) -> font object
_font_cache = {}
# Common system font directories
FONT_DIRS = [
"/usr/share/fonts",
"/usr/local/share/fonts",
"~/.fonts",
"~/.local/share/fonts",
"/System/Library/Fonts", # macOS
"/Library/Fonts", # macOS
"C:/Windows/Fonts", # Windows
]
# Default fonts to try (in order of preference)
DEFAULT_FONTS = [
"DejaVuSans.ttf",
"DejaVuSansMono.ttf",
"Arial.ttf",
"Helvetica.ttf",
"FreeSans.ttf",
"LiberationSans-Regular.ttf",
]
def _find_font_file(name: str) -> Optional[str]:
"""Find a font file by name in system directories."""
# If it's already a full path
if os.path.isfile(name):
return name
# Expand user paths
expanded = os.path.expanduser(name)
if os.path.isfile(expanded):
return expanded
# Search in font directories
for font_dir in FONT_DIRS:
font_dir = os.path.expanduser(font_dir)
if not os.path.isdir(font_dir):
continue
# Direct match
direct = os.path.join(font_dir, name)
if os.path.isfile(direct):
return direct
# Recursive search
for root, dirs, files in os.walk(font_dir):
for f in files:
if f.lower() == name.lower():
return os.path.join(root, f)
# Also match without extension
base = os.path.splitext(f)[0]
if base.lower() == name.lower():
return os.path.join(root, f)
return None
def _get_default_font(size: int = 24) -> ImageFont.FreeTypeFont:
"""Get a default font at the given size."""
for font_name in DEFAULT_FONTS:
path = _find_font_file(font_name)
if path:
try:
return ImageFont.truetype(path, size)
except:
continue
# Last resort: PIL default
return ImageFont.load_default()
def prim_make_font(name_or_path: str, size: int = 24) -> ImageFont.FreeTypeFont:
"""
Load a font by name or path.
(make-font "Arial" 32) ; system font by name
(make-font "/path/to/font.ttf" 24) ; font file path
(make-font "DejaVuSans" 48) ; searches common locations
Returns a font object for use with text primitives.
"""
size = int(size)
# Check cache
cache_key = (name_or_path, size)
if cache_key in _font_cache:
return _font_cache[cache_key]
# Find the font file
path = _find_font_file(name_or_path)
if not path:
raise FileNotFoundError(f"Font not found: {name_or_path}")
# Load and cache
font = ImageFont.truetype(path, size)
_font_cache[cache_key] = font
return font
def prim_list_fonts() -> List[str]:
"""
List available system fonts.
(list-fonts) ; -> ("Arial.ttf" "DejaVuSans.ttf" ...)
Returns list of font filenames found in system directories.
"""
fonts = set()
for font_dir in FONT_DIRS:
font_dir = os.path.expanduser(font_dir)
if not os.path.isdir(font_dir):
continue
for root, dirs, files in os.walk(font_dir):
for f in files:
if f.lower().endswith(('.ttf', '.otf', '.ttc')):
fonts.add(f)
return sorted(fonts)
def prim_font_size(font: ImageFont.FreeTypeFont) -> int:
"""
Get the size of a font.
(font-size my-font) ; -> 24
"""
return font.size
# =============================================================================
# Text Measurement
# =============================================================================
def prim_text_size(text: str, font=None, font_size: int = 24) -> Tuple[int, int]:
"""
Measure text dimensions.
(text-size "Hello" my-font) ; -> (width height)
(text-size "Hello" :font-size 32) ; -> (width height) with default font
For multi-line text, returns total bounding box.
"""
if font is None:
font = _get_default_font(int(font_size))
elif isinstance(font, (int, float)):
font = _get_default_font(int(font))
# Create temporary image for measurement
img = Image.new('RGB', (1, 1))
draw = ImageDraw.Draw(img)
bbox = draw.textbbox((0, 0), str(text), font=font)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
return (width, height)
def prim_text_metrics(font=None, font_size: int = 24) -> dict:
"""
Get font metrics.
(text-metrics my-font) ; -> {ascent: 20, descent: 5, height: 25}
Useful for precise text layout.
"""
if font is None:
font = _get_default_font(int(font_size))
elif isinstance(font, (int, float)):
font = _get_default_font(int(font))
ascent, descent = font.getmetrics()
return {
'ascent': ascent,
'descent': descent,
'height': ascent + descent,
'size': font.size,
}
def prim_fit_text_size(text: str, max_width: int, max_height: int,
font_name: str = None, min_size: int = 8,
max_size: int = 500) -> int:
"""
Calculate font size to fit text within bounds.
(fit-text-size "Hello World" 400 100) ; -> 48
(fit-text-size "Title" 800 200 :font-name "Arial")
Returns the largest font size that fits within max_width x max_height.
"""
max_width = int(max_width)
max_height = int(max_height)
min_size = int(min_size)
max_size = int(max_size)
text = str(text)
# Binary search for optimal size
best_size = min_size
low, high = min_size, max_size
while low <= high:
mid = (low + high) // 2
if font_name:
try:
font = prim_make_font(font_name, mid)
except:
font = _get_default_font(mid)
else:
font = _get_default_font(mid)
w, h = prim_text_size(text, font)
if w <= max_width and h <= max_height:
best_size = mid
low = mid + 1
else:
high = mid - 1
return best_size
def prim_fit_font(text: str, max_width: int, max_height: int,
font_name: str = None, min_size: int = 8,
max_size: int = 500) -> ImageFont.FreeTypeFont:
"""
Create a font sized to fit text within bounds.
(fit-font "Hello World" 400 100) ; -> font object
(fit-font "Title" 800 200 :font-name "Arial")
Returns a font object at the optimal size.
"""
size = prim_fit_text_size(text, max_width, max_height,
font_name, min_size, max_size)
if font_name:
try:
return prim_make_font(font_name, size)
except:
pass
return _get_default_font(size)
# =============================================================================
# Text Drawing
# =============================================================================
def prim_text(img: np.ndarray, text: str,
x: int = None, y: int = None,
width: int = None, height: int = None,
font=None, font_size: int = 24, font_name: str = None,
color=None, opacity: float = 1.0,
align: str = "left", valign: str = "top",
fit: bool = False,
shadow: bool = False, shadow_color=None, shadow_offset: int = 2,
outline: bool = False, outline_color=None, outline_width: int = 1,
line_spacing: float = 1.2) -> np.ndarray:
"""
Draw text with alignment, opacity, and effects.
Basic usage:
(text frame "Hello" :x 100 :y 50)
Centered in frame:
(text frame "Title" :align "center" :valign "middle")
Fit to box:
(text frame "Big Text" :x 50 :y 50 :width 400 :height 100 :fit true)
With fade (for animations):
(text frame "Fading" :x 100 :y 100 :opacity 0.5)
With effects:
(text frame "Shadow" :x 100 :y 100 :shadow true)
(text frame "Outline" :x 100 :y 100 :outline true :outline-color (0 0 0))
Args:
img: Input frame
text: Text to draw
x, y: Position (if not specified, uses alignment in full frame)
width, height: Bounding box (for fit and alignment within box)
font: Font object from make-font
font_size: Size if no font specified
font_name: Font name to load
color: RGB tuple (default white)
opacity: 0.0 (invisible) to 1.0 (opaque) for fading
align: "left", "center", "right"
valign: "top", "middle", "bottom"
fit: If true, auto-size font to fit in box
shadow: Draw drop shadow
shadow_color: Shadow color (default black)
shadow_offset: Shadow offset in pixels
outline: Draw text outline
outline_color: Outline color (default black)
outline_width: Outline thickness
line_spacing: Multiplier for line height (for multi-line)
Returns:
Frame with text drawn
"""
h, w = img.shape[:2]
text = str(text)
# Default colors
if color is None:
color = (255, 255, 255)
else:
color = tuple(int(c) for c in color)
if shadow_color is None:
shadow_color = (0, 0, 0)
else:
shadow_color = tuple(int(c) for c in shadow_color)
if outline_color is None:
outline_color = (0, 0, 0)
else:
outline_color = tuple(int(c) for c in outline_color)
# Determine bounding box
if x is None:
x = 0
if width is None:
width = w
if y is None:
y = 0
if height is None:
height = h
x, y = int(x), int(y)
box_width = int(width) if width else w - x
box_height = int(height) if height else h - y
# Get or create font
if font is None:
if fit:
font = prim_fit_font(text, box_width, box_height, font_name)
elif font_name:
try:
font = prim_make_font(font_name, int(font_size))
except:
font = _get_default_font(int(font_size))
else:
font = _get_default_font(int(font_size))
# Measure text
text_w, text_h = prim_text_size(text, font)
# Calculate position based on alignment
if align == "center":
draw_x = x + (box_width - text_w) // 2
elif align == "right":
draw_x = x + box_width - text_w
else: # left
draw_x = x
if valign == "middle":
draw_y = y + (box_height - text_h) // 2
elif valign == "bottom":
draw_y = y + box_height - text_h
else: # top
draw_y = y
# Create RGBA image for compositing with opacity
pil_img = Image.fromarray(img).convert('RGBA')
# Create text layer with transparency
text_layer = Image.new('RGBA', (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(text_layer)
# Draw shadow first (if enabled)
if shadow:
shadow_x = draw_x + shadow_offset
shadow_y = draw_y + shadow_offset
shadow_rgba = shadow_color + (int(255 * opacity * 0.5),)
draw.text((shadow_x, shadow_y), text, fill=shadow_rgba, font=font)
# Draw outline (if enabled)
if outline:
outline_rgba = outline_color + (int(255 * opacity),)
ow = int(outline_width)
for dx in range(-ow, ow + 1):
for dy in range(-ow, ow + 1):
if dx != 0 or dy != 0:
draw.text((draw_x + dx, draw_y + dy), text,
fill=outline_rgba, font=font)
# Draw main text
text_rgba = color + (int(255 * opacity),)
draw.text((draw_x, draw_y), text, fill=text_rgba, font=font)
# Composite
result = Image.alpha_composite(pil_img, text_layer)
return np.array(result.convert('RGB'))
def prim_text_box(img: np.ndarray, text: str,
x: int, y: int, width: int, height: int,
font=None, font_size: int = 24, font_name: str = None,
color=None, opacity: float = 1.0,
align: str = "center", valign: str = "middle",
fit: bool = True,
padding: int = 0,
background=None, background_opacity: float = 0.5,
**kwargs) -> np.ndarray:
"""
Draw text fitted within a box, optionally with background.
(text-box frame "Title" 50 50 400 100)
(text-box frame "Subtitle" 50 160 400 50
:background (0 0 0) :background-opacity 0.7)
Convenience wrapper around text() for common box-with-text pattern.
"""
x, y = int(x), int(y)
width, height = int(width), int(height)
padding = int(padding)
result = img.copy()
# Draw background if specified
if background is not None:
bg_color = tuple(int(c) for c in background)
# Create background with opacity
pil_img = Image.fromarray(result).convert('RGBA')
bg_layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
bg_draw = ImageDraw.Draw(bg_layer)
bg_rgba = bg_color + (int(255 * background_opacity),)
bg_draw.rectangle([x, y, x + width, y + height], fill=bg_rgba)
result = np.array(Image.alpha_composite(pil_img, bg_layer).convert('RGB'))
# Draw text within padded box
return prim_text(result, text,
x=x + padding, y=y + padding,
width=width - 2 * padding, height=height - 2 * padding,
font=font, font_size=font_size, font_name=font_name,
color=color, opacity=opacity,
align=align, valign=valign, fit=fit,
**kwargs)
# =============================================================================
# Legacy text functions (keep for compatibility)
# =============================================================================
def prim_draw_char(img, char, x, y, font_size=16, color=None):
"""Draw a single character at (x, y). Legacy function."""
return prim_text(img, str(char), x=int(x), y=int(y),
font_size=int(font_size), color=color)
def prim_draw_text(img, text, x, y, font_size=16, color=None):
"""Draw text string at (x, y). Legacy function."""
return prim_text(img, str(text), x=int(x), y=int(y),
font_size=int(font_size), color=color)
# =============================================================================
# Shape Drawing
# =============================================================================
def prim_fill_rect(img, x, y, w, h, color=None, opacity: float = 1.0):
"""
Fill a rectangle with color.
(fill-rect frame 10 10 100 50 (255 0 0))
(fill-rect frame 10 10 100 50 (255 0 0) :opacity 0.5)
"""
if color is None:
color = [255, 255, 255]
x, y, w, h = int(x), int(y), int(w), int(h)
if opacity >= 1.0:
result = img.copy()
result[y:y+h, x:x+w] = color
return result
# With opacity, use alpha compositing
pil_img = Image.fromarray(img).convert('RGBA')
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
fill_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
draw.rectangle([x, y, x + w, y + h], fill=fill_rgba)
result = Image.alpha_composite(pil_img, layer)
return np.array(result.convert('RGB'))
def prim_draw_rect(img, x, y, w, h, color=None, thickness=1, opacity: float = 1.0):
"""Draw rectangle outline."""
if color is None:
color = [255, 255, 255]
if opacity >= 1.0:
result = img.copy()
cv2.rectangle(result, (int(x), int(y)), (int(x+w), int(y+h)),
tuple(int(c) for c in color), int(thickness))
return result
# With opacity
pil_img = Image.fromarray(img).convert('RGBA')
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
outline_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
draw.rectangle([int(x), int(y), int(x+w), int(y+h)],
outline=outline_rgba, width=int(thickness))
result = Image.alpha_composite(pil_img, layer)
return np.array(result.convert('RGB'))
def prim_draw_line(img, x1, y1, x2, y2, color=None, thickness=1, opacity: float = 1.0):
"""Draw a line from (x1, y1) to (x2, y2)."""
if color is None:
color = [255, 255, 255]
if opacity >= 1.0:
result = img.copy()
cv2.line(result, (int(x1), int(y1)), (int(x2), int(y2)),
tuple(int(c) for c in color), int(thickness))
return result
# With opacity
pil_img = Image.fromarray(img).convert('RGBA')
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
line_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
draw.line([(int(x1), int(y1)), (int(x2), int(y2))],
fill=line_rgba, width=int(thickness))
result = Image.alpha_composite(pil_img, layer)
return np.array(result.convert('RGB'))
def prim_draw_circle(img, cx, cy, radius, color=None, thickness=1,
fill=False, opacity: float = 1.0):
"""Draw a circle."""
if color is None:
color = [255, 255, 255]
if opacity >= 1.0:
result = img.copy()
t = -1 if fill else int(thickness)
cv2.circle(result, (int(cx), int(cy)), int(radius),
tuple(int(c) for c in color), t)
return result
# With opacity
pil_img = Image.fromarray(img).convert('RGBA')
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cx, cy, r = int(cx), int(cy), int(radius)
rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
if fill:
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgba)
else:
draw.ellipse([cx - r, cy - r, cx + r, cy + r],
outline=rgba, width=int(thickness))
result = Image.alpha_composite(pil_img, layer)
return np.array(result.convert('RGB'))
def prim_draw_ellipse(img, cx, cy, rx, ry, angle=0, color=None,
thickness=1, fill=False, opacity: float = 1.0):
"""Draw an ellipse."""
if color is None:
color = [255, 255, 255]
if opacity >= 1.0:
result = img.copy()
t = -1 if fill else int(thickness)
cv2.ellipse(result, (int(cx), int(cy)), (int(rx), int(ry)),
float(angle), 0, 360, tuple(int(c) for c in color), t)
return result
# With opacity (note: PIL doesn't support rotated ellipses easily)
# Fall back to cv2 on a separate layer
layer = np.zeros((img.shape[0], img.shape[1], 4), dtype=np.uint8)
t = -1 if fill else int(thickness)
rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
cv2.ellipse(layer, (int(cx), int(cy)), (int(rx), int(ry)),
float(angle), 0, 360, rgba, t)
pil_img = Image.fromarray(img).convert('RGBA')
pil_layer = Image.fromarray(layer)
result = Image.alpha_composite(pil_img, pil_layer)
return np.array(result.convert('RGB'))
def prim_draw_polygon(img, points, color=None, thickness=1,
fill=False, opacity: float = 1.0):
"""Draw a polygon from list of [x, y] points."""
if color is None:
color = [255, 255, 255]
if opacity >= 1.0:
result = img.copy()
pts = np.array(points, dtype=np.int32).reshape((-1, 1, 2))
if fill:
cv2.fillPoly(result, [pts], tuple(int(c) for c in color))
else:
cv2.polylines(result, [pts], True,
tuple(int(c) for c in color), int(thickness))
return result
# With opacity
pil_img = Image.fromarray(img).convert('RGBA')
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
pts_flat = [(int(p[0]), int(p[1])) for p in points]
rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
if fill:
draw.polygon(pts_flat, fill=rgba)
else:
draw.polygon(pts_flat, outline=rgba, width=int(thickness))
result = Image.alpha_composite(pil_img, layer)
return np.array(result.convert('RGB'))
# =============================================================================
# PRIMITIVES Export
# =============================================================================
PRIMITIVES = {
# Font management
'make-font': prim_make_font,
'list-fonts': prim_list_fonts,
'font-size': prim_font_size,
# Text measurement
'text-size': prim_text_size,
'text-metrics': prim_text_metrics,
'fit-text-size': prim_fit_text_size,
'fit-font': prim_fit_font,
# Text drawing
'text': prim_text,
'text-box': prim_text_box,
# Legacy text (compatibility)
'draw-char': prim_draw_char,
'draw-text': prim_draw_text,
# Rectangles
'fill-rect': prim_fill_rect,
'draw-rect': prim_draw_rect,
# Lines and shapes
'draw-line': prim_draw_line,
'draw-circle': prim_draw_circle,
'draw-ellipse': prim_draw_ellipse,
'draw-polygon': prim_draw_polygon,
}

View File

@@ -0,0 +1,119 @@
"""
Filters Primitives Library
Image filters: blur, sharpen, edges, convolution.
"""
import numpy as np
import cv2
def prim_blur(img, radius):
"""Gaussian blur with given radius."""
radius = max(1, int(radius))
ksize = radius * 2 + 1
return cv2.GaussianBlur(img, (ksize, ksize), 0)
def prim_box_blur(img, radius):
"""Box blur with given radius."""
radius = max(1, int(radius))
ksize = radius * 2 + 1
return cv2.blur(img, (ksize, ksize))
def prim_median_blur(img, radius):
"""Median blur (good for noise removal)."""
radius = max(1, int(radius))
ksize = radius * 2 + 1
return cv2.medianBlur(img, ksize)
def prim_bilateral(img, d=9, sigma_color=75, sigma_space=75):
"""Bilateral filter (edge-preserving blur)."""
return cv2.bilateralFilter(img, d, sigma_color, sigma_space)
def prim_sharpen(img, amount=1.0):
"""Sharpen image using unsharp mask."""
blurred = cv2.GaussianBlur(img, (0, 0), 3)
return cv2.addWeighted(img, 1.0 + amount, blurred, -amount, 0)
def prim_edges(img, low=50, high=150):
"""Canny edge detection."""
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
edges = cv2.Canny(gray, low, high)
return cv2.cvtColor(edges, cv2.COLOR_GRAY2RGB)
def prim_sobel(img, ksize=3):
"""Sobel edge detection."""
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
sobelx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=ksize)
sobely = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=ksize)
mag = np.sqrt(sobelx**2 + sobely**2)
mag = np.clip(mag, 0, 255).astype(np.uint8)
return cv2.cvtColor(mag, cv2.COLOR_GRAY2RGB)
def prim_laplacian(img, ksize=3):
"""Laplacian edge detection."""
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
lap = cv2.Laplacian(gray, cv2.CV_64F, ksize=ksize)
lap = np.abs(lap)
lap = np.clip(lap, 0, 255).astype(np.uint8)
return cv2.cvtColor(lap, cv2.COLOR_GRAY2RGB)
def prim_emboss(img):
"""Emboss effect."""
kernel = np.array([[-2, -1, 0],
[-1, 1, 1],
[ 0, 1, 2]])
result = cv2.filter2D(img, -1, kernel)
return np.clip(result + 128, 0, 255).astype(np.uint8)
def prim_dilate(img, size=1):
"""Morphological dilation."""
kernel = np.ones((size * 2 + 1, size * 2 + 1), np.uint8)
return cv2.dilate(img, kernel)
def prim_erode(img, size=1):
"""Morphological erosion."""
kernel = np.ones((size * 2 + 1, size * 2 + 1), np.uint8)
return cv2.erode(img, kernel)
def prim_convolve(img, kernel):
"""Apply custom convolution kernel."""
kernel = np.array(kernel, dtype=np.float32)
return cv2.filter2D(img, -1, kernel)
PRIMITIVES = {
# Blur
'blur': prim_blur,
'box-blur': prim_box_blur,
'median-blur': prim_median_blur,
'bilateral': prim_bilateral,
# Sharpen
'sharpen': prim_sharpen,
# Edges
'edges': prim_edges,
'sobel': prim_sobel,
'laplacian': prim_laplacian,
# Effects
'emboss': prim_emboss,
# Morphology
'dilate': prim_dilate,
'erode': prim_erode,
# Custom
'convolve': prim_convolve,
}

View File

@@ -0,0 +1,143 @@
"""
Geometry Primitives Library
Geometric transforms: rotate, scale, flip, translate, remap.
"""
import numpy as np
import cv2
def prim_translate(img, dx, dy):
"""Translate image by (dx, dy) pixels."""
h, w = img.shape[:2]
M = np.float32([[1, 0, dx], [0, 1, dy]])
return cv2.warpAffine(img, M, (w, h))
def prim_rotate(img, angle, cx=None, cy=None):
"""Rotate image by angle degrees around center (cx, cy)."""
h, w = img.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
return cv2.warpAffine(img, M, (w, h))
def prim_scale(img, sx, sy, cx=None, cy=None):
"""Scale image by (sx, sy) around center (cx, cy)."""
h, w = img.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
# Build transform matrix
M = np.float32([
[sx, 0, cx * (1 - sx)],
[0, sy, cy * (1 - sy)]
])
return cv2.warpAffine(img, M, (w, h))
def prim_flip_h(img):
"""Flip image horizontally."""
return cv2.flip(img, 1)
def prim_flip_v(img):
"""Flip image vertically."""
return cv2.flip(img, 0)
def prim_flip(img, direction="horizontal"):
"""Flip image in given direction."""
if direction in ("horizontal", "h"):
return prim_flip_h(img)
elif direction in ("vertical", "v"):
return prim_flip_v(img)
elif direction in ("both", "hv", "vh"):
return cv2.flip(img, -1)
return img
def prim_transpose(img):
"""Transpose image (swap x and y)."""
return np.transpose(img, (1, 0, 2))
def prim_remap(img, map_x, map_y):
"""Remap image using coordinate maps."""
return cv2.remap(img, map_x.astype(np.float32),
map_y.astype(np.float32),
cv2.INTER_LINEAR)
def prim_make_coords(w, h):
"""Create coordinate grids for remapping."""
x = np.arange(w, dtype=np.float32)
y = np.arange(h, dtype=np.float32)
map_x, map_y = np.meshgrid(x, y)
return (map_x, map_y)
def prim_perspective(img, src_pts, dst_pts):
"""Apply perspective transform."""
src = np.float32(src_pts)
dst = np.float32(dst_pts)
M = cv2.getPerspectiveTransform(src, dst)
h, w = img.shape[:2]
return cv2.warpPerspective(img, M, (w, h))
def prim_affine(img, src_pts, dst_pts):
"""Apply affine transform using 3 point pairs."""
src = np.float32(src_pts)
dst = np.float32(dst_pts)
M = cv2.getAffineTransform(src, dst)
h, w = img.shape[:2]
return cv2.warpAffine(img, M, (w, h))
def _get_legacy_geometry_primitives():
"""Import geometry primitives from legacy primitives module."""
from sexp_effects.primitives import (
prim_coords_x,
prim_coords_y,
prim_ripple_displace,
prim_fisheye_displace,
prim_kaleidoscope_displace,
)
return {
'coords-x': prim_coords_x,
'coords-y': prim_coords_y,
'ripple-displace': prim_ripple_displace,
'fisheye-displace': prim_fisheye_displace,
'kaleidoscope-displace': prim_kaleidoscope_displace,
}
PRIMITIVES = {
# Basic transforms
'translate': prim_translate,
'rotate-img': prim_rotate,
'scale-img': prim_scale,
# Flips
'flip-h': prim_flip_h,
'flip-v': prim_flip_v,
'flip': prim_flip,
'transpose': prim_transpose,
# Remapping
'remap': prim_remap,
'make-coords': prim_make_coords,
# Advanced transforms
'perspective': prim_perspective,
'affine': prim_affine,
# Displace / coordinate ops (from legacy primitives)
**_get_legacy_geometry_primitives(),
}

View File

@@ -0,0 +1,403 @@
"""
GPU-Accelerated Geometry Primitives Library
Uses CuPy for CUDA-accelerated image transforms.
Falls back to CPU if GPU unavailable.
Performance Mode:
- Set STREAMING_GPU_PERSIST=1 to keep frames on GPU between operations
- This dramatically improves performance by avoiding CPU<->GPU transfers
- Frames only transfer to CPU at final output
"""
import os
import numpy as np
# Try to import CuPy for GPU acceleration
try:
import cupy as cp
from cupyx.scipy import ndimage as cpndimage
GPU_AVAILABLE = True
print("[geometry_gpu] CuPy GPU acceleration enabled")
except ImportError:
cp = np
GPU_AVAILABLE = False
print("[geometry_gpu] CuPy not available, using CPU fallback")
# GPU persistence mode - keep frames on GPU between operations
# Set STREAMING_GPU_PERSIST=1 for maximum performance
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
if GPU_AVAILABLE and GPU_PERSIST:
print("[geometry_gpu] GPU persistence enabled - frames stay on GPU")
def _to_gpu(img):
"""Move image to GPU if available."""
if GPU_AVAILABLE and not isinstance(img, cp.ndarray):
return cp.asarray(img)
return img
def _to_cpu(img):
"""Move image back to CPU (only if GPU_PERSIST is disabled)."""
if not GPU_PERSIST and GPU_AVAILABLE and isinstance(img, cp.ndarray):
return cp.asnumpy(img)
return img
def _ensure_output_format(img):
"""Ensure output is in correct format based on GPU_PERSIST setting."""
return _to_cpu(img)
def prim_rotate(img, angle, cx=None, cy=None):
"""Rotate image by angle degrees around center (cx, cy).
Uses fast CUDA kernel when available (< 1ms vs 20ms for scipy).
"""
if not GPU_AVAILABLE:
# Fallback to OpenCV
import cv2
h, w = img.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
return cv2.warpAffine(img, M, (w, h))
# Use fast CUDA kernel (prim_rotate_gpu defined below)
return prim_rotate_gpu(img, angle, cx, cy)
def prim_scale(img, sx, sy, cx=None, cy=None):
"""Scale image by (sx, sy) around center (cx, cy)."""
if not GPU_AVAILABLE:
import cv2
h, w = img.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
M = np.float32([
[sx, 0, cx * (1 - sx)],
[0, sy, cy * (1 - sy)]
])
return cv2.warpAffine(img, M, (w, h))
img_gpu = _to_gpu(img)
h, w = img_gpu.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
# Use cupyx.scipy.ndimage.zoom
if img_gpu.ndim == 3:
zoom_factors = (sy, sx, 1) # Don't zoom color channels
else:
zoom_factors = (sy, sx)
zoomed = cpndimage.zoom(img_gpu, zoom_factors, order=1)
# Crop/pad to original size
zh, zw = zoomed.shape[:2]
result = cp.zeros_like(img_gpu)
# Calculate offsets
src_y = max(0, (zh - h) // 2)
src_x = max(0, (zw - w) // 2)
dst_y = max(0, (h - zh) // 2)
dst_x = max(0, (w - zw) // 2)
copy_h = min(h - dst_y, zh - src_y)
copy_w = min(w - dst_x, zw - src_x)
result[dst_y:dst_y+copy_h, dst_x:dst_x+copy_w] = zoomed[src_y:src_y+copy_h, src_x:src_x+copy_w]
return _to_cpu(result)
def prim_translate(img, dx, dy):
"""Translate image by (dx, dy) pixels."""
if not GPU_AVAILABLE:
import cv2
h, w = img.shape[:2]
M = np.float32([[1, 0, dx], [0, 1, dy]])
return cv2.warpAffine(img, M, (w, h))
img_gpu = _to_gpu(img)
# Use cupyx.scipy.ndimage.shift
if img_gpu.ndim == 3:
shift = (dy, dx, 0) # Don't shift color channels
else:
shift = (dy, dx)
shifted = cpndimage.shift(img_gpu, shift, order=1)
return _to_cpu(shifted)
def prim_flip_h(img):
"""Flip image horizontally."""
if GPU_AVAILABLE:
img_gpu = _to_gpu(img)
return _to_cpu(cp.flip(img_gpu, axis=1))
return np.flip(img, axis=1)
def prim_flip_v(img):
"""Flip image vertically."""
if GPU_AVAILABLE:
img_gpu = _to_gpu(img)
return _to_cpu(cp.flip(img_gpu, axis=0))
return np.flip(img, axis=0)
def prim_flip(img, direction="horizontal"):
"""Flip image in given direction."""
if direction in ("horizontal", "h"):
return prim_flip_h(img)
elif direction in ("vertical", "v"):
return prim_flip_v(img)
elif direction in ("both", "hv", "vh"):
if GPU_AVAILABLE:
img_gpu = _to_gpu(img)
return _to_cpu(cp.flip(cp.flip(img_gpu, axis=0), axis=1))
return np.flip(np.flip(img, axis=0), axis=1)
return img
# CUDA kernel for ripple effect
if GPU_AVAILABLE:
_ripple_kernel = cp.RawKernel(r'''
extern "C" __global__
void ripple(const unsigned char* src, unsigned char* dst,
int width, int height, int channels,
float amplitude, float frequency, float decay,
float speed, float time, float cx, float cy) {
int x = blockDim.x * blockIdx.x + threadIdx.x;
int y = blockDim.y * blockIdx.y + threadIdx.y;
if (x >= width || y >= height) return;
// Distance from center
float dx = x - cx;
float dy = y - cy;
float dist = sqrtf(dx * dx + dy * dy);
// Ripple displacement
float wave = sinf(dist * frequency * 0.1f - time * speed) * amplitude;
float falloff = expf(-dist * decay * 0.01f);
float displacement = wave * falloff;
// Direction from center
float len = dist + 0.0001f; // Avoid division by zero
float dir_x = dx / len;
float dir_y = dy / len;
// Source coordinates
float src_x = x - dir_x * displacement;
float src_y = y - dir_y * displacement;
// Clamp to bounds
src_x = fmaxf(0.0f, fminf(width - 1.0f, src_x));
src_y = fmaxf(0.0f, fminf(height - 1.0f, src_y));
// Bilinear interpolation
int x0 = (int)src_x;
int y0 = (int)src_y;
int x1 = min(x0 + 1, width - 1);
int y1 = min(y0 + 1, height - 1);
float fx = src_x - x0;
float fy = src_y - y0;
for (int c = 0; c < channels; c++) {
float v00 = src[(y0 * width + x0) * channels + c];
float v10 = src[(y0 * width + x1) * channels + c];
float v01 = src[(y1 * width + x0) * channels + c];
float v11 = src[(y1 * width + x1) * channels + c];
float v0 = v00 * (1 - fx) + v10 * fx;
float v1 = v01 * (1 - fx) + v11 * fx;
float val = v0 * (1 - fy) + v1 * fy;
dst[(y * width + x) * channels + c] = (unsigned char)fminf(255.0f, fmaxf(0.0f, val));
}
}
''', 'ripple')
def prim_ripple(img, amplitude=10.0, frequency=8.0, decay=2.0, speed=5.0,
time=0.0, center_x=None, center_y=None):
"""Apply ripple distortion effect."""
h, w = img.shape[:2]
channels = img.shape[2] if img.ndim == 3 else 1
if center_x is None:
center_x = w / 2
if center_y is None:
center_y = h / 2
if not GPU_AVAILABLE:
# CPU fallback using coordinate mapping
import cv2
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
dx = x_coords - center_x
dy = y_coords - center_y
dist = np.sqrt(dx**2 + dy**2)
wave = np.sin(dist * frequency * 0.1 - time * speed) * amplitude
falloff = np.exp(-dist * decay * 0.01)
displacement = wave * falloff
length = dist + 0.0001
dir_x = dx / length
dir_y = dy / length
map_x = (x_coords - dir_x * displacement).astype(np.float32)
map_y = (y_coords - dir_y * displacement).astype(np.float32)
return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR)
# GPU implementation
img_gpu = _to_gpu(img.astype(np.uint8))
if img_gpu.ndim == 2:
img_gpu = img_gpu[:, :, cp.newaxis]
channels = 1
dst = cp.zeros_like(img_gpu)
block = (16, 16)
grid = ((w + block[0] - 1) // block[0], (h + block[1] - 1) // block[1])
_ripple_kernel(grid, block, (
img_gpu, dst,
np.int32(w), np.int32(h), np.int32(channels),
np.float32(amplitude), np.float32(frequency), np.float32(decay),
np.float32(speed), np.float32(time),
np.float32(center_x), np.float32(center_y)
))
result = _to_cpu(dst)
if channels == 1:
result = result[:, :, 0]
return result
# CUDA kernel for fast rotation with bilinear interpolation
if GPU_AVAILABLE:
_rotate_kernel = cp.RawKernel(r'''
extern "C" __global__
void rotate_img(const unsigned char* src, unsigned char* dst,
int width, int height, int channels,
float cos_a, float sin_a, float cx, float cy) {
int x = blockDim.x * blockIdx.x + threadIdx.x;
int y = blockDim.y * blockIdx.y + threadIdx.y;
if (x >= width || y >= height) return;
// Translate to center, rotate, translate back
float dx = x - cx;
float dy = y - cy;
float src_x = cos_a * dx + sin_a * dy + cx;
float src_y = -sin_a * dx + cos_a * dy + cy;
// Check bounds
if (src_x < 0 || src_x >= width - 1 || src_y < 0 || src_y >= height - 1) {
for (int c = 0; c < channels; c++) {
dst[(y * width + x) * channels + c] = 0;
}
return;
}
// Bilinear interpolation
int x0 = (int)src_x;
int y0 = (int)src_y;
int x1 = x0 + 1;
int y1 = y0 + 1;
float fx = src_x - x0;
float fy = src_y - y0;
for (int c = 0; c < channels; c++) {
float v00 = src[(y0 * width + x0) * channels + c];
float v10 = src[(y0 * width + x1) * channels + c];
float v01 = src[(y1 * width + x0) * channels + c];
float v11 = src[(y1 * width + x1) * channels + c];
float v0 = v00 * (1 - fx) + v10 * fx;
float v1 = v01 * (1 - fx) + v11 * fx;
float val = v0 * (1 - fy) + v1 * fy;
dst[(y * width + x) * channels + c] = (unsigned char)fminf(255.0f, fmaxf(0.0f, val));
}
}
''', 'rotate_img')
def prim_rotate_gpu(img, angle, cx=None, cy=None):
"""Fast GPU rotation using custom CUDA kernel."""
if not GPU_AVAILABLE:
return prim_rotate(img, angle, cx, cy)
h, w = img.shape[:2]
channels = img.shape[2] if img.ndim == 3 else 1
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
img_gpu = _to_gpu(img.astype(np.uint8))
if img_gpu.ndim == 2:
img_gpu = img_gpu[:, :, cp.newaxis]
channels = 1
dst = cp.zeros_like(img_gpu)
# Convert angle to radians
rad = np.radians(angle)
cos_a = np.cos(rad)
sin_a = np.sin(rad)
block = (16, 16)
grid = ((w + block[0] - 1) // block[0], (h + block[1] - 1) // block[1])
_rotate_kernel(grid, block, (
img_gpu, dst,
np.int32(w), np.int32(h), np.int32(channels),
np.float32(cos_a), np.float32(sin_a),
np.float32(cx), np.float32(cy)
))
result = _to_cpu(dst)
if channels == 1:
result = result[:, :, 0]
return result
# Import CPU primitives as fallbacks for functions we don't GPU-accelerate
def _get_cpu_primitives():
"""Get all primitives from CPU geometry module as fallbacks."""
from sexp_effects.primitive_libs import geometry
return geometry.PRIMITIVES
# Export functions - start with CPU primitives, then override with GPU versions
PRIMITIVES = _get_cpu_primitives().copy()
# Override specific primitives with GPU-accelerated versions
PRIMITIVES.update({
'translate': prim_translate,
'rotate': prim_rotate_gpu if GPU_AVAILABLE else prim_rotate, # Fast CUDA kernel
'rotate-img': prim_rotate_gpu if GPU_AVAILABLE else prim_rotate, # Alias
'scale-img': prim_scale,
'flip-h': prim_flip_h,
'flip-v': prim_flip_v,
'flip': prim_flip,
'ripple': prim_ripple, # Fast CUDA kernel
# Note: ripple-displace uses CPU version (different API - returns coords, not image)
})

View File

@@ -0,0 +1,150 @@
"""
Image Primitives Library
Basic image operations: dimensions, pixels, resize, crop, paste.
"""
import numpy as np
import cv2
def prim_width(img):
if isinstance(img, (list, tuple)):
raise TypeError(f"image:width expects an image array, got {type(img).__name__} with {len(img)} elements")
return img.shape[1]
def prim_height(img):
if isinstance(img, (list, tuple)):
import sys
print(f"DEBUG image:height got list: {img[:3]}... (types: {[type(x).__name__ for x in img[:3]]})", file=sys.stderr)
raise TypeError(f"image:height expects an image array, got {type(img).__name__} with {len(img)} elements: {img}")
return img.shape[0]
def prim_make_image(w, h, color=None):
"""Create a new image filled with color (default black)."""
if color is None:
color = [0, 0, 0]
img = np.zeros((h, w, 3), dtype=np.uint8)
img[:] = color
return img
def prim_copy(img):
return img.copy()
def prim_pixel(img, x, y):
"""Get pixel color at (x, y) as [r, g, b]."""
h, w = img.shape[:2]
if 0 <= x < w and 0 <= y < h:
return list(img[int(y), int(x)])
return [0, 0, 0]
def prim_set_pixel(img, x, y, color):
"""Set pixel at (x, y) to color, returns modified image."""
result = img.copy()
h, w = result.shape[:2]
if 0 <= x < w and 0 <= y < h:
result[int(y), int(x)] = color
return result
def prim_sample(img, x, y):
"""Bilinear sample at float coordinates, returns [r, g, b] as floats."""
h, w = img.shape[:2]
x = max(0, min(w - 1.001, x))
y = max(0, min(h - 1.001, y))
x0, y0 = int(x), int(y)
x1, y1 = min(x0 + 1, w - 1), min(y0 + 1, h - 1)
fx, fy = x - x0, y - y0
c00 = img[y0, x0].astype(float)
c10 = img[y0, x1].astype(float)
c01 = img[y1, x0].astype(float)
c11 = img[y1, x1].astype(float)
top = c00 * (1 - fx) + c10 * fx
bottom = c01 * (1 - fx) + c11 * fx
return list(top * (1 - fy) + bottom * fy)
def prim_channel(img, c):
"""Extract single channel (0=R, 1=G, 2=B)."""
return img[:, :, c]
def prim_merge_channels(r, g, b):
"""Merge three single-channel arrays into RGB image."""
return np.stack([r, g, b], axis=2).astype(np.uint8)
def prim_resize(img, w, h, mode="linear"):
"""Resize image to w x h."""
interp = cv2.INTER_LINEAR
if mode == "nearest":
interp = cv2.INTER_NEAREST
elif mode == "cubic":
interp = cv2.INTER_CUBIC
elif mode == "area":
interp = cv2.INTER_AREA
return cv2.resize(img, (int(w), int(h)), interpolation=interp)
def prim_crop(img, x, y, w, h):
"""Crop rectangle from image."""
x, y, w, h = int(x), int(y), int(w), int(h)
ih, iw = img.shape[:2]
x = max(0, min(x, iw - 1))
y = max(0, min(y, ih - 1))
w = min(w, iw - x)
h = min(h, ih - y)
return img[y:y+h, x:x+w].copy()
def prim_paste(dst, src, x, y):
"""Paste src onto dst at position (x, y)."""
result = dst.copy()
x, y = int(x), int(y)
sh, sw = src.shape[:2]
dh, dw = dst.shape[:2]
# Clip to bounds
sx1 = max(0, -x)
sy1 = max(0, -y)
dx1 = max(0, x)
dy1 = max(0, y)
sx2 = min(sw, dw - x)
sy2 = min(sh, dh - y)
if sx2 > sx1 and sy2 > sy1:
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = src[sy1:sy2, sx1:sx2]
return result
PRIMITIVES = {
# Dimensions
'width': prim_width,
'height': prim_height,
# Creation
'make-image': prim_make_image,
'copy': prim_copy,
# Pixel access
'pixel': prim_pixel,
'set-pixel': prim_set_pixel,
'sample': prim_sample,
# Channels
'channel': prim_channel,
'merge-channels': prim_merge_channels,
# Geometry
'resize': prim_resize,
'crop': prim_crop,
'paste': prim_paste,
}

View File

@@ -0,0 +1,164 @@
"""
Math Primitives Library
Trigonometry, rounding, clamping, random numbers, etc.
"""
import math
import random as rand_module
def prim_sin(x):
return math.sin(x)
def prim_cos(x):
return math.cos(x)
def prim_tan(x):
return math.tan(x)
def prim_asin(x):
return math.asin(x)
def prim_acos(x):
return math.acos(x)
def prim_atan(x):
return math.atan(x)
def prim_atan2(y, x):
return math.atan2(y, x)
def prim_sqrt(x):
return math.sqrt(x)
def prim_pow(x, y):
return math.pow(x, y)
def prim_exp(x):
return math.exp(x)
def prim_log(x, base=None):
if base is None:
return math.log(x)
return math.log(x, base)
def prim_abs(x):
return abs(x)
def prim_floor(x):
return math.floor(x)
def prim_ceil(x):
return math.ceil(x)
def prim_round(x):
return round(x)
def prim_min(*args):
if len(args) == 1 and hasattr(args[0], '__iter__'):
return min(args[0])
return min(args)
def prim_max(*args):
if len(args) == 1 and hasattr(args[0], '__iter__'):
return max(args[0])
return max(args)
def prim_clamp(x, lo, hi):
return max(lo, min(hi, x))
def prim_lerp(a, b, t):
"""Linear interpolation: a + (b - a) * t"""
return a + (b - a) * t
def prim_smoothstep(edge0, edge1, x):
"""Smooth interpolation between 0 and 1."""
t = prim_clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)
return t * t * (3 - 2 * t)
def prim_random(lo=0.0, hi=1.0):
return rand_module.uniform(lo, hi)
def prim_randint(lo, hi):
return rand_module.randint(lo, hi)
def prim_gaussian(mean=0.0, std=1.0):
return rand_module.gauss(mean, std)
def prim_sign(x):
if x > 0:
return 1
elif x < 0:
return -1
return 0
def prim_fract(x):
"""Fractional part of x."""
return x - math.floor(x)
PRIMITIVES = {
# Trigonometry
'sin': prim_sin,
'cos': prim_cos,
'tan': prim_tan,
'asin': prim_asin,
'acos': prim_acos,
'atan': prim_atan,
'atan2': prim_atan2,
# Powers and roots
'sqrt': prim_sqrt,
'pow': prim_pow,
'exp': prim_exp,
'log': prim_log,
# Rounding
'abs': prim_abs,
'floor': prim_floor,
'ceil': prim_ceil,
'round': prim_round,
'sign': prim_sign,
'fract': prim_fract,
# Min/max/clamp
'min': prim_min,
'max': prim_max,
'clamp': prim_clamp,
'lerp': prim_lerp,
'smoothstep': prim_smoothstep,
# Random
'random': prim_random,
'randint': prim_randint,
'gaussian': prim_gaussian,
# Constants
'pi': math.pi,
'tau': math.tau,
'e': math.e,
}

View File

@@ -0,0 +1,593 @@
"""
Streaming primitives for video/audio processing.
These primitives handle video source reading and audio analysis,
keeping the interpreter completely generic.
GPU Acceleration:
- Set STREAMING_GPU_PERSIST=1 to output CuPy arrays (frames stay on GPU)
- Hardware video decoding (NVDEC) is used when available
- Dramatically improves performance on GPU nodes
Async Prefetching:
- Set STREAMING_PREFETCH=1 to enable background frame prefetching
- Decodes upcoming frames while current frame is being processed
"""
import os
import numpy as np
import subprocess
import json
import threading
from collections import deque
from pathlib import Path
# Try to import CuPy for GPU acceleration
try:
import cupy as cp
CUPY_AVAILABLE = True
except ImportError:
cp = None
CUPY_AVAILABLE = False
# GPU persistence mode - output CuPy arrays instead of numpy
# Disabled by default until all primitives support GPU frames
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1" and CUPY_AVAILABLE
# Async prefetch mode - decode frames in background thread
PREFETCH_ENABLED = os.environ.get("STREAMING_PREFETCH", "1") == "1"
PREFETCH_BUFFER_SIZE = int(os.environ.get("STREAMING_PREFETCH_SIZE", "10"))
# Check for hardware decode support (cached)
_HWDEC_AVAILABLE = None
def _check_hwdec():
"""Check if NVIDIA hardware decode is available."""
global _HWDEC_AVAILABLE
if _HWDEC_AVAILABLE is not None:
return _HWDEC_AVAILABLE
try:
result = subprocess.run(["nvidia-smi"], capture_output=True, timeout=2)
if result.returncode != 0:
_HWDEC_AVAILABLE = False
return False
result = subprocess.run(["ffmpeg", "-hwaccels"], capture_output=True, text=True, timeout=5)
_HWDEC_AVAILABLE = "cuda" in result.stdout
except Exception:
_HWDEC_AVAILABLE = False
return _HWDEC_AVAILABLE
class VideoSource:
"""Video source with persistent streaming pipe for fast sequential reads."""
def __init__(self, path: str, fps: float = 30):
self.path = Path(path)
self.fps = fps # Output fps for the stream
self._frame_size = None
self._duration = None
self._proc = None # Persistent ffmpeg process
self._stream_time = 0.0 # Current position in stream
self._frame_time = 1.0 / fps # Time per frame at output fps
self._last_read_time = -1
self._cached_frame = None
# Check if file exists
if not self.path.exists():
raise FileNotFoundError(f"Video file not found: {self.path}")
# Get video info
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", str(self.path)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to probe video '{self.path}': {result.stderr}")
try:
info = json.loads(result.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"Invalid video file or ffprobe failed: {self.path}")
for stream in info.get("streams", []):
if stream.get("codec_type") == "video":
self._frame_size = (stream.get("width", 720), stream.get("height", 720))
# Try direct duration field first
if "duration" in stream:
self._duration = float(stream["duration"])
# Fall back to tags.DURATION (webm format: "00:01:00.124000000")
elif "tags" in stream and "DURATION" in stream["tags"]:
dur_str = stream["tags"]["DURATION"]
parts = dur_str.split(":")
if len(parts) == 3:
h, m, s = parts
self._duration = int(h) * 3600 + int(m) * 60 + float(s)
break
# Fallback: check format duration if stream duration not found
if self._duration is None and "format" in info and "duration" in info["format"]:
self._duration = float(info["format"]["duration"])
if not self._frame_size:
self._frame_size = (720, 720)
import sys
print(f"VideoSource: {self.path.name} duration={self._duration} size={self._frame_size}", file=sys.stderr)
def _start_stream(self, seek_time: float = 0):
"""Start or restart the ffmpeg streaming process.
Uses NVIDIA hardware decoding (NVDEC) when available for better performance.
"""
if self._proc:
self._proc.kill()
self._proc = None
# Check file exists before trying to open
if not self.path.exists():
raise FileNotFoundError(f"Video file not found: {self.path}")
w, h = self._frame_size
# Build ffmpeg command with optional hardware decode
cmd = ["ffmpeg", "-v", "error"]
# Use hardware decode if available (significantly faster)
if _check_hwdec():
cmd.extend(["-hwaccel", "cuda"])
cmd.extend([
"-ss", f"{seek_time:.3f}",
"-i", str(self.path),
"-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{w}x{h}",
"-r", str(self.fps), # Output at specified fps
"-"
])
self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self._stream_time = seek_time
# Check if process started successfully by reading first bit of stderr
import select
import sys
readable, _, _ = select.select([self._proc.stderr], [], [], 0.5)
if readable:
err = self._proc.stderr.read(4096).decode('utf-8', errors='ignore')
if err:
print(f"ffmpeg error for {self.path.name}: {err}", file=sys.stderr)
def _read_frame_from_stream(self):
"""Read one frame from the stream.
Returns CuPy array if GPU_PERSIST is enabled, numpy array otherwise.
"""
w, h = self._frame_size
frame_size = w * h * 3
if not self._proc or self._proc.poll() is not None:
return None
data = self._proc.stdout.read(frame_size)
if len(data) < frame_size:
return None
frame = np.frombuffer(data, dtype=np.uint8).reshape((h, w, 3)).copy()
# Transfer to GPU if persistence mode enabled
if GPU_PERSIST:
return cp.asarray(frame)
return frame
def read(self) -> np.ndarray:
"""Read frame (uses last cached or t=0)."""
if self._cached_frame is not None:
return self._cached_frame
return self.read_at(0)
def read_at(self, t: float) -> np.ndarray:
"""Read frame at specific time using streaming with smart seeking."""
# Cache check - return same frame for same time
if t == self._last_read_time and self._cached_frame is not None:
return self._cached_frame
w, h = self._frame_size
# Loop time if video is shorter
seek_time = t
if self._duration and self._duration > 0:
seek_time = t % self._duration
# If we're within 0.1s of the end, wrap to beginning to avoid EOF issues
if seek_time > self._duration - 0.1:
seek_time = 0.0
# Decide whether to seek or continue streaming
# Seek if: no stream, going backwards (more than 1 frame), or jumping more than 2 seconds ahead
# Allow small backward tolerance to handle floating point and timing jitter
need_seek = (
self._proc is None or
self._proc.poll() is not None or
seek_time < self._stream_time - self._frame_time or # More than 1 frame backward
seek_time > self._stream_time + 2.0
)
if need_seek:
import sys
reason = "no proc" if self._proc is None else "proc dead" if self._proc.poll() is not None else "backward" if seek_time < self._stream_time else "jump"
print(f"SEEK {self.path.name}: t={t:.4f} seek={seek_time:.4f} stream={self._stream_time:.4f} ({reason})", file=sys.stderr)
self._start_stream(seek_time)
# Skip frames to reach target time
skip_retries = 0
while self._stream_time + self._frame_time <= seek_time:
frame = self._read_frame_from_stream()
if frame is None:
# Stream ended or failed - restart from seek point
import time
skip_retries += 1
if skip_retries > 3:
# Give up skipping, just start fresh at seek_time
self._start_stream(seek_time)
time.sleep(0.1)
break
self._start_stream(seek_time)
time.sleep(0.05)
continue
self._stream_time += self._frame_time
skip_retries = 0 # Reset on successful read
# Read the target frame with retry logic
frame = None
max_retries = 3
for attempt in range(max_retries):
frame = self._read_frame_from_stream()
if frame is not None:
break
# Stream failed - try restarting
import sys
import time
print(f"RETRY {self.path.name}: attempt {attempt+1}/{max_retries} at t={t:.2f}", file=sys.stderr)
# Check for ffmpeg errors
if self._proc and self._proc.stderr:
try:
import select
readable, _, _ = select.select([self._proc.stderr], [], [], 0.1)
if readable:
err = self._proc.stderr.read(4096).decode('utf-8', errors='ignore')
if err:
print(f"ffmpeg error: {err}", file=sys.stderr)
except:
pass
# Wait a bit and restart
time.sleep(0.1)
self._start_stream(seek_time)
# Give ffmpeg time to start
time.sleep(0.1)
if frame is None:
import sys
raise RuntimeError(f"Failed to read video frame from {self.path.name} at t={t:.2f} after {max_retries} retries")
else:
self._stream_time += self._frame_time
self._last_read_time = t
self._cached_frame = frame
return frame
def skip(self):
"""No-op for seek-based reading."""
pass
@property
def size(self):
return self._frame_size
def close(self):
if self._proc:
self._proc.kill()
self._proc = None
class PrefetchingVideoSource:
"""
Video source with background prefetching for improved performance.
Wraps VideoSource and adds a background thread that pre-decodes
upcoming frames while the main thread processes the current frame.
"""
def __init__(self, path: str, fps: float = 30, buffer_size: int = None):
self._source = VideoSource(path, fps)
self._buffer_size = buffer_size or PREFETCH_BUFFER_SIZE
self._buffer = {} # time -> frame
self._buffer_lock = threading.Lock()
self._prefetch_time = 0.0
self._frame_time = 1.0 / fps
self._stop_event = threading.Event()
self._request_event = threading.Event()
self._target_time = 0.0
# Start prefetch thread
self._thread = threading.Thread(target=self._prefetch_loop, daemon=True)
self._thread.start()
import sys
print(f"PrefetchingVideoSource: {path} buffer_size={self._buffer_size}", file=sys.stderr)
def _prefetch_loop(self):
"""Background thread that pre-reads frames."""
while not self._stop_event.is_set():
# Wait for work or timeout
self._request_event.wait(timeout=0.01)
self._request_event.clear()
if self._stop_event.is_set():
break
# Prefetch frames ahead of target time
target = self._target_time
with self._buffer_lock:
# Clean old frames (more than 1 second behind)
old_times = [t for t in self._buffer.keys() if t < target - 1.0]
for t in old_times:
del self._buffer[t]
# Count how many frames we have buffered ahead
buffered_ahead = sum(1 for t in self._buffer.keys() if t >= target)
# Prefetch if buffer not full
if buffered_ahead < self._buffer_size:
# Find next time to prefetch
prefetch_t = target
with self._buffer_lock:
existing_times = set(self._buffer.keys())
for _ in range(self._buffer_size):
if prefetch_t not in existing_times:
break
prefetch_t += self._frame_time
# Read the frame (this is the slow part)
try:
frame = self._source.read_at(prefetch_t)
with self._buffer_lock:
self._buffer[prefetch_t] = frame
except Exception as e:
import sys
print(f"Prefetch error at t={prefetch_t}: {e}", file=sys.stderr)
def read_at(self, t: float) -> np.ndarray:
"""Read frame at specific time, using prefetch buffer if available."""
self._target_time = t
self._request_event.set() # Wake up prefetch thread
# Round to frame time for buffer lookup
t_key = round(t / self._frame_time) * self._frame_time
# Check buffer first
with self._buffer_lock:
if t_key in self._buffer:
return self._buffer[t_key]
# Also check for close matches (within half frame time)
for buf_t, frame in self._buffer.items():
if abs(buf_t - t) < self._frame_time * 0.5:
return frame
# Not in buffer - read directly (blocking)
frame = self._source.read_at(t)
# Store in buffer
with self._buffer_lock:
self._buffer[t_key] = frame
return frame
def read(self) -> np.ndarray:
"""Read frame (uses last cached or t=0)."""
return self.read_at(0)
def skip(self):
"""No-op for seek-based reading."""
pass
@property
def size(self):
return self._source.size
@property
def path(self):
return self._source.path
def close(self):
self._stop_event.set()
self._request_event.set() # Wake up thread to exit
self._thread.join(timeout=1.0)
self._source.close()
class AudioAnalyzer:
"""Audio analyzer for energy and beat detection."""
def __init__(self, path: str, sample_rate: int = 22050):
self.path = Path(path)
self.sample_rate = sample_rate
# Check if file exists
if not self.path.exists():
raise FileNotFoundError(f"Audio file not found: {self.path}")
# Load audio via ffmpeg
cmd = ["ffmpeg", "-v", "error", "-i", str(self.path),
"-f", "f32le", "-ac", "1", "-ar", str(sample_rate), "-"]
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to load audio '{self.path}': {result.stderr.decode()}")
self._audio = np.frombuffer(result.stdout, dtype=np.float32)
if len(self._audio) == 0:
raise RuntimeError(f"Audio file is empty or invalid: {self.path}")
# Get duration
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", str(self.path)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to probe audio '{self.path}': {result.stderr}")
info = json.loads(result.stdout)
self.duration = float(info.get("format", {}).get("duration", 60))
# Beat detection state
self._flux_history = []
self._last_beat_time = -1
self._beat_count = 0
self._last_beat_check_time = -1
# Cache beat result for current time (so multiple scans see same result)
self._beat_cache_time = -1
self._beat_cache_result = False
def get_energy(self, t: float) -> float:
"""Get energy level at time t (0-1)."""
idx = int(t * self.sample_rate)
start = max(0, idx - 512)
end = min(len(self._audio), idx + 512)
if start >= end:
return 0.0
return min(1.0, np.sqrt(np.mean(self._audio[start:end] ** 2)) * 3.0)
def get_beat(self, t: float) -> bool:
"""Check if there's a beat at time t."""
# Return cached result if same time (multiple scans query same frame)
if t == self._beat_cache_time:
return self._beat_cache_result
idx = int(t * self.sample_rate)
size = 2048
start, end = max(0, idx - size//2), min(len(self._audio), idx + size//2)
if end - start < size/2:
self._beat_cache_time = t
self._beat_cache_result = False
return False
curr = self._audio[start:end]
pstart, pend = max(0, start - 512), max(0, end - 512)
if pend <= pstart:
self._beat_cache_time = t
self._beat_cache_result = False
return False
prev = self._audio[pstart:pend]
curr_spec = np.abs(np.fft.rfft(curr * np.hanning(len(curr))))
prev_spec = np.abs(np.fft.rfft(prev * np.hanning(len(prev))))
n = min(len(curr_spec), len(prev_spec))
flux = np.sum(np.maximum(0, curr_spec[:n] - prev_spec[:n])) / (n + 1)
self._flux_history.append((t, flux))
if len(self._flux_history) > 50:
self._flux_history = self._flux_history[-50:]
if len(self._flux_history) < 5:
self._beat_cache_time = t
self._beat_cache_result = False
return False
recent = [f for _, f in self._flux_history[-20:]]
threshold = np.mean(recent) + 1.5 * np.std(recent)
is_beat = flux > threshold and (t - self._last_beat_time) > 0.1
if is_beat:
self._last_beat_time = t
if t > self._last_beat_check_time:
self._beat_count += 1
self._last_beat_check_time = t
# Cache result for this time
self._beat_cache_time = t
self._beat_cache_result = is_beat
return is_beat
def get_beat_count(self, t: float) -> int:
"""Get cumulative beat count up to time t."""
# Ensure beat detection has run up to this time
self.get_beat(t)
return self._beat_count
# === Primitives ===
def prim_make_video_source(path: str, fps: float = 30):
"""Create a video source from a file path.
Uses PrefetchingVideoSource if STREAMING_PREFETCH=1 (default).
"""
if PREFETCH_ENABLED:
return PrefetchingVideoSource(path, fps)
return VideoSource(path, fps)
def prim_source_read(source: VideoSource, t: float = None):
"""Read a frame from a video source."""
import sys
if t is not None:
frame = source.read_at(t)
# Debug: show source and time
if int(t * 10) % 10 == 0: # Every second
print(f"READ {source.path.name}: t={t:.2f} stream={source._stream_time:.2f}", file=sys.stderr)
return frame
return source.read()
def prim_source_skip(source: VideoSource):
"""Skip a frame (keep pipe in sync)."""
source.skip()
def prim_source_size(source: VideoSource):
"""Get (width, height) of source."""
return source.size
def prim_make_audio_analyzer(path: str):
"""Create an audio analyzer from a file path."""
return AudioAnalyzer(path)
def prim_audio_energy(analyzer: AudioAnalyzer, t: float) -> float:
"""Get energy level (0-1) at time t."""
return analyzer.get_energy(t)
def prim_audio_beat(analyzer: AudioAnalyzer, t: float) -> bool:
"""Check if there's a beat at time t."""
return analyzer.get_beat(t)
def prim_audio_beat_count(analyzer: AudioAnalyzer, t: float) -> int:
"""Get cumulative beat count up to time t."""
return analyzer.get_beat_count(t)
def prim_audio_duration(analyzer: AudioAnalyzer) -> float:
"""Get audio duration in seconds."""
return analyzer.duration
# Export primitives
PRIMITIVES = {
# Video source
'make-video-source': prim_make_video_source,
'source-read': prim_source_read,
'source-skip': prim_source_skip,
'source-size': prim_source_size,
# Audio analyzer
'make-audio-analyzer': prim_make_audio_analyzer,
'audio-energy': prim_audio_energy,
'audio-beat': prim_audio_beat,
'audio-beat-count': prim_audio_beat_count,
'audio-duration': prim_audio_duration,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
#!/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 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")
print("=" * 60)
test_parser()
test_interpreter_basics()
test_primitives()
test_effect_loading()
test_ascii_fx_zone()
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()

View File

@@ -0,0 +1,715 @@
"""
S-Expression to WGSL Compiler
Compiles sexp effect definitions to WGSL compute shaders for GPU execution.
The compilation happens at effect upload time (AOT), not at runtime.
Architecture:
- Parse sexp AST
- Analyze primitives used
- Generate WGSL compute shader
Shader Categories:
1. Per-pixel ops: brightness, invert, grayscale, sepia (1 thread per pixel)
2. Geometric transforms: rotate, scale, wave, ripple (coordinate remap + sample)
3. Neighborhood ops: blur, sharpen, edge detect (sample neighbors)
"""
from typing import Dict, List, Any, Optional, Tuple, Set
from dataclasses import dataclass, field
from pathlib import Path
import math
from .parser import parse, parse_all, Symbol, Keyword
@dataclass
class WGSLParam:
"""A shader parameter (uniform)."""
name: str
wgsl_type: str # f32, i32, u32, vec2f, etc.
default: Any
@dataclass
class CompiledEffect:
"""Result of compiling an sexp effect to WGSL."""
name: str
wgsl_code: str
params: List[WGSLParam]
workgroup_size: Tuple[int, int, int] = (16, 16, 1)
# Metadata for runtime
uses_time: bool = False
uses_sampling: bool = False # Needs texture sampler
category: str = "per_pixel" # per_pixel, geometric, neighborhood
@dataclass
class CompilerContext:
"""Context during compilation."""
effect_name: str = ""
params: Dict[str, WGSLParam] = field(default_factory=dict)
locals: Dict[str, str] = field(default_factory=dict) # local var -> wgsl expr
required_libs: Set[str] = field(default_factory=set)
uses_time: bool = False
uses_sampling: bool = False
temp_counter: int = 0
def fresh_temp(self) -> str:
"""Generate a fresh temporary variable name."""
self.temp_counter += 1
return f"_t{self.temp_counter}"
class SexpToWGSLCompiler:
"""
Compiles S-expression effect definitions to WGSL compute shaders.
"""
# Map sexp types to WGSL types
TYPE_MAP = {
'int': 'i32',
'float': 'f32',
'bool': 'u32', # WGSL doesn't have bool in storage
'string': None, # Strings handled specially
}
# Per-pixel primitives that can be compiled directly
PER_PIXEL_PRIMITIVES = {
'color_ops:invert-img',
'color_ops:grayscale',
'color_ops:sepia',
'color_ops:adjust',
'color_ops:adjust-brightness',
'color_ops:shift-hsv',
'color_ops:quantize',
}
# Geometric primitives (coordinate remapping)
GEOMETRIC_PRIMITIVES = {
'geometry:scale-img',
'geometry:rotate-img',
'geometry:translate',
'geometry:flip-h',
'geometry:flip-v',
'geometry:remap',
}
def __init__(self):
self.ctx: Optional[CompilerContext] = None
def compile_file(self, path: str) -> CompiledEffect:
"""Compile an effect from a .sexp file."""
with open(path, 'r') as f:
content = f.read()
exprs = parse_all(content)
return self.compile(exprs)
def compile_string(self, sexp_code: str) -> CompiledEffect:
"""Compile an effect from an sexp string."""
exprs = parse_all(sexp_code)
return self.compile(exprs)
def compile(self, expr: Any) -> CompiledEffect:
"""Compile a parsed sexp expression."""
self.ctx = CompilerContext()
# Handle multiple top-level expressions (require-primitives, define-effect)
if isinstance(expr, list) and expr and isinstance(expr[0], list):
for e in expr:
self._process_toplevel(e)
else:
self._process_toplevel(expr)
# Generate the WGSL shader
wgsl = self._generate_wgsl()
# Determine category based on primitives used
category = self._determine_category()
return CompiledEffect(
name=self.ctx.effect_name,
wgsl_code=wgsl,
params=list(self.ctx.params.values()),
uses_time=self.ctx.uses_time,
uses_sampling=self.ctx.uses_sampling,
category=category,
)
def _process_toplevel(self, expr: Any):
"""Process a top-level expression."""
if not isinstance(expr, list) or not expr:
return
head = expr[0]
if isinstance(head, Symbol):
if head.name == 'require-primitives':
# Track required primitive libraries
for lib in expr[1:]:
lib_name = lib.name if isinstance(lib, Symbol) else str(lib)
self.ctx.required_libs.add(lib_name)
elif head.name == 'define-effect':
self._compile_effect_def(expr)
def _compile_effect_def(self, expr: list):
"""Compile a define-effect form."""
# (define-effect name :params (...) body)
self.ctx.effect_name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
# Parse :params and body
i = 2
body = None
while i < len(expr):
item = expr[i]
if isinstance(item, Keyword) and item.name == 'params':
self._parse_params(expr[i + 1])
i += 2
elif isinstance(item, Keyword):
i += 2 # Skip other keywords
else:
body = item
i += 1
if body:
self.ctx.body_expr = body
def _parse_params(self, params_list: list):
"""Parse the :params block."""
for param_def in params_list:
if not isinstance(param_def, list):
continue
name = param_def[0].name if isinstance(param_def[0], Symbol) else str(param_def[0])
# Parse keyword args
param_type = 'float'
default = 0
i = 1
while i < len(param_def):
item = param_def[i]
if isinstance(item, Keyword):
if i + 1 < len(param_def):
val = param_def[i + 1]
if item.name == 'type':
param_type = val.name if isinstance(val, Symbol) else str(val)
elif item.name == 'default':
default = val
i += 2
else:
i += 1
wgsl_type = self.TYPE_MAP.get(param_type, 'f32')
if wgsl_type:
self.ctx.params[name] = WGSLParam(name, wgsl_type, default)
def _determine_category(self) -> str:
"""Determine shader category based on primitives used."""
for lib in self.ctx.required_libs:
if lib == 'geometry':
return 'geometric'
if lib == 'filters':
return 'neighborhood'
return 'per_pixel'
def _generate_wgsl(self) -> str:
"""Generate the complete WGSL shader code."""
lines = []
# Header comment
lines.append(f"// WGSL Shader: {self.ctx.effect_name}")
lines.append(f"// Auto-generated from sexp effect definition")
lines.append("")
# Bindings
lines.append("@group(0) @binding(0) var<storage, read> input: array<u32>;")
lines.append("@group(0) @binding(1) var<storage, read_write> output: array<u32>;")
lines.append("")
# Params struct
if self.ctx.params:
lines.append("struct Params {")
lines.append(" width: u32,")
lines.append(" height: u32,")
lines.append(" time: f32,")
for param in self.ctx.params.values():
lines.append(f" {param.name}: {param.wgsl_type},")
lines.append("}")
lines.append("@group(0) @binding(2) var<uniform> params: Params;")
else:
lines.append("struct Params {")
lines.append(" width: u32,")
lines.append(" height: u32,")
lines.append(" time: f32,")
lines.append("}")
lines.append("@group(0) @binding(2) var<uniform> params: Params;")
lines.append("")
# Helper functions
lines.extend(self._generate_helpers())
lines.append("")
# Main compute shader
lines.append("@compute @workgroup_size(16, 16, 1)")
lines.append("fn main(@builtin(global_invocation_id) gid: vec3<u32>) {")
lines.append(" let x = gid.x;")
lines.append(" let y = gid.y;")
lines.append(" if (x >= params.width || y >= params.height) { return; }")
lines.append(" let idx = y * params.width + x;")
lines.append("")
# Compile the effect body
body_code = self._compile_expr(self.ctx.body_expr)
lines.append(f" // Effect: {self.ctx.effect_name}")
lines.append(body_code)
lines.append("}")
return "\n".join(lines)
def _generate_helpers(self) -> List[str]:
"""Generate WGSL helper functions."""
helpers = []
# Pack/unpack RGB from u32
helpers.append("fn unpack_rgb(packed: u32) -> vec3<f32> {")
helpers.append(" let r = f32((packed >> 16u) & 0xFFu) / 255.0;")
helpers.append(" let g = f32((packed >> 8u) & 0xFFu) / 255.0;")
helpers.append(" let b = f32(packed & 0xFFu) / 255.0;")
helpers.append(" return vec3<f32>(r, g, b);")
helpers.append("}")
helpers.append("")
helpers.append("fn pack_rgb(rgb: vec3<f32>) -> u32 {")
helpers.append(" let r = u32(clamp(rgb.r, 0.0, 1.0) * 255.0);")
helpers.append(" let g = u32(clamp(rgb.g, 0.0, 1.0) * 255.0);")
helpers.append(" let b = u32(clamp(rgb.b, 0.0, 1.0) * 255.0);")
helpers.append(" return (r << 16u) | (g << 8u) | b;")
helpers.append("}")
helpers.append("")
# Bilinear sampling for geometric transforms
if self.ctx.uses_sampling or 'geometry' in self.ctx.required_libs:
helpers.append("fn sample_bilinear(sx: f32, sy: f32) -> vec3<f32> {")
helpers.append(" let w = f32(params.width);")
helpers.append(" let h = f32(params.height);")
helpers.append(" let cx = clamp(sx, 0.0, w - 1.001);")
helpers.append(" let cy = clamp(sy, 0.0, h - 1.001);")
helpers.append(" let x0 = u32(cx);")
helpers.append(" let y0 = u32(cy);")
helpers.append(" let x1 = min(x0 + 1u, params.width - 1u);")
helpers.append(" let y1 = min(y0 + 1u, params.height - 1u);")
helpers.append(" let fx = cx - f32(x0);")
helpers.append(" let fy = cy - f32(y0);")
helpers.append(" let c00 = unpack_rgb(input[y0 * params.width + x0]);")
helpers.append(" let c10 = unpack_rgb(input[y0 * params.width + x1]);")
helpers.append(" let c01 = unpack_rgb(input[y1 * params.width + x0]);")
helpers.append(" let c11 = unpack_rgb(input[y1 * params.width + x1]);")
helpers.append(" let top = mix(c00, c10, fx);")
helpers.append(" let bot = mix(c01, c11, fx);")
helpers.append(" return mix(top, bot, fy);")
helpers.append("}")
helpers.append("")
# HSV conversion for color effects
if 'color_ops' in self.ctx.required_libs or 'color' in self.ctx.required_libs:
helpers.append("fn rgb_to_hsv(rgb: vec3<f32>) -> vec3<f32> {")
helpers.append(" let mx = max(max(rgb.r, rgb.g), rgb.b);")
helpers.append(" let mn = min(min(rgb.r, rgb.g), rgb.b);")
helpers.append(" let d = mx - mn;")
helpers.append(" var h = 0.0;")
helpers.append(" if (d > 0.0) {")
helpers.append(" if (mx == rgb.r) { h = (rgb.g - rgb.b) / d; }")
helpers.append(" else if (mx == rgb.g) { h = 2.0 + (rgb.b - rgb.r) / d; }")
helpers.append(" else { h = 4.0 + (rgb.r - rgb.g) / d; }")
helpers.append(" h = h / 6.0;")
helpers.append(" if (h < 0.0) { h = h + 1.0; }")
helpers.append(" }")
helpers.append(" let s = select(0.0, d / mx, mx > 0.0);")
helpers.append(" return vec3<f32>(h, s, mx);")
helpers.append("}")
helpers.append("")
helpers.append("fn hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {")
helpers.append(" let h = hsv.x * 6.0;")
helpers.append(" let s = hsv.y;")
helpers.append(" let v = hsv.z;")
helpers.append(" let c = v * s;")
helpers.append(" let x = c * (1.0 - abs(h % 2.0 - 1.0));")
helpers.append(" let m = v - c;")
helpers.append(" var rgb: vec3<f32>;")
helpers.append(" if (h < 1.0) { rgb = vec3<f32>(c, x, 0.0); }")
helpers.append(" else if (h < 2.0) { rgb = vec3<f32>(x, c, 0.0); }")
helpers.append(" else if (h < 3.0) { rgb = vec3<f32>(0.0, c, x); }")
helpers.append(" else if (h < 4.0) { rgb = vec3<f32>(0.0, x, c); }")
helpers.append(" else if (h < 5.0) { rgb = vec3<f32>(x, 0.0, c); }")
helpers.append(" else { rgb = vec3<f32>(c, 0.0, x); }")
helpers.append(" return rgb + vec3<f32>(m, m, m);")
helpers.append("}")
helpers.append("")
return helpers
def _compile_expr(self, expr: Any, indent: int = 4) -> str:
"""Compile an sexp expression to WGSL code."""
ind = " " * indent
# Literals
if isinstance(expr, (int, float)):
return f"{ind}// literal: {expr}"
if isinstance(expr, str):
return f'{ind}// string: "{expr}"'
# Symbol reference
if isinstance(expr, Symbol):
name = expr.name
if name == 'frame':
return f"{ind}let rgb = unpack_rgb(input[idx]);"
if name == 't' or name == '_time':
self.ctx.uses_time = True
return f"{ind}let t = params.time;"
if name in self.ctx.params:
return f"{ind}let {name} = params.{name};"
if name in self.ctx.locals:
return f"{ind}// local: {name}"
return f"{ind}// unknown symbol: {name}"
# List (function call or special form)
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol):
form = head.name
# Special forms
if form == 'let' or form == 'let*':
return self._compile_let(expr, indent)
if form == 'if':
return self._compile_if(expr, indent)
if form == 'or':
# (or a b) - return a if truthy, else b
return self._compile_or(expr, indent)
# Primitive calls
if ':' in form:
return self._compile_primitive_call(expr, indent)
# Arithmetic
if form in ('+', '-', '*', '/'):
return self._compile_arithmetic(expr, indent)
if form in ('>', '<', '>=', '<=', '='):
return self._compile_comparison(expr, indent)
if form == 'max':
return self._compile_builtin('max', expr[1:], indent)
if form == 'min':
return self._compile_builtin('min', expr[1:], indent)
return f"{ind}// unhandled: {expr}"
def _compile_let(self, expr: list, indent: int) -> str:
"""Compile let/let* binding form."""
ind = " " * indent
lines = []
bindings = expr[1]
body = expr[2]
# Parse bindings (Clojure style: [x 1 y 2] or Scheme style: ((x 1) (y 2)))
pairs = []
if bindings and isinstance(bindings[0], Symbol):
# Clojure style
i = 0
while i < len(bindings) - 1:
name = bindings[i].name if isinstance(bindings[i], Symbol) else str(bindings[i])
value = bindings[i + 1]
pairs.append((name, value))
i += 2
else:
# Scheme style
for binding in bindings:
name = binding[0].name if isinstance(binding[0], Symbol) else str(binding[0])
value = binding[1]
pairs.append((name, value))
# Compile bindings
for name, value in pairs:
val_code = self._expr_to_wgsl(value)
lines.append(f"{ind}let {name} = {val_code};")
self.ctx.locals[name] = val_code
# Compile body
body_lines = self._compile_body(body, indent)
lines.append(body_lines)
return "\n".join(lines)
def _compile_body(self, body: Any, indent: int) -> str:
"""Compile the body of an effect (the final image expression)."""
ind = " " * indent
# Most effects end with a primitive call that produces the output
if isinstance(body, list) and body:
head = body[0]
if isinstance(head, Symbol) and ':' in head.name:
return self._compile_primitive_call(body, indent)
# If body is just 'frame', pass through
if isinstance(body, Symbol) and body.name == 'frame':
return f"{ind}output[idx] = input[idx];"
return f"{ind}// body: {body}"
def _compile_primitive_call(self, expr: list, indent: int) -> str:
"""Compile a primitive function call."""
ind = " " * indent
head = expr[0]
prim_name = head.name if isinstance(head, Symbol) else str(head)
args = expr[1:]
# Per-pixel color operations
if prim_name == 'color_ops:invert-img':
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}let result = vec3<f32>(1.0, 1.0, 1.0) - rgb;
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'color_ops:grayscale':
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}let gray = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b;
{ind}let result = vec3<f32>(gray, gray, gray);
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'color_ops:adjust-brightness':
amount = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}let adj = f32({amount}) / 255.0;
{ind}let result = clamp(rgb + vec3<f32>(adj, adj, adj), vec3<f32>(0.0, 0.0, 0.0), vec3<f32>(1.0, 1.0, 1.0));
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'color_ops:adjust':
# (adjust img brightness contrast)
brightness = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
contrast = self._expr_to_wgsl(args[2]) if len(args) > 2 else "1.0"
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}let centered = rgb - vec3<f32>(0.5, 0.5, 0.5);
{ind}let contrasted = centered * {contrast};
{ind}let brightened = contrasted + vec3<f32>(0.5, 0.5, 0.5) + vec3<f32>({brightness}/255.0);
{ind}let result = clamp(brightened, vec3<f32>(0.0), vec3<f32>(1.0));
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'color_ops:sepia':
intensity = self._expr_to_wgsl(args[1]) if len(args) > 1 else "1.0"
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}let sepia_r = 0.393 * rgb.r + 0.769 * rgb.g + 0.189 * rgb.b;
{ind}let sepia_g = 0.349 * rgb.r + 0.686 * rgb.g + 0.168 * rgb.b;
{ind}let sepia_b = 0.272 * rgb.r + 0.534 * rgb.g + 0.131 * rgb.b;
{ind}let sepia = vec3<f32>(sepia_r, sepia_g, sepia_b);
{ind}let result = mix(rgb, sepia, {intensity});
{ind}output[idx] = pack_rgb(clamp(result, vec3<f32>(0.0), vec3<f32>(1.0)));"""
if prim_name == 'color_ops:shift-hsv':
h_shift = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
s_mult = self._expr_to_wgsl(args[2]) if len(args) > 2 else "1.0"
v_mult = self._expr_to_wgsl(args[3]) if len(args) > 3 else "1.0"
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}var hsv = rgb_to_hsv(rgb);
{ind}hsv.x = fract(hsv.x + {h_shift} / 360.0);
{ind}hsv.y = clamp(hsv.y * {s_mult}, 0.0, 1.0);
{ind}hsv.z = clamp(hsv.z * {v_mult}, 0.0, 1.0);
{ind}let result = hsv_to_rgb(hsv);
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'color_ops:quantize':
levels = self._expr_to_wgsl(args[1]) if len(args) > 1 else "8.0"
return f"""{ind}let rgb = unpack_rgb(input[idx]);
{ind}let lvl = max(2.0, {levels});
{ind}let result = floor(rgb * lvl) / lvl;
{ind}output[idx] = pack_rgb(result);"""
# Geometric transforms
if prim_name == 'geometry:scale-img':
sx = self._expr_to_wgsl(args[1]) if len(args) > 1 else "1.0"
sy = self._expr_to_wgsl(args[2]) if len(args) > 2 else sx
self.ctx.uses_sampling = True
return f"""{ind}let w = f32(params.width);
{ind}let h = f32(params.height);
{ind}let cx = w / 2.0;
{ind}let cy = h / 2.0;
{ind}let sx = f32(x) - cx;
{ind}let sy = f32(y) - cy;
{ind}let src_x = sx / {sx} + cx;
{ind}let src_y = sy / {sy} + cy;
{ind}let result = sample_bilinear(src_x, src_y);
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'geometry:rotate-img':
angle = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
self.ctx.uses_sampling = True
return f"""{ind}let w = f32(params.width);
{ind}let h = f32(params.height);
{ind}let cx = w / 2.0;
{ind}let cy = h / 2.0;
{ind}let angle_rad = {angle} * 3.14159265 / 180.0;
{ind}let cos_a = cos(-angle_rad);
{ind}let sin_a = sin(-angle_rad);
{ind}let dx = f32(x) - cx;
{ind}let dy = f32(y) - cy;
{ind}let src_x = dx * cos_a - dy * sin_a + cx;
{ind}let src_y = dx * sin_a + dy * cos_a + cy;
{ind}let result = sample_bilinear(src_x, src_y);
{ind}output[idx] = pack_rgb(result);"""
if prim_name == 'geometry:flip-h':
return f"""{ind}let src_idx = y * params.width + (params.width - 1u - x);
{ind}output[idx] = input[src_idx];"""
if prim_name == 'geometry:flip-v':
return f"""{ind}let src_idx = (params.height - 1u - y) * params.width + x;
{ind}output[idx] = input[src_idx];"""
# Image library
if prim_name == 'image:blur':
radius = self._expr_to_wgsl(args[1]) if len(args) > 1 else "5"
# Box blur approximation (separable would be better)
return f"""{ind}let radius = i32({radius});
{ind}var sum = vec3<f32>(0.0, 0.0, 0.0);
{ind}var count = 0.0;
{ind}for (var dy = -radius; dy <= radius; dy = dy + 1) {{
{ind} for (var dx = -radius; dx <= radius; dx = dx + 1) {{
{ind} let sx = i32(x) + dx;
{ind} let sy = i32(y) + dy;
{ind} if (sx >= 0 && sx < i32(params.width) && sy >= 0 && sy < i32(params.height)) {{
{ind} let sidx = u32(sy) * params.width + u32(sx);
{ind} sum = sum + unpack_rgb(input[sidx]);
{ind} count = count + 1.0;
{ind} }}
{ind} }}
{ind}}}
{ind}let result = sum / count;
{ind}output[idx] = pack_rgb(result);"""
# Fallback - passthrough
return f"""{ind}// Unimplemented primitive: {prim_name}
{ind}output[idx] = input[idx];"""
def _compile_if(self, expr: list, indent: int) -> str:
"""Compile if expression."""
ind = " " * indent
cond = self._expr_to_wgsl(expr[1])
then_expr = expr[2]
else_expr = expr[3] if len(expr) > 3 else None
lines = []
lines.append(f"{ind}if ({cond}) {{")
lines.append(self._compile_body(then_expr, indent + 4))
if else_expr:
lines.append(f"{ind}}} else {{")
lines.append(self._compile_body(else_expr, indent + 4))
lines.append(f"{ind}}}")
return "\n".join(lines)
def _compile_or(self, expr: list, indent: int) -> str:
"""Compile or expression - returns first truthy value."""
# For numeric context, (or a b) means "a if a != 0 else b"
a = self._expr_to_wgsl(expr[1])
b = self._expr_to_wgsl(expr[2]) if len(expr) > 2 else "0.0"
return f"select({b}, {a}, {a} != 0.0)"
def _compile_arithmetic(self, expr: list, indent: int) -> str:
"""Compile arithmetic expression to inline WGSL."""
op = expr[0].name
operands = [self._expr_to_wgsl(arg) for arg in expr[1:]]
if len(operands) == 1:
if op == '-':
return f"(-{operands[0]})"
return operands[0]
return f"({f' {op} '.join(operands)})"
def _compile_comparison(self, expr: list, indent: int) -> str:
"""Compile comparison expression."""
op = expr[0].name
if op == '=':
op = '=='
a = self._expr_to_wgsl(expr[1])
b = self._expr_to_wgsl(expr[2])
return f"({a} {op} {b})"
def _compile_builtin(self, fn: str, args: list, indent: int) -> str:
"""Compile builtin function call."""
compiled_args = [self._expr_to_wgsl(arg) for arg in args]
return f"{fn}({', '.join(compiled_args)})"
def _expr_to_wgsl(self, expr: Any) -> str:
"""Convert an expression to inline WGSL code."""
if isinstance(expr, (int, float)):
# Ensure floats have decimal point
if isinstance(expr, float) or '.' not in str(expr):
return f"{float(expr)}"
return str(expr)
if isinstance(expr, str):
return f'"{expr}"'
if isinstance(expr, Symbol):
name = expr.name
if name == 'frame':
return "rgb" # Assume rgb is already loaded
if name == 't' or name == '_time':
self.ctx.uses_time = True
return "params.time"
if name == 'pi':
return "3.14159265"
if name in self.ctx.params:
return f"params.{name}"
if name in self.ctx.locals:
return name
return name
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol):
form = head.name
# Arithmetic
if form in ('+', '-', '*', '/'):
return self._compile_arithmetic(expr, 0)
# Comparison
if form in ('>', '<', '>=', '<=', '='):
return self._compile_comparison(expr, 0)
# Builtins
if form in ('max', 'min', 'abs', 'floor', 'ceil', 'sin', 'cos', 'sqrt'):
args = [self._expr_to_wgsl(a) for a in expr[1:]]
return f"{form}({', '.join(args)})"
if form == 'or':
return self._compile_or(expr, 0)
# Image dimension queries
if form == 'image:width':
return "f32(params.width)"
if form == 'image:height':
return "f32(params.height)"
return f"/* unknown: {expr} */"
def compile_effect(sexp_code: str) -> CompiledEffect:
"""Convenience function to compile an sexp effect string."""
compiler = SexpToWGSLCompiler()
return compiler.compile_string(sexp_code)
def compile_effect_file(path: str) -> CompiledEffect:
"""Convenience function to compile an sexp effect file."""
compiler = SexpToWGSLCompiler()
return compiler.compile_file(path)