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>
164 lines
5.0 KiB
Python
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
|