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>
149 lines
4.4 KiB
Python
149 lines
4.4 KiB
Python
# /// 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
|