Add JAX typography, xector primitives, deferred effect chains, and GPU streaming
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m28s

- Add JAX text rendering with font atlas, styled text placement, and typography primitives
- Add xector (element-wise/reduction) operations library and sexp effects
- Add deferred effect chain fusion for JIT-compiled effect pipelines
- Expand drawing primitives with font management, alignment, shadow, and outline
- Add interpreter support for function-style define and require
- Add GPU persistence mode and hardware decode support to streaming
- Add new sexp effects: cell_pattern, halftone, mosaic, and derived definitions
- Add path registry for asset resolution
- Add integration, primitives, and xector tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-02-06 15:12:54 +00:00
parent dbc4ece2cc
commit fc9597456f
30 changed files with 7749 additions and 165 deletions

206
sexp_effects/derived.sexp Normal file
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

@@ -5,7 +5,7 @@
: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")
(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])

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

@@ -6,10 +6,10 @@
(num_echoes :type int :default 4 :range [1 20])
(decay :type float :default 0.5 :range [0 1])
)
(let* ((buffer (state-get 'buffer (list)))
(let* ((buffer (state-get "buffer" (list)))
(new-buffer (take (cons frame buffer) (+ num_echoes 1))))
(begin
(state-set 'buffer new-buffer)
(state-set "buffer" new-buffer)
;; Blend frames with decay
(if (< (length new-buffer) 2)
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,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

@@ -5,9 +5,9 @@
: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)
(color :type list :default (list 0 0 0))
(fill_mode :type string :default "original")
)
(fill_mode "original"))
(let* ((edge-img (image:edge-detect frame (/ threshold 2) threshold))
(dilated (if (> thickness 1)
(dilate edge-img thickness)

View File

@@ -5,12 +5,12 @@
:params (
(frame_rate :type int :default 12 :range [1 60])
)
(let* ((held (state-get 'held nil))
(held-until (state-get 'held-until 0))
(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))
(state-set "held" (copy frame))
(state-set "held-until" (+ t frame-duration))
frame)
held)))

View File

@@ -5,16 +5,16 @@
:params (
(persistence :type float :default 0.8 :range [0 0.99])
)
(let* ((buffer (state-get 'buffer nil))
(let* ((buffer (state-get "buffer" nil))
(current frame))
(if (= buffer nil)
(begin
(state-set 'buffer (copy frame))
(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)
(state-set "buffer" result)
result)))))

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

@@ -156,11 +156,21 @@ class Interpreter:
if form == 'define':
name = expr[1]
if _is_symbol(name):
# Simple define: (define name value)
value = self.eval(expr[2], env)
self.global_env.set(name.name, value)
return value
elif isinstance(name, list) and len(name) >= 1 and _is_symbol(name[0]):
# Function define: (define (fn-name args...) body)
# Desugars to: (define fn-name (lambda (args...) body))
fn_name = name[0].name
params = [p.name if _is_symbol(p) else p for p in name[1:]]
body = expr[2]
fn = Lambda(params, body, env)
self.global_env.set(fn_name, fn)
return fn
else:
raise SyntaxError(f"define requires symbol, got {name}")
raise SyntaxError(f"define requires symbol or (name args...), got {name}")
# Define-effect
if form == 'define-effect':
@@ -276,6 +286,10 @@ class Interpreter:
if form == 'require-primitives':
return self._eval_require_primitives(expr, env)
# require - load .sexp file into current scope
if form == 'require':
return self._eval_require(expr, env)
# Function call
fn = self.eval(head, env)
args = [self.eval(arg, env) for arg in expr[1:]]
@@ -488,6 +502,61 @@ class Interpreter:
from .primitive_libs import load_primitive_library
return load_primitive_library(name, path)
def _eval_require(self, expr: Any, env: Environment) -> Any:
"""
Evaluate require: load a .sexp file and evaluate its definitions.
Syntax:
(require "derived") ; loads derived.sexp from sexp_effects/
(require "path/to/file.sexp") ; loads from explicit path
Definitions from the file are added to the current environment.
"""
for lib_expr in expr[1:]:
if _is_symbol(lib_expr):
lib_name = lib_expr.name
else:
lib_name = lib_expr
# Find the .sexp file
sexp_path = self._find_sexp_file(lib_name)
if sexp_path is None:
raise ValueError(f"Cannot find sexp file: {lib_name}")
# Parse and evaluate the file
content = parse_file(sexp_path)
# Evaluate all top-level expressions
if isinstance(content, list) and content and isinstance(content[0], list):
for e in content:
self.eval(e, env)
else:
self.eval(content, env)
return None
def _find_sexp_file(self, name: str) -> Optional[str]:
"""Find a .sexp file by name."""
# Try various locations
candidates = [
# Explicit path
name,
name + '.sexp',
# In sexp_effects directory
Path(__file__).parent / f'{name}.sexp',
Path(__file__).parent / name,
# In effects directory
Path(__file__).parent / 'effects' / f'{name}.sexp',
Path(__file__).parent / 'effects' / name,
]
for path in candidates:
p = Path(path) if not isinstance(path, Path) else path
if p.exists() and p.is_file():
return str(p)
return None
def _eval_ascii_fx_zone(self, expr: Any, env: Environment) -> Any:
"""
Evaluate ascii-fx-zone special form.
@@ -876,8 +945,8 @@ class Interpreter:
for pname, pdefault in effect.params.items():
value = params.get(pname)
if value is None:
# Evaluate default if it's an expression (list)
if isinstance(pdefault, list):
# Evaluate default if it's an expression (list) or a symbol (like 'nil')
if isinstance(pdefault, list) or _is_symbol(pdefault):
value = self.eval(pdefault, env)
else:
value = pdefault

View File

@@ -71,7 +71,8 @@ class Tokenizer:
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 = 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

View File

@@ -1,126 +1,680 @@
"""
Drawing Primitives Library
Draw shapes, text, and characters on images.
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
# Default font (will be loaded lazily)
_default_font = None
# =============================================================================
# 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 _get_default_font(size=16):
"""Get default font, creating if needed."""
global _default_font
if _default_font is None or _default_font.size != size:
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:
_default_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", size)
return prim_make_font(font_name, size)
except:
_default_font = ImageFont.load_default()
return _default_font
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)."""
if color is None:
color = [255, 255, 255]
pil_img = Image.fromarray(img)
draw = ImageDraw.Draw(pil_img)
font = _get_default_font(font_size)
draw.text((x, y), char, fill=tuple(color), font=font)
return np.array(pil_img)
"""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)."""
"""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]
pil_img = Image.fromarray(img)
draw = ImageDraw.Draw(pil_img)
font = _get_default_font(font_size)
draw.text((x, y), text, fill=tuple(color), font=font)
return np.array(pil_img)
def prim_fill_rect(img, x, y, w, h, color=None):
"""Fill a rectangle with color."""
if color is None:
color = [255, 255, 255]
result = img.copy()
x, y, w, h = int(x), int(y), int(w), int(h)
result[y:y+h, x:x+w] = color
return result
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):
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]
result = img.copy()
cv2.rectangle(result, (int(x), int(y)), (int(x+w), int(y+h)),
tuple(color), thickness)
return result
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):
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]
result = img.copy()
cv2.line(result, (int(x1), int(y1)), (int(x2), int(y2)),
tuple(color), thickness)
return result
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):
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]
result = img.copy()
t = -1 if fill else thickness
cv2.circle(result, (int(cx), int(cy)), int(radius), tuple(color), t)
return result
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):
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]
result = img.copy()
t = -1 if fill else thickness
cv2.ellipse(result, (int(cx), int(cy)), (int(rx), int(ry)),
angle, 0, 360, tuple(color), t)
return result
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):
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]
result = img.copy()
pts = np.array(points, dtype=np.int32).reshape((-1, 1, 2))
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:
cv2.fillPoly(result, [pts], tuple(color))
draw.polygon(pts_flat, fill=rgba)
else:
cv2.polylines(result, [pts], True, tuple(color), thickness)
draw.polygon(pts_flat, outline=rgba, width=int(thickness))
return result
result = Image.alpha_composite(pil_img, layer)
return np.array(result.convert('RGB'))
# =============================================================================
# PRIMITIVES Export
# =============================================================================
PRIMITIVES = {
# Text
# 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,

View File

@@ -8,12 +8,18 @@ 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
@@ -28,6 +34,10 @@ except ImportError:
# 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
@@ -283,6 +293,122 @@ class VideoSource:
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."""
@@ -394,7 +520,12 @@ class AudioAnalyzer:
# === Primitives ===
def prim_make_video_source(path: str, fps: float = 30):
"""Create a video source from a file path."""
"""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)

