From 740976ae499bad9b7117c1b9537f6cf20a68addb Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sat, 11 Jul 2026 14:34:56 +0100 Subject: [PATCH 1/2] Usucessful attempt at resolving timeline writing changes to unarmed tracks bug --- cleave/config_schema.py | 23 +++++++++- cleave/timeline.py | 37 ++++++++++++---- cleave/viz/layer_visibility.py | 8 ++-- cleave/viz/timeline_controls.py | 12 +++++- cleave/viz/timeline_overlay.py | 6 +-- cleave/viz/timeline_panel_cache.py | 7 +++- tests/cleave/test_config.py | 39 +++++++++++++++++ tests/cleave/test_config_snapshot.py | 34 +++++++++++++++ tests/cleave/test_timeline.py | 49 ++++++++++++++++++++++ tests/cleave/test_timeline_snap.py | 10 +++-- tests/cleave/viz/test_layer.py | 20 +++++++-- tests/cleave/viz/test_timeline_controls.py | 44 ++++++++++++++++++- tests/cleave/viz/test_timeline_overlay.py | 34 ++++++++++++--- 13 files changed, 285 insertions(+), 38 deletions(-) diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 3f51f2d..26f0f7f 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -1825,7 +1825,19 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: + ", ".join(unknown) ) layers = {stem: bool(layers_raw[stem]) for stem in layers_raw} - cues.append(TimelineCue(t=t, layers=layers)) + no_tick_raw = cue_map.get("no_tick", []) + if no_tick_raw is None: + no_tick_raw = [] + if not isinstance(no_tick_raw, list): + raise ValueError(f"timeline.cues[{index}].no_tick must be a list") + no_tick = frozenset(str(stem) for stem in no_tick_raw) + unknown_no_tick = sorted(no_tick - set(layers)) + if unknown_no_tick: + raise ValueError( + f"timeline.cues[{index}].no_tick must reference layers in the cue: " + + ", ".join(unknown_no_tick) + ) + cues.append(TimelineCue(t=t, layers=layers, no_tick_slots=no_tick)) cues.sort(key=lambda cue: cue.t) return TimelineConfig(enabled=enabled, cues=tuple(cues)) @@ -1835,12 +1847,19 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: out: dict[str, Any] = {"enabled": runtime.enabled} if runtime.cues: out["cues"] = [ - {"t": cue.t, "layers": dict(cue.layers)} + _persist_timeline_cue(cue) for cue in sorted(runtime.cues, key=lambda cue: cue.t) ] return out +def _persist_timeline_cue(cue: TimelineCue) -> dict[str, Any]: + entry: dict[str, Any] = {"t": cue.t, "layers": dict(cue.layers)} + if cue.no_tick_slots: + entry["no_tick"] = sorted(cue.no_tick_slots) + return entry + + def persisted_session_payload(cfg: Any, session: Any) -> dict[str, Any]: cfg_dir = getattr(cfg, "config_path", None) cfg_dir = cfg_dir.parent if cfg_dir is not None else None diff --git a/cleave/timeline.py b/cleave/timeline.py index fe1fbcf..48f74a0 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -24,7 +24,18 @@ class TimelineCue: t: float layers: dict[str, bool] - show_tick: bool = True + no_tick_slots: frozenset[str] = frozenset() + + def shows_tick(self, slot: str) -> bool: + """True when *slot* changes here and marks a real (ticked) transition. + + Tick-ness is per slot so cues that merge several slots at the same time + keep each slot's own transition semantics. A slot in ``no_tick_slots`` is + a synthetic anchor/restore (baseline capture, committed restore, seek + fill) that neither draws a tick nor counts as a transition for + pre-first-cue anchor inference. + """ + return slot in self.layers and slot not in self.no_tick_slots def stem_abbreviation(stem: StemSource) -> str: @@ -73,7 +84,7 @@ def _merge_cues_at_same_t(cues: list[TimelineCue]) -> list[TimelineCue]: merged: list[TimelineCue] = [] current_t: float | None = None current_layers: dict[str, bool] = {} - current_show_tick = True + current_no_tick: set[str] = set() for cue in sorted(cues, key=lambda c: c.t): if current_t is None: current_t = cue.t @@ -82,20 +93,24 @@ def _merge_cues_at_same_t(cues: list[TimelineCue]) -> list[TimelineCue]: TimelineCue( t=current_t, layers=dict(current_layers), - show_tick=current_show_tick, + no_tick_slots=frozenset(current_no_tick), ) ) current_t = cue.t current_layers = {} - current_show_tick = True - current_layers.update(cue.layers) - current_show_tick = current_show_tick and cue.show_tick + current_no_tick = set() + for slot, value in cue.layers.items(): + current_layers[slot] = value + if slot in cue.no_tick_slots: + current_no_tick.add(slot) + else: + current_no_tick.discard(slot) if current_t is not None: merged.append( TimelineCue( t=current_t, layers=dict(current_layers), - show_tick=current_show_tick, + no_tick_slots=frozenset(current_no_tick), ) ) return merged @@ -142,7 +157,11 @@ def snap_cues_to_beats( if beats.size == 1: sole = float(beats[0]) snapped = [ - TimelineCue(t=sole, layers=dict(cue.layers), show_tick=cue.show_tick) + TimelineCue( + t=sole, + layers=dict(cue.layers), + no_tick_slots=cue.no_tick_slots, + ) for cue in cues ] return _merge_cues_at_same_t(snapped) @@ -171,7 +190,7 @@ def snap_t(t: float) -> float: TimelineCue( t=snap_t(cue.t), layers=dict(cue.layers), - show_tick=cue.show_tick, + no_tick_slots=cue.no_tick_slots, ) for cue in cues ] diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index c807515..daad51d 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -39,7 +39,7 @@ def _anchor_visibility_for_slot( if not slot_cues: return layer_enabled_fallback earliest = min(slot_cues, key=lambda cue: cue.t) - if earliest.t > 0.0 and earliest.show_tick: + if earliest.t > 0.0 and earliest.shows_tick(slot): return not earliest.layers[slot] return layer_enabled_fallback @@ -149,7 +149,7 @@ def build_record_punch_cues( layers={ slot: timeline_committed_visible(session, slot, 0.0), }, - show_tick=False, + no_tick_slots=frozenset({slot}), ) ) for slot, baseline in tl.record_baseline.items(): @@ -160,7 +160,7 @@ def build_record_punch_cues( TimelineCue( t=record_start, layers={slot: baseline}, - show_tick=False, + no_tick_slots=frozenset({slot}), ) ) punch.extend( @@ -178,7 +178,7 @@ def build_record_punch_cues( TimelineCue( t=record_stop, layers={slot: committed_at_stop}, - show_tick=False, + no_tick_slots=frozenset({slot}), ) ) return punch diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 3599c68..bd3f910 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -346,13 +346,21 @@ def _fill_record_at_seek(self, old_t: float, new_t: float) -> None: remaining = {k: val for k, val in cue.layers.items() if k != slot} if remaining: cleaned.append( - TimelineCue(t=cue.t, layers=remaining, show_tick=cue.show_tick) + TimelineCue( + t=cue.t, + layers=remaining, + no_tick_slots=cue.no_tick_slots.intersection(remaining), + ) ) else: cleaned.append(cue) tl.record_buffer = cleaned tl.record_buffer.append( - TimelineCue(t=skip_start, layers={slot: v}, show_tick=False) + TimelineCue( + t=skip_start, + layers={slot: v}, + no_tick_slots=frozenset({slot}), + ) ) self._last_toggle_t.pop(slot, None) tl.record_buffer.sort(key=lambda c: c.t) diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index 331dea7..1ab3190 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -125,11 +125,7 @@ def cue_times_for_stem( { cue.t for cue in cues - if ( - slot in cue.layers - and cue.show_tick - and 0.0 <= cue.t <= duration_sec - ) + if cue.shows_tick(slot) and 0.0 <= cue.t <= duration_sec } ) diff --git a/cleave/viz/timeline_panel_cache.py b/cleave/viz/timeline_panel_cache.py index 1c9e4dd..1485315 100644 --- a/cleave/viz/timeline_panel_cache.py +++ b/cleave/viz/timeline_panel_cache.py @@ -65,9 +65,12 @@ def visibility_bucket(visibility: float) -> int: return min(255, int(visibility * 255)) -def _cue_fingerprint(cues: list) -> tuple[tuple[float, tuple[tuple[str, bool], ...], bool], ...]: +def _cue_fingerprint( + cues: list, +) -> tuple[tuple[float, tuple[tuple[str, bool], ...], tuple[str, ...]], ...]: return tuple( - (cue.t, tuple(sorted(cue.layers.items())), cue.show_tick) for cue in cues + (cue.t, tuple(sorted(cue.layers.items())), tuple(sorted(cue.no_tick_slots))) + for cue in cues ) diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 71bcace..c26c126 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -1035,6 +1035,45 @@ def test_parse_timeline_reads_cues_sorted_by_t() -> None: ) +def test_parse_timeline_reads_no_tick_slots() -> None: + timeline = parse_timeline_section( + { + "timeline": { + "cues": [ + { + "t": 12.0, + "layers": {"layer_1": True, "layer_2": False}, + "no_tick": ["layer_1"], + }, + ], + } + }, + _timeline_parse_ctx(), + ) + assert timeline is not None + assert timeline.cues == ( + TimelineCue( + t=12.0, + layers={"layer_1": True, "layer_2": False}, + no_tick_slots=frozenset({"layer_1"}), + ), + ) + + +def test_parse_timeline_rejects_no_tick_not_in_layers() -> None: + with pytest.raises(ValueError, match="no_tick must reference layers in the cue"): + parse_timeline_section( + { + "timeline": { + "cues": [ + {"t": 1.0, "layers": {"layer_1": True}, "no_tick": ["layer_2"]}, + ], + } + }, + _timeline_parse_ctx(), + ) + + def test_parse_timeline_rejects_unknown_stem() -> None: with pytest.raises(ValueError, match="unknown layer keys in timeline.cues"): parse_timeline_section( diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index b0f75e7..d1065de 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -966,6 +966,40 @@ def test_write_session_snapshot_persists_timeline_at_bottom(tmp_path: Path) -> N assert session2.timeline.cues == list(timeline.cues) +def test_write_session_snapshot_round_trips_no_tick_slots(tmp_path: Path) -> None: + cfg, session, out_path = _snapshot_fixture(tmp_path) + session.timeline.enabled = True + session.timeline.cues = [ + TimelineCue(t=0.0, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"})), + TimelineCue(t=12.0, layers={"layer_1": False}), + ] + write_session_snapshot(out_path, cfg=cfg, session=session) + + data = yaml.safe_load(out_path.read_text(encoding="utf-8")) + assert data["timeline"]["cues"] == [ + {"t": 0.0, "layers": {"layer_1": True}, "no_tick": ["layer_1"]}, + {"t": 12.0, "layers": {"layer_1": False}}, + ] + + timeline = parse_timeline_section( + data, + ParseCtx(layer_slots=tuple(cfg.layer_z_order)), + ) + assert timeline is not None + playlists = _round_trip_playlists(cfg.paths.preset_root) + cfg_with_timeline = CleaveConfig( + paths=cfg.paths, + layers=cfg.layers, + editor=cfg.editor, + config_path=out_path, + user_config_path=cfg.user_config_path, + render=cfg.render, + timeline=timeline, + ) + session2 = session_from_cfg(cfg_with_timeline, playlists) + assert session2.timeline.cues == session.timeline.cues + + def test_write_session_snapshot_persists_timeline_disabled_without_cues( tmp_path: Path, ) -> None: diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index 1ca025a..a6145a7 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -144,6 +144,55 @@ def test_punch_replace_merges_cues_at_same_t() -> None: ] +def test_punch_replace_preserves_unarmed_transition_tick_on_collision() -> None: + """A synthetic armed punch cue colliding with an unarmed slot's real + transition must not mark that transition silent (regression: the AND-merge of + a per-cue tick flag flipped the unarmed slot's pre-first-cue anchor).""" + cues = [TimelineCue(t=12.0, layers={"layer_2": True})] + result = punch_replace( + cues, + armed_stems={"layer_1"}, + start_sec=5.0, + stop_sec=12.0, + new_cues=[ + TimelineCue( + t=12.0, + layers={"layer_1": True}, + no_tick_slots=frozenset({"layer_1"}), + ), + ], + ) + merged = next(cue for cue in result if cue.t == 12.0) + assert merged.layers == {"layer_2": True, "layer_1": True} + assert merged.shows_tick("layer_2") is True + assert merged.shows_tick("layer_1") is False + + +def test_punch_replace_unarmed_anchor_unchanged_by_armed_record() -> None: + """The unarmed slot's inferred leading section stays put after an armed take + lands a colliding cue at its first transition.""" + from cleave.viz.layer_visibility import _anchor_visibility_for_slot + + cues = [TimelineCue(t=12.0, layers={"layer_2": True})] + # Real transition ON at 12 with no t=0 anchor -> inferred OFF before it. + assert _anchor_visibility_for_slot(cues, "layer_2", True) is False + + result = punch_replace( + cues, + armed_stems={"layer_1"}, + start_sec=5.0, + stop_sec=12.0, + new_cues=[ + TimelineCue( + t=12.0, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"}) + ), + ], + ) + inferred = _anchor_visibility_for_slot(result, "layer_2", True) + assert inferred is False # still OFF; layer_1's silent cue did not corrupt it + assert layer_visible_at(result, {"layer_2": inferred}, "layer_2", 6.0) is False + + def test_should_accept_toggle_debounces() -> None: assert should_accept_toggle(None, 1.0) is True assert should_accept_toggle(1.0, 1.0 + RECORD_DEBOUNCE_SEC - 0.01) is False diff --git a/tests/cleave/test_timeline_snap.py b/tests/cleave/test_timeline_snap.py index 39fdec7..b1b62bd 100644 --- a/tests/cleave/test_timeline_snap.py +++ b/tests/cleave/test_timeline_snap.py @@ -76,9 +76,13 @@ def test_snap_single_beat_snaps_everything() -> None: ] -def test_snap_preserves_show_tick() -> None: - cues = [TimelineCue(t=0.1, layers={"layer_1": True}, show_tick=False)] +def test_snap_preserves_no_tick_slots() -> None: + cues = [ + TimelineCue(t=0.1, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"})) + ] result = snap_cues_to_beats(cues, (0.0, 1.0)) assert result == [ - TimelineCue(t=0.0, layers={"layer_1": True}, show_tick=False), + TimelineCue( + t=0.0, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"}) + ), ] diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index eba9949..b05dcad 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -287,7 +287,10 @@ def test_build_record_punch_cues_writes_baseline_only_when_it_differs() -> None: session.timeline.record_buffer = [TimelineCue(t=12.0, layers={"layer_1": False})] punch = build_record_punch_cues(session, record_start=10.0, record_stop=20.0) - assert TimelineCue(t=10.0, layers={"layer_1": True}, show_tick=False) in punch + assert ( + TimelineCue(t=10.0, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"})) + in punch + ) assert TimelineCue(t=12.0, layers={"layer_1": False}) in punch session.timeline.record_baseline = {"layer_1": False} @@ -515,7 +518,10 @@ def test_build_record_punch_cues_restores_committed_at_stop() -> None: punch = build_record_punch_cues(session, record_start=10.0, record_stop=20.0) assert TimelineCue(t=15.0, layers={"layer_1": True}) in punch - assert TimelineCue(t=20.0, layers={"layer_1": False}, show_tick=False) in punch + assert ( + TimelineCue(t=20.0, layers={"layer_1": False}, no_tick_slots=frozenset({"layer_1"})) + in punch + ) assert timeline_committed_visible(session, "layer_1", 20.0) is False @@ -533,7 +539,10 @@ def test_build_record_punch_cues_restores_when_disable_only_inside_punch() -> No session.timeline.record_buffer = [TimelineCue(t=18.0, layers={"layer_1": True})] punch = build_record_punch_cues(session, record_start=10.0, record_stop=22.0) - assert TimelineCue(t=22.0, layers={"layer_1": False}, show_tick=False) in punch + assert ( + TimelineCue(t=22.0, layers={"layer_1": False}, no_tick_slots=frozenset({"layer_1"})) + in punch + ) assert committed_visible_outside_punch(session, "layer_1", 10.0, 22.0) is True assert timeline_committed_visible(session, "layer_1", 22.0) is False @@ -579,7 +588,10 @@ def test_build_record_punch_cues_adds_t0_anchor_on_first_record() -> None: ] punch = build_record_punch_cues(session, record_start=2.0, record_stop=20.0) - assert TimelineCue(t=0.0, layers={"layer_1": False}, show_tick=False) in punch + assert ( + TimelineCue(t=0.0, layers={"layer_1": False}, no_tick_slots=frozenset({"layer_1"})) + in punch + ) def test_timeline_bar_preserves_pre_first_cue_after_layer_toggle() -> None: diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 8384545..98b1401 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -576,7 +576,7 @@ def test_forward_seek_during_record_fills_with_active_state() -> None: assert any( cue.t == 10.0 and cue.layers.get("layer_1") is True - and cue.show_tick is False + and not cue.shows_tick("layer_1") for cue in session.timeline.record_buffer ) assert armed_recording_visible(session, "layer_1", 15.0) == active_at_start @@ -672,7 +672,7 @@ def test_stop_record_restores_committed_after_punch_range() -> None: cue for cue in session.timeline.cues if cue.t == 20.0 and "layer_1" in cue.layers ] assert len(restore_cues) == 1 - assert restore_cues[0].show_tick is False + assert not restore_cues[0].shows_tick("layer_1") def test_stop_record_restores_disabled_tail_when_disable_inside_punch() -> None: @@ -747,6 +747,46 @@ def test_stop_record_preserves_unarmed_cues_in_punch_range() -> None: ) +def test_stop_record_does_not_flip_unarmed_leading_section() -> None: + """Regression: recording an armed layer must not change an unarmed layer. + + layer_2 (unarmed) has a real transition at t=12 turning it ON and no t=0 + anchor - a state reachable after reload or in evolved projects. Recording + armed layer_1 lands a synthetic committed-restore cue exactly at t=12; the + per-slot tick must keep layer_2's transition real so its inferred leading + section (0..12 = OFF) is unchanged. + """ + from cleave.viz.layer_visibility import timeline_committed_visible + + controls, session, _, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + cues=[ + TimelineCue(t=12.0, layers={"layer_2": True}), + TimelineCue(t=0.0, layers={"layer_1": False}), + TimelineCue(t=30.0, layers={"layer_1": True}), + ], + ) + session.layers["layer_2"].enabled = True + + sample = (0.0, 6.0, 11.9, 12.0, 20.0) + before = [timeline_committed_visible(session, "layer_2", t) for t in sample] + assert before == [False, False, False, True, True] + + controls.handle_keydown(keydown(pygame.K_r)) # start record on layer_1 + controls.playback.player.seek(8.0) + controls.handle_keydown(keydown(pygame.K_1)) # toggle layer_1 on + controls.playback.player.seek(12.0) + controls.handle_keydown(keydown(pygame.K_r)) # stop -> restore cue at t=12 + + merged = [cue for cue in session.timeline.cues if cue.t == 12.0] + assert len(merged) == 1 + assert merged[0].shows_tick("layer_2") is True + + after = [timeline_committed_visible(session, "layer_2", t) for t in sample] + assert after == before + + def test_ctrl_space_starts_record_when_paused() -> None: controls, session, visibility_calls, _, _, _ = _make_timeline_controls( armed_slots={"layer_1"}, diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index 3841816..97b98ab 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -522,14 +522,26 @@ def test_unique_cue_times_clamps_to_duration() -> None: assert unique_cue_times(cues, 10.0) == [5.0] -def test_cue_times_for_stem_skips_show_tick_false() -> None: +def test_cue_times_for_stem_skips_no_tick_slots() -> None: cues = [ TimelineCue(t=5.0, layers={"layer_1": False}), - TimelineCue(t=10.0, layers={"layer_1": True}, show_tick=False), + TimelineCue(t=10.0, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"})), ] assert cue_times_for_stem(cues, "layer_1", 30.0) == [5.0] +def test_cue_times_for_stem_tick_is_per_slot_in_merged_cue() -> None: + cues = [ + TimelineCue( + t=10.0, + layers={"layer_1": True, "layer_2": False}, + no_tick_slots=frozenset({"layer_1"}), + ), + ] + assert cue_times_for_stem(cues, "layer_1", 30.0) == [] + assert cue_times_for_stem(cues, "layer_2", 30.0) == [10.0] + + def test_cue_times_for_stem_only_includes_relevant_layers() -> None: cues = [ TimelineCue(t=5.0, layers={"layer_1": False}), @@ -716,7 +728,11 @@ def test_bar_shows_fill_for_backward_skipped_range() -> None: recording=True, record_start_sec=20.0, record_baseline={"layer_1": True}, - record_buffer=[TimelineCue(t=20.0, layers={"layer_1": False}, show_tick=False)], + record_buffer=[ + TimelineCue( + t=20.0, layers={"layer_1": False}, no_tick_slots=frozenset({"layer_1"}) + ) + ], record_high_water_mark=30.0, ) assert _bar_visible_at(state, slot, 25.0) is False @@ -735,7 +751,11 @@ def test_bar_shows_fill_for_backward_seek_with_expanded_punch_start() -> None: recording=True, record_start_sec=10.0, record_baseline={"layer_1": False}, - record_buffer=[TimelineCue(t=10.0, layers={"layer_1": True}, show_tick=False)], + record_buffer=[ + TimelineCue( + t=10.0, layers={"layer_1": True}, no_tick_slots=frozenset({"layer_1"}) + ) + ], record_high_water_mark=20.0, ) assert _bar_visible_at(state, slot, 15.0) is True @@ -754,7 +774,11 @@ def test_bar_without_high_water_mark_behaves_as_before() -> None: recording=True, record_start_sec=20.0, record_baseline={"layer_1": True}, - record_buffer=[TimelineCue(t=20.0, layers={"layer_1": False}, show_tick=False)], + record_buffer=[ + TimelineCue( + t=20.0, layers={"layer_1": False}, no_tick_slots=frozenset({"layer_1"}) + ) + ], record_high_water_mark=None, ) assert _bar_visible_at(state, slot, 22.0) is False From d07aad32c7fc987c20c85b7d6b181e40ea6dd1a2 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sat, 11 Jul 2026 15:14:01 +0100 Subject: [PATCH 2/2] Fix the timeline punch on unarmed tracks bug --- cleave/timeline.py | 30 +++++-- tests/cleave/test_timeline.py | 39 +++++++++ tests/cleave/viz/test_timeline_controls.py | 96 ++++++++++++++++++++++ 3 files changed, 160 insertions(+), 5 deletions(-) diff --git a/cleave/timeline.py b/cleave/timeline.py index 48f74a0..2846c23 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -123,14 +123,34 @@ def punch_replace( stop_sec: float, new_cues: list[TimelineCue], ) -> list[TimelineCue]: - kept = [ - cue - for cue in cues + """Overwrite armed slots in ``[start_sec, stop_sec]``; leave unarmed slots intact. + + Cues are often multi-slot (timeline presets put every slot on the t=0 cue, + and simultaneous transitions share one object). Deleting a whole cue when it + mentions an armed stem would erase unarmed slots that share that object — + strip armed keys only, then merge in ``new_cues``. + """ + kept: list[TimelineCue] = [] + for cue in cues: if not ( start_sec <= cue.t <= stop_sec and _cue_modifies_armed_stem(cue, armed_stems) - ) - ] + ): + kept.append(cue) + continue + remaining = { + slot: visible + for slot, visible in cue.layers.items() + if slot not in armed_stems + } + if remaining: + kept.append( + TimelineCue( + t=cue.t, + layers=remaining, + no_tick_slots=cue.no_tick_slots.intersection(remaining), + ) + ) combined = kept + list(new_cues) return _merge_cues_at_same_t(combined) diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index a6145a7..77da1cb 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -110,10 +110,49 @@ def test_punch_replace_removes_armed_cues_in_range() -> None: TimelineCue(t=1.0, layers={"layer_1": False}), TimelineCue(t=5.0, layers={"layer_2": False}), TimelineCue(t=6.0, layers={"layer_1": False}), + # Unarmed layer_3 entry from the shared cue at t=8 must survive. + TimelineCue(t=8.0, layers={"layer_3": False}), TimelineCue(t=12.0, layers={"layer_4": False}), ] +def test_punch_replace_preserves_unarmed_slots_in_shared_cue() -> None: + """Preset-style multi-slot cues must not lose unarmed keys when punching.""" + cues = [ + TimelineCue( + t=0.0, + layers={ + "layer_1": True, + "layer_2": False, + "layer_3": True, + "layer_4": False, + }, + ), + TimelineCue(t=10.0, layers={"layer_1": False, "layer_2": True}), + TimelineCue(t=20.0, layers={"layer_3": False}), + ] + result = punch_replace( + cues, + armed_stems={"layer_1"}, + start_sec=0.0, + stop_sec=15.0, + new_cues=[ + TimelineCue(t=0.0, layers={"layer_1": False}), + TimelineCue(t=5.0, layers={"layer_1": True}), + ], + ) + by_t = {cue.t: cue.layers for cue in result} + assert by_t[0.0] == { + "layer_1": False, + "layer_2": False, + "layer_3": True, + "layer_4": False, + } + assert by_t[5.0] == {"layer_1": True} + assert by_t[10.0] == {"layer_2": True} + assert by_t[20.0] == {"layer_3": False} + + def test_punch_replace_keeps_unarmed_cues_in_range() -> None: cues = [TimelineCue(t=5.0, layers={"layer_2": False})] result = punch_replace( diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 98b1401..aed448f 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -747,6 +747,102 @@ def test_stop_record_preserves_unarmed_cues_in_punch_range() -> None: ) +def test_stop_record_preserves_unarmed_slots_in_preset_style_cues() -> None: + """Recording one armed layer must not rewrite shared multi-slot preset cues. + + Timeline presets emit a full t=0 cue for every slot and pack simultaneous + transitions into one TimelineCue. Whole-cue punch deletion used to erase + unarmed keys from those objects, flipping whole sections on other tracks. + """ + from cleave.viz.layer_visibility import timeline_committed_visible + + slots = tuple(DEFAULT_LAYER_SLOTS) + cues = [ + TimelineCue( + t=0.0, + layers={ + "layer_1": True, + "layer_2": False, + "layer_3": True, + "layer_4": False, + }, + ), + TimelineCue(t=8.0, layers={"layer_1": False, "layer_2": True}), + TimelineCue(t=16.0, layers={"layer_3": False, "layer_4": True}), + TimelineCue(t=24.0, layers={"layer_2": False}), + ] + controls, session, _, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + cues=cues, + ) + for slot in slots: + session.layers[slot].enabled = cues[0].layers[slot] + + unarmed = ("layer_2", "layer_3", "layer_4") + sample = (0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 24.0, 30.0) + before = { + slot: [timeline_committed_visible(session, slot, t) for t in sample] + for slot in unarmed + } + + controls.handle_keydown(keydown(pygame.K_r)) + controls.handle_keydown(keydown(pygame.K_1)) + controls.playback.player.seek(12.0) + controls.handle_keydown(keydown(pygame.K_1)) + controls.playback.player.seek(18.0) + controls.handle_keydown(keydown(pygame.K_r)) + + after = { + slot: [timeline_committed_visible(session, slot, t) for t in sample] + for slot in unarmed + } + assert after == before + # Shared cue at t=8 kept layer_2; armed layer_1 was rewritten separately. + cue_at_8 = next(cue for cue in session.timeline.cues if cue.t == 8.0) + assert cue_at_8.layers.get("layer_2") is True + cue_at_16 = next(cue for cue in session.timeline.cues if cue.t == 16.0) + assert cue_at_16.layers == {"layer_3": False, "layer_4": True} + + +def test_stop_record_preserves_unarmed_after_breathing_preset() -> None: + """End-to-end: punch through a real preset must leave unarmed tracks alone.""" + import random + + from cleave.timeline_presets import build_breathing_cues + from cleave.viz.layer_visibility import timeline_committed_visible + + slots = list(DEFAULT_LAYER_SLOTS) + cues = build_breathing_cues(slots, 60.0, random.Random(42)) + controls, session, _, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + cues=cues, + ) + for slot in slots: + session.layers[slot].enabled = cues[0].layers[slot] + + unarmed = ("layer_2", "layer_3", "layer_4") + sample = tuple(i * 2.5 for i in range(25)) + before = { + slot: [timeline_committed_visible(session, slot, t) for t in sample] + for slot in unarmed + } + + controls.handle_keydown(keydown(pygame.K_r)) + controls.handle_keydown(keydown(pygame.K_1)) + controls.playback.player.seek(25.0) + controls.handle_keydown(keydown(pygame.K_1)) + controls.playback.player.seek(35.0) + controls.handle_keydown(keydown(pygame.K_r)) + + after = { + slot: [timeline_committed_visible(session, slot, t) for t in sample] + for slot in unarmed + } + assert after == before + + def test_stop_record_does_not_flip_unarmed_leading_section() -> None: """Regression: recording an armed layer must not change an unarmed layer.