# /// 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