File diff suppressed because it is too large Load Diff

View File

@@ -797,31 +797,63 @@ def prim_tan(x: float) -> float:
return math.tan(x)
def prim_atan2(y: float, x: float) -> float:
def prim_atan2(y, x):
if hasattr(y, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
return Xector(np.arctan2(y._data, x._data if hasattr(x, '_data') else x), y._shape)
return math.atan2(y, x)
def prim_sqrt(x: float) -> float:
def prim_sqrt(x):
if hasattr(x, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
return Xector(np.sqrt(np.maximum(0, x._data)), x._shape)
if isinstance(x, np.ndarray):
return np.sqrt(np.maximum(0, x))
return math.sqrt(max(0, x))
def prim_pow(x: float, y: float) -> float:
def prim_pow(x, y):
if hasattr(x, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
y_data = y._data if hasattr(y, '_data') else y
return Xector(np.power(x._data, y_data), x._shape)
return math.pow(x, y)
def prim_abs(x: float) -> float:
def prim_abs(x):
if hasattr(x, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
return Xector(np.abs(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.abs(x)
return abs(x)
def prim_floor(x: float) -> int:
def prim_floor(x):
if hasattr(x, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
return Xector(np.floor(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.floor(x)
return int(math.floor(x))
def prim_ceil(x: float) -> int:
def prim_ceil(x):
if hasattr(x, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
return Xector(np.ceil(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.ceil(x)
return int(math.ceil(x))
def prim_round(x: float) -> int:
def prim_round(x):
if hasattr(x, '_data'): # Xector
from sexp_effects.primitive_libs.xector import Xector
return Xector(np.round(x._data), x._shape)
if isinstance(x, np.ndarray):
return np.round(x)
return int(round(x))