OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / blender-addon / cthuloid_lights / anim.py

94 lines · python ref:
"""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