From b824280fed9eba21355503224e30b7fd8173083a Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Mon, 22 Jun 2026 23:41:47 +0100 Subject: [PATCH 1/3] First attempt at timeline skup recording - some bugs on skipback --- cleave/viz/help_overlay.py | 9 ++- cleave/viz/timeline_controls.py | 41 ++++++++++++-- tests/cleave/viz/test_help_overlay.py | 3 +- tests/cleave/viz/test_timeline_controls.py | 65 +++++++++++++++++++++- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/cleave/viz/help_overlay.py b/cleave/viz/help_overlay.py index 7e07ffe..b74be89 100644 --- a/cleave/viz/help_overlay.py +++ b/cleave/viz/help_overlay.py @@ -137,7 +137,14 @@ def _timeline_strip_section( else: entries.append(("Space", "pause")) - if not recording: + if recording: + entries.extend( + ( + ("Left/Right", "skip 10s, fills range"), + ("Ctrl + Left/Right", "skip 30s, fills range"), + ) + ) + else: entries.extend( ( ("Left/Right", "skip 10s"), diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 360d17c..1a48401 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -105,11 +105,10 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True if event.key in (pygame.K_LEFT, pygame.K_RIGHT): - if not self.session.timeline.recording: - self._do_seek( - event.key == pygame.K_RIGHT, - long=mod_ctrl(event.mod), - ) + self._do_seek( + event.key == pygame.K_RIGHT, + long=mod_ctrl(event.mod), + ) return True if event.key in _LAYER_KEY_INDEX: @@ -327,10 +326,42 @@ def _toggle_armed_layer_at(self, slot: str, t_sec: float) -> None: if self._on_visibility_change is not None: self._on_visibility_change() + def _fill_record_at_seek(self, old_t: float, new_t: float) -> None: + tl = self.session.timeline + skip_start = min(old_t, new_t) + skip_end = max(old_t, new_t) + for slot in list(tl.armed_slots): + if slot not in tl.record_baseline: + continue + v = armed_recording_visible(self.session, slot, old_t) + cleaned: list[TimelineCue] = [] + for cue in tl.record_buffer: + if skip_start <= cue.t <= skip_end and slot in cue.layers: + 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) + ) + else: + cleaned.append(cue) + tl.record_buffer = cleaned + tl.record_buffer.append( + TimelineCue(t=skip_start, layers={slot: v}, show_tick=False) + ) + self._last_toggle_t.pop(slot, None) + tl.record_buffer.sort(key=lambda c: c.t) + if tl.record_start_sec is not None and new_t < tl.record_start_sec: + tl.record_start_sec = new_t + def _do_seek(self, forward: bool, *, long: bool) -> None: delta_sec = SEEK_LONG if long else SEEK_SHORT if not forward: delta_sec = -delta_sec + if self.session.timeline.recording: + old_t = current_sec(self.playback, self.duration_sec) + new_t = max(0.0, min(self.duration_sec, old_t + delta_sec)) + self._fill_record_at_seek(old_t, new_t) + self._refresh_visibility() if self._on_seek is not None: self._on_seek(delta_sec) else: diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index 043bc78..c11c59e 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -193,7 +193,8 @@ def test_timeline_strip_help_recording_while_playing() -> None: assert "Ctrl + Enter" not in entries assert entries["1-4"] == "toggle layer visibility" assert "Shift + Enter" not in entries - assert "Left/Right" not in entries + assert entries["Left/Right"] == "skip 10s, fills range" + assert entries["Ctrl + Left/Right"] == "skip 30s, fills range" assert entries["r"] == "stop record" assert entries["Ctrl + Space / Space"] == "stop record and pause" assert "Space" not in entries diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 1f13bdc..8384545 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -12,6 +12,7 @@ from cleave.timeline import TimelineCue from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, TuningControls from cleave.viz.session import LayerRuntime, TuningSession +from cleave.viz.layer_visibility import armed_recording_visible, effective_layer_enabled from cleave.viz.timeline_controls import TimelineControls from tests.support.viz import keydown, make_playlist, stub_playback_state @@ -543,7 +544,7 @@ def test_disarm_one_slot_keeps_recording_on_remaining_armed() -> None: assert visibility_calls -def test_seek_blocked_while_recording() -> None: +def test_seek_allowed_while_recording() -> None: controls, session, _, _, seeks, _ = _make_timeline_controls( armed_slots={"layer_1"}, ) @@ -555,7 +556,67 @@ def test_seek_blocked_while_recording() -> None: controls.handle_keydown(keydown(pygame.K_LEFT)) controls.handle_keydown(keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) controls.handle_keydown(keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) - assert seeks == [] + assert seeks == [SEEK_SHORT, -SEEK_SHORT, SEEK_LONG, -SEEK_LONG] + + +def test_forward_seek_during_record_fills_with_active_state() -> None: + controls, session, visibility_calls, _, seeks, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=10.0, + ) + session.layers["layer_1"].enabled = True + + controls.handle_keydown(keydown(pygame.K_r)) + assert session.timeline.record_baseline == {"layer_1": True} + active_at_start = armed_recording_visible(session, "layer_1", 10.0) + + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert seeks == [SEEK_SHORT] + + assert any( + cue.t == 10.0 + and cue.layers.get("layer_1") is True + and cue.show_tick is False + for cue in session.timeline.record_buffer + ) + assert armed_recording_visible(session, "layer_1", 15.0) == active_at_start + assert visibility_calls + + +def test_backward_seek_during_record_fills_and_expands_punch_start() -> None: + controls, session, visibility_calls, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=20.0, + ) + session.layers["layer_1"].enabled = True + + controls.handle_keydown(keydown(pygame.K_r)) + active_before_seek = armed_recording_visible(session, "layer_1", 20.0) + assert session.timeline.record_start_sec == 20.0 + + controls.handle_keydown(keydown(pygame.K_LEFT)) + + assert session.timeline.record_start_sec == 10.0 + assert armed_recording_visible(session, "layer_1", 15.0) == active_before_seek + assert visibility_calls + + +def test_seek_during_record_active_state_not_overwritten_by_committed_cues() -> None: + controls, session, visibility_calls, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=10.0, + cues=[TimelineCue(t=0.0, layers={"layer_1": False})], + ) + session.layers["layer_1"].enabled = True + + controls.handle_keydown(keydown(pygame.K_r)) + controls.handle_keydown(keydown(pygame.K_1)) + assert effective_layer_enabled(session, "layer_1", 10.0) is True + + controls.handle_keydown(keydown(pygame.K_RIGHT)) + + assert effective_layer_enabled(session, "layer_1", 15.0) is True + assert visibility_calls def test_r_stop_punches_cues_and_clears_record_state() -> None: From 5c79fe83303549fffbdfe467d55caf8b5d06e416 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Tue, 23 Jun 2026 00:18:17 +0100 Subject: [PATCH 2/3] Fix timeline backseek bug. One still remains --- cleave/viz/layer_visibility.py | 1 + cleave/viz/session.py | 1 + cleave/viz/timeline_controls.py | 5 ++ cleave/viz/timeline_overlay.py | 17 +++--- tests/cleave/viz/test_timeline_overlay.py | 73 +++++++++++++++++++++++ 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index 768fbd4..c807515 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -258,6 +258,7 @@ def build_timeline_view_state( record_start_sec=tl.record_start_sec, record_baseline=dict(tl.record_baseline), record_buffer=list(tl.record_buffer), + record_high_water_mark=tl.record_high_water_mark, enabled=tl.enabled, submenu_focused=submenu_focused, arm_flash_start_ms=dict(tl.arm_flash_start_ms), diff --git a/cleave/viz/session.py b/cleave/viz/session.py index 2b878ce..1a69ba5 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -82,6 +82,7 @@ class TimelineRuntime: record_buffer: list[TimelineCue] = field(default_factory=list) record_baseline: dict[str, bool] = field(default_factory=dict) record_start_sec: float | None = None + record_high_water_mark: float | None = None preview_active: bool = False monitor: dict[str, bool] = field(default_factory=dict) override_slots: set[str] = field(default_factory=set) diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 1a48401..9936d47 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -214,6 +214,7 @@ def _commit_recording_slot(self, slot: str) -> None: tl.record_start_sec = None tl.record_buffer = [] tl.record_baseline = {} + tl.record_high_water_mark = None self._last_toggle_t = {} if self._on_visibility_change is not None: @@ -240,6 +241,7 @@ def _start_record(self) -> None: tl.recording = True tl.record_start_sec = t_sec tl.record_buffer = [] + tl.record_high_water_mark = None self._last_toggle_t = {} self._refresh_visibility() @@ -251,6 +253,7 @@ def _stop_record(self) -> None: tl.recording = False tl.record_buffer = [] tl.record_baseline = {} + tl.record_high_water_mark = None return record_stop = current_sec(self.playback, self.duration_sec) @@ -265,6 +268,7 @@ def _stop_record(self) -> None: tl.record_start_sec = None tl.record_buffer = [] tl.record_baseline = {} + tl.record_high_water_mark = None self._last_toggle_t = {} if self._on_visibility_change is not None: @@ -350,6 +354,7 @@ def _fill_record_at_seek(self, old_t: float, new_t: float) -> None: ) self._last_toggle_t.pop(slot, None) tl.record_buffer.sort(key=lambda c: c.t) + tl.record_high_water_mark = max(tl.record_high_water_mark or 0.0, old_t) if tl.record_start_sec is not None and new_t < tl.record_start_sec: tl.record_start_sec = new_t diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index 6b5c0d2..fd1a882 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -71,6 +71,7 @@ class TimelineViewState: record_start_sec: float | None = None record_baseline: dict[str, bool] = field(default_factory=dict) record_buffer: list[TimelineCue] = field(default_factory=list) + record_high_water_mark: float | None = None enabled: bool = False submenu_focused: bool = False arm_flash_start_ms: dict[str, int] = field(default_factory=dict) @@ -157,21 +158,22 @@ def bar_segments_for_row( if record_start > 0.0: segments.extend(_clip_segments(committed, 0.0, record_start)) - if playhead > record_start: + effective_end = max(playhead, state.record_high_water_mark or 0.0) + if effective_end > record_start: armed_defaults = dict(state.defaults) armed_defaults.update(state.record_baseline) segments.extend( _clip_segments( visibility_segments( - state.record_buffer, armed_defaults, slot, playhead + state.record_buffer, armed_defaults, slot, effective_end ), record_start, - playhead, + effective_end, ) ) - if playhead < duration: - segments.extend(_clip_segments(committed, playhead, duration)) + if effective_end < duration: + segments.extend(_clip_segments(committed, effective_end, duration)) return segments @@ -185,15 +187,16 @@ def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: if record_start is None: record_start = state.position_sec playhead = state.position_sec + effective_end = max(playhead, state.record_high_water_mark or 0.0) committed_ticks = [ t for t in cue_times_for_stem(state.cues, slot, duration) - if t < record_start or t > playhead + if t < record_start or t > effective_end ] live_ticks = [ t for t in cue_times_for_stem(state.record_buffer, slot, duration) - if record_start <= t <= playhead + if record_start <= t <= effective_end ] return sorted(set(committed_ticks) | set(live_ticks)) diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index 9c2d64e..3841816 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -27,6 +27,7 @@ arm_abbrev_flash_active, arm_abbrev_flash_visible, armed_abbrev_bg_visible, + bar_segments_for_row, bar_tick_times_for_row, cue_times_for_stem, layer_num_prefix, @@ -56,6 +57,7 @@ def _view_state( record_start_sec: float | None = None, record_baseline: dict[str, bool] | None = None, record_buffer: list[TimelineCue] | None = None, + record_high_water_mark: float | None = None, enabled: bool = True, layer_z_order: list[str] | None = None, monitor_visible: dict[str, bool] | None = None, @@ -94,6 +96,7 @@ def _view_state( record_start_sec=record_start_sec, record_baseline=dict(record_baseline or ()), record_buffer=list(record_buffer or ()), + record_high_water_mark=record_high_water_mark, enabled=enabled, submenu_focused=submenu_focused, arm_flash_start_ms=dict(arm_flash_start_ms or ()), @@ -688,6 +691,76 @@ def test_upscale_expands_bar_width_not_row_height() -> None: assert upscaled_panel[2] > baseline_panel[2] +def _bar_visible_at( + state: TimelineViewState, + slot: str, + t: float, +) -> bool: + """Return the bar's visibility value for a slot at time t.""" + segs = bar_segments_for_row(state, slot) + visible = False + for seg_start, seg_end, seg_visible in segs: + if seg_start <= t < seg_end: + visible = seg_visible + return visible + + +def test_bar_shows_fill_for_backward_skipped_range() -> None: + """After a backward seek during recording, the bar shows the fill state.""" + slot = "layer_1" + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": True}, + position_sec=20.0, + duration_sec=100.0, + 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_high_water_mark=30.0, + ) + assert _bar_visible_at(state, slot, 25.0) is False + assert _bar_visible_at(state, slot, 20.0) is False + assert _bar_visible_at(state, slot, 10.0) is True + + +def test_bar_shows_fill_for_backward_seek_with_expanded_punch_start() -> None: + """Backward seek past record_start: bar still shows fill for skipped range.""" + slot = "layer_1" + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": False}, + position_sec=10.0, + duration_sec=100.0, + 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_high_water_mark=20.0, + ) + assert _bar_visible_at(state, slot, 15.0) is True + assert _bar_visible_at(state, slot, 10.0) is True + assert _bar_visible_at(state, slot, 5.0) is False + + +def test_bar_without_high_water_mark_behaves_as_before() -> None: + """No backward seek: bar shows record_buffer only up to playhead.""" + slot = "layer_1" + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": True}, + position_sec=25.0, + duration_sec=100.0, + 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_high_water_mark=None, + ) + assert _bar_visible_at(state, slot, 22.0) is False + assert _bar_visible_at(state, slot, 30.0) is True + + def test_row_height_constant_across_layer_counts() -> None: pygame.init() overlay = TimelineOverlay() From 269133001543e651db9022b61a29c3005f7402c6 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Tue, 23 Jun 2026 20:53:52 +0100 Subject: [PATCH 3/3] Fix timeline skip-recording bug --- cleave/viz/timeline_controls.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 9936d47..3599c68 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -191,15 +191,16 @@ def _commit_recording_slot(self, slot: str) -> None: return record_stop = current_sec(self.playback, self.duration_sec) + punch_end = max(record_stop, tl.record_high_water_mark or record_stop) tl.cues = punch_replace( tl.cues, {slot}, record_start, - record_stop, + punch_end, build_record_punch_cues( self.session, record_start, - record_stop, + punch_end, slots={slot}, ), ) @@ -257,12 +258,13 @@ def _stop_record(self) -> None: return record_stop = current_sec(self.playback, self.duration_sec) + punch_end = max(record_stop, tl.record_high_water_mark or record_stop) tl.cues = punch_replace( tl.cues, set(tl.record_baseline), record_start, - record_stop, - build_record_punch_cues(self.session, record_start, record_stop), + punch_end, + build_record_punch_cues(self.session, record_start, punch_end), ) tl.recording = False tl.record_start_sec = None