OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / c0b127d

blender-addon: port keyframe-aware fades from light_controller2.py
Streaming the interpolated colour at frame rate is both redundant and faster than the single-threaded firmware can absorb: it already interpolates by itself, on wall-clock (updateFade computes t = elapsed / totalMs every loop()) and in perceptual space. So during playback, look ahead instead. New fade_mode 'To Next Keyframe' (now the default) evaluates each light's FCurves at the next keyframe, sends ONE pwm2 carrying that target plus the wall-clock gap to reach it, and then sends nothing until the segment ends. A 30-frame segment at 30 fps costs one command rather than ~25. Targets are deduplicated per fixture, because a resend mid-segment would restart the fade from the LEDs' current position rather than continuing it. Scrubbing, pausing and running past the last keyframe fall back to a plain pwm, which also cancels any fade in flight. The depsgraph handler now bows out during playback for the same reason -- it would otherwise fire mid-segment and cancel the fade. anim.get_animated_fcurves reads both animation shapes: legacy Action.fcurves (<= 4.3) and Blender 4.4+ slotted actions, where curves live on a per-slot channelbag reached via bpy_extras.anim_utils.action_get_channelbag_for_slot. Only the slot bound to that AnimData is read, since other slots in the same Action belong to other datablocks. protocol.pick_pwm2_args replaces the previous decomposition, holding both numPeriods and periodMs inside the firmware's 1..1000 bounds. Because interpolation is wall-clock, the pair only has to multiply out to the intended duration -- numPeriods does not quantise the fade. protocol.channels_from_rgbe splits the colour conversion out of slot_channels so the same path serves both the current frame (read off the datablock) and a future keyframe (evaluated off FCurves). The red cap applies to evaluated targets too. 19 new tests: target is the next keyframe's value not the current one, fade equals the frame gap, mid-segment sends suppressed, fallback past the last keyframe, fresh pwm2 per segment, red cap on targets, and the Fixed/Off modes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c0b127de36d2979ed45cb01d4c0c436537909a10
by plankatron <fleeky@gmail.com> · 2026-08-30T19:05:41.000Z

Changed files (6) +271 -33

blender-addon/README.md +30 -0
……@@ -69,6 +69,36 @@ Ctrl+L → Link Animation Data carries light data across.
6969 offset the strip start.
7070- **Per-fixture variation** — drivers on top of the shared action.
7171
72## Fade modes
73
74Per fixture, under Fixture → Fade:
75
76| Mode | Behaviour |
77|---|---|
78| **To Next Keyframe** (default) | During playback, evaluate the light curves at the next keyframe and send **one** `pwm2` faded to it, then stay quiet until the segment ends |
79| **Fixed** | `pwm2` with a constant `Fade Time` |
80| **Off** | plain `pwm` at the current value on every update |
81
82To Next Keyframe is the important one. Streaming the interpolated colour every
83frame is both redundant and too fast for the firmware — it interpolates by
84itself, on wall-clock (`updateFade`: `t = elapsed / totalMs`, recomputed every
85`loop()`), in perceptual space. So the addon hands it the *destination* and the
86time to get there, and says nothing more until the next keyframe. A 30-frame
87segment at 30 fps costs one command instead of ~25.
88
89Targets are deduplicated per fixture, so a resend cannot restart a fade that is
90already running. Scrubbing, pausing, and running past the last keyframe all fall
91back to a plain `pwm`, which also cancels any fade in flight.
92
93`pick_pwm2_args` splits the duration into `(numPeriods, periodMs)` with both
94inside the firmware's 1..1000 bounds; since interpolation is wall-clock, the
95pair only has to multiply out correctly and does not quantise the fade.
96
97Ported from `LightArm/blender/light_controller2.py`, which also supplied
98`get_animated_fcurves` — it handles both the legacy `Action.fcurves` shape
99(≤ 4.3) and Blender 4.4+ slotted actions, where curves live on a per-slot
100channelbag and only the slot bound to that datablock may be read.
101
72102## Sending
73103
74104`LIVE` arms output. Frames are sent on frame change and on viewport edit
blender-addon/cthuloid_lights/anim.py +93 -0
……@@ -0,0 +1,93 @@
1"""Reading Blender's animation system to drive firmware-side fades.
2
3Ported from LightArm/blender/light_controller2.py.
4
5The idea: during playback, do not stream the current interpolated colour at
6frame rate -- the firmware cannot keep up, and the values are redundant because
7the firmware can interpolate by itself. Instead look ahead to the next keyframe,
8evaluate the curves AT that keyframe, and send ONE pwm2 whose fade duration is
9the wall-clock gap to it. The firmware then interpolates in perceptual space
10while Blender sends nothing further for that segment.
11
12Scrubbing and paused playback fall back to a plain pwm at the current value,
13which also cancels any fade in flight.
14"""
15
16import bpy
17
18
19def is_playing():
20 """True only if a window is actually playing the timeline (not scrubbing)."""
21 wm = bpy.context.window_manager
22 if not wm:
23 return False
24 for win in wm.windows:
25 if win.screen and win.screen.is_animation_playing:
26 return True
27 return False
28
29
30def frames_to_ms(scene, frames):
31 fps = scene.render.fps / scene.render.fps_base
32 return max(1, int(round(frames * 1000.0 / fps)))
33
34
35def get_animated_fcurves(ad):
36 """FCurves driving one AnimData block, across both animation shapes.
37
38 Blender <= 4.3 keeps FCurves directly on the Action. Blender 4.4+ (slotted
39 actions) keeps them on a per-slot channelbag, and one Action may carry slots
40 for several datablocks -- so we must fetch only the slot bound to THIS
41 AnimData, or we would pick up another datablock's curves.
42 """
43 if not ad or not ad.action:
44 return []
45 action = ad.action
46
47 # Legacy: pre-4.4 Action.fcurves.
48 if hasattr(action, "fcurves"):
49 return list(action.fcurves)
50
51 slot = getattr(ad, "action_slot", None)
52 if slot is None:
53 return []
54 try:
55 from bpy_extras import anim_utils
56 cb = anim_utils.action_get_channelbag_for_slot(action, slot)
57 except Exception:
58 return []
59 return list(cb.fcurves) if cb else []
60
61
62def curves_for(light_data, data_path):
63 ad = light_data.animation_data if light_data else None
64 return [fc for fc in get_animated_fcurves(ad) if fc.data_path == data_path]
65
66
67def next_keyframe_time(fcurves, current_frame):
68 """Earliest keyframe strictly after current_frame, or None."""
69 nxt = None
70 for fc in fcurves:
71 for kp in fc.keyframe_points:
72 x = kp.co.x
73 if x > current_frame and (nxt is None or x < nxt):
74 nxt = x
75 return nxt
76
77
78def sample_light(light_data, frame, color_curves, energy_curves):
79 """(r, g, b, energy) for a light evaluated at `frame`.
80
81 Channels with no curve keep their current static value.
82 """
83 by_index = {fc.array_index: fc for fc in color_curves}
84 rgb = []
85 for i in range(3):
86 fc = by_index.get(i)
87 rgb.append(fc.evaluate(frame) if fc is not None else light_data.color[i])
88
89 energy = light_data.energy
90 if energy_curves:
91 energy = energy_curves[0].evaluate(frame)
92
93 return rgb[0], rgb[1], rgb[2], energy
blender-addon/cthuloid_lights/engine.py +92 -11
……@@ -8,20 +8,88 @@ working after running it.
88
99import bpy
1010
11from . import protocol, rig
11from . import anim, protocol, rig
1212from .net import SENDER
1313
14# Sentinel: this fixture's current animation segment has already been commanded,
15# so send nothing. Distinct from None, which means "no keyframe ahead, fall back
16# to a plain pwm".
17SKIP = object()
18
19# fixture id -> the target tuple of the pwm2 currently in flight.
20_last_pwm2_target = {}
21
22
23def reset_state():
24 _last_pwm2_target.clear()
25
26
27def _keyframe_payload(scene, fixture, slot_map):
28 """One pwm2 per animation segment, faded to the next keyframe.
29
30 Rather than streaming the interpolated colour every frame -- which the
31 single-threaded firmware cannot absorb, and which is redundant because the
32 firmware interpolates by itself -- evaluate the curves at the next keyframe
33 and hand the firmware that target plus the wall-clock time to reach it.
34 """
35 per_slot = {}
36 all_curves = []
37 for slot, obj in slot_map.items():
38 data = getattr(obj, "data", None)
39 if data is None:
40 continue
41 color_curves = anim.curves_for(data, "color")
42 energy_curves = anim.curves_for(data, "energy") if fixture.use_energy else []
43 per_slot[slot] = (data, color_curves, energy_curves)
44 all_curves.extend(color_curves)
45 all_curves.extend(energy_curves)
46
47 frame = scene.frame_current_final
48 nxt = anim.next_keyframe_time(all_curves, frame)
49 if nxt is None:
50 return None # nothing ahead -- caller falls back to plain pwm
51
52 values = []
53 for slot in range(1, protocol.SLOTS_PER_FIXTURE + 1):
54 entry = per_slot.get(slot)
55 if entry is None:
56 values.extend([0, 0, 0])
57 continue
58 data, color_curves, energy_curves = entry
59 r, g, b, energy = anim.sample_light(data, nxt, color_curves, energy_curves)
60 values.extend(protocol.channels_from_rgbe(
61 r, g, b, energy,
62 fixture.red_max_pwm, fixture.use_energy, fixture.energy_ref))
63
64 target = tuple(values)
65 if _last_pwm2_target.get(fixture.id) == target:
66 # Same segment; the fade is already running on the hardware. Sending
67 # anything now would restart it from the current position.
68 return SKIP
69 _last_pwm2_target[fixture.id] = target
70
71 return protocol.pwm2_command(values, anim.frames_to_ms(scene, nxt - frame))
72
1473
1574def build_command(scene, fixture):
75 """Payload for one fixture, or SKIP to send nothing this tick."""
1676 slot_map = rig.fixture_lights(scene, fixture)
77
78 if fixture.fade_mode == 'KEYFRAME' and anim.is_playing():
79 payload = _keyframe_payload(scene, fixture, slot_map)
80 if payload is not None:
81 return payload
82 # No keyframe ahead: fall through to a plain pwm at the current value.
83
1784 values = protocol.frame_values(
18 slot_map,
19 fixture.red_max_pwm,
20 fixture.use_energy,
21 fixture.energy_ref,
22 )
23 if fixture.use_fade:
85 slot_map, fixture.red_max_pwm, fixture.use_energy, fixture.energy_ref)
86
87 if fixture.fade_mode == 'FIXED':
2488 return protocol.pwm2_command(values, fixture.fade_ms)
89
90 # Plain pwm also cancels any fade still running on the firmware, so drop the
91 # remembered target -- the next segment must be re-sent.
92 _last_pwm2_target.pop(fixture.id, None)
2593 return protocol.pwm_command(values)
2694
2795
……@@ -34,7 +102,10 @@ def push_scene(scene, force=False):
34102 for fixture in props.fixtures:
35103 if not fixture.enabled or not fixture.host:
36104 continue
37 SENDER.send(fixture.host, fixture.port, build_command(scene, fixture), force=force)
105 payload = build_command(scene, fixture)
106 if payload is SKIP:
107 continue
108 SENDER.send(fixture.host, fixture.port, payload, force=force)
38109 sent += 1
39110 return sent
40111
……@@ -50,6 +121,7 @@ def blackout(scene):
50121 props = getattr(scene, "cthuloid", None)
51122 if props is None:
52123 return 0
124 reset_state()
53125 count = 0
54126 for fixture in props.fixtures:
55127 if not fixture.host:
……@@ -76,12 +148,14 @@ def _on_depsgraph_update(scene, depsgraph=None):
76148 props = getattr(scene, "cthuloid", None)
77149 if props is None or not props.live or not props.send_on_edit:
78150 return
151 if anim.is_playing():
152 # During playback the frame handler owns output; depsgraph updates would
153 # otherwise fire mid-segment and cancel the running fade.
154 return
79155 if depsgraph is not None:
80 # Only react to light or transform changes; ignore unrelated churn.
81156 interesting = False
82157 for update in depsgraph.updates:
83 idblock = update.id
84 if isinstance(idblock, (bpy.types.Light, bpy.types.Object)):
158 if isinstance(update.id, (bpy.types.Light, bpy.types.Object)):
85159 interesting = True
86160 break
87161 if not interesting:
……@@ -89,9 +163,15 @@ def _on_depsgraph_update(scene, depsgraph=None):
89163 push_scene(scene)
90164
91165
166@bpy.app.handlers.persistent
167def _on_load_post(*args):
168 reset_state()
169
170
92171_HANDLERS = (
93172 (bpy.app.handlers.frame_change_post, _on_frame_change),
94173 (bpy.app.handlers.depsgraph_update_post, _on_depsgraph_update),
174 (bpy.app.handlers.load_post, _on_load_post),
95175)
96176
97177
……@@ -106,3 +186,4 @@ def unregister_handlers():
106186 # Remove only our own callback -- never clear() the list.
107187 while fn in handler_list:
108188 handler_list.remove(fn)
189 reset_state()
blender-addon/cthuloid_lights/props.py +19 -8
……@@ -75,16 +75,27 @@ class CthuloidFixture(PropertyGroup):
7575
7676 # --- motion -----------------------------------------------------------
7777
78 use_fade: BoolProperty(
79 name="Smooth", default=False,
80 description=(
81 "Send pwm2 (firmware-side interpolation in perceptual space) instead "
82 "of pwm. Smooths coarse frame steps at the cost of trailing the "
83 "timeline slightly"))
78 fade_mode: EnumProperty(
79 name="Fade",
80 default='KEYFRAME',
81 items=[
82 ('OFF', "Off",
83 "Send a plain pwm at the current value on every update. Simplest, "
84 "but steps rather than glides between sparse keyframes", 0),
85 ('FIXED', "Fixed",
86 "Send pwm2 with a constant fade duration. Smooths coarse frame "
87 "steps at the cost of trailing the timeline slightly", 1),
88 ('KEYFRAME', "To Next Keyframe",
89 "During playback, evaluate the light curves at the next keyframe "
90 "and send ONE pwm2 faded to it, then stay quiet until the segment "
91 "ends. The firmware interpolates in perceptual space, so output is "
92 "smooth without streaming a frame-rate command stream it cannot "
93 "absorb. Scrubbing and pausing fall back to a plain pwm", 2),
94 ])
8495
8596 fade_ms: IntProperty(
86 name="Fade", default=60, min=1, max=1000, subtype='TIME',
87 description="Duration of the firmware-side interpolation, in milliseconds")
97 name="Fade Time", default=60, min=1, max=1000, subtype='TIME',
98 description="Fade duration used by Fixed mode, in milliseconds")
8899
89100 # --- firmware-side settings pushed on demand --------------------------
90101
blender-addon/cthuloid_lights/protocol.py +35 -12
……@@ -34,19 +34,17 @@ def pwm_from_unit(value, pwm_max):
3434 return int(clamp(round(abs(value) * pwm_max), 0, pwm_max))
3535
3636
37def slot_channels(light_obj, red_max, use_energy, energy_ref):
38 """Return [B, G, R] PWM ints for one light object, or None if unusable."""
39 data = getattr(light_obj, "data", None)
40 if data is None or not hasattr(data, "color"):
41 return None
42
43 r, g, b = data.color[0], data.color[1], data.color[2]
37def channels_from_rgbe(r, g, b, energy, red_max, use_energy, energy_ref):
38 """[B, G, R] PWM ints from raw channel values.
4439
40 Split out from slot_channels so the same conversion serves both the current
41 frame (read off the datablock) and a future keyframe (evaluated off FCurves).
42 """
4543 if use_energy:
4644 # Scale colour by normalised energy so animators can key intensity the
4745 # way they normally would. energy_ref is what counts as "full".
4846 ref = energy_ref if energy_ref > 0.0 else 1.0
49 scale = clamp(getattr(data, "energy", ref) / ref, 0.0, 1.0)
47 scale = clamp(energy / ref, 0.0, 1.0)
5048 r, g, b = r * scale, g * scale, b * scale
5149
5250 return [
……@@ -56,6 +54,17 @@ def slot_channels(light_obj, red_max, use_energy, energy_ref):
5654 ]
5755
5856
57def slot_channels(light_obj, red_max, use_energy, energy_ref):
58 """Return [B, G, R] PWM ints for one light object, or None if unusable."""
59 data = getattr(light_obj, "data", None)
60 if data is None or not hasattr(data, "color"):
61 return None
62 return channels_from_rgbe(
63 data.color[0], data.color[1], data.color[2],
64 getattr(data, "energy", energy_ref),
65 red_max, use_energy, energy_ref)
66
67
5968def frame_values(slot_map, red_max, use_energy, energy_ref):
6069 """Build the 12 channel values for a fixture from {slot: light_object}."""
6170 values = []
……@@ -70,15 +79,29 @@ def pwm_command(values):
7079 return "pwm " + " ".join(str(v) for v in values) + "\n"
7180
7281
82def pick_pwm2_args(total_ms):
83 """Split a fade duration into (numPeriods, periodMs), both within 1..1000.
84
85 updateFade() interpolates on wall-clock -- t = elapsed / totalMs, recomputed
86 every loop() -- so numPeriods does NOT quantise the fade. The pair only has
87 to multiply out to the intended duration while both stay inside the range
88 parsePWM enforces. Total is therefore capped at 1000*1000 ms.
89 """
90 total_ms = int(clamp(total_ms, 1, 1000 * 1000))
91 if total_ms <= 1000:
92 return 1, total_ms
93 period_ms = (total_ms + 999) // 1000 # ceil, so numPeriods stays <= 1000
94 num_periods = max(1, total_ms // period_ms)
95 return num_periods, period_ms
96
97
7398def pwm2_command(values, fade_ms):
74 """Interpolated move. Firmware clamps both numPeriods and periodMs to 1..1000.
99 """Interpolated move.
75100
76101 The firmware fades in perceptual space and keeps PinCurrentValues live, so a
77102 new pwm2 mid-fade picks up from where the LEDs actually are.
78103 """
79 fade_ms = int(clamp(fade_ms, 1, 1000 * 1000))
80 periods = int(clamp(fade_ms // 10 or 1, 1, 1000))
81 period_ms = int(clamp(fade_ms // periods or 1, 1, 1000))
104 periods, period_ms = pick_pwm2_args(fade_ms)
82105 return ("pwm2 %d %d " % (periods, period_ms)) + " ".join(str(v) for v in values) + "\n"
83106
84107
blender-addon/cthuloid_lights/ui.py +2 -2
……@@ -95,9 +95,9 @@ class CTHULOID_PT_fixture(CthuloidPanelBase, Panel):
9595
9696 layout.separator()
9797 col = layout.column(align=True)
98 col.prop(fixture, "use_fade")
98 col.prop(fixture, "fade_mode")
9999 sub = col.column()
100 sub.enabled = fixture.use_fade
100 sub.enabled = fixture.fade_mode == 'FIXED'
101101 sub.prop(fixture, "fade_ms")
102102
103103