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>
171 lines
5.1 KiB
Python
171 lines
5.1 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy", "opencv-python"]
|
|
# ///
|
|
"""
|
|
@effect displacement
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
Displacement effect. Warps the image based on a pattern (sine waves,
|
|
noise, or radial). Creates flowing, liquid-like distortions.
|
|
|
|
@param amount float
|
|
@range 0 100
|
|
@default 20
|
|
Displacement strength in pixels.
|
|
|
|
@param pattern string
|
|
@enum sine noise radial turbulence
|
|
@default sine
|
|
Displacement pattern type:
|
|
- sine: smooth sine wave
|
|
- noise: random displacement
|
|
- radial: swirl from center
|
|
- turbulence: multi-scale noise
|
|
|
|
@param frequency float
|
|
@range 1 50
|
|
@default 10
|
|
Pattern frequency (waves per frame width).
|
|
|
|
@param speed float
|
|
@range 0 10
|
|
@default 1
|
|
Animation speed.
|
|
|
|
@param direction string
|
|
@enum horizontal vertical both
|
|
@default both
|
|
Displacement direction.
|
|
|
|
@param seed int
|
|
@default 42
|
|
Random seed for noise patterns.
|
|
|
|
@state rng DeterministicRNG
|
|
Random number generator.
|
|
|
|
@example
|
|
(effect displacement :amount 30 :pattern "sine" :frequency 5)
|
|
|
|
@example
|
|
;; Reactive turbulence
|
|
(effect displacement :amount (bind energy :range [10 50]) :pattern "turbulence")
|
|
"""
|
|
|
|
import numpy as np
|
|
import cv2
|
|
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 displacement 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)
|
|
"""
|
|
amount = params.get("amount", 20)
|
|
pattern = params.get("pattern", "sine")
|
|
frequency = max(1, params.get("frequency", 10))
|
|
speed = params.get("speed", 1)
|
|
direction = params.get("direction", "both")
|
|
seed = int(params.get("seed", 42))
|
|
t = params.get("_time", 0)
|
|
|
|
if state is None:
|
|
state = {}
|
|
|
|
if amount == 0:
|
|
return frame, state
|
|
|
|
# Initialize RNG
|
|
if "rng" not in state:
|
|
state["rng"] = DeterministicRNG(seed)
|
|
|
|
h, w = frame.shape[:2]
|
|
|
|
# Create base coordinate maps
|
|
map_x = np.tile(np.arange(w, dtype=np.float32), (h, 1))
|
|
map_y = np.tile(np.arange(h, dtype=np.float32).reshape(-1, 1), (1, w))
|
|
|
|
# Generate displacement based on pattern
|
|
if pattern == "sine":
|
|
# Sine wave displacement
|
|
phase = t * speed * 2 * np.pi
|
|
if direction in ["horizontal", "both"]:
|
|
map_x = map_x + amount * np.sin(2 * np.pi * map_y / h * frequency + phase)
|
|
if direction in ["vertical", "both"]:
|
|
map_y = map_y + amount * np.sin(2 * np.pi * map_x / w * frequency + phase)
|
|
|
|
elif pattern == "noise":
|
|
# Generate noise displacement
|
|
rng = state["rng"]
|
|
if "noise_x" not in state or state.get("noise_size") != (h, w):
|
|
state["noise_x"] = np.array([[rng.uniform(-1, 1) for _ in range(w)] for _ in range(h)], dtype=np.float32)
|
|
state["noise_y"] = np.array([[rng.uniform(-1, 1) for _ in range(w)] for _ in range(h)], dtype=np.float32)
|
|
state["noise_size"] = (h, w)
|
|
|
|
if direction in ["horizontal", "both"]:
|
|
map_x = map_x + amount * state["noise_x"]
|
|
if direction in ["vertical", "both"]:
|
|
map_y = map_y + amount * state["noise_y"]
|
|
|
|
elif pattern == "radial":
|
|
# Radial/swirl displacement
|
|
cx, cy = w / 2, h / 2
|
|
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
dx = x_coords - cx
|
|
dy = y_coords - cy
|
|
dist = np.sqrt(dx**2 + dy**2) + 1e-6
|
|
angle = np.arctan2(dy, dx)
|
|
|
|
# Swirl amount varies with distance and time
|
|
swirl = amount * 0.01 * np.sin(dist / (w / frequency) + t * speed * 2 * np.pi)
|
|
|
|
new_angle = angle + swirl
|
|
if direction in ["horizontal", "both"]:
|
|
map_x = cx + dist * np.cos(new_angle)
|
|
if direction in ["vertical", "both"]:
|
|
map_y = cy + dist * np.sin(new_angle)
|
|
|
|
elif pattern == "turbulence":
|
|
# Multi-scale noise
|
|
rng = state["rng"]
|
|
disp_x = np.zeros((h, w), dtype=np.float32)
|
|
disp_y = np.zeros((h, w), dtype=np.float32)
|
|
|
|
for scale in [1, 2, 4]:
|
|
sh, sw = h // scale, w // scale
|
|
noise_x = np.array([[rng.uniform(-1, 1) for _ in range(sw)] for _ in range(sh)], dtype=np.float32)
|
|
noise_y = np.array([[rng.uniform(-1, 1) for _ in range(sw)] for _ in range(sh)], dtype=np.float32)
|
|
if scale > 1:
|
|
noise_x = cv2.resize(noise_x, (w, h))
|
|
noise_y = cv2.resize(noise_y, (w, h))
|
|
disp_x += noise_x / scale
|
|
disp_y += noise_y / scale
|
|
|
|
if direction in ["horizontal", "both"]:
|
|
map_x = map_x + amount * disp_x
|
|
if direction in ["vertical", "both"]:
|
|
map_y = map_y + amount * disp_y
|
|
|
|
# Apply remapping
|
|
result = cv2.remap(frame, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
|
|
|
|
return result, state
|