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>
117 lines
3.0 KiB
Python
117 lines
3.0 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy"]
|
|
# ///
|
|
"""
|
|
@effect scanlines
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
VHS/CRT-style scan line shift. Horizontally displaces alternating lines
|
|
to create analog video distortion. Great for retro/glitch aesthetics.
|
|
|
|
@param amplitude float
|
|
@range 0 100
|
|
@default 10
|
|
Maximum shift amount in pixels. Bind to bass for reactive glitch.
|
|
|
|
@param frequency float
|
|
@range 1 100
|
|
@default 10
|
|
Lines per cycle (affects pattern density).
|
|
|
|
@param randomness float
|
|
@range 0 1
|
|
@default 0.5
|
|
0 = regular sine pattern, 1 = fully random shifts.
|
|
|
|
@param line_gap int
|
|
@range 1 20
|
|
@default 1
|
|
Only shift every Nth line (1 = all lines).
|
|
|
|
@param seed int
|
|
@default 42
|
|
Random seed for deterministic patterns.
|
|
|
|
@state rng DeterministicRNG
|
|
Random number generator for reproducible results.
|
|
|
|
@example
|
|
(effect scanlines :amplitude 20)
|
|
|
|
@example
|
|
;; Heavy glitch on bass
|
|
(effect scanlines :amplitude (bind bass :range [0 50]) :randomness 0.8)
|
|
|
|
@example
|
|
;; Reproducible scanlines
|
|
(effect scanlines :amplitude 30 :randomness 0.7 :seed 999)
|
|
"""
|
|
|
|
import numpy as np
|
|
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 scan line shift to a video frame.
|
|
|
|
Args:
|
|
frame: Input frame as numpy array (H, W, 3) RGB uint8
|
|
params: Effect parameters
|
|
- amplitude: max shift in pixels (default 10)
|
|
- frequency: lines per cycle (default 10)
|
|
- randomness: 0-1 random vs sine (default 0.5)
|
|
- line_gap: shift every Nth line (default 1)
|
|
- seed: random seed (default 42)
|
|
state: Persistent state dict
|
|
- rng: DeterministicRNG instance
|
|
|
|
Returns:
|
|
Tuple of (processed_frame, new_state)
|
|
"""
|
|
amplitude = params.get("amplitude", 10)
|
|
frequency = params.get("frequency", 10) or 1
|
|
randomness = params.get("randomness", 0.5)
|
|
line_gap = max(1, int(params.get("line_gap", 1)))
|
|
seed = int(params.get("seed", 42))
|
|
|
|
if state is None:
|
|
state = {}
|
|
|
|
# Initialize RNG
|
|
if "rng" not in state:
|
|
state["rng"] = DeterministicRNG(seed)
|
|
rng = state["rng"]
|
|
|
|
if amplitude == 0:
|
|
return frame, state
|
|
|
|
h, w = frame.shape[:2]
|
|
result = frame.copy()
|
|
|
|
for y in range(0, h, line_gap):
|
|
# Calculate shift amount
|
|
if randomness >= 1.0:
|
|
shift = int(rng.uniform(-amplitude, amplitude))
|
|
elif randomness <= 0:
|
|
shift = int(amplitude * np.sin(2 * np.pi * y / frequency))
|
|
else:
|
|
sine_shift = amplitude * np.sin(2 * np.pi * y / frequency)
|
|
rand_shift = rng.uniform(-amplitude, amplitude)
|
|
shift = int(sine_shift * (1 - randomness) + rand_shift * randomness)
|
|
|
|
if shift != 0:
|
|
result[y] = np.roll(result[y], shift, axis=0)
|
|
|
|
return result, state
|