Initial commit: video effects processing system
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>
This commit is contained in:
233
effects/shatter.py
Normal file
233
effects/shatter.py
Normal file
@@ -0,0 +1,233 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["numpy", "scipy"]
|
||||
# ///
|
||||
"""
|
||||
@effect shatter
|
||||
@version 1.0.0
|
||||
@author artdag
|
||||
|
||||
@description
|
||||
Shatter effect. Explodes the image into flying pieces that move
|
||||
outward from a center point. Great for beat drops and transitions.
|
||||
|
||||
@param intensity float
|
||||
@range 0 1
|
||||
@default 0.5
|
||||
Explosion force (0 = no effect, 1 = full explosion).
|
||||
|
||||
@param num_pieces int
|
||||
@range 10 200
|
||||
@default 50
|
||||
Number of shatter pieces.
|
||||
|
||||
@param center_x float
|
||||
@range 0 1
|
||||
@default 0.5
|
||||
Explosion center X position.
|
||||
|
||||
@param center_y float
|
||||
@range 0 1
|
||||
@default 0.5
|
||||
Explosion center Y position.
|
||||
|
||||
@param rotation_speed float
|
||||
@range 0 5
|
||||
@default 1.0
|
||||
How fast pieces rotate as they fly.
|
||||
|
||||
@param gravity float
|
||||
@range 0 2
|
||||
@default 0.3
|
||||
Downward pull on pieces.
|
||||
|
||||
@param fade_out bool
|
||||
@default true
|
||||
Fade pieces as they fly away.
|
||||
|
||||
@param seed int
|
||||
@default 42
|
||||
Random seed for piece positions.
|
||||
|
||||
@state pieces list
|
||||
List of piece positions and velocities.
|
||||
|
||||
@example
|
||||
(effect shatter :intensity 0.7 :num_pieces 80)
|
||||
|
||||
@example
|
||||
;; Beat-reactive explosion
|
||||
(effect shatter :intensity (bind onset :range [0 1]) :gravity 0.5)
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Import DeterministicRNG from same directory
|
||||
_effects_dir = Path(__file__).parent
|
||||
if str(_effects_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_effects_dir))
|
||||
from random import DeterministicRNG
|
||||
|
||||
|
||||
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
||||
"""
|
||||
Apply shatter effect to a video frame.
|
||||
|
||||
Args:
|
||||
frame: Input frame as numpy array (H, W, 3) RGB uint8
|
||||
params: Effect parameters
|
||||
state: Persistent state dict
|
||||
|
||||
Returns:
|
||||
Tuple of (processed_frame, new_state)
|
||||
"""
|
||||
intensity = np.clip(params.get("intensity", 0.5), 0, 1)
|
||||
num_pieces = max(10, min(int(params.get("num_pieces", 50)), 200))
|
||||
center_x = params.get("center_x", 0.5)
|
||||
center_y = params.get("center_y", 0.5)
|
||||
rotation_speed = params.get("rotation_speed", 1.0)
|
||||
gravity = params.get("gravity", 0.3)
|
||||
fade_out = params.get("fade_out", True)
|
||||
seed = int(params.get("seed", 42))
|
||||
t = params.get("_time", 0)
|
||||
|
||||
if state is None:
|
||||
state = {}
|
||||
|
||||
if intensity < 0.01:
|
||||
return frame, state
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
cx, cy = int(center_x * w), int(center_y * h)
|
||||
|
||||
# Initialize pieces
|
||||
if "pieces" not in state or len(state["pieces"]) != num_pieces:
|
||||
state["pieces"] = _init_pieces(w, h, num_pieces, cx, cy, seed)
|
||||
state["start_time"] = t
|
||||
|
||||
pieces = state["pieces"]
|
||||
start_time = state.get("start_time", t)
|
||||
effect_t = t - start_time
|
||||
|
||||
# Create output with black background
|
||||
result = np.zeros_like(frame)
|
||||
|
||||
time_factor = effect_t * intensity * 3.0
|
||||
|
||||
# Draw each piece
|
||||
for piece in pieces:
|
||||
px, py, pw, ph, vx, vy, rot = piece
|
||||
|
||||
if pw <= 0 or ph <= 0:
|
||||
continue
|
||||
|
||||
# Calculate current position with physics
|
||||
curr_x = px + vx * time_factor * w * 0.5
|
||||
curr_y = py + vy * time_factor * h * 0.5 + gravity * time_factor ** 2 * h * 0.2
|
||||
curr_rot = rot * rotation_speed * time_factor * 180
|
||||
|
||||
# Calculate alpha (fade out over distance)
|
||||
distance = np.sqrt((curr_x - px)**2 + (curr_y - py)**2)
|
||||
alpha = 1.0 - (distance / max(w, h)) if fade_out else 1.0
|
||||
alpha = max(0, min(1, alpha))
|
||||
|
||||
if alpha < 0.05:
|
||||
continue
|
||||
|
||||
# Extract piece from original frame
|
||||
px1, py1 = max(0, int(px)), max(0, int(py))
|
||||
px2, py2 = min(w, int(px + pw)), min(h, int(py + ph))
|
||||
|
||||
if px2 <= px1 or py2 <= py1:
|
||||
continue
|
||||
|
||||
piece_img = frame[py1:py2, px1:px2].copy()
|
||||
|
||||
# Rotate piece
|
||||
if abs(curr_rot) > 1:
|
||||
piece_img = ndimage.rotate(piece_img, curr_rot, reshape=False, mode='constant', cval=0)
|
||||
|
||||
# Calculate destination
|
||||
dest_x = int(curr_x)
|
||||
dest_y = int(curr_y)
|
||||
piece_h, piece_w = piece_img.shape[:2]
|
||||
|
||||
# Clip to frame bounds
|
||||
src_x1, src_y1 = 0, 0
|
||||
src_x2, src_y2 = piece_w, piece_h
|
||||
dst_x1, dst_y1 = dest_x, dest_y
|
||||
dst_x2, dst_y2 = dest_x + piece_w, dest_y + piece_h
|
||||
|
||||
if dst_x1 < 0:
|
||||
src_x1 = -dst_x1
|
||||
dst_x1 = 0
|
||||
if dst_y1 < 0:
|
||||
src_y1 = -dst_y1
|
||||
dst_y1 = 0
|
||||
if dst_x2 > w:
|
||||
src_x2 -= (dst_x2 - w)
|
||||
dst_x2 = w
|
||||
if dst_y2 > h:
|
||||
src_y2 -= (dst_y2 - h)
|
||||
dst_y2 = h
|
||||
|
||||
if dst_x2 <= dst_x1 or dst_y2 <= dst_y1:
|
||||
continue
|
||||
if src_x2 <= src_x1 or src_y2 <= src_y1:
|
||||
continue
|
||||
|
||||
# Blend piece onto result
|
||||
piece_region = piece_img[src_y1:src_y2, src_x1:src_x2]
|
||||
if piece_region.size == 0:
|
||||
continue
|
||||
|
||||
result_region = result[dst_y1:dst_y2, dst_x1:dst_x2]
|
||||
if result_region.shape != piece_region.shape:
|
||||
continue
|
||||
|
||||
result[dst_y1:dst_y2, dst_x1:dst_x2] = (
|
||||
result_region * (1 - alpha) + piece_region * alpha
|
||||
).astype(np.uint8)
|
||||
|
||||
# Blend with original based on intensity
|
||||
final = ((1 - intensity) * frame + intensity * result).astype(np.uint8)
|
||||
|
||||
return final, state
|
||||
|
||||
|
||||
def _init_pieces(w: int, h: int, num_pieces: int, cx: int, cy: int, seed: int) -> list:
|
||||
"""Initialize shatter pieces with random positions and velocities."""
|
||||
rng = DeterministicRNG(seed)
|
||||
|
||||
pieces = []
|
||||
|
||||
# Create grid of pieces
|
||||
cols = max(1, int(np.sqrt(num_pieces * w / h)))
|
||||
rows = max(1, int(num_pieces / cols))
|
||||
piece_w = w // cols
|
||||
piece_h = h // rows
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
px = col * piece_w
|
||||
py = row * piece_h
|
||||
pw = piece_w + (w % cols if col == cols - 1 else 0)
|
||||
ph = piece_h + (h % rows if row == rows - 1 else 0)
|
||||
|
||||
# Velocity away from center
|
||||
piece_cx = px + pw // 2
|
||||
piece_cy = py + ph // 2
|
||||
dx = piece_cx - cx
|
||||
dy = piece_cy - cy
|
||||
dist = max(1, np.sqrt(dx*dx + dy*dy))
|
||||
|
||||
vx = dx / dist + rng.uniform(-0.3, 0.3)
|
||||
vy = dy / dist + rng.uniform(-0.3, 0.3)
|
||||
rot = rng.uniform(-2, 2)
|
||||
|
||||
pieces.append((px, py, pw, ph, vx, vy, rot))
|
||||
|
||||
return pieces
|
||||
Reference in New Issue
Block a user