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>
218 lines
6.4 KiB
Python
218 lines
6.4 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy"]
|
|
# ///
|
|
"""
|
|
@effect random
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
Deterministic random number generator for use in recipes and effects.
|
|
Given the same seed, produces the same sequence of values every time.
|
|
|
|
This effect doesn't modify the frame - it provides random values that
|
|
can be bound to other effect parameters. The random state persists
|
|
across frames for consistent sequences.
|
|
|
|
@param seed int
|
|
@default 42
|
|
Random seed for reproducibility. Same seed = same sequence.
|
|
|
|
@param min float
|
|
@default 0
|
|
Minimum output value.
|
|
|
|
@param max float
|
|
@default 1
|
|
Maximum output value.
|
|
|
|
@param mode string
|
|
@enum uniform gaussian integer choice
|
|
@default uniform
|
|
Distribution type:
|
|
- uniform: even distribution between min and max
|
|
- gaussian: normal distribution (min=mean, max=stddev)
|
|
- integer: random integers between min and max (inclusive)
|
|
- choice: randomly pick from a list (use choices param)
|
|
|
|
@param choices list
|
|
@default []
|
|
List of values to choose from (for mode=choice).
|
|
|
|
@param step_every int
|
|
@default 1
|
|
Only generate new value every N frames (1 = every frame).
|
|
|
|
@state rng RandomState
|
|
Numpy random state for deterministic sequence.
|
|
|
|
@state frame_count int
|
|
Tracks frames for step_every.
|
|
|
|
@state current_value float
|
|
Current random value (persists between steps).
|
|
|
|
@example
|
|
;; Random value 0-1 each frame
|
|
(bind (random :seed 123))
|
|
|
|
@example
|
|
;; Random integer 1-10, changes every 5 frames
|
|
(random :seed 42 :mode "integer" :min 1 :max 10 :step_every 5)
|
|
|
|
@example
|
|
;; Gaussian noise around 0.5
|
|
(random :mode "gaussian" :min 0.5 :max 0.1)
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
|
|
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
|
"""
|
|
Generate deterministic random values.
|
|
|
|
This effect passes through the frame unchanged but updates state
|
|
with random values that can be used by the recipe/executor.
|
|
|
|
Args:
|
|
frame: Input frame (passed through unchanged)
|
|
params: Effect parameters
|
|
- seed: random seed (default 42)
|
|
- min: minimum value (default 0)
|
|
- max: maximum value (default 1)
|
|
- mode: uniform/gaussian/integer/choice (default uniform)
|
|
- choices: list for choice mode
|
|
- step_every: frames between new values (default 1)
|
|
state: Persistent state dict
|
|
- rng: numpy RandomState
|
|
- frame_count: frame counter
|
|
- current_value: last generated value
|
|
|
|
Returns:
|
|
Tuple of (frame, state_with_random_value)
|
|
"""
|
|
seed = int(params.get("seed", 42))
|
|
min_val = params.get("min", 0)
|
|
max_val = params.get("max", 1)
|
|
mode = params.get("mode", "uniform")
|
|
choices = params.get("choices", [])
|
|
step_every = max(1, int(params.get("step_every", 1)))
|
|
|
|
if state is None:
|
|
state = {}
|
|
|
|
# Initialize RNG on first call
|
|
if "rng" not in state:
|
|
state["rng"] = np.random.RandomState(seed)
|
|
state["frame_count"] = 0
|
|
state["current_value"] = None
|
|
|
|
rng = state["rng"]
|
|
frame_count = state["frame_count"]
|
|
|
|
# Generate new value if needed
|
|
if frame_count % step_every == 0 or state["current_value"] is None:
|
|
if mode == "uniform":
|
|
value = rng.uniform(min_val, max_val)
|
|
elif mode == "gaussian":
|
|
# min = mean, max = stddev
|
|
value = rng.normal(min_val, max_val)
|
|
elif mode == "integer":
|
|
value = rng.randint(int(min_val), int(max_val) + 1)
|
|
elif mode == "choice" and choices:
|
|
value = choices[rng.randint(0, len(choices))]
|
|
else:
|
|
value = rng.uniform(min_val, max_val)
|
|
|
|
state["current_value"] = value
|
|
|
|
state["frame_count"] = frame_count + 1
|
|
|
|
# Store value in state for recipe access
|
|
state["value"] = state["current_value"]
|
|
|
|
return frame, state
|
|
|
|
|
|
# Standalone RNG class for use in other effects
|
|
class DeterministicRNG:
|
|
"""
|
|
Deterministic random number generator for use in effects.
|
|
|
|
Usage in effects:
|
|
from effects.random import DeterministicRNG
|
|
|
|
def process_frame(frame, params, state):
|
|
if "rng" not in state:
|
|
state["rng"] = DeterministicRNG(params.get("seed", 42))
|
|
rng = state["rng"]
|
|
|
|
value = rng.uniform(0, 1)
|
|
integer = rng.randint(0, 10)
|
|
choice = rng.choice(["a", "b", "c"])
|
|
"""
|
|
|
|
def __init__(self, seed: int = 42):
|
|
"""Initialize with seed for reproducibility."""
|
|
self._rng = np.random.RandomState(seed)
|
|
self._seed = seed
|
|
|
|
def seed(self, seed: int):
|
|
"""Reset with new seed."""
|
|
self._rng = np.random.RandomState(seed)
|
|
self._seed = seed
|
|
|
|
def uniform(self, low: float = 0, high: float = 1) -> float:
|
|
"""Random float in [low, high)."""
|
|
return self._rng.uniform(low, high)
|
|
|
|
def randint(self, low: int, high: int) -> int:
|
|
"""Random integer in [low, high]."""
|
|
return self._rng.randint(low, high + 1)
|
|
|
|
def gaussian(self, mean: float = 0, stddev: float = 1) -> float:
|
|
"""Random float from normal distribution."""
|
|
return self._rng.normal(mean, stddev)
|
|
|
|
def choice(self, items: list):
|
|
"""Random choice from list."""
|
|
if not items:
|
|
return None
|
|
return items[self._rng.randint(0, len(items))]
|
|
|
|
def shuffle(self, items: list) -> list:
|
|
"""Return shuffled copy of list."""
|
|
result = list(items)
|
|
self._rng.shuffle(result)
|
|
return result
|
|
|
|
def sample(self, items: list, n: int) -> list:
|
|
"""Random sample of n items without replacement."""
|
|
if n >= len(items):
|
|
return self.shuffle(items)
|
|
indices = self._rng.choice(len(items), n, replace=False)
|
|
return [items[i] for i in indices]
|
|
|
|
def weighted_choice(self, items: list, weights: list):
|
|
"""Random choice with weights."""
|
|
if not items or not weights:
|
|
return None
|
|
weights = np.array(weights, dtype=float)
|
|
weights /= weights.sum()
|
|
idx = self._rng.choice(len(items), p=weights)
|
|
return items[idx]
|
|
|
|
@property
|
|
def state(self) -> dict:
|
|
"""Get RNG state for serialization."""
|
|
return {"seed": self._seed, "state": self._rng.get_state()}
|
|
|
|
@classmethod
|
|
def from_state(cls, state: dict) -> 'DeterministicRNG':
|
|
"""Restore RNG from serialized state."""
|
|
rng = cls(state["seed"])
|
|
rng._rng.set_state(state["state"])
|
|
return rng
|