Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cleave/viz/help_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions cleave/viz/layer_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions cleave/viz/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 47 additions & 9 deletions cleave/viz/timeline_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -192,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},
),
)
Expand All @@ -215,6 +215,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:
Expand All @@ -241,6 +242,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()
Expand All @@ -252,20 +254,23 @@ 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)
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
tl.record_buffer = []
tl.record_baseline = {}
tl.record_high_water_mark = None
self._last_toggle_t = {}

if self._on_visibility_change is not None:
Expand Down Expand Up @@ -327,10 +332,43 @@ 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)
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

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:
Expand Down
17 changes: 10 additions & 7 deletions cleave/viz/timeline_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand All @@ -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))

Expand Down
3 changes: 2 additions & 1 deletion tests/cleave/viz/test_help_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 63 additions & 2 deletions tests/cleave/viz/test_timeline_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"},
)
Expand All @@ -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:
Expand Down
Loading
Loading