Add generic streaming interpreter with configurable sources/audio

- Add stream_sexp_generic.py: fully generic sexp interpreter
- Add streaming primitives for video sources and audio analysis
- Add config system for external sources and audio files
- Add templates for reusable scans and macros
- Fix video/audio stream mapping in file output
- Add dynamic source cycling based on sources array length
- Remove old Python effect files (migrated to sexp)
- Update sexp effects to use namespaced primitives

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-02-02 17:48:04 +00:00
parent d241e2a663
commit 95fcc67dcc
179 changed files with 3935 additions and 8226 deletions

View File

@@ -1,56 +1,31 @@
;; Blend effect - combines two video streams
;; Multi-input effect: uses frame-a and frame-b
;; 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)
;; resize_mode - how to resize frame-b to match frame-a (fit, crop, stretch)
;; priority - which dimension takes priority (width, height)
;; pad_color - color for padding in fit mode [r g b]
(require-primitives "image" "blending")
(require-primitives "image" "blending" "core")
(define-effect blend
:params (
(mode :type string :default "overlay")
(overlay :type frame :default nil)
(mode :type string :default "alpha")
(opacity :type float :default 0.5)
(resize_mode :type string :default "fit")
(priority :type string :default "width")
(pad_color :type list :default (quote [0 0 0]))
)
(let [a frame-a
a-w (width a)
a-h (height a)
b-raw frame-b
b-w (width b-raw)
b-h (height b-raw)
;; Calculate scale based on resize mode and priority
scale-w (/ a-w b-w)
scale-h (/ a-h b-h)
scale (if (= resize_mode "stretch")
1 ;; Will use explicit dimensions
(if (= resize_mode "crop")
(max scale-w scale-h) ;; Scale to cover, then crop
(if (= priority "width")
scale-w
scale-h)))
;; For stretch, use target dimensions directly
new-w (if (= resize_mode "stretch") a-w (round (* b-w scale)))
new-h (if (= resize_mode "stretch") a-h (round (* b-h scale)))
;; Resize b
b-resized (resize b-raw new-w new-h "linear")
;; Handle fit (pad) or crop to exact size
b (if (= resize_mode "crop")
;; Crop to center
(let [cx (/ (- new-w a-w) 2)
cy (/ (- new-h a-h) 2)]
(crop b-resized cx cy a-w a-h))
(if (and (= resize_mode "fit") (or (!= new-w a-w) (!= new-h a-h)))
;; Pad to center
(let [pad-x (/ (- a-w new-w) 2)
pad-y (/ (- a-h new-h) 2)
canvas (make-image a-w a-h pad_color)]
(paste canvas b-resized pad-x pad-y))
b-resized))]
(if (= mode "alpha")
(blend-images a b opacity)
(blend-images a (blend-mode a b mode) opacity))))
(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

@@ -1,9 +1,9 @@
;; N-way weighted blend effect
;;
;; Takes N input frames via `inputs` and N per-frame weights.
;; Produces a single frame: the normalised weighted composite.
;; 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)
@@ -30,14 +30,15 @@
(define-effect blend_multi
:params (
(weights :type list :default (quote []))
(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 (width (nth inputs 0))
target-h (height (nth inputs 0))
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))
@@ -48,10 +49,10 @@
w (nth weights i)
new-running (+ running w)
opacity (/ w (max new-running 0.001))
f (resize (nth inputs i) target-w target-h "linear")
f (image:resize (nth inputs i) target-w target-h "linear")
;; Apply blend mode then mix with opacity
blended (if (= mode "alpha")
(blend-images acc f opacity)
(blend-images acc (blend-mode acc f mode) opacity))]
(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

@@ -1,4 +1,5 @@
;; Bloom effect - glow on bright areas
(require-primitives "image" "blending")
(define-effect bloom
:params (
@@ -11,5 +12,5 @@
(if (> (luminance c) threshold)
c
(rgb 0 0 0)))))
(blurred (blur bright radius)))
(blend-mode frame blurred "add")))
(blurred (image:blur bright radius)))
(blending:blend-mode frame blurred "add")))

