Files
test/effects/vhs.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

164 lines
5.0 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect vhs
@version 1.0.0
@author artdag
@description
VHS / Analog Video effect. Complete VHS tape simulation combining
tracking errors, color bleeding, noise, and scan line distortion.
@param tracking_error float
@range 0 50
@default 5
Horizontal displacement amount. Sync to onset for glitches.
@param color_bleed int
@range 0 20
@default 3
Horizontal color smearing (typical VHS artifact).
@param noise_intensity float
@range 0 1
@default 0.2
Static noise amount.
@param chroma_shift int
@range 0 15
@default 2
Color channel offset (VHS color alignment issues).
@param head_switching bool
@default true
Bottom-of-frame distortion.
@param tape_crease_prob float
@range 0 0.5
@default 0.05
Probability of random tape crease distortion per frame.
@param blur_amount float
@range 0 5
@default 1
VHS softness blur.
@param seed int
@default 42
Random seed for deterministic artifacts.
@state rng DeterministicRNG
Random number generator for artifacts.
@example
(effect vhs :tracking_error 10 :color_bleed 5)
@example
;; Reactive VHS glitch
(effect vhs :tracking_error (bind onset :range [0 30]) :tape_crease_prob 0.1)
"""
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 VHS 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)
"""
tracking_error = params.get("tracking_error", 5)
color_bleed = int(params.get("color_bleed", 3))
noise_intensity = params.get("noise_intensity", 0.2)
chroma_shift = int(params.get("chroma_shift", 2))
head_switching = params.get("head_switching", True)
tape_crease_prob = params.get("tape_crease_prob", 0.05)
blur_amount = params.get("blur_amount", 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"]
h, w = frame.shape[:2]
result = frame.astype(np.float32).copy()
# Apply slight blur (VHS softness)
if blur_amount > 0:
ksize = int(blur_amount * 2) * 2 + 1
result = cv2.GaussianBlur(result, (ksize, 1), 0)
# Apply color bleed (horizontal color smearing)
if color_bleed > 0:
ksize = color_bleed * 2 + 1
result[:, :, 0] = cv2.blur(result[:, :, 0], (ksize, 1))
result[:, :, 2] = cv2.blur(result[:, :, 2], (ksize, 1))
# Apply chroma shift (color channel misalignment)
if chroma_shift > 0:
shifted = np.zeros_like(result)
M_r = np.float32([[1, 0, chroma_shift], [0, 1, 0]])
M_b = np.float32([[1, 0, -chroma_shift], [0, 1, 0]])
shifted[:, :, 0] = cv2.warpAffine(result[:, :, 0], M_r, (w, h), borderMode=cv2.BORDER_REPLICATE)
shifted[:, :, 1] = result[:, :, 1]
shifted[:, :, 2] = cv2.warpAffine(result[:, :, 2], M_b, (w, h), borderMode=cv2.BORDER_REPLICATE)
result = shifted
# Apply tracking error (horizontal line displacement)
if tracking_error > 0:
for y in range(h):
sine_shift = np.sin(y * 0.05)
rand_shift = rng.uniform(-0.3, 0.3)
displacement = int(tracking_error * (sine_shift + rand_shift))
if displacement != 0:
result[y] = np.roll(result[y], displacement, axis=0)
# Apply tape crease (random distortion bands)
if tape_crease_prob > 0 and rng.uniform() < tape_crease_prob:
band_start = rng.randint(0, max(1, h - 20))
band_height = rng.randint(5, 20)
for y in range(band_start, min(band_start + band_height, h)):
displacement = rng.randint(-50, 50)
result[y] = np.roll(result[y], displacement, axis=0)
result[y] = result[y] * rng.uniform(0.5, 1.5)
# Apply head switching noise (bottom of frame distortion)
if head_switching:
switch_height = rng.randint(5, 15)
for y in range(h - switch_height, h):
factor = (y - (h - switch_height)) / switch_height
displacement = int(factor * 30 * rng.uniform(0.5, 1.5))
result[y] = np.roll(result[y], displacement, axis=0)
noise = np.array([[rng.gaussian(0, 20 * factor) for _ in range(3)] for _ in range(w)])
result[y] = result[y] + noise
# Apply static noise
if noise_intensity > 0:
noise = np.array([[[rng.gaussian(0, noise_intensity * 30) for _ in range(3)]
for _ in range(w)]
for _ in range(h)])
result = result + noise
return np.clip(result, 0, 255).astype(np.uint8), state