| … | … | @@ -69,6 +69,36 @@ Ctrl+L → Link Animation Data carries light data across. |
| 69 | 69 | offset the strip start. |
| 70 | 70 | - **Per-fixture variation** — drivers on top of the shared action. |
| 71 | 71 | |
| 72 | ## Fade modes | |
| 73 | ||
| 74 | Per 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 | ||
| 82 | To Next Keyframe is the important one. Streaming the interpolated colour every | |
| 83 | frame is both redundant and too fast for the firmware — it interpolates by | |
| 84 | itself, on wall-clock (`updateFade`: `t = elapsed / totalMs`, recomputed every | |
| 85 | `loop()`), in perceptual space. So the addon hands it the *destination* and the | |
| 86 | time to get there, and says nothing more until the next keyframe. A 30-frame | |
| 87 | segment at 30 fps costs one command instead of ~25. | |
| 88 | ||
| 89 | Targets are deduplicated per fixture, so a resend cannot restart a fade that is | |
| 90 | already running. Scrubbing, pausing, and running past the last keyframe all fall | |
| 91 | back to a plain `pwm`, which also cancels any fade in flight. | |
| 92 | ||
| 93 | `pick_pwm2_args` splits the duration into `(numPeriods, periodMs)` with both | |
| 94 | inside the firmware's 1..1000 bounds; since interpolation is wall-clock, the | |
| 95 | pair only has to multiply out correctly and does not quantise the fade. | |
| 96 | ||
| 97 | Ported 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 | |
| 100 | channelbag and only the slot bound to that datablock may be read. | |
| 101 | ||
| 72 | 102 | ## Sending |
| 73 | 103 | |
| 74 | 104 | `LIVE` arms output. Frames are sent on frame change and on viewport edit |
| … | … | @@ -0,0 +1,93 @@ |
| 1 | """Reading Blender's animation system to drive firmware-side fades. | |
| 2 | ||
| 3 | Ported from LightArm/blender/light_controller2.py. | |
| 4 | ||
| 5 | The idea: during playback, do not stream the current interpolated colour at | |
| 6 | frame rate -- the firmware cannot keep up, and the values are redundant because | |
| 7 | the firmware can interpolate by itself. Instead look ahead to the next keyframe, | |
| 8 | evaluate the curves AT that keyframe, and send ONE pwm2 whose fade duration is | |
| 9 | the wall-clock gap to it. The firmware then interpolates in perceptual space | |
| 10 | while Blender sends nothing further for that segment. | |
| 11 | ||
| 12 | Scrubbing and paused playback fall back to a plain pwm at the current value, | |
| 13 | which also cancels any fade in flight. | |
| 14 | """ | |
| 15 | ||
| 16 | import bpy | |
| 17 | ||
| 18 | ||
| 19 | def 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 | ||
| 30 | def 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 | ||
| 35 | def 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 | ||
| 62 | def 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 | ||
| 67 | def 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 | ||
| 78 | def 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 |
| … | … | @@ -8,20 +8,88 @@ working after running it. |
| 8 | 8 | |
| 9 | 9 | import bpy |
| 10 | 10 | |
| 11 | from . import protocol, rig | |
| 11 | from . import anim, protocol, rig | |
| 12 | 12 | from .net import SENDER |
| 13 | 13 | |
| 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". | |
| 17 | SKIP = object() | |
| 18 | ||
| 19 | # fixture id -> the target tuple of the pwm2 currently in flight. | |
| 20 | _last_pwm2_target = {} | |
| 21 | ||
| 22 | ||
| 23 | def reset_state(): | |
| 24 | _last_pwm2_target.clear() | |
| 25 | ||
| 26 | ||
| 27 | def _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 | ||
| 14 | 73 | |
| 15 | 74 | def build_command(scene, fixture): |
| 75 | """Payload for one fixture, or SKIP to send nothing this tick.""" | |
| 16 | 76 | 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 | ||
| 17 | 84 | 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': | |
| 24 | 88 | 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) | |
| 25 | 93 | return protocol.pwm_command(values) |
| 26 | 94 | |
| 27 | 95 | |
| … | … | @@ -34,7 +102,10 @@ def push_scene(scene, force=False): |
| 34 | 102 | for fixture in props.fixtures: |
| 35 | 103 | if not fixture.enabled or not fixture.host: |
| 36 | 104 | 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) | |
| 38 | 109 | sent += 1 |
| 39 | 110 | return sent |
| 40 | 111 | |
| … | … | @@ -50,6 +121,7 @@ def blackout(scene): |
| 50 | 121 | props = getattr(scene, "cthuloid", None) |
| 51 | 122 | if props is None: |
| 52 | 123 | return 0 |
| 124 | reset_state() | |
| 53 | 125 | count = 0 |
| 54 | 126 | for fixture in props.fixtures: |
| 55 | 127 | if not fixture.host: |
| … | … | @@ -76,12 +148,14 @@ def _on_depsgraph_update(scene, depsgraph=None): |
| 76 | 148 | props = getattr(scene, "cthuloid", None) |
| 77 | 149 | if props is None or not props.live or not props.send_on_edit: |
| 78 | 150 | 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 | |
| 79 | 155 | if depsgraph is not None: |
| 80 | # Only react to light or transform changes; ignore unrelated churn. | |
| 81 | 156 | interesting = False |
| 82 | 157 | 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)): | |
| 85 | 159 | interesting = True |
| 86 | 160 | break |
| 87 | 161 | if not interesting: |
| … | … | @@ -89,9 +163,15 @@ def _on_depsgraph_update(scene, depsgraph=None): |
| 89 | 163 | push_scene(scene) |
| 90 | 164 | |
| 91 | 165 | |
| 166 | @bpy.app.handlers.persistent | |
| 167 | def _on_load_post(*args): | |
| 168 | reset_state() | |
| 169 | ||
| 170 | ||
| 92 | 171 | _HANDLERS = ( |
| 93 | 172 | (bpy.app.handlers.frame_change_post, _on_frame_change), |
| 94 | 173 | (bpy.app.handlers.depsgraph_update_post, _on_depsgraph_update), |
| 174 | (bpy.app.handlers.load_post, _on_load_post), | |
| 95 | 175 | ) |
| 96 | 176 | |
| 97 | 177 | |
| … | … | @@ -106,3 +186,4 @@ def unregister_handlers(): |
| 106 | 186 | # Remove only our own callback -- never clear() the list. |
| 107 | 187 | while fn in handler_list: |
| 108 | 188 | handler_list.remove(fn) |
| 189 | reset_state() |
| … | … | @@ -75,16 +75,27 @@ class CthuloidFixture(PropertyGroup): |
| 75 | 75 | |
| 76 | 76 | # --- motion ----------------------------------------------------------- |
| 77 | 77 | |
| 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 | ]) | |
| 84 | 95 | |
| 85 | 96 | 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") | |
| 88 | 99 | |
| 89 | 100 | # --- firmware-side settings pushed on demand -------------------------- |
| 90 | 101 |
| … | … | @@ -34,19 +34,17 @@ def pwm_from_unit(value, pwm_max): |
| 34 | 34 | return int(clamp(round(abs(value) * pwm_max), 0, pwm_max)) |
| 35 | 35 | |
| 36 | 36 | |
| 37 | def 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] | |
| 37 | def channels_from_rgbe(r, g, b, energy, red_max, use_energy, energy_ref): | |
| 38 | """[B, G, R] PWM ints from raw channel values. | |
| 44 | 39 | |
| 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 | """ | |
| 45 | 43 | if use_energy: |
| 46 | 44 | # Scale colour by normalised energy so animators can key intensity the |
| 47 | 45 | # way they normally would. energy_ref is what counts as "full". |
| 48 | 46 | 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) | |
| 50 | 48 | r, g, b = r * scale, g * scale, b * scale |
| 51 | 49 | |
| 52 | 50 | return [ |
| … | … | @@ -56,6 +54,17 @@ def slot_channels(light_obj, red_max, use_energy, energy_ref): |
| 56 | 54 | ] |
| 57 | 55 | |
| 58 | 56 | |
| 57 | def 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 | ||
| 59 | 68 | def frame_values(slot_map, red_max, use_energy, energy_ref): |
| 60 | 69 | """Build the 12 channel values for a fixture from {slot: light_object}.""" |
| 61 | 70 | values = [] |
| … | … | @@ -70,15 +79,29 @@ def pwm_command(values): |
| 70 | 79 | return "pwm " + " ".join(str(v) for v in values) + "\n" |
| 71 | 80 | |
| 72 | 81 | |
| 82 | def 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 | ||
| 73 | 98 | def pwm2_command(values, fade_ms): |
| 74 | """Interpolated move. Firmware clamps both numPeriods and periodMs to 1..1000. | |
| 99 | """Interpolated move. | |
| 75 | 100 | |
| 76 | 101 | The firmware fades in perceptual space and keeps PinCurrentValues live, so a |
| 77 | 102 | new pwm2 mid-fade picks up from where the LEDs actually are. |
| 78 | 103 | """ |
| 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) | |
| 82 | 105 | return ("pwm2 %d %d " % (periods, period_ms)) + " ".join(str(v) for v in values) + "\n" |
| 83 | 106 | |
| 84 | 107 |
| … | … | @@ -95,9 +95,9 @@ class CTHULOID_PT_fixture(CthuloidPanelBase, Panel): |
| 95 | 95 | |
| 96 | 96 | layout.separator() |
| 97 | 97 | col = layout.column(align=True) |
| 98 | col.prop(fixture, "use_fade") | |
| 98 | col.prop(fixture, "fade_mode") | |
| 99 | 99 | sub = col.column() |
| 100 | sub.enabled = fixture.use_fade | |
| 100 | sub.enabled = fixture.fade_mode == 'FIXED' | |
| 101 | 101 | sub.prop(fixture, "fade_ms") |
| 102 | 102 | |
| 103 | 103 |