"""Scene -> wire. Owns the Blender handlers.
Handlers are added and removed by identity. quadpanel.py called
frame_change_post.clear(), which silently removed every other addon's handlers
too -- a rig with any other frame-driven addon in the scene would quietly stop
working after running it.
"""
import bpy
from . import anim, protocol, rig
from . import props as props_mod
from .net import SENDER
# Sentinel: this fixture's current animation segment has already been commanded,
# so send nothing. Distinct from None, which means "no keyframe ahead, fall back
# to a plain pwm".
SKIP = object()
# fixture id -> the target tuple of the pwm2 currently in flight.
_last_pwm2_target = {}
def reset_state():
_last_pwm2_target.clear()
def _keyframe_payload(scene, fixture, slot_map):
"""One pwm2 per animation segment, faded to the next keyframe.
Rather than streaming the interpolated colour every frame -- which the
single-threaded firmware cannot absorb, and which is redundant because the
firmware interpolates by itself -- evaluate the curves at the next keyframe
and hand the firmware that target plus the wall-clock time to reach it.
"""
per_slot = {}
all_curves = []
for slot, obj in slot_map.items():
data = getattr(obj, "data", None)
if data is None:
continue
color_curves = anim.curves_for(data, "color")
energy_curves = anim.curves_for(data, "energy") if fixture.use_energy else []
per_slot[slot] = (data, color_curves, energy_curves)
all_curves.extend(color_curves)
all_curves.extend(energy_curves)
frame = scene.frame_current_final
nxt = anim.next_keyframe_time(all_curves, frame)
if nxt is None:
return None # nothing ahead -- caller falls back to plain pwm
values = []
for slot in range(1, protocol.SLOTS_PER_FIXTURE + 1):
entry = per_slot.get(slot)
if entry is None:
values.extend([0, 0, 0])
continue
data, color_curves, energy_curves = entry
r, g, b, energy = anim.sample_light(data, nxt, color_curves, energy_curves)
values.extend(protocol.channels_from_rgbe(
r, g, b, energy,
fixture.red_max_pwm, fixture.use_energy, fixture.energy_ref))
target = tuple(values)
if _last_pwm2_target.get(fixture.id) == target:
# Same segment; the fade is already running on the hardware. Sending
# anything now would restart it from the current position.
return SKIP
_last_pwm2_target[fixture.id] = target
return protocol.Frame(target, anim.frames_to_ms(scene, nxt - frame))
def build_command(scene, fixture):
"""A protocol.Frame for one fixture, or SKIP to send nothing this tick.
The sender turns the Frame into whatever the unit speaks: a whole-unit
pwm/pwm2 (proto <= 2) or per-slot grants and `slot` lines (proto 3)."""
slot_map = rig.fixture_lights(scene, fixture)
if fixture.fade_mode == 'KEYFRAME' and anim.is_playing():
payload = _keyframe_payload(scene, fixture, slot_map)
if payload is not None:
return payload
# No keyframe ahead: fall through to a plain pwm at the current value.
values = protocol.frame_values(
slot_map, fixture.red_max_pwm, fixture.use_energy, fixture.energy_ref)
if fixture.fade_mode == 'FIXED':
return protocol.Frame(tuple(values), fixture.fade_ms)
# Plain pwm also cancels any fade still running on the firmware, so drop the
# remembered target -- the next segment must be re-sent.
_last_pwm2_target.pop(fixture.id, None)
return protocol.Frame(tuple(values), 0)
def push_scene(scene, force=False):
props = getattr(scene, "cthuloid", None)
if props is None:
return 0
SENDER.min_interval = 1.0 / max(1, props.max_hz)
sent = 0
for fixture in props.fixtures:
if push_fixture(scene, fixture, force):
sent += 1
return sent
def push_fixture(scene, fixture, force=False):
"""Send one fixture its current state. False if disabled or nothing to send."""
if not fixture.enabled or not fixture.host:
return False
payload = build_command(scene, fixture)
if payload is SKIP:
return False
SENDER.send(fixture.host, fixture.port, payload, force=force)
return True
def push_settings(scene, fixture):
"""Push the firmware-side settings (gamma, freq) for one fixture."""
payload = protocol.gamma_command(fixture.gamma_b, fixture.gamma_g, fixture.gamma_r)
payload += protocol.freq_command(fixture.freq)
SENDER.send(fixture.host, fixture.port, payload, force=True)
def blackout(scene):
props = getattr(scene, "cthuloid", None)
if props is None:
return 0
reset_state()
count = 0
for fixture in props.fixtures:
if not fixture.host:
continue
# Blackout works connected or not. Disconnected, it is a one-shot:
# connect, deliver, let go -- it must not leave connections behind.
SENDER.send(fixture.host, fixture.port, protocol.blackout_command(),
force=True, once=not props_mod._connected)
count += 1
return count
# ---------------------------------------------------------------------------
# handlers
@bpy.app.handlers.persistent
def _on_frame_change(scene, depsgraph=None):
props = getattr(scene, "cthuloid", None)
if props is None or not props.live or not props.send_on_frame:
return
push_scene(scene)
@bpy.app.handlers.persistent
def _on_depsgraph_update(scene, depsgraph=None):
props = getattr(scene, "cthuloid", None)
if props is None or not props.live or not props.send_on_edit:
return
if anim.is_playing():
# During playback the frame handler owns output; depsgraph updates would
# otherwise fire mid-segment and cancel the running fade.
return
if depsgraph is not None:
interesting = False
for update in depsgraph.updates:
if isinstance(update.id, (bpy.types.Light, bpy.types.Object)):
interesting = True
break
if not interesting:
return
push_scene(scene)
@bpy.app.handlers.persistent
def _on_load_post(*args):
# A newly opened file starts disconnected, like a fresh session.
props_mod.reset_connection()
reset_state()
_HANDLERS = (
(bpy.app.handlers.frame_change_post, _on_frame_change),
(bpy.app.handlers.depsgraph_update_post, _on_depsgraph_update),
(bpy.app.handlers.load_post, _on_load_post),
)
def register_handlers():
for handler_list, fn in _HANDLERS:
if fn not in handler_list:
handler_list.append(fn)
def unregister_handlers():
for handler_list, fn in _HANDLERS:
# Remove only our own callback -- never clear() the list.
while fn in handler_list:
handler_list.remove(fn)
reset_state()