"""Reading Blender's animation system to drive firmware-side fades.
Ported from LightArm/blender/light_controller2.py.
The idea: during playback, do not stream the current interpolated colour at
frame rate -- the firmware cannot keep up, and the values are redundant because
the firmware can interpolate by itself. Instead look ahead to the next keyframe,
evaluate the curves AT that keyframe, and send ONE pwm2 whose fade duration is
the wall-clock gap to it. The firmware then interpolates in perceptual space
while Blender sends nothing further for that segment.
Scrubbing and paused playback fall back to a plain pwm at the current value,
which also cancels any fade in flight.
"""
import bpy
def is_playing():
"""True only if a window is actually playing the timeline (not scrubbing)."""
wm = bpy.context.window_manager
if not wm:
return False
for win in wm.windows:
if win.screen and win.screen.is_animation_playing:
return True
return False
def frames_to_ms(scene, frames):
fps = scene.render.fps / scene.render.fps_base
return max(1, int(round(frames * 1000.0 / fps)))
def get_animated_fcurves(ad):
"""FCurves driving one AnimData block, across both animation shapes.
Blender <= 4.3 keeps FCurves directly on the Action. Blender 4.4+ (slotted
actions) keeps them on a per-slot channelbag, and one Action may carry slots
for several datablocks -- so we must fetch only the slot bound to THIS
AnimData, or we would pick up another datablock's curves.
"""
if not ad or not ad.action:
return []
action = ad.action
# Legacy: pre-4.4 Action.fcurves.
if hasattr(action, "fcurves"):
return list(action.fcurves)
slot = getattr(ad, "action_slot", None)
if slot is None:
return []
try:
from bpy_extras import anim_utils
cb = anim_utils.action_get_channelbag_for_slot(action, slot)
except Exception:
return []
return list(cb.fcurves) if cb else []
def curves_for(light_data, data_path):
ad = light_data.animation_data if light_data else None
return [fc for fc in get_animated_fcurves(ad) if fc.data_path == data_path]
def next_keyframe_time(fcurves, current_frame):
"""Earliest keyframe strictly after current_frame, or None."""
nxt = None
for fc in fcurves:
for kp in fc.keyframe_points:
x = kp.co.x
if x > current_frame and (nxt is None or x < nxt):
nxt = x
return nxt
def sample_light(light_data, frame, color_curves, energy_curves):
"""(r, g, b, energy) for a light evaluated at `frame`.
Channels with no curve keep their current static value.
"""
by_index = {fc.array_index: fc for fc in color_curves}
rgb = []
for i in range(3):
fc = by_index.get(i)
rgb.append(fc.evaluate(frame) if fc is not None else light_data.color[i])
energy = light_data.energy
if energy_curves:
energy = energy_curves[0].evaluate(frame)
return rgb[0], rgb[1], rgb[2], energy