312 lines
8.7 KiB
Python
312 lines
8.7 KiB
Python
"""
|
|
Parameter binding resolution.
|
|
|
|
Resolves bind expressions to per-frame lookup tables at plan time.
|
|
Binding options:
|
|
- :range [lo hi] - map 0-1 to output range
|
|
- :smooth N - smoothing window in seconds
|
|
- :offset N - time offset in seconds
|
|
- :on-event V - value on discrete events
|
|
- :decay N - exponential decay after event
|
|
- :noise N - add deterministic noise (seeded)
|
|
- :seed N - explicit RNG seed
|
|
"""
|
|
|
|
import hashlib
|
|
import math
|
|
import random
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
@dataclass
|
|
class AnalysisData:
|
|
"""
|
|
Analysis data for binding resolution.
|
|
|
|
Attributes:
|
|
frame_rate: Video frame rate
|
|
total_frames: Total number of frames
|
|
features: Dict mapping feature name to per-frame values
|
|
events: Dict mapping event name to list of frame indices
|
|
"""
|
|
|
|
frame_rate: float
|
|
total_frames: int
|
|
features: Dict[str, List[float]] # feature -> [value_per_frame]
|
|
events: Dict[str, List[int]] # event -> [frame_indices]
|
|
|
|
def get_feature(self, name: str, frame: int) -> float:
|
|
"""Get feature value at frame, interpolating if needed."""
|
|
if name not in self.features:
|
|
return 0.0
|
|
values = self.features[name]
|
|
if not values:
|
|
return 0.0
|
|
if frame >= len(values):
|
|
return values[-1]
|
|
return values[frame]
|
|
|
|
def get_events_in_range(
|
|
self, name: str, start_frame: int, end_frame: int
|
|
) -> List[int]:
|
|
"""Get event frames in range."""
|
|
if name not in self.events:
|
|
return []
|
|
return [f for f in self.events[name] if start_frame <= f < end_frame]
|
|
|
|
|
|
@dataclass
|
|
class ResolvedBinding:
|
|
"""
|
|
Resolved binding with per-frame values.
|
|
|
|
Attributes:
|
|
param_name: Parameter this binding applies to
|
|
values: List of values, one per frame
|
|
"""
|
|
|
|
param_name: str
|
|
values: List[float]
|
|
|
|
def get(self, frame: int) -> float:
|
|
"""Get value at frame."""
|
|
if frame >= len(self.values):
|
|
return self.values[-1] if self.values else 0.0
|
|
return self.values[frame]
|
|
|
|
|
|
def resolve_binding(
|
|
binding: Dict[str, Any],
|
|
analysis: AnalysisData,
|
|
param_name: str,
|
|
cache_id: str = None,
|
|
) -> ResolvedBinding:
|
|
"""
|
|
Resolve a binding specification to per-frame values.
|
|
|
|
Args:
|
|
binding: Binding spec with source, feature, and options
|
|
analysis: Analysis data with features and events
|
|
param_name: Name of the parameter being bound
|
|
cache_id: Cache ID for deterministic seeding
|
|
|
|
Returns:
|
|
ResolvedBinding with values for each frame
|
|
"""
|
|
feature = binding.get("feature")
|
|
if not feature:
|
|
raise ValueError(f"Binding for {param_name} missing feature")
|
|
|
|
# Get base values
|
|
values = []
|
|
is_event = feature in analysis.events
|
|
|
|
if is_event:
|
|
# Event-based binding
|
|
on_event = binding.get("on_event", 1.0)
|
|
decay = binding.get("decay", 0.0)
|
|
values = _resolve_event_binding(
|
|
analysis.events.get(feature, []),
|
|
analysis.total_frames,
|
|
analysis.frame_rate,
|
|
on_event,
|
|
decay,
|
|
)
|
|
else:
|
|
# Continuous feature binding
|
|
feature_values = analysis.features.get(feature, [])
|
|
if not feature_values:
|
|
# No data, use zeros
|
|
values = [0.0] * analysis.total_frames
|
|
else:
|
|
# Extend to total frames if needed
|
|
values = list(feature_values)
|
|
while len(values) < analysis.total_frames:
|
|
values.append(values[-1] if values else 0.0)
|
|
|
|
# Apply offset
|
|
offset = binding.get("offset")
|
|
if offset:
|
|
offset_frames = int(offset * analysis.frame_rate)
|
|
values = _apply_offset(values, offset_frames)
|
|
|
|
# Apply smoothing
|
|
smooth = binding.get("smooth")
|
|
if smooth:
|
|
window_frames = int(smooth * analysis.frame_rate)
|
|
values = _apply_smoothing(values, window_frames)
|
|
|
|
# Apply range mapping
|
|
range_spec = binding.get("range")
|
|
if range_spec:
|
|
lo, hi = range_spec
|
|
values = _apply_range(values, lo, hi)
|
|
|
|
# Apply noise
|
|
noise = binding.get("noise")
|
|
if noise:
|
|
seed = binding.get("seed")
|
|
if seed is None and cache_id:
|
|
# Derive seed from cache_id for determinism
|
|
seed = int(hashlib.sha256(cache_id.encode()).hexdigest()[:8], 16)
|
|
values = _apply_noise(values, noise, seed or 0)
|
|
|
|
return ResolvedBinding(param_name=param_name, values=values)
|
|
|
|
|
|
def _resolve_event_binding(
|
|
event_frames: List[int],
|
|
total_frames: int,
|
|
frame_rate: float,
|
|
on_event: float,
|
|
decay: float,
|
|
) -> List[float]:
|
|
"""
|
|
Resolve event-based binding with optional decay.
|
|
|
|
Args:
|
|
event_frames: List of frame indices where events occur
|
|
total_frames: Total number of frames
|
|
frame_rate: Video frame rate
|
|
on_event: Value at event
|
|
decay: Decay time constant in seconds (0 = instant)
|
|
|
|
Returns:
|
|
List of values per frame
|
|
"""
|
|
values = [0.0] * total_frames
|
|
|
|
if not event_frames:
|
|
return values
|
|
|
|
event_set = set(event_frames)
|
|
|
|
if decay <= 0:
|
|
# No decay - just mark event frames
|
|
for f in event_frames:
|
|
if 0 <= f < total_frames:
|
|
values[f] = on_event
|
|
else:
|
|
# Apply exponential decay
|
|
decay_frames = decay * frame_rate
|
|
for f in event_frames:
|
|
if f < 0 or f >= total_frames:
|
|
continue
|
|
# Apply decay from this event forward
|
|
for i in range(f, total_frames):
|
|
elapsed = i - f
|
|
decayed = on_event * math.exp(-elapsed / decay_frames)
|
|
if decayed < 0.001:
|
|
break
|
|
values[i] = max(values[i], decayed)
|
|
|
|
return values
|
|
|
|
|
|
def _apply_offset(values: List[float], offset_frames: int) -> List[float]:
|
|
"""Shift values by offset frames (positive = delay)."""
|
|
if offset_frames == 0:
|
|
return values
|
|
|
|
n = len(values)
|
|
result = [0.0] * n
|
|
|
|
for i in range(n):
|
|
src = i - offset_frames
|
|
if 0 <= src < n:
|
|
result[i] = values[src]
|
|
|
|
return result
|
|
|
|
|
|
def _apply_smoothing(values: List[float], window_frames: int) -> List[float]:
|
|
"""Apply moving average smoothing."""
|
|
if window_frames <= 1:
|
|
return values
|
|
|
|
n = len(values)
|
|
result = []
|
|
half = window_frames // 2
|
|
|
|
for i in range(n):
|
|
start = max(0, i - half)
|
|
end = min(n, i + half + 1)
|
|
avg = sum(values[start:end]) / (end - start)
|
|
result.append(avg)
|
|
|
|
return result
|
|
|
|
|
|
def _apply_range(values: List[float], lo: float, hi: float) -> List[float]:
|
|
"""Map values from 0-1 to lo-hi range."""
|
|
return [lo + v * (hi - lo) for v in values]
|
|
|
|
|
|
def _apply_noise(values: List[float], amount: float, seed: int) -> List[float]:
|
|
"""Add deterministic noise to values."""
|
|
rng = random.Random(seed)
|
|
return [v + rng.uniform(-amount, amount) for v in values]
|
|
|
|
|
|
def resolve_all_bindings(
|
|
config: Dict[str, Any],
|
|
analysis: AnalysisData,
|
|
cache_id: str = None,
|
|
) -> Dict[str, ResolvedBinding]:
|
|
"""
|
|
Resolve all bindings in a config dict.
|
|
|
|
Looks for values with _binding: True marker.
|
|
|
|
Args:
|
|
config: Node config with potential bindings
|
|
analysis: Analysis data
|
|
cache_id: Cache ID for seeding
|
|
|
|
Returns:
|
|
Dict mapping param name to resolved binding
|
|
"""
|
|
resolved = {}
|
|
|
|
for key, value in config.items():
|
|
if isinstance(value, dict) and value.get("_binding"):
|
|
resolved[key] = resolve_binding(value, analysis, key, cache_id)
|
|
|
|
return resolved
|
|
|
|
|
|
def bindings_to_lookup_table(
|
|
bindings: Dict[str, ResolvedBinding],
|
|
) -> Dict[str, List[float]]:
|
|
"""
|
|
Convert resolved bindings to simple lookup tables.
|
|
|
|
Returns dict mapping param name to list of per-frame values.
|
|
This format is JSON-serializable for inclusion in execution plans.
|
|
"""
|
|
return {name: binding.values for name, binding in bindings.items()}
|
|
|
|
|
|
def has_bindings(config: Dict[str, Any]) -> bool:
|
|
"""Check if config contains any bindings."""
|
|
for value in config.values():
|
|
if isinstance(value, dict) and value.get("_binding"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def extract_binding_sources(config: Dict[str, Any]) -> List[str]:
|
|
"""
|
|
Extract all analysis source references from bindings.
|
|
|
|
Returns list of node IDs that provide analysis data.
|
|
"""
|
|
sources = []
|
|
for value in config.values():
|
|
if isinstance(value, dict) and value.get("_binding"):
|
|
source = value.get("source")
|
|
if source and source not in sources:
|
|
sources.append(source)
|
|
return sources
|