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

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)