From fe95076195e910b2fbb54f4155e6c318071abccf Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Jul 2026 20:52:27 +0200 Subject: [PATCH] fix: anchor locked marker review at start --- main.py | 48 +++++++++++++++++++++++------------- tests/test_ui_structure.py | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index c13b65b..67ad298 100755 --- a/main.py +++ b/main.py @@ -2528,16 +2528,16 @@ class TimelineWidget(QWidget): p.drawText(mx + 1, rh + 2, 13, 12, Qt.AlignmentFlag.AlignCenter, str(num)) - # ── scan mode cursor + playback line ───────────────────────── + # ── scan/locked playback line ──────────────────────────────── if self._scan_mode: # Export cursor (dim) p.setPen(QPen(QColor(255, 255, 255, 80), 1)) p.drawLine(x_start, rh, x_start, h) - # Playback position (bright green) - if self._play_pos is not None and self._play_pos >= 0: - px = int(self._time_to_x(self._play_pos)) - p.setPen(QPen(QColor(80, 255, 80, 220), 2)) - p.drawLine(px, rh, px, h) + if ((self._scan_mode or self._locked) + and self._play_pos is not None and self._play_pos >= 0): + px = int(self._time_to_x(self._play_pos)) + p.setPen(QPen(QColor(80, 255, 80, 220), 2)) + p.drawLine(px, rh, px, h) # ── crop keyframe diamonds ──────────────────────────────────── if self._crop_keyframes and self._duration > 0: @@ -5977,7 +5977,7 @@ class MainWindow(QMainWindow): "Ctrl+ZUndo last scan panel action" "? / F1This help" "
" - "Double-click markerEnter overwrite mode (locked: jump to end of clip span)" + "Double-click markerEnter overwrite mode (locked: review from start to end)" "Right-click markerDelete clip group" "Click video / crop barReposition portrait crop" "Shift+drag scan region edgeResize scan region" @@ -7013,22 +7013,36 @@ class MainWindow(QMainWindow): self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000) def _on_marker_clicked(self, start_time: float, output_path: str) -> None: - # In lock mode, move cursor to the end of this marker's span. + # In lock mode, keep the marker start anchored and scrub the playhead + # to the marker end so the highlighted zone matches what is reviewed. if self._btn_lock.isChecked(): meta = self._db.get_by_output_path(output_path) - clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value() - clip_dur = meta.get("clip_duration", self._clip_dur) if meta else self._clip_dur - spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value() - next_pos = start_time + clip_dur + (clip_count - 1) * spread - self._cursor = next_pos - self._timeline.set_cursor(next_pos) - self._mpv.seek(next_pos) - self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}") + clip_count = ( + meta.get("clip_count") or self._spn_clips.value() + ) if meta else self._spn_clips.value() + clip_dur = ( + meta.get("clip_duration") or self._clip_dur + ) if meta else self._clip_dur + spread = ( + meta.get("spread") or self._spn_spread.value() + ) if meta else self._spn_spread.value() + self._spn_clips.setValue(int(clip_count)) + self._spn_clip_dur.setValue(float(clip_dur)) + self._spn_spread.setValue(float(spread)) + clip_span = float(clip_dur) + (int(clip_count) - 1) * float(spread) + dur = self._mpv.get_duration() + end_pos = min(start_time + clip_span, dur) if dur else start_time + clip_span + self._cursor = start_time + self._timeline.set_cursor(start_time) + self._timeline.set_clip_span(clip_span, float(clip_dur), float(spread)) + self._timeline.set_play_position(end_pos) + self._mpv.seek(end_pos) + self._lbl_time.setText(f"{format_time(end_pos)} / {format_time(dur)}") self._update_next_label() self._preview_timer.start() stem = os.path.splitext(os.path.basename(output_path))[0] group_label = stem.rsplit("_", 1)[0] - self._show_status(f"Cursor → end of {group_label}", 3000) + self._show_status(f"Reviewing {group_label}: start locked, playhead at end", 3000) return self._cursor = start_time self._timeline.set_cursor(start_time) diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 56d2088..9f2fdca 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -407,6 +407,56 @@ def test_timeline_audio_mode_lock_click_scrubs_playhead_not_region(win): assert got_seek and abs(got_seek[-1] - 12.0) < 0.1 +def test_timeline_lock_mode_paints_playhead_line(win): + from PyQt6.QtGui import QColor, QImage + tl = win._timeline + tl._duration = 20.0 + tl._view_start = 0.0 + tl._view_span = 20.0 + tl.resize(400, 160) + tl.set_audio_mode(False) + tl.set_clip_span(8.0, 8.0, 3.0) + tl.set_cursor(4.0) + tl._locked = True + tl.set_play_position(7.0) + + img = QImage(tl.size(), QImage.Format.Format_ARGB32) + img.fill(QColor(0, 0, 0)) + tl.render(img) + + x = int(tl._time_to_x(7.0)) + y = tl._SCROLLBAR_H + tl._RULER_H + 24 + px = QColor(img.pixel(x, y)) + assert px.green() > 200 + assert px.green() > px.red() + 80 + + +def test_locked_marker_click_anchors_start_and_shows_end_playhead(win, monkeypatch): + win._file_path = "/x/video.mp4" + win._timeline._duration = 60.0 + win._spn_clip_dur.setValue(8.0) + win._spn_clips.setValue(1) + win._spn_spread.setValue(3.0) + win._timeline.set_clip_span(win._clip_span, win._clip_dur, win._spn_spread.value()) + win._btn_lock.setChecked(True) + seeks = [] + monkeypatch.setattr(win._mpv, "seek", lambda t: seeks.append(t)) + monkeypatch.setattr(win._mpv, "get_duration", lambda: 60.0) + monkeypatch.setattr( + win._db, + "get_by_output_path", + lambda _path: {"clip_count": 3, "clip_duration": 6.0, "spread": 2.0}, + ) + + win._on_marker_clicked(10.0, "/tmp/clip_001.mp4") + + assert abs(win._cursor - 10.0) < 0.01 + assert abs(win._timeline._cursor - 10.0) < 0.01 + assert abs(win._timeline._clip_span - 10.0) < 0.01 + assert abs((win._timeline._play_pos or 0.0) - 20.0) < 0.01 + assert seeks == [20.0] + + def test_timeline_audio_mode_follows_deck(win): win._control_deck.setCurrentWidget(win._tab_audio) assert win._timeline._audio_mode is True