From 61ba40b34c0601abe68d18bddeb5a42c9ab5c3b0 Mon Sep 17 00:00:00 2001 From: Eric Levy Date: Tue, 14 Jul 2026 14:59:07 -0400 Subject: [PATCH 1/2] Add playback performance HUD View > Performance HUD (Ctrl+Alt+H) overlays measured FPS against target, frame, resolution, average decode cost and cache depth. It 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, or they are grading the playback and not the shot. playback/stats.py is pure and takes an injected clock, so the rolling averages are asserted exactly instead of being slept for. - Frame rate is measured where frames reach the viewer, which is the only honest definition of a displayed frame -- not at the playback timer, which keeps firing whether or not a frame made it. - Decode cost is timed around the decode itself. For sequences that is inside the decode thread, so the queue wait in front of it is not blamed on the decoder; for movies it covers the proxy scale, RGB conversion and OCIO, which is what stands between a packet and the screen. - A decode slower than its share of the frame budget is flagged: it cannot sustain real time however fast the rest of the pipeline is. A stall and a pause both stop frames arriving and both empty the rolling window, so measured FPS falls to zero, which is the same reading as playback never having started. Without telling those apart, a total freeze renders as a calm placeholder dash, and that is the exact failure this HUD exists to catch. stalled() tracks whether frames were ever flowing, and the window passes in whether the player is actually running, so a freeze reads STALLED in red while a pause stays quiet. Only a genuinely bad row is coloured, so the eye goes to the thing that is actually wrong rather than a wall of red. 26 tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb --- playback/player.py | 41 ++++++- playback/stats.py | 201 +++++++++++++++++++++++++++++++ tests/test_stats.py | 284 ++++++++++++++++++++++++++++++++++++++++++++ widgets/__init__.py | 65 ++++++++++ widgets/viewer.py | 76 ++++++++++++ 5 files changed, 664 insertions(+), 3 deletions(-) create mode 100644 playback/stats.py create mode 100644 tests/test_stats.py diff --git a/playback/player.py b/playback/player.py index 4484a7b..ac4d3a8 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 @@ -1479,6 +1506,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 +1549,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..c3d3882 --- /dev/null +++ b/playback/stats.py @@ -0,0 +1,201 @@ +"""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 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 + + +if __name__ == "__main__": + pass diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..4da2af6 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,284 @@ +"""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 + + +# --------------------------------------------------------------------------- # +# 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("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) diff --git a/widgets/__init__.py b/widgets/__init__.py index 95d5808..67f6c8e 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,19 @@ 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( + lambda _image: self.playback_stats.record_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 +509,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 +1203,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. @@ -2340,6 +2370,41 @@ 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 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()) + + rows = statsmath.hud_lines( + self.playback_stats, + target_fps=self._current_fps(), + playing=bool(self.player.is_playing), + frame=self.viewframe.timeline.current_frame, + frame_count=self.player.frame_count, + resolution=resolution, + 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: From d0b9cf8ab63b9edbf572a4e3c9e618ac88977220 Mon Sep 17 00:00:00 2001 From: D-Mad <8207507+D-Mad@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:18:53 +0700 Subject: [PATCH 2/2] Integrate performance HUD with speed and proxy state --- playback/player.py | 4 ++++ playback/stats.py | 17 +++++++++++++++ tests/test_stats.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ widgets/__init__.py | 22 +++++++++++++++---- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/playback/player.py b/playback/player.py index ac4d3a8..57e2544 100644 --- a/playback/player.py +++ b/playback/player.py @@ -1456,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. @@ -1473,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): diff --git a/playback/stats.py b/playback/stats.py index c3d3882..3b4b0be 100644 --- a/playback/stats.py +++ b/playback/stats.py @@ -56,6 +56,11 @@ def reset(self): 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() @@ -197,5 +202,17 @@ def hud_lines(stats, target_fps=0, playing=False, frame=None, frame_count=None, 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 index 4da2af6..285a0f5 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -142,6 +142,20 @@ def test_reset_forgets_everything(): 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 # --------------------------------------------------------------------------- # @@ -186,6 +200,19 @@ def test_realtime_allows_a_small_shortfall(): 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() @@ -282,3 +309,27 @@ def test_hud_reports_dropped_frames_only_when_there_are_some(): 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 67f6c8e..e0c8e48 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -342,9 +342,7 @@ def setupUi(self): 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( - lambda _image: self.playback_stats.record_frame() - ) + 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) @@ -2159,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: @@ -2381,6 +2385,11 @@ def set_stats_hud(self, enabled): 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: @@ -2394,13 +2403,18 @@ def refresh_stats_hud(self): 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=self._current_fps(), + 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)