View File

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

View File

@@ -1,10 +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])
)
(adjust frame amount 1))
(color_ops:adjust-brightness frame amount))

View File

@@ -1,4 +1,5 @@
;; Color adjustment effect - replaces TRANSFORM node
(require-primitives "color_ops")
(define-effect color-adjust
:params (
@@ -7,5 +8,6 @@
(saturation :type float :default 1 :range [0 2] :desc "Saturation multiplier")
)
(-> frame
(adjust :brightness brightness :contrast contrast)
(shift-hsv :s saturation)))
(color_ops:adjust-brightness brightness)
(color_ops:adjust-contrast contrast)
(color_ops:adjust-saturation saturation)))

View File

@@ -1,4 +1,5 @@
;; Color Cycle effect - animated hue rotation
(require-primitives "color_ops")
(define-effect color_cycle
:params (

View File

@@ -1,10 +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])
)
(adjust frame 0 amount))
(color_ops:adjust-contrast frame amount))

View File

@@ -1,4 +1,5 @@
;; CRT effect - old monitor simulation
(require-primitives "image")
(define-effect crt
:params (
@@ -6,8 +7,8 @@
(line_opacity :type float :default 0.3 :range [0 1])
(vignette_amount :type float :default 0.2)
)
(let* ((w (width frame))
(h (height frame))
(let* ((w (image:width frame))
(h (image:height frame))
(cx (/ w 2))
(cy (/ h 2))
(max-dist (sqrt (+ (* cx cx) (* cy cy)))))

View File

@@ -1,4 +1,5 @@
;; Echo effect - motion trails using frame buffer
(require-primitives "blending")
(define-effect echo
:params (
@@ -15,4 +16,4 @@
(let ((result (copy frame)))
;; Simple blend of first two frames for now
;; Full version would fold over all frames
(blend-images frame (nth new-buffer 1) (* decay 0.5)))))))
(blending:blend-images frame (nth new-buffer 1) (* decay 0.5)))))))

View File

@@ -1,8 +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])
)
(edges frame low high))
(image:edge-detect frame low high))

View File

@@ -1,4 +1,5 @@
;; Emboss effect - creates raised/3D appearance
(require-primitives "blending")
(define-effect emboss
:params (
@@ -9,4 +10,4 @@
(list (- strength) 1 strength)
(list 0 strength strength)))
(embossed (convolve frame kernel)))
(blend-images embossed frame blend)))
(blending:blend-images embossed frame blend)))

View File

@@ -1,4 +1,5 @@
;; Film Grain effect - adds film grain texture
(require-primitives "core")
(define-effect film_grain
:params (

View File

@@ -1,4 +1,5 @@
;; Fisheye effect - barrel/pincushion lens distortion
(require-primitives "geometry" "image")
(define-effect fisheye
:params (
@@ -7,9 +8,9 @@
(center_y :type float :default 0.5 :range [0 1])
(zoom_correct :type bool :default true)
)
(let* ((w (width frame))
(h (height frame))
(let* ((w (image:width frame))
(h (image:height frame))
(cx (* w center_x))
(cy (* h center_y))
(coords (fisheye-displace w h strength cx cy zoom_correct)))
(remap frame (coords-x coords) (coords-y coords))))
(coords (geometry:fisheye-coords w h strength cx cy zoom_correct)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -1,4 +1,5 @@
;; Flip effect - flips image horizontally or vertically
(require-primitives "geometry")
(define-effect flip
:params (
@@ -7,9 +8,9 @@
)
(let ((result frame))
(if horizontal
(set! result (flip-h result))
(set! result (geometry:flip-img result "horizontal"))
nil)
(if vertical
(set! result (flip-v result))
(set! result (geometry:flip-img result "vertical"))
nil)
result))

View File

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

View File

@@ -9,4 +9,4 @@
(speed :type int :default 0 :desc "rotation per second")
)
(let ((shift (+ degrees (* speed t))))
(shift-hsv frame shift 1 1)))
(color_ops:shift-hsv frame shift 1 1)))

View File

@@ -6,4 +6,4 @@
(define-effect invert
:params ((amount :type float :default 1 :range [0 1]))
(if (> amount 0.5) (invert-img frame) frame))
(if (> amount 0.5) (color_ops:invert-img frame) frame))

