Files
test/effects/scanlines.py
gilesb 406cc7c0c7 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>
2026-01-19 12:34:45 +00:00

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