Add S-expression based video effects pipeline with modular effect definitions, constructs, and recipe files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
138 lines
3.6 KiB
Python
138 lines
3.6 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy", "opencv-python"]
|
|
# ///
|
|
"""
|
|
@effect kaleidoscope
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
Kaleidoscope effect. Creates mesmerizing mandala-like patterns by
|
|
dividing the frame into pie-slice segments and reflecting them.
|
|
Great for psychedelic visuals.
|
|
|
|
@param segments int
|
|
@range 3 16
|
|
@default 6
|
|
Number of symmetry segments.
|
|
|
|
@param rotation float
|
|
@range 0 360
|
|
@default 0
|
|
Base rotation angle in degrees.
|
|
|
|
@param rotation_speed float
|
|
@range -180 180
|
|
@default 0
|
|
Continuous rotation speed in degrees/second.
|
|
|
|
@param center_x float
|
|
@range 0 1
|
|
@default 0.5
|
|
Center X position (0-1).
|
|
|
|
@param center_y float
|
|
@range 0 1
|
|
@default 0.5
|
|
Center Y position (0-1).
|
|
|
|
@param zoom float
|
|
@range 0.5 3.0
|
|
@default 1.0
|
|
Zoom factor for the source region.
|
|
|
|
@state cumulative_rotation float
|
|
Tracks rotation over time.
|
|
|
|
@example
|
|
(effect kaleidoscope :segments 8 :rotation_speed 30)
|
|
|
|
@example
|
|
;; Beat-reactive segments
|
|
(effect kaleidoscope :segments (bind bass :range [4 12]) :zoom 1.5)
|
|
"""
|
|
|
|
import numpy as np
|
|
import cv2
|
|
|
|
|
|
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
|
"""
|
|
Apply kaleidoscope effect to a video frame.
|
|
|
|
Args:
|
|
frame: Input frame as numpy array (H, W, 3) RGB uint8
|
|
params: Effect parameters
|
|
- segments: number of segments 3-16 (default 6)
|
|
- rotation: base rotation degrees (default 0)
|
|
- rotation_speed: degrees per second (default 0)
|
|
- center_x: center X 0-1 (default 0.5)
|
|
- center_y: center Y 0-1 (default 0.5)
|
|
- zoom: zoom factor 0.5-3 (default 1.0)
|
|
state: Persistent state dict
|
|
|
|
Returns:
|
|
Tuple of (processed_frame, new_state)
|
|
"""
|
|
segments = max(3, min(int(params.get("segments", 6)), 16))
|
|
rotation = params.get("rotation", 0)
|
|
rotation_speed = params.get("rotation_speed", 0)
|
|
center_x = params.get("center_x", 0.5)
|
|
center_y = params.get("center_y", 0.5)
|
|
zoom = max(0.5, min(params.get("zoom", 1.0), 3.0))
|
|
|
|
# Get time for animation
|
|
t = params.get("_time", 0)
|
|
|
|
if state is None:
|
|
state = {}
|
|
|
|
h, w = frame.shape[:2]
|
|
|
|
# Calculate center in pixels
|
|
cx = int(w * center_x)
|
|
cy = int(h * center_y)
|
|
|
|
# Total rotation including time-based animation
|
|
total_rotation = rotation + rotation_speed * t
|
|
|
|
# Calculate the angle per segment
|
|
segment_angle = 2 * np.pi / segments
|
|
|
|
# Create coordinate maps
|
|
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
|
|
# Translate to center
|
|
x_centered = x_coords - cx
|
|
y_centered = y_coords - cy
|
|
|
|
# Convert to polar coordinates
|
|
r = np.sqrt(x_centered**2 + y_centered**2)
|
|
theta = np.arctan2(y_centered, x_centered)
|
|
|
|
# Apply rotation
|
|
theta = theta - np.deg2rad(total_rotation)
|
|
|
|
# Fold angle into first segment and mirror
|
|
theta_normalized = theta % (2 * np.pi)
|
|
segment_idx = (theta_normalized / segment_angle).astype(int)
|
|
theta_in_segment = theta_normalized - segment_idx * segment_angle
|
|
|
|
# Mirror alternating segments
|
|
mirror_mask = (segment_idx % 2) == 1
|
|
theta_in_segment = np.where(mirror_mask, segment_angle - theta_in_segment, theta_in_segment)
|
|
|
|
# Apply zoom
|
|
r = r / zoom
|
|
|
|
# Convert back to Cartesian (source coordinates)
|
|
src_x = (r * np.cos(theta_in_segment) + cx).astype(np.float32)
|
|
src_y = (r * np.sin(theta_in_segment) + cy).astype(np.float32)
|
|
|
|
# Remap
|
|
result = cv2.remap(frame, src_x, src_y,
|
|
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
|
|
|
|
return result, state
|