View File

@@ -1,4 +1,5 @@
;; Kaleidoscope effect - mandala-like symmetry patterns
(require-primitives "geometry" "image")
(define-effect kaleidoscope
:params (
@@ -9,11 +10,11 @@
(center_y :type float :default 0.5 :range [0 1])
(zoom :type int :default 1 :range [0.5 3])
)
(let* ((w (width frame))
(h (height frame))
(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 (kaleidoscope-displace w h segments total_rot cx cy zoom)))
(remap frame (coords-x coords) (coords-y coords))))
(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

@@ -1,28 +1,36 @@
;; Layer effect - composite frame-b over frame-a at position
;; Multi-input effect: uses frame-a (background) and frame-b (overlay)
;; Params: x, y (position), opacity (0-1), mode (blend mode)
;; 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")
(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")
)
(let [bg (copy frame-a)
fg frame-b
;; Resize fg if needed to fit
fg-w (width fg)
fg-h (height fg)]
(if (= opacity 1.0)
;; Simple paste
(paste bg fg x y)
;; Blend with opacity
(let [blended (if (= mode "alpha")
(blend-images (crop bg x y fg-w fg-h) fg opacity)
(blend-images (crop bg x y fg-w fg-h)
(blend-mode (crop bg x y fg-w fg-h) fg mode)
opacity))]
(paste bg blended x y)))))
(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

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

View File

@@ -1,4 +1,5 @@
;; Neon Glow effect - glowing edge effect
(require-primitives "image" "blending")
(define-effect neon_glow
:params (
@@ -8,15 +9,15 @@
(glow_intensity :type int :default 2 :range [0.5 5])
(background :type float :default 0.3 :range [0 1])
)
(let* ((edge-img (edges frame edge_low edge_high))
(glow (blur edge-img glow_radius))
(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))))))
(blend-mode (blend-images frame (make-image (width frame) (height frame) (list 0 0 0))
(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

@@ -1,4 +1,5 @@
;; Outline effect - shows only edges
(require-primitives "image")
(define-effect outline
:params (
@@ -7,14 +8,14 @@
(color :type list :default (list 0 0 0)
)
(fill_mode "original"))
(let* ((edge-img (edges frame (/ threshold 2) threshold))
(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 (width frame) (height frame) (list 255 255 255)))
(else (make-image (width frame) (height frame) (list 0 0 0))))))
((= 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))))

View File

@@ -1,12 +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 (width frame))
(h (height frame))
(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 (resize frame small-w small-h "area")))
(resize small w h "nearest")))
(small (image:resize frame small-w small-h "area")))
(image:resize small w h "nearest")))

View File

@@ -1,12 +1,8 @@
;; Posterize effect - reduces color levels
(require-primitives "color_ops")
(define-effect posterize
:params (
(levels :type int :default 8 :range [2 32])
)
(let ((step (floor (/ 256 levels))))
(map-pixels frame
(lambda (x y c)
(rgb (* (floor (/ (red c) step)) step)
(* (floor (/ (green c) step)) step)
(* (floor (/ (blue c) step)) step))))))
(color_ops:posterize frame levels))

View File

@@ -1,5 +1,6 @@
;; 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 (
@@ -7,4 +8,4 @@
(target-h :type int :default 480 :desc "Target height in pixels")
(mode :type string :default "linear" :choices [linear nearest area] :desc "Interpolation mode")
)
(resize frame target-w target-h mode))
(image:resize frame target-w target-h mode))

