From 21093eb0981da4157a23d2ce166cf8e0db8a5079 Mon Sep 17 00:00:00 2001 From: Eric Levy Date: Tue, 14 Jul 2026 16:35:37 -0400 Subject: [PATCH] Mark annotated frames on the timeline Ported from reviewapp Timeline.set_markers. A reviewer scrubbing a shot can now see where the notes are instead of hunting for them. Colours and shape follow reviewapp: a 3px tick per annotated frame, blue for a frame holding comments and purple for one holding only drawings. A frame with both reads as a comment, because the words are the reviewable part and the drawing marker must not hide them. The markers are drawn under the playhead, so a note never hides the frame the reviewer is actually sitting on. Annotations are keyed by the player LOCAL frame while the timeline is a global range during playlist playback, so every marker is mapped across with _timeline_frame_for_local. Without that the notes for one shot land on another. Drawing a stroke changes what the timeline should show and only the viewer knows it happened, so ViewerWidget gains an annotations_changed signal, emitted when a stroke is completed, undone, redone or cleared. Comment add/delete/resolve and the sidecar load already funnel through single call sites, so the refresh hangs off those. 10 tests. The timeline is a plain QWidget, so they render it offscreen and probe the real pixels: the marker colours, a both-kinds frame reading as a comment, an unannotated frame staying clean, out-of-range markers not smearing onto the track ends, and the playhead winning the pixel it shares with a marker. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb --- constants/__init__.py | 7 ++ tests/test_timeline_markers.py | 131 +++++++++++++++++++++++++++++++++ widgets/__init__.py | 32 ++++++++ widgets/timeline.py | 74 +++++++++++++++++++ widgets/viewer.py | 13 ++++ 5 files changed, 257 insertions(+) create mode 100644 tests/test_timeline_markers.py diff --git a/constants/__init__.py b/constants/__init__.py index 70f60ca..97bf0dd 100644 --- a/constants/__init__.py +++ b/constants/__init__.py @@ -135,6 +135,13 @@ COMMENT_PIN_COLOR = (255, 68, 68) COMMENT_PIN_DONE_COLOR = (76, 175, 80) +# Annotation markers drawn on the timeline. A frame holding a comment reads as +# a comment even if it also carries drawings: the words are the reviewable part, +# and a drawing-only frame is a different kind of note. +TIMELINE_COMMENT_MARKER_COLOR = (79, 195, 247) +TIMELINE_DRAWING_MARKER_COLOR = (171, 71, 188) +TIMELINE_MARKER_WIDTH = 3 + # Container support is provided by the FFmpeg libraries bundled with PyAV. # Keep these lists centralized so import, drag/drop and reader selection never # disagree about a valid movie. Codec support is detected from the stream, not diff --git a/tests/test_timeline_markers.py b/tests/test_timeline_markers.py new file mode 100644 index 0000000..d847304 --- /dev/null +++ b/tests/test_timeline_markers.py @@ -0,0 +1,131 @@ +"""Tests for the timeline's annotation markers. + +The timeline is a plain QWidget, so these render it offscreen and probe the +actual pixels -- the assertions are about what a reviewer sees, not about what +the widget believes it stored. +""" + +import pytest + +import constants + +from tests.helpers import probe_pixel +from widgets.timeline import TimelineWidget + + +MARKER_Y = int(60 * 0.45) + 6 # inside the marker band + + +@pytest.fixture +def timeline(qapp): + widget = TimelineWidget(None) + widget.resize(900, 60) + widget.set_range(1, 100) + widget.set_current_frame(1) + + yield widget + + widget.close() + widget.deleteLater() + qapp.processEvents() + + +def _marker_color(widget, frame): + image = widget.grab().toImage() + x = int(widget.frame_to_pos(frame)) + return probe_pixel(image, x, MARKER_Y)[:3] + + +# --------------------------------------------------------------------------- # +# What gets marked +# --------------------------------------------------------------------------- # +def test_a_commented_frame_is_marked(timeline): + timeline.set_annotated_frames(comment_frames=[20], drawing_frames=[]) + + assert _marker_color(timeline, 20) == constants.TIMELINE_COMMENT_MARKER_COLOR + + +def test_a_drawn_frame_is_marked_differently(timeline): + timeline.set_annotated_frames(comment_frames=[], drawing_frames=[60]) + + assert _marker_color(timeline, 60) == constants.TIMELINE_DRAWING_MARKER_COLOR + + +def test_a_frame_with_both_reads_as_a_comment(timeline): + """The words are the reviewable part; the drawing marker must not hide them.""" + timeline.set_annotated_frames(comment_frames=[80], drawing_frames=[80]) + + assert timeline.drawing_frames == set() # subsumed, not drawn twice + assert _marker_color(timeline, 80) == constants.TIMELINE_COMMENT_MARKER_COLOR + + +def test_an_unannotated_frame_is_not_marked(timeline): + timeline.set_annotated_frames(comment_frames=[20], drawing_frames=[60]) + + color = _marker_color(timeline, 40) + + assert color != constants.TIMELINE_COMMENT_MARKER_COLOR + assert color != constants.TIMELINE_DRAWING_MARKER_COLOR + + +def test_clearing_the_markers_removes_them(timeline): + timeline.set_annotated_frames(comment_frames=[20], drawing_frames=[]) + assert _marker_color(timeline, 20) == constants.TIMELINE_COMMENT_MARKER_COLOR + + timeline.set_annotated_frames(comment_frames=[], drawing_frames=[]) + + assert _marker_color(timeline, 20) != constants.TIMELINE_COMMENT_MARKER_COLOR + + +# --------------------------------------------------------------------------- # +# Robustness +# --------------------------------------------------------------------------- # +def test_markers_outside_the_range_are_not_drawn(timeline): + """A stale marker from a longer clip must not smear onto the edge.""" + timeline.set_annotated_frames(comment_frames=[500], drawing_frames=[-20]) + + # Renders without error, and nothing lands at either end of the track. + image = timeline.grab().toImage() + left = probe_pixel(image, timeline.timeline_margin, MARKER_Y)[:3] + right = probe_pixel(image, timeline.width() - timeline.timeline_margin, MARKER_Y)[:3] + + assert left != constants.TIMELINE_COMMENT_MARKER_COLOR + assert right != constants.TIMELINE_COMMENT_MARKER_COLOR + + +def test_no_markers_renders_cleanly(timeline): + timeline.set_annotated_frames(comment_frames=[], drawing_frames=[]) + + assert timeline.comment_frames == set() + assert timeline.drawing_frames == set() + assert not timeline.grab().toImage().isNull() + + +def test_none_is_treated_as_empty(timeline): + timeline.set_annotated_frames(None, None) + + assert timeline.comment_frames == set() + assert timeline.drawing_frames == set() + + +def test_markers_accept_any_iterable(timeline): + timeline.set_annotated_frames(comment_frames=(5, 6), drawing_frames=range(9, 11)) + + assert timeline.comment_frames == {5, 6} + assert timeline.drawing_frames == {9, 10} + + +# --------------------------------------------------------------------------- # +# The playhead stays readable +# --------------------------------------------------------------------------- # +def test_the_playhead_is_drawn_over_a_marker_on_the_same_frame(timeline): + """A note must never hide the frame the reviewer is actually sitting on.""" + timeline.set_current_frame(20) + timeline.set_annotated_frames(comment_frames=[20], drawing_frames=[]) + + image = timeline.grab().toImage() + x = int(timeline.frame_to_pos(20)) + red, green, blue = probe_pixel(image, x, MARKER_Y)[:3] + + # The playhead is red; the marker is blue. Red wins on the shared pixel. + assert red > green and red > blue diff --git a/widgets/__init__.py b/widgets/__init__.py index 5b6b8fe..355f82e 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -306,6 +306,11 @@ def setupUi(self): ) self.viewframe.viewer.fullscreen_requested.connect(self.toggle_fullscreen) self.viewframe.viewer.comment_pin_clicked.connect(self.add_pinned_comment) + # Drawing a stroke changes what the timeline should show, and the viewer + # is the only thing that knows it happened. + self.viewframe.viewer.annotations_changed.connect( + self.refresh_timeline_markers + ) self.commentSidebar.add_requested.connect(self.add_frame_comment) self.commentSidebar.pin_requested.connect(self.begin_pinned_comment) self.commentSidebar.pin_cancel_requested.connect(self.cancel_pinned_comment) @@ -1779,6 +1784,27 @@ def _timeline_frame_for_local(self, local_frame): return entry["start"] + int(local_frame) - constants.VL_START_FRAME return int(local_frame) + def refresh_timeline_markers(self): + """Redraw the timeline's annotation markers from the current sketch. + + Annotations are keyed by the player's LOCAL frame, but the timeline is a + global range while a playlist is playing, so every frame has to be + mapped across or the markers land on the wrong shot. + """ + annotations = self.viewframe.viewer.annotations + + comments = [ + self._timeline_frame_for_local(frame) + for frame in annotations.commented_frames() + ] + drawings = [ + self._timeline_frame_for_local(frame) + for frame, strokes in annotations.strokes.items() + if strokes + ] + + self.viewframe.timeline.set_annotated_frames(comments, drawings) + def add_frame_comment(self, text): """Add an unpinned comment to the viewer's current local frame.""" frame = self.viewframe.viewer.annotations.current_frame @@ -1789,6 +1815,7 @@ def add_frame_comment(self, text): self._save_current_notes() self.commentSidebar.clear_editor() self.commentSidebar.refresh() + self.refresh_timeline_markers() self.viewframe.viewer.update() self.statusBar().showMessage(f"Comment added at frame {frame}", 2500) @@ -1816,6 +1843,7 @@ def add_pinned_comment(self, x, y): self._save_current_notes() self.commentSidebar.clear_editor() self.commentSidebar.refresh() + self.refresh_timeline_markers() self.viewframe.viewer.update() self.statusBar().showMessage(f"Pinned comment added at frame {frame}", 2500) self.viewframe.viewer.set_comment_pin_mode(False) @@ -1834,6 +1862,7 @@ def toggle_comment_done(self, frame, comment_id): return self._save_current_notes() self.commentSidebar.refresh() + self.refresh_timeline_markers() self.viewframe.viewer.update() def delete_comment(self, frame, comment_id): @@ -1841,12 +1870,14 @@ def delete_comment(self, frame, comment_id): return self._save_current_notes() self.commentSidebar.refresh() + self.refresh_timeline_markers() self.viewframe.viewer.update() def annotation_state_changed(self): """Persist and refresh after a toolbar/menu Clear Notes action.""" self._save_current_notes() self.commentSidebar.refresh() + self.refresh_timeline_markers() self.viewframe.viewer.update() def jump_to_annotation(self, step): @@ -1935,6 +1966,7 @@ def _load_notes_for_source(self, source): self.commentSidebar.set_current_frame( self.viewframe.viewer.annotations.current_frame ) + self.refresh_timeline_markers() self.viewframe.viewer.update() except Exception: LOGGER.exception("Unable to load annotation notes") diff --git a/widgets/timeline.py b/widgets/timeline.py index 58e5b82..465023d 100644 --- a/widgets/timeline.py +++ b/widgets/timeline.py @@ -103,6 +103,10 @@ def __init__(self, parent=None): # Cached frame storage self.cached_frames = set() + # Annotated frame storage, in timeline (not player-local) frames. + self.comment_frames = set() + self.drawing_frames = set() + self.setMouseTracking(True) def set_range(self, start, end): @@ -154,6 +158,72 @@ def set_cached_frames(self, frames): # Refresh widget self.update() + def set_annotated_frames(self, comment_frames, drawing_frames): + """ + Update the annotation markers. + + A frame carrying both a comment and a drawing counts as a comment: the + words are the reviewable part, and the marker should not hide them. + + Args: + comment_frames (iterable[int]): + Timeline frames holding at least one comment. + + drawing_frames (iterable[int]): + Timeline frames holding at least one stroke. + """ + + self.comment_frames = set(int(frame) for frame in comment_frames or []) + self.drawing_frames = set( + int(frame) for frame in drawing_frames or [] + ) - self.comment_frames + + # Refresh widget + self.update() + + def draw_annotation_markers(self, painter, height): + """ + Draw a tick on the timeline for every annotated frame. + + Args: + painter (QtGui.QPainter): + Active painter. + + height (int): + Widget height. + """ + + if not self.comment_frames and not self.drawing_frames: + return + + # Sit below the frame-number labels, above the playhead readout. The + # playhead is drawn afterwards so it always stays legible on top. + top = int(height * 0.45) + marker_height = max(10, int(height * 0.45)) + width = constants.TIMELINE_MARKER_WIDTH + + painter.save() + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + for frames, color in ( + (self.drawing_frames, constants.TIMELINE_DRAWING_MARKER_COLOR), + (self.comment_frames, constants.TIMELINE_COMMENT_MARKER_COLOR), + ): + painter.setBrush(QtGui.QColor(*color)) + for frame in frames: + if frame < self.start_frame or frame > self.end_frame: + continue + x = self.frame_to_pos(frame) + painter.drawRoundedRect( + QtCore.QRectF( + x - (width / 2.0), top, width, marker_height + ), + 1, + 1, + ) + + painter.restore() + def paintEvent(self, event): """ Paint timeline widget. @@ -215,6 +285,10 @@ def paintEvent(self, event): painter.drawLine(x, 0, x, 25) painter.drawText(x + 2, 40, str(frame)) + # Annotation markers sit under the playhead, so a note never hides the + # frame the reviewer is actually on. + self.draw_annotation_markers(painter, height) + # Calculate playhead position current_x = int( self.timeline_margin + ((self.current_frame - self.start_frame) * pixels_per_frame) diff --git a/widgets/viewer.py b/widgets/viewer.py index 5153a5d..fe0bb97 100644 --- a/widgets/viewer.py +++ b/widgets/viewer.py @@ -1202,6 +1202,11 @@ class ViewerWidget(QtOpenGLWidgets.QOpenGLWidget): fullscreen_requested = QtCore.Signal() comment_pin_clicked = QtCore.Signal(float, float) + # Emitted whenever the stroke set changes (drawn, undone, redone, cleared), + # so the timeline markers follow the annotations instead of drifting from + # them until the next reload. + annotations_changed = QtCore.Signal() + def __init__(self, parent=None): """ Initialize viewer widget. @@ -2184,6 +2189,8 @@ def mouseReleaseEvent(self, event): self.annotations.set_enabled(False) self.annotation_tool_finished.emit("txt") + self.annotations_changed.emit() + self.update() def wheelEvent(self, event): @@ -2242,6 +2249,8 @@ def undo_strokes(self): self.annotations.undo() + self.annotations_changed.emit() + self.update() def redo_strokes(self): @@ -2251,6 +2260,8 @@ def redo_strokes(self): self.annotations.redo() + self.annotations_changed.emit() + self.update() def clear_strokes(self): @@ -2260,6 +2271,8 @@ def clear_strokes(self): self.annotations.clear() + self.annotations_changed.emit() + self.update() def render_current_frame(self):