Skip to content
Closed
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
7 changes: 7 additions & 0 deletions constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions tests/test_timeline_markers.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -1834,19 +1862,22 @@ 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):
if not self.viewframe.viewer.annotations.delete_comment(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):
Expand Down Expand Up @@ -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")
Expand Down
74 changes: 74 additions & 0 deletions widgets/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions widgets/viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -2242,6 +2249,8 @@ def undo_strokes(self):

self.annotations.undo()

self.annotations_changed.emit()

self.update()

def redo_strokes(self):
Expand All @@ -2251,6 +2260,8 @@ def redo_strokes(self):

self.annotations.redo()

self.annotations_changed.emit()

self.update()

def clear_strokes(self):
Expand All @@ -2260,6 +2271,8 @@ def clear_strokes(self):

self.annotations.clear()

self.annotations_changed.emit()

self.update()

def render_current_frame(self):
Expand Down
Loading