View File

@@ -10,10 +10,10 @@
(decay :type int :default 1 :range [0 5])
(speed :type int :default 1 :range [0 10])
)
(let* ((w (width frame))
(h (height frame))
(let* ((w (image:width frame))
(h (image:height frame))
(cx (* w center_x))
(cy (* h center_y))
(phase (* (or _time 0) speed 2 pi))
(coords (ripple-displace w h frequency amplitude cx cy decay phase)))
(remap frame (coords-x coords) (coords-y coords))))
(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

@@ -8,4 +8,4 @@
(speed :type int :default 0 :desc "rotation per second")
)
(let ((total-angle (+ angle (* speed t))))
(rotate-img frame total-angle)))
(geometry:rotate-img frame total-angle)))

View File

@@ -1,10 +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])
)
(shift-hsv frame 0 amount 1))
(color_ops:adjust-saturation frame amount))

View File

@@ -1,4 +1,5 @@
;; Scanlines effect - VHS-style horizontal line shifting
(require-primitives "core")
(define-effect scanlines
:params (
@@ -9,6 +10,6 @@
(map-rows frame
(lambda (y row)
(let* ((sine-shift (* amplitude (sin (/ (* y 6.28) (max 1 frequency)))))
(rand-shift (random (- amplitude) amplitude))
(rand-shift (core:rand-range (- amplitude) amplitude))
(shift (floor (lerp sine-shift rand-shift randomness))))
(roll row shift 0)))))

View File

@@ -1,9 +1,7 @@
;; Sepia effect - applies sepia tone
;; Classic warm vintage look
(require-primitives "color_ops")
(define-effect sepia
:params ()
(color-matrix frame
(list (list 0.393 0.769 0.189)
(list 0.349 0.686 0.168)
(list 0.272 0.534 0.131))))
(color_ops:sepia frame))

View File

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

View File

