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>
140 lines
3.7 KiB
Python
140 lines
3.7 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy", "opencv-python"]
|
|
# ///
|
|
"""
|
|
@effect color_grade
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
Color grading effect. Applies cinematic color adjustments including
|
|
shadows/midtones/highlights tinting, lift/gamma/gain, and temperature.
|
|
|
|
@param shadows list
|
|
@default [0, 0, 0]
|
|
RGB tint for dark areas.
|
|
|
|
@param midtones list
|
|
@default [0, 0, 0]
|
|
RGB tint for middle tones.
|
|
|
|
@param highlights list
|
|
@default [0, 0, 0]
|
|
RGB tint for bright areas.
|
|
|
|
@param lift float
|
|
@range -0.5 0.5
|
|
@default 0
|
|
Raise/lower shadow levels.
|
|
|
|
@param gamma float
|
|
@range 0.5 2
|
|
@default 1
|
|
Midtone brightness curve.
|
|
|
|
@param gain float
|
|
@range 0.5 2
|
|
@default 1
|
|
Highlight intensity.
|
|
|
|
@param temperature float
|
|
@range -100 100
|
|
@default 0
|
|
Color temperature (-100 = cool/blue, +100 = warm/orange).
|
|
|
|
@param tint float
|
|
@range -100 100
|
|
@default 0
|
|
Green/magenta tint (-100 = green, +100 = magenta).
|
|
|
|
@example
|
|
(effect color_grade :temperature 30 :shadows [0 0 20])
|
|
|
|
@example
|
|
;; Cinematic teal-orange look
|
|
(effect color_grade :shadows [0 10 20] :highlights [20 10 0])
|
|
"""
|
|
|
|
import numpy as np
|
|
import cv2
|
|
|
|
|
|
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
|
"""
|
|
Apply color grading 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)
|
|
"""
|
|
shadows = params.get("shadows", [0, 0, 0])
|
|
midtones = params.get("midtones", [0, 0, 0])
|
|
highlights = params.get("highlights", [0, 0, 0])
|
|
lift = params.get("lift", 0)
|
|
gamma = max(0.5, min(params.get("gamma", 1), 2))
|
|
gain = max(0.5, min(params.get("gain", 1), 2))
|
|
temperature = params.get("temperature", 0)
|
|
tint = params.get("tint", 0)
|
|
|
|
if state is None:
|
|
state = {}
|
|
|
|
result = frame.astype(np.float32) / 255.0
|
|
|
|
# Apply lift (shadows)
|
|
result = result + lift
|
|
|
|
# Apply gamma (midtones)
|
|
result = np.power(np.clip(result, 0.001, 1), 1 / gamma)
|
|
|
|
# Apply gain (highlights)
|
|
result = result * gain
|
|
|
|
# Convert tints to float
|
|
if isinstance(shadows, (list, tuple)) and len(shadows) >= 3:
|
|
shadows = np.array(shadows[:3], dtype=np.float32) / 255.0
|
|
else:
|
|
shadows = np.zeros(3, dtype=np.float32)
|
|
|
|
if isinstance(midtones, (list, tuple)) and len(midtones) >= 3:
|
|
midtones = np.array(midtones[:3], dtype=np.float32) / 255.0
|
|
else:
|
|
midtones = np.zeros(3, dtype=np.float32)
|
|
|
|
if isinstance(highlights, (list, tuple)) and len(highlights) >= 3:
|
|
highlights = np.array(highlights[:3], dtype=np.float32) / 255.0
|
|
else:
|
|
highlights = np.zeros(3, dtype=np.float32)
|
|
|
|
# Calculate luminance for zone-based grading
|
|
lum = 0.299 * result[:, :, 0] + 0.587 * result[:, :, 1] + 0.114 * result[:, :, 2]
|
|
|
|
# Create zone masks
|
|
shadow_mask = np.clip(1 - lum * 3, 0, 1)[:, :, np.newaxis]
|
|
highlight_mask = np.clip((lum - 0.67) * 3, 0, 1)[:, :, np.newaxis]
|
|
midtone_mask = 1 - shadow_mask - highlight_mask
|
|
|
|
# Apply zone tints
|
|
for c in range(3):
|
|
result[:, :, c] += shadows[c] * shadow_mask[:, :, 0]
|
|
result[:, :, c] += midtones[c] * midtone_mask[:, :, 0]
|
|
result[:, :, c] += highlights[c] * highlight_mask[:, :, 0]
|
|
|
|
# Apply temperature (blue <-> orange)
|
|
if temperature != 0:
|
|
temp_shift = temperature / 100.0
|
|
result[:, :, 0] += temp_shift * 0.3 # Red
|
|
result[:, :, 2] -= temp_shift * 0.3 # Blue
|
|
|
|
# Apply tint (green <-> magenta)
|
|
if tint != 0:
|
|
tint_shift = tint / 100.0
|
|
result[:, :, 1] -= tint_shift * 0.2 # Green
|
|
|
|
return (np.clip(result, 0, 1) * 255).astype(np.uint8), state
|