# /// script # requires-python = ">=3.10" # dependencies = ["numpy", "opencv-python"] # /// """ @effect film_grain @version 1.0.0 @author artdag @description Film grain / Noise effect. Adds realistic film grain texture. Great for vintage aesthetics and subtle texture. @param intensity float @range 0 1 @default 0.2 Noise intensity. Bind to energy for reactive grain. @param grain_size float @range 0.5 5 @default 1.0 Size of grain particles. Larger = coarser grain. @param colored bool @default false Use colored noise instead of monochrome. @param temporal_variation float @range 0 1 @default 1.0 How much grain changes frame-to-frame. 0 = static, 1 = full animation. @param seed int @default 42 Random seed for reproducible grain. @state rng DeterministicRNG Random number generator for consistent grain. @example (effect film_grain :intensity 0.3) @example ;; Colored grain, reactive to energy (effect film_grain :intensity (bind energy :range [0.1 0.5]) :colored true) """ 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 film grain effect to a video frame. Args: frame: Input frame as numpy array (H, W, 3) RGB uint8 params: Effect parameters - intensity: noise intensity 0-1 (default 0.2) - grain_size: grain particle size (default 1.0) - colored: use colored noise (default False) - temporal_variation: grain animation 0-1 (default 1.0) - seed: random seed (default 42) state: Persistent state dict - rng: DeterministicRNG instance - frame_count: frame counter Returns: Tuple of (processed_frame, new_state) """ intensity = params.get("intensity", 0.2) grain_size = max(0.5, params.get("grain_size", 1.0)) colored = params.get("colored", False) temporal_var = params.get("temporal_variation", 1.0) seed = int(params.get("seed", 42)) if state is None: state = {} if intensity <= 0: return frame, state # Initialize RNG if "rng" not in state: state["rng"] = DeterministicRNG(seed) state["frame_count"] = 0 state["static_noise"] = None rng = state["rng"] frame_count = state["frame_count"] h, w = frame.shape[:2] # Calculate noise dimensions if grain_size > 1: noise_h = max(1, int(h / grain_size)) noise_w = max(1, int(w / grain_size)) else: noise_h, noise_w = h, w # Generate noise based on temporal variation if temporal_var >= 1.0 or state.get("static_noise") is None: # Generate new noise if colored: noise = np.array([[[rng.gaussian(0, 1) for _ in range(3)] for _ in range(noise_w)] for _ in range(noise_h)]) * intensity * 50 else: noise_2d = np.array([[rng.gaussian(0, 1) for _ in range(noise_w)] for _ in range(noise_h)]) * intensity * 50 noise = np.stack([noise_2d, noise_2d, noise_2d], axis=-1) if temporal_var < 1.0: state["static_noise"] = noise else: # Blend static and new noise static = state["static_noise"] if colored: new_noise = np.array([[[rng.gaussian(0, 1) for _ in range(3)] for _ in range(noise_w)] for _ in range(noise_h)]) * intensity * 50 else: noise_2d = np.array([[rng.gaussian(0, 1) for _ in range(noise_w)] for _ in range(noise_h)]) * intensity * 50 new_noise = np.stack([noise_2d, noise_2d, noise_2d], axis=-1) noise = static * (1 - temporal_var) + new_noise * temporal_var # Scale noise up if using larger grain if grain_size > 1: noise = cv2.resize(noise.astype(np.float32), (w, h), interpolation=cv2.INTER_LINEAR) # Add noise to frame result = frame.astype(np.float32) + noise state["frame_count"] = frame_count + 1 return np.clip(result, 0, 255).astype(np.uint8), state