@@ -1,4 +1,5 @@
;; Strobe effect - holds frames for choppy look
(require-primitives "core")
(define-effect strobe
:params (
@@ -7,7 +8,7 @@
(let* ((held (state-get 'held nil))
(held-until (state-get 'held-until 0))
(frame-duration (/ 1 frame_rate)))
(if (or (= held nil) (>= t held-until))
(if (or (core:is-nil held) (>= t held-until))
(begin
(state-set 'held (copy frame))
(state-set 'held-until (+ t frame-duration))

View File

@@ -1,4 +1,5 @@
;; Swirl effect - spiral vortex distortion
(require-primitives "geometry" "image")
(define-effect swirl
:params (
@@ -8,9 +9,9 @@
(center_y :type float :default 0.5 :range [0 1])
(falloff :type string :default "quadratic")
)
(let* ((w (width frame))
(h (height frame))
(let* ((w (image:width frame))
(h (image:height frame))
(cx (* w center_x))
(cy (* h center_y))
(coords (swirl-displace w h strength radius cx cy falloff)))
(remap frame (coords-x coords) (coords-y coords))))
(coords (geometry:swirl-coords w h strength radius cx cy falloff)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -1,14 +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)
)
(map-pixels frame
(lambda (x y c)
(let* ((lum (luminance c))
(above (if invert (< lum level) (> lum level))))
(if above
(rgb 255 255 255)
(rgb 0 0 0))))))
(color_ops:threshold frame level invert))

View File

@@ -1,4 +1,5 @@
;; Tile Grid effect - tiles image in grid
(require-primitives "geometry" "image")
(define-effect tile_grid
:params (
@@ -6,11 +7,11 @@
(cols :type int :default 2 :range [1 10])
(gap :type int :default 0 :range [0 50])
)
(let* ((w (width frame))
(h (height frame))
(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 (resize frame tile-w tile-h "area"))
(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

View File

@@ -1,4 +1,5 @@
;; Trails effect - persistent motion trails
(require-primitives "image" "blending")
(define-effect trails
:params (
@@ -10,10 +11,10 @@
(begin
(state-set 'buffer (copy frame))
frame)
(let* ((faded (blend-images buffer
(make-image (width frame) (height frame) (list 0 0 0))
(let* ((faded (blending:blend-images buffer
(make-image (image:width frame) (image:height frame) (list 0 0 0))
(- 1 persistence)))
(result (blend-mode faded current "lighten")))
(result (blending:blend-mode faded current "lighten")))
(begin
(state-set 'buffer result)
result)))))

View File

@@ -1,12 +1,13 @@
;; 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 (width frame))
(h (height frame))
(let* ((w (image:width frame))
(h (image:height frame))
(cx (/ w 2))
(cy (/ h 2))
(max-dist (* (sqrt (+ (* cx cx) (* cy cy))) radius)))

View File

@@ -1,4 +1,5 @@
;; Wave effect - sine wave displacement distortion
(require-primitives "geometry" "image")
(define-effect wave
:params (
@@ -7,8 +8,8 @@
(speed :type int :default 1 :range [0 10])
(direction :type string :default "horizontal")
)
(let* ((w (width frame))
(h (height frame))
(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
@@ -17,5 +18,5 @@
((= direction "horizontal") "x")
((= direction "vertical") "y")
(else "both")))
(coords (wave-displace w h axis freq amplitude phase)))
(remap frame (coords-x coords) (coords-y coords))))
(coords (geometry:wave-coords w h axis freq amplitude phase)))
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))

View File

@@ -5,4 +5,4 @@
:params (
(amount :type int :default 1 :range [0.1 5])
)
(scale-img frame amount amount))
(geometry:scale-img frame amount amount))

View File

@@ -189,6 +189,30 @@ def prim_range(*args):
return []
# Random
import random
_rng = random.Random()
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
@@ -231,10 +255,17 @@ PRIMITIVES = {
'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,304 @@
"""
Streaming primitives for video/audio processing.
These primitives handle video source reading and audio analysis,
keeping the interpreter completely generic.
"""
import numpy as np
import subprocess
import json
from pathlib import Path
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
# Get video info
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", str(self.path)]
result = subprocess.run(cmd, capture_output=True, text=True)
info = json.loads(result.stdout)
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
if not self._frame_size:
self._frame_size = (720, 720)
def _start_stream(self, seek_time: float = 0):
"""Start or restart the ffmpeg streaming process."""
if self._proc:
self._proc.kill()
self._proc = None
w, h = self._frame_size
cmd = [
"ffmpeg", "-v", "quiet",
"-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.DEVNULL)
self._stream_time = seek_time
def _read_frame_from_stream(self) -> np.ndarray:
"""Read one frame from the stream."""
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
return np.frombuffer(data, dtype=np.uint8).reshape((h, w, 3)).copy()
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
# 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
while self._stream_time + self._frame_time <= seek_time:
frame = self._read_frame_from_stream()
if frame is None:
# Stream ended, restart from seek point
self._start_stream(seek_time)
break
self._stream_time += self._frame_time
# Read the target frame
frame = self._read_frame_from_stream()
if frame is None:
import sys
print(f"NULL FRAME {self.path.name}: t={t:.2f} seek={seek_time:.2f}", file=sys.stderr)
frame = np.zeros((h, w, 3), dtype=np.uint8)
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 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
# Load audio via ffmpeg
cmd = ["ffmpeg", "-v", "quiet", "-i", str(self.path),
"-f", "f32le", "-ac", "1", "-ar", str(sample_rate), "-"]
result = subprocess.run(cmd, capture_output=True)
self._audio = np.frombuffer(result.stdout, dtype=np.float32)
# Get duration
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", str(self.path)]
info = json.loads(subprocess.run(cmd, capture_output=True, text=True).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."""
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