diff --git a/playback/player.py b/playback/player.py index 4484a7b..57e2544 100644 --- a/playback/player.py +++ b/playback/player.py @@ -55,6 +55,7 @@ from __future__ import absolute_import +import time import numpy import threading @@ -81,7 +82,8 @@ class SequenceDecodeThread(QtCore.QThread): """Bounded EXR/image decoder queue modeled after a media decoder FIFO.""" - decoded = QtCore.Signal(int, object, int, str) + # frame_number, image, generation, error, decode_milliseconds + decoded = QtCore.Signal(int, object, int, str, float) def __init__(self, parent=None): super().__init__(parent) @@ -147,6 +149,7 @@ def run(self): reader, frame_number, aov, processor, generation, _key = task error = "" image = None + started = time.perf_counter() try: image = reader.get_frame( frame_number, @@ -158,7 +161,10 @@ def run(self): finally: with self._condition: self._inflight_key = None - self.decoded.emit(frame_number, image, generation, error) + # Timed here rather than on the GUI thread: this is the decode, and + # the queue wait in front of it is not the decoder's fault. + decode_ms = (time.perf_counter() - started) * 1000.0 + self.decoded.emit(frame_number, image, generation, error, decode_ms) class BasePlayer(QtCore.QObject): @@ -222,6 +228,9 @@ def __init__(self): # silently reset the speed while the UI still shows the old value. self.playback_speed = constants.DEFAULT_PLAYBACK_SPEED + # Optional PlaybackStats shared with whichever implementation is live. + self.stats = None + # Active OCIO processor self.ocio_processor = None @@ -266,6 +275,10 @@ def load(self, path): self.player.set_speed(self.playback_speed) + # load() rebuilds the implementation, so the stats object has to be + # re-attached or the HUD silently goes dead on the next source. + self.player.stats = self.stats + if self.ocio_processor: self.player.set_ocio(self.ocio_processor, self.input_space, self.display, self.view) @@ -370,6 +383,12 @@ def set_speed(self, value): if self.player and hasattr(self.player, "set_speed"): self.player.set_speed(self.playback_speed) + def set_stats(self, stats): + """Attach a PlaybackStats collector (None disables measurement).""" + self.stats = stats + if self.player is not None: + self.player.stats = stats + def set_aov(self, aov): """Set active AOV. @@ -473,6 +492,9 @@ def __init__(self): # still shows every frame -- it just steps through them faster/slower. self.speed = constants.DEFAULT_PLAYBACK_SPEED + # Optional PlaybackStats, assigned by MediaPlayer when the HUD is on. + self.stats = None + # Active AOV self.current_aov = "rgb" @@ -959,7 +981,7 @@ def update_frame(self): priority=True, ) - def _frame_decoded(self, frame_number, frame, generation, error): + def _frame_decoded(self, frame_number, frame, generation, error, decode_ms=0.0): if generation != self.decode_generation or self.reader is None: return if error: @@ -967,6 +989,8 @@ def _frame_decoded(self, frame_number, frame, generation, error): return if frame is None: return + if self.stats is not None: + self.stats.record_decode(decode_ms) self.cache.add(frame_number, frame) self.cache_changed.emit(self.cache.cached_frames()) if frame_number == self.display_request_frame: @@ -1065,6 +1089,9 @@ def __init__(self): # Playback speed multiplier, applied to the elapsed playback clock. self.speed = constants.DEFAULT_PLAYBACK_SPEED + # Optional PlaybackStats, assigned by MediaPlayer when the HUD is on. + self.stats = None + # Timeline start frame. self.start_frame = constants.VL_START_FRAME @@ -1429,6 +1456,7 @@ def display_video(self, current_time): # Consume all ready frames, but render only the newest. If decoding or # painting falls behind, displaying stale frames makes the lag worse. ready_frame = None + ready_count = 0 while self.video_queue: # Peek at the next decoded frame. @@ -1446,8 +1474,11 @@ def display_video(self, current_time): self.video_queue.popleft() ready_frame = frame + ready_count += 1 if ready_frame is not None: + if self.stats is not None and ready_count > 1: + self.stats.record_dropped(ready_count - 1) self.display_video_frame(ready_frame) def display_video_frame(self, frame): @@ -1479,6 +1510,8 @@ def display_video_frame(self, frame): ensuring accurate synchronization with playback time. """ + started = time.perf_counter() + frame_time = frame.time if frame_time is None and frame.pts is not None: frame_time = float(frame.pts * self.reader.video_stream.time_base) @@ -1520,6 +1553,12 @@ def display_video_frame(self, frame): min(frame_number, self.start_frame + self.frame_count - 1), ) + # The proxy scale, RGB conversion and OCIO transform are what stand + # between a decoded packet and the screen, so that is the cost the HUD + # reports for movies. + if self.stats is not None: + self.stats.record_decode((time.perf_counter() - started) * 1000.0) + # Send the image to the viewer. self.frame_ready.emit(image) diff --git a/playback/stats.py b/playback/stats.py new file mode 100644 index 0000000..3b4b0be --- /dev/null +++ b/playback/stats.py @@ -0,0 +1,218 @@ +"""Playback performance measurement for the viewer HUD. + +Answers the question a reviewer actually asks when playback feels wrong: *is it +me, or is it the machine?* A supervisor calling a note on timing needs to know +they are watching 24 fps and not 17 -- otherwise they are grading the playback, +not the shot. + +Everything here is pure. The clock is injected, so the rolling averages can be +tested exactly rather than by sleeping and hoping. +""" + +from __future__ import absolute_import + +import time + +from collections import deque + +# Frames are measured over a short trailing window: long enough to be stable, +# short enough that a stall shows up immediately rather than being averaged away. +DEFAULT_WINDOW_SECONDS = 1.0 + +# Decode timings are averaged over a fixed count instead of a time window, so a +# paused player still reports the cost of the frames it did decode. +DEFAULT_DECODE_SAMPLES = 30 + + +class PlaybackStats(object): + """Rolling measurement of displayed frame rate and decode cost. + + Example: + >>> stats = PlaybackStats() + >>> stats.record_frame() + >>> stats.measured_fps() + """ + + def __init__(self, clock=None, window=DEFAULT_WINDOW_SECONDS, + decode_samples=DEFAULT_DECODE_SAMPLES): + # Injected for tests; perf_counter is monotonic, unlike time(). + self.clock = clock or time.perf_counter + self.window = float(window) + + self.frame_times = deque() + self.decode_times = deque(maxlen=int(decode_samples)) + + self.dropped = 0 + + # Total frames seen since the last reset. Distinguishes "playback has + # not started" from "playback started and then died" -- both leave the + # rolling window empty, but only one of them is a problem. + self.frames_seen = 0 + + def reset(self): + """Forget every measurement (a new source is not the old one's tail).""" + self.frame_times.clear() + self.decode_times.clear() + self.dropped = 0 + self.frames_seen = 0 + + def reset_frame_timing(self): + """Start a fresh FPS window without discarding recent decode cost.""" + self.frame_times.clear() + self.frames_seen = 0 + + def record_frame(self): + """Record that a frame reached the screen.""" + now = self.clock() + self.frame_times.append(now) + self.frames_seen += 1 + self._trim(now) + + def record_decode(self, milliseconds): + """Record how long one frame took to decode.""" + try: + value = float(milliseconds) + except (TypeError, ValueError): + return + if value >= 0: + self.decode_times.append(value) + + def record_dropped(self, count=1): + """Record frames the player had to skip to keep up.""" + self.dropped += max(0, int(count)) + + def _trim(self, now): + threshold = now - self.window + while self.frame_times and self.frame_times[0] < threshold: + self.frame_times.popleft() + + def measured_fps(self): + """Return the displayed frame rate over the trailing window. + + Returns 0.0 until two frames have been seen -- one timestamp measures no + interval, and reporting a rate from it would be a guess. + """ + self._trim(self.clock()) + + if len(self.frame_times) < 2: + return 0.0 + + span = self.frame_times[-1] - self.frame_times[0] + if span <= 0: + return 0.0 + + # N timestamps bound N-1 intervals. + return (len(self.frame_times) - 1) / span + + def average_decode_ms(self): + """Return the mean decode time over the recent samples.""" + if not self.decode_times: + return 0.0 + return sum(self.decode_times) / len(self.decode_times) + + def stalled(self): + """True when frames were playing and then stopped arriving entirely. + + A hard stall empties the rolling window, so measured_fps() drops to + zero -- the same reading as "nothing has played yet". Without this + distinction a total freeze would render as a calm "--", which is exactly + the failure the HUD exists to catch. + """ + return self.frames_seen >= 2 and self.measured_fps() <= 0 + + def is_realtime(self, target_fps, tolerance=0.95): + """True when playback is holding *target_fps* (within tolerance). + + A player that has not shown two frames yet is not failing -- it just has + nothing to say -- so it reports True rather than alarming the reviewer. + A player that HAS played and then stopped is a different matter: see + :meth:`stalled`, which the HUD checks alongside this. + """ + measured = self.measured_fps() + if measured <= 0: + return True + + try: + target = float(target_fps) + except (TypeError, ValueError): + return True + + if target <= 0: + return True + + return measured >= target * tolerance + + +def hud_lines(stats, target_fps=0, playing=False, frame=None, frame_count=None, + resolution=None, proxy_label=None, cached=None): + """Format the HUD as a list of ``(label, value, ok)`` rows. + + ``ok`` is False only for a genuinely bad reading, so the HUD can colour just + that row rather than shouting about everything at once. + + ``playing`` is needed to tell a stall from a pause: both stop frames + arriving, but only one of them is a fault. + """ + rows = list() + + measured = stats.measured_fps() + + try: + target = float(target_fps or 0) + except (TypeError, ValueError): + target = 0.0 + + if playing and stats.stalled(): + # Frames were arriving and then stopped: say so loudly. + rows.append(("FPS", "STALLED", False)) + else: + if measured > 0 and target > 0: + fps_text = "{0:.1f} / {1:g}".format(measured, target) + elif measured > 0: + fps_text = "{0:.1f}".format(measured) + else: + fps_text = "--" + rows.append(("FPS", fps_text, stats.is_realtime(target))) + + if frame is not None: + total = "" if frame_count in (None, 0) else " / {0}".format(frame_count) + rows.append(("FRAME", "{0}{1}".format(frame, total), True)) + + if resolution: + text = "{0} x {1}".format(resolution[0], resolution[1]) + if proxy_label: + text = "{0} {1}".format(text, proxy_label) + rows.append(("RES", text, True)) + + decode = stats.average_decode_ms() + if decode > 0: + budget = (1000.0 / target) if target > 0 else 0 + # A frame that takes longer to decode than its share of the clock cannot + # sustain real time, no matter how fast the rest of the pipeline is. + rows.append( + ("DECODE", "{0:.1f} ms".format(decode), not budget or decode <= budget) + ) + + if cached is not None: + rows.append(("CACHE", "{0} frames".format(cached), True)) + + if stats.dropped: + rows.append(("DROPPED", str(stats.dropped), False)) + + return rows + + +def effective_target_fps(source_fps, playback_speed=1.0): + """Return the displayed FPS expected at the selected transport speed.""" + try: + rate = float(source_fps) + multiplier = float(playback_speed) + except (TypeError, ValueError): + return 0.0 + if rate <= 0 or multiplier <= 0 or rate != rate or multiplier != multiplier: + return 0.0 + return rate * multiplier + + +if __name__ == "__main__": + pass diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..285a0f5 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,335 @@ +"""Tests for the playback performance HUD.""" + +import pytest + +from playback import stats as statsmath +from playback.stats import PlaybackStats + + +class _Clock: + """A hand-cranked clock, so rates are asserted exactly rather than slept for.""" + + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +def _stats(window=1.0): + clock = _Clock() + return PlaybackStats(clock=clock, window=window), clock + + +# --------------------------------------------------------------------------- # +# Measured frame rate +# --------------------------------------------------------------------------- # +def test_no_frames_reports_nothing_rather_than_zero_fps(): + stats, _clock = _stats() + + # Nothing has played; the HUD must not claim playback is broken. + assert stats.measured_fps() == 0.0 + assert stats.is_realtime(24) is True + + +def test_a_single_frame_is_not_a_rate(): + stats, _clock = _stats() + stats.record_frame() + + # One timestamp bounds no interval. Reporting a rate from it would be a + # fabrication. + assert stats.measured_fps() == 0.0 + + +def test_frames_at_24fps_measure_24fps(): + stats, clock = _stats() + + for _ in range(25): + stats.record_frame() + clock.advance(1 / 24.0) + + # 25 timestamps across 24 intervals of 1/24s. + assert stats.measured_fps() == pytest.approx(24.0, rel=1e-6) + + +def test_half_rate_playback_measures_half_rate(): + stats, clock = _stats(window=10.0) + + for _ in range(13): + stats.record_frame() + clock.advance(1 / 12.0) + + assert stats.measured_fps() == pytest.approx(12.0, rel=1e-6) + assert stats.is_realtime(24) is False # this is the whole point of the HUD + + +def test_old_frames_fall_out_of_the_window(): + stats, clock = _stats(window=1.0) + + for _ in range(10): + stats.record_frame() + clock.advance(0.01) + + # A stall: the window slides past every recorded frame. + clock.advance(5.0) + + assert stats.measured_fps() == 0.0 + + +def test_a_stall_is_visible_immediately_not_averaged_away(): + stats, clock = _stats(window=1.0) + + # A second of healthy 24 fps... + for _ in range(24): + stats.record_frame() + clock.advance(1 / 24.0) + assert stats.measured_fps() == pytest.approx(24.0, rel=0.05) + + # ...then a 2 second freeze. The window empties, so the measured rate falls + # to zero -- the same reading as "nothing has played yet". stalled() is what + # tells those two apart; without it a total freeze renders as a calm "--", + # which is the exact failure this HUD exists to catch. + clock.advance(2.0) + + assert stats.measured_fps() == 0.0 + assert stats.stalled() is True + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24, playing=True)) + assert rows["FPS"] == ("STALLED", False) + + +def test_a_pause_is_not_a_stall(): + """Paused playback stops frames arriving too, but it is not a fault.""" + stats, clock = _stats(window=1.0) + + for _ in range(24): + stats.record_frame() + clock.advance(1 / 24.0) + + clock.advance(5.0) # the reviewer hit pause and went for coffee + + assert stats.stalled() is True # the measurement cannot tell on its own... + + # ...so the HUD is told whether the player is actually running. + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24, playing=False)) + assert rows["FPS"] == ("--", True) + + +def test_a_fresh_player_is_not_stalled(): + stats, _clock = _stats() + + assert stats.stalled() is False + + stats.record_frame() + assert stats.stalled() is False # one frame is not "was playing" + + +def test_reset_forgets_everything(): + stats, clock = _stats() + for _ in range(5): + stats.record_frame() + clock.advance(0.04) + stats.record_decode(12.0) + stats.record_dropped(3) + + stats.reset() + + assert stats.measured_fps() == 0.0 + assert stats.average_decode_ms() == 0.0 + assert stats.dropped == 0 + + +def test_new_playback_window_keeps_decode_history(): + stats, clock = _stats() + stats.record_decode(12.0) + stats.record_frame() + clock.advance(0.04) + stats.record_frame() + + stats.reset_frame_timing() + + assert stats.measured_fps() == 0.0 + assert stats.stalled() is False + assert stats.average_decode_ms() == 12.0 + + +# --------------------------------------------------------------------------- # +# Decode timing +# --------------------------------------------------------------------------- # +def test_decode_average(): + stats, _clock = _stats() + + for value in (10.0, 20.0, 30.0): + stats.record_decode(value) + + assert stats.average_decode_ms() == pytest.approx(20.0) + + +def test_decode_samples_are_bounded(): + stats = PlaybackStats(clock=_Clock(), decode_samples=3) + + for value in (100.0, 1.0, 2.0, 3.0): + stats.record_decode(value) + + # The 100 ms outlier has aged out; the HUD reflects recent behaviour. + assert stats.average_decode_ms() == pytest.approx(2.0) + + +@pytest.mark.parametrize("value", [None, "slow", -5]) +def test_junk_decode_timings_are_ignored(value): + stats, _clock = _stats() + stats.record_decode(value) + + assert stats.average_decode_ms() == 0.0 + + +# --------------------------------------------------------------------------- # +# is_realtime +# --------------------------------------------------------------------------- # +def test_realtime_allows_a_small_shortfall(): + stats, clock = _stats(window=10.0) + + # 23.5 fps against a 24 fps target is not a problem worth colouring red. + for _ in range(24): + stats.record_frame() + clock.advance(1 / 23.5) + + assert stats.is_realtime(24) is True + + +@pytest.mark.parametrize( + "fps,multiplier,expected", + [(24, 0.5, 12.0), (24, 2.0, 48.0), (23.976, 1.0, 23.976)], +) +def test_effective_target_fps_follows_transport_speed(fps, multiplier, expected): + assert statsmath.effective_target_fps(fps, multiplier) == pytest.approx(expected) + + +@pytest.mark.parametrize("fps,multiplier", [(0, 1), (24, 0), (None, 1), (24, "fast")]) +def test_effective_target_fps_survives_bad_metadata(fps, multiplier): + assert statsmath.effective_target_fps(fps, multiplier) == 0.0 + + +@pytest.mark.parametrize("target", [0, None, "", "unknown"]) +def test_realtime_survives_a_missing_target(target): + stats, clock = _stats() + for _ in range(5): + stats.record_frame() + clock.advance(0.5) + + # Without a usable target there is nothing to fail against. + assert stats.is_realtime(target) is True + + +# --------------------------------------------------------------------------- # +# HUD rows +# --------------------------------------------------------------------------- # +def _rows_by_label(rows): + return {label: (value, ok) for label, value, ok in rows} + + +def test_hud_reports_measured_against_target(): + stats, clock = _stats(window=10.0) + for _ in range(13): + stats.record_frame() + clock.advance(1 / 12.0) + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["FPS"][0] == "12.0 / 24" + assert rows["FPS"][1] is False # flagged: playback is not holding up + + +def test_hud_with_no_playback_yet(): + stats, _clock = _stats() + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["FPS"] == ("--", True) + + +def test_hud_includes_frame_resolution_and_cache(): + stats, _clock = _stats() + + rows = _rows_by_label( + statsmath.hud_lines( + stats, + target_fps=24, + frame=42, + frame_count=120, + resolution=(2048, 1152), + proxy_label="2K", + cached=48, + ) + ) + + assert rows["FRAME"][0] == "42 / 120" + assert rows["RES"][0] == "2048 x 1152 2K" + assert rows["CACHE"][0] == "48 frames" + + +def test_hud_flags_a_decode_that_cannot_hold_the_frame_rate(): + stats, _clock = _stats() + + # 60 ms per frame against a 24 fps budget of ~41.7 ms: it cannot keep up, + # however fast the rest of the pipeline is. + stats.record_decode(60.0) + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["DECODE"] == ("60.0 ms", False) + + +def test_hud_accepts_a_decode_inside_the_frame_budget(): + stats, _clock = _stats() + stats.record_decode(12.0) + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["DECODE"] == ("12.0 ms", True) + + +def test_hud_omits_rows_it_has_nothing_to_say_about(): + stats, _clock = _stats() + + labels = [label for label, _value, _ok in statsmath.hud_lines(stats)] + + # No decode samples, no frame, no resolution, no cache, no drops. + assert labels == ["FPS"] + + +def test_hud_reports_dropped_frames_only_when_there_are_some(): + stats, _clock = _stats() + assert "DROPPED" not in _rows_by_label(statsmath.hud_lines(stats)) + + stats.record_dropped(4) + rows = _rows_by_label(statsmath.hud_lines(stats)) + + assert rows["DROPPED"] == ("4", False) + + +def test_movie_queue_counts_frames_skipped_to_catch_up(qapp): + from playback.player import MoviePlayer + + class _Frame: + def __init__(self, frame_time): + self.time = frame_time + self.pts = None + + player = MoviePlayer() + player.stats = PlaybackStats() + displayed = [] + player.display_video_frame = displayed.append + for frame_time in (0.0, 0.04, 0.08): + player.video_queue.append(_Frame(frame_time)) + + player.display_video(0.1) + + assert len(displayed) == 1 + assert displayed[0].time == 0.08 + assert player.stats.dropped == 2 + player.timer.stop() + player.audio_player.stop() diff --git a/widgets/__init__.py b/widgets/__init__.py index 95d5808..e0c8e48 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -36,6 +36,8 @@ from playback import speed as speedmath from playback import proxy +from playback.stats import PlaybackStats +from playback import stats as statsmath from utils import notescsv from PySide6 import QtGui @@ -132,6 +134,11 @@ def __init__(self, parent=None, **kwargs): self.loop_enabled = False self.loop_mode = "off" self.playback_speed = constants.DEFAULT_PLAYBACK_SPEED + + # Performance HUD. Stats are always collected (they cost a deque append + # per frame); only the drawing is gated on the toggle. + self.playback_stats = PlaybackStats() + self.stats_hud_enabled = False self._playlist_loading = False self.playlist_entries = list() self.playlist_entry_index = -1 @@ -328,6 +335,17 @@ def setupUi(self): self.viewframe.timelineToolbarLayout.speed_changed.connect( self.set_playback_speed ) + + # -------------------------------------------------------------------- + # Performance HUD + # -------------------------------------------------------------------- + self.player.set_stats(self.playback_stats) + # A frame reaching the viewer is the only honest definition of a + # displayed frame, so the rate is measured there and not at the timer. + self.player.frame_ready.connect(self.record_playback_frame) + self.statsHudTimer = QtCore.QTimer(self) + self.statsHudTimer.setInterval(250) + self.statsHudTimer.timeout.connect(self.refresh_stats_hud) # Keyboard Shortcuts # Play / Pause self.playShortcut = QtGui.QShortcut(QtGui.QKeySequence("Space"), self) @@ -489,6 +507,11 @@ def setup_review_chrome(self): self.actionShotTimeline.setChecked(True) self.actionShotTimeline.toggled.connect(self.shotSequenceWidget.setVisible) view_menu.addAction(self.actionShotTimeline) + self.actionStatsHud = QtGui.QAction("Performance HUD", self, checkable=True) + self.actionStatsHud.setIcon(NamePixmapIcon("display")) + self.actionStatsHud.setShortcut(QtGui.QKeySequence("Ctrl+Alt+H")) + self.actionStatsHud.toggled.connect(self.set_stats_hud) + view_menu.addAction(self.actionStatsHud) self.actionRecaps = QtGui.QAction("Review Notes Panel", self, checkable=True) self.actionRecaps.setIcon(NamePixmapIcon("txt")) self.actionRecaps.toggled.connect(self.recapsWidget.set_current_recaps) @@ -1178,6 +1201,11 @@ def openMedia(self, filepath=None, add_to_playlist=True): # Persist the outgoing source's annotations before the viewer is wiped. self._save_current_notes() + + # Clear measurements BEFORE the new source loads. Loading displays its + # first frame synchronously, so resetting afterwards would throw away + # the very frame (and decode timing) that was just recorded. + self.playback_stats.reset() # The old source is no longer active. Clearing this before opening the # replacement prevents a failed import followed by app exit from # overwriting the outgoing shot's sidecar with an empty sketch. @@ -2129,6 +2157,12 @@ def toggle_play_pause(self): Toggle playback state. """ + starting_playback = not self.player.is_playing + if starting_playback: + # Do not carry a stale pre-pause window into a new playback run; + # decode averages remain useful and are intentionally preserved. + self.playback_stats.reset_frame_timing() + if self.playlist_playback_active and self.player.player is None: self.start_playlist_playback() elif self.compare_active and self.compare_player.player is not None: @@ -2340,6 +2374,51 @@ def set_proxy_level(self, key): f"Proxy resolution: {label} (source reloaded)", 4500 ) + def set_stats_hud(self, enabled): + """Show or hide the viewer performance HUD.""" + self.stats_hud_enabled = bool(enabled) + self.viewframe.viewer.set_stats_hud(self.stats_hud_enabled) + + if self.stats_hud_enabled: + self.refresh_stats_hud() + self.statsHudTimer.start() + else: + self.statsHudTimer.stop() + + def record_playback_frame(self, _image=None): + """Measure presentation frames only while transport is running.""" + if self.player.is_playing: + self.playback_stats.record_frame() + + def refresh_stats_hud(self): + """Rebuild the HUD rows from the current measurements.""" + if not self.stats_hud_enabled: + return + + image = self.viewframe.viewer.qimage + resolution = (image.width(), image.height()) if image is not None else None + + cached = None + implementation = self.player.player + if implementation is not None and hasattr(implementation, "cache"): + cached = len(implementation.cache.cached_frames()) + + target_fps = statsmath.effective_target_fps( + self._current_fps(), self.playback_speed + ) + proxy_label = proxy.label_for().split(" (", 1)[0] + rows = statsmath.hud_lines( + self.playback_stats, + target_fps=target_fps, + playing=bool(self.player.is_playing), + frame=self.viewframe.timeline.current_frame, + frame_count=self.player.frame_count, + resolution=resolution, + proxy_label=proxy_label, + cached=cached, + ) + self.viewframe.viewer.set_stats_rows(rows) + def set_gamma_check(self, enabled): """Toggle temporary Y-drag gamma inspection in the viewer.""" enabled = bool(enabled) diff --git a/widgets/viewer.py b/widgets/viewer.py index edd2b29..01e3d61 100644 --- a/widgets/viewer.py +++ b/widgets/viewer.py @@ -1279,6 +1279,10 @@ def __init__(self, parent=None): # untouched for caching, annotations, snapshots, and exports. self.gamma_check_enabled = False self.exposure_check_enabled = False + + # Performance HUD, driven by MainWindow from PlaybackStats. + self.stats_hud_enabled = False + self.stats_rows = list() self.display_gamma = 1.0 self.display_exposure = 0.0 self.channel_view = "RGB" @@ -1868,6 +1872,8 @@ def paintGL(self): ) if self.gamma_check_enabled or self.exposure_check_enabled: self._draw_display_adjustment_hud(painter) + if self.stats_hud_enabled: + self._draw_stats_hud(painter) painter.end() @staticmethod @@ -1883,6 +1889,76 @@ def _draw_compare_label(painter, x, y, text): metrics.elidedText(text, QtCore.Qt.TextElideMode.ElideMiddle, width - 18), ) + def set_stats_hud(self, enabled): + """Show or hide the performance HUD.""" + self.stats_hud_enabled = bool(enabled) + self.update() + + def set_stats_rows(self, rows): + """Set the HUD contents as (label, value, ok) rows and repaint.""" + self.stats_rows = list(rows or []) + if self.stats_hud_enabled: + self.update() + + def _draw_stats_hud(self, painter): + """Draw the performance HUD in the top-left of the viewer.""" + rows = self.stats_rows + if not rows: + return + + painter.save() + + font = QtGui.QFont("Consolas") + font.setStyleHint(QtGui.QFont.StyleHint.Monospace) + font.setPointSize(9) + painter.setFont(font) + + metrics = painter.fontMetrics() + label_width = max(metrics.horizontalAdvance(row[0]) for row in rows) + value_width = max(metrics.horizontalAdvance(row[1]) for row in rows) + + padding = 10 + gap = 14 + line_height = metrics.height() + 2 + + width = label_width + gap + value_width + padding * 2 + height = line_height * len(rows) + padding * 2 + + rect = QtCore.QRectF(12, 12, width, height) + painter.fillRect(rect, QtGui.QColor(18, 21, 24, 225)) + painter.setPen(QtGui.QPen(QtGui.QColor(224, 174, 74), 1.0)) + painter.drawRect(rect) + + y = rect.top() + padding + for label, value, ok in rows: + painter.setPen(QtGui.QColor(150, 158, 166)) + painter.drawText( + QtCore.QRectF(rect.left() + padding, y, label_width, line_height), + QtCore.Qt.AlignmentFlag.AlignVCenter + | QtCore.Qt.AlignmentFlag.AlignLeft, + label, + ) + + # Only a genuinely bad reading is coloured, so the eye goes straight + # to the row that is actually wrong. + painter.setPen( + QtGui.QColor(238, 238, 238) if ok else QtGui.QColor(255, 92, 92) + ) + painter.drawText( + QtCore.QRectF( + rect.left() + padding + label_width + gap, + y, + value_width, + line_height, + ), + QtCore.Qt.AlignmentFlag.AlignVCenter + | QtCore.Qt.AlignmentFlag.AlignLeft, + value, + ) + y += line_height + + painter.restore() + def _draw_display_adjustment_hud(self, painter): """Draw a compact production-style status chip for image inspection.""" if self.gamma_check_enabled: