diff --git a/constants/__init__.py b/constants/__init__.py index 70f60ca..6aff060 100644 --- a/constants/__init__.py +++ b/constants/__init__.py @@ -171,6 +171,13 @@ ("flicker", "Flicker A/B"), ) +# Playback speed multipliers offered in the UI, and the range accepted at all. +# Audio is only submitted at 1x -- see MoviePlayer.play_audio. +PLAYBACK_SPEEDS = (0.25, 0.5, 1.0, 1.5, 2.0, 4.0) +DEFAULT_PLAYBACK_SPEED = 1.0 +MIN_PLAYBACK_SPEED = 0.1 +MAX_PLAYBACK_SPEED = 8.0 + FPS_VALUES = [ {"code": "23.976- FPS", "value": 23.976}, {"code": "24- FPS", "value": 24}, diff --git a/playback/player.py b/playback/player.py index 1f2b071..a680781 100644 --- a/playback/player.py +++ b/playback/player.py @@ -63,6 +63,7 @@ import constants from playback import loopmode +from playback import speed from collections import deque @@ -355,6 +356,11 @@ def set_fps(self, fps): if self.player and hasattr(self.player, "set_fps"): self.player.set_fps(fps) + def set_speed(self, value): + """Set the playback speed multiplier on the active implementation.""" + if self.player and hasattr(self.player, "set_speed"): + self.player.set_speed(value) + def set_aov(self, aov): """Set active AOV. @@ -454,6 +460,10 @@ def __init__(self): self.playback_direction = 1 self.is_playing = False + # Playback speed multiplier. Scales the timer interval, so the sequence + # still shows every frame -- it just steps through them faster/slower. + self.speed = constants.DEFAULT_PLAYBACK_SPEED + # Active AOV self.current_aov = "rgb" @@ -617,8 +627,10 @@ def play(self): # Get Playback FPS fps = self.reader.get_fps() - # Convert FPS To Timer Interval - interval = int(1000 / fps) + # Convert FPS To Timer Interval, scaled by the playback speed + interval = speed.interval_ms(fps, self.speed) + if not interval: + return # Start Playback Timer self.timer.start(interval) @@ -707,12 +719,35 @@ def set_fps(self, fps): # Restart timer if currently playing if self.is_playing: - interval = int(1000 / fps) - self.timer.start(interval) + interval = speed.interval_ms(fps, self.speed) + if interval: + self.timer.start(interval) # Log FPS Update LOGGER.info(f'Current FPS, has been changed into, "{fps}-FPS"') + def set_speed(self, value): + """Set the playback speed multiplier. + + The sequence still steps one frame per tick -- only the tick rate + changes -- so every frame is still shown, just sooner or later. + + Args: + value (float): + Speed multiplier (1.0 is real time). + """ + + self.speed = speed.normalize(value) + + # Re-arm the running timer so the new rate takes effect immediately + # rather than after the next tick. + if self.is_playing and self.reader: + interval = speed.interval_ms(self.reader.get_fps(), self.speed) + if interval: + self.timer.start(interval) + + LOGGER.info(f'Playback speed set to "{speed.label_for(self.speed)}"') + def set_aov(self, aov): """Set active AOV/layer. @@ -1025,6 +1060,9 @@ def __init__(self): self.loop_enabled = False self.loop_mode = loopmode.OFF + # Playback speed multiplier, applied to the elapsed playback clock. + self.speed = constants.DEFAULT_PLAYBACK_SPEED + # Timeline start frame. self.start_frame = constants.VL_START_FRAME @@ -1203,9 +1241,14 @@ def current_playback_time(self): When playback is paused, only the stored playback offset is returned. """ - # While playing, combine the stored playback position with the elapsed time measured by the high-resolution timer. + # While playing, combine the stored playback position with the elapsed + # time measured by the high-resolution timer, scaled by the playback + # speed. Presentation stays timestamp-driven, so a faster clock simply + # makes more frames fall due per tick -- no frames are decoded twice. if self.is_playing: - return self.playback_offset + self.elapsed_timer.elapsed() / 1000.0 + return self.playback_offset + speed.scale_elapsed( + self.elapsed_timer.elapsed() / 1000.0, self.speed + ) # When paused, return the last stored playback position. return self.playback_offset @@ -1511,6 +1554,15 @@ def play_audio(self, current_time): * The audio output device is ready to receive additional data. """ + # Audio is only submitted at 1x. The samples are decoded at their native + # rate, so submitting them against a scaled video clock would drift + # steadily out of sync, and playing them faster without resampling would + # shift the pitch. Off-speed playback is therefore silent, which is what + # a reviewer expects when they shuttle -- and it keeps A/V sync honest. + if self.speed != constants.DEFAULT_PLAYBACK_SPEED: + self.audio_queue.clear() + return + # Continue submitting audio frames while they are ready. while self.audio_queue: @@ -1783,6 +1835,41 @@ def set_loop_mode(self, mode): self.loop_mode = mode self.loop_enabled = mode != loopmode.OFF + def set_speed(self, value): + """Set the playback speed multiplier. + + Movie playback is clock-driven, so speed scales the elapsed playback + clock: more (or fewer) decoded frames fall due per tick. Presentation + stays timestamp-driven, so frames are still shown in order and none are + decoded twice. + + Audio is dropped at any speed other than 1x -- see :meth:`play_audio`. + + Args: + value (float): + Speed multiplier (1.0 is real time). + """ + + value = speed.normalize(value) + if value == self.speed: + return + + # Re-anchor the clock BEFORE swapping the multiplier: bank the position + # already reached at the old speed and restart the elapsed timer, so the + # new multiplier applies only from here. Without this the whole elapsed + # span is retroactively rescaled and playback jumps. + if self.is_playing: + self.playback_offset = self.current_playback_time() + self.elapsed_timer.restart() + + self.speed = value + + # Drop buffered audio on the way out of 1x so stale samples are not + # submitted against the rescaled clock. + if self.speed != constants.DEFAULT_PLAYBACK_SPEED: + self.audio_queue.clear() + self.audio_player.flush() + def volume_changed(self, value): """Update playback volume. diff --git a/playback/speed.py b/playback/speed.py new file mode 100644 index 0000000..5eee837 --- /dev/null +++ b/playback/speed.py @@ -0,0 +1,76 @@ +"""Playback speed maths, kept separate from the players so it can be tested. + +Speed is a multiplier on the playback clock: 0.5 runs at half rate, 2.0 at +double. The two players apply it differently, because they keep time +differently: + +* SequencePlayer is timer-driven -- it fires every ``1000 / fps`` ms and steps + one frame. Speed scales that interval. +* MoviePlayer is clock-driven -- it reads a monotonic elapsed time and presents + whichever decoded frame is due. Speed scales the elapsed time. + +Both reduce to a single multiplier, so the arithmetic lives here. +""" + +from __future__ import absolute_import + +import constants + +# A timer interval below this is pointless: Qt cannot fire faster than the +# event loop drains, and a 0 ms interval spins the CPU. +MINIMUM_INTERVAL_MS = 1 + + +def normalize(speed): + """Clamp *speed* into the supported range, falling back to 1.0 on junk.""" + try: + value = float(speed) + except (TypeError, ValueError): + return 1.0 + + if value != value or value <= 0: # NaN or non-positive + return 1.0 + + return max(constants.MIN_PLAYBACK_SPEED, min(constants.MAX_PLAYBACK_SPEED, value)) + + +def interval_ms(fps, speed=1.0): + """Return the sequence-player timer interval for *fps* at *speed*. + + Returns 0 when *fps* is unusable, which callers treat as "do not start". + """ + try: + rate = float(fps) + except (TypeError, ValueError): + return 0 + + if rate <= 0: + return 0 + + effective = rate * normalize(speed) + return max(MINIMUM_INTERVAL_MS, int(round(1000.0 / effective))) + + +def scale_elapsed(seconds, speed=1.0): + """Scale a movie player's elapsed wall-clock *seconds* by *speed*.""" + try: + value = float(seconds) + except (TypeError, ValueError): + return 0.0 + + return value * normalize(speed) + + +def label_for(speed): + """Return the display label for *speed* (``1x``, ``0.5x``, ``1.25x``).""" + value = normalize(speed) + + if value == int(value): + return "{0}x".format(int(value)) + + # Trim trailing zeros so 0.50 reads as 0.5x, not 0.50x. + return "{0}x".format(("%.2f" % value).rstrip("0").rstrip(".")) + + +if __name__ == "__main__": + pass diff --git a/tests/test_speed.py b/tests/test_speed.py new file mode 100644 index 0000000..152a507 --- /dev/null +++ b/tests/test_speed.py @@ -0,0 +1,265 @@ +"""Tests for the playback speed multiplier.""" + +import pytest + +import constants + +from playback import speed + + +# --------------------------------------------------------------------------- # +# normalize +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("value", [1.0, 0.5, 2.0, 0.25, 4.0]) +def test_supported_speeds_pass_through(value): + assert speed.normalize(value) == value + + +def test_speed_is_clamped_to_the_supported_range(): + assert speed.normalize(999) == constants.MAX_PLAYBACK_SPEED + assert speed.normalize(0.0001) == constants.MIN_PLAYBACK_SPEED + + +@pytest.mark.parametrize("value", [0, -1, -0.5, None, "fast", float("nan")]) +def test_junk_speed_falls_back_to_real_time(value): + # A bad speed must never stop playback or divide by zero -- it just plays + # at 1x. + assert speed.normalize(value) == 1.0 + + +# --------------------------------------------------------------------------- # +# interval_ms (SequencePlayer: speed scales the timer interval) +# --------------------------------------------------------------------------- # +def test_interval_at_real_time_matches_the_frame_rate(): + assert speed.interval_ms(24, 1.0) == 42 # 1000/24 = 41.67 + assert speed.interval_ms(25, 1.0) == 40 + + +def test_half_speed_doubles_the_interval(): + assert speed.interval_ms(25, 0.5) == 80 + + +def test_double_speed_halves_the_interval(): + assert speed.interval_ms(25, 2.0) == 20 + + +def test_interval_never_drops_below_one_millisecond(): + # A 0 ms timer spins the event loop; Qt cannot fire faster than it drains. + assert speed.interval_ms(240, 8.0) == speed.MINIMUM_INTERVAL_MS + + +@pytest.mark.parametrize("fps", [0, -24, None, "24fps"]) +def test_unusable_fps_yields_no_interval(fps): + # Callers read 0 as "do not start the timer" rather than dividing by zero. + assert speed.interval_ms(fps, 1.0) == 0 + + +# --------------------------------------------------------------------------- # +# scale_elapsed (MoviePlayer: speed scales the playback clock) +# --------------------------------------------------------------------------- # +def test_elapsed_scales_with_speed(): + assert speed.scale_elapsed(2.0, 1.0) == 2.0 + assert speed.scale_elapsed(2.0, 0.5) == 1.0 + assert speed.scale_elapsed(2.0, 2.0) == 4.0 + + +def test_elapsed_survives_junk(): + assert speed.scale_elapsed(None, 2.0) == 0.0 + + +# --------------------------------------------------------------------------- # +# label_for +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "value,expected", + [(1.0, "1x"), (2.0, "2x"), (0.5, "0.5x"), (0.25, "0.25x"), (1.5, "1.5x")], +) +def test_labels_read_naturally(value, expected): + assert speed.label_for(value) == expected + + +# --------------------------------------------------------------------------- # +# SequencePlayer +# --------------------------------------------------------------------------- # +class _Reader: + media_type = "sequence" + + def __init__(self, fps=24): + self._fps = fps + + def get_fps(self): + return self._fps + + def set_fps(self, fps): + self._fps = fps + + +@pytest.fixture +def sequence_player(qapp): + """A SequencePlayer wired to a fake 24 fps reader. + + SequencePlayer starts a decode QThread in its constructor; only reset() + shuts it down. Leaving it running to interpreter exit kills the process, so + the teardown is not optional. + """ + from playback.player import SequencePlayer + + player = SequencePlayer() + player.reader = _Reader(fps=24) + player.start_frame = 1 + player.current_frame = 1 + player.end_frame = 100 + player.frame_count = 100 + + yield player + + player.pause() + player.decoder.shutdown() + qapp.processEvents() + + +def test_sequence_player_defaults_to_real_time(sequence_player): + assert sequence_player.speed == 1.0 + + +def test_sequence_set_speed_rearms_a_running_timer(sequence_player): + player = sequence_player + + player.play() + assert player.is_playing is True + assert player.timer.interval() == 42 # 24 fps at 1x + + player.set_speed(2.0) + + # The new rate takes effect immediately, not after the next tick. + assert player.speed == 2.0 + assert player.timer.interval() == 21 + assert player.timer.isActive() is True + + player.pause() + + +def test_sequence_set_speed_while_paused_does_not_start_playback(sequence_player): + player = sequence_player + + player.set_speed(0.5) + + assert player.speed == 0.5 + assert player.is_playing is False + assert player.timer.isActive() is False + + # It applies on the next play(). + player.play() + assert player.timer.interval() == 83 # 1000 / (24 * 0.5) + player.pause() + + +def test_sequence_speed_survives_an_fps_change(sequence_player): + player = sequence_player + player.set_speed(2.0) + player.play() + + player.set_fps(50) + + assert player.speed == 2.0 + assert player.timer.interval() == 10 # 1000 / (50 * 2) + player.pause() + + +# --------------------------------------------------------------------------- # +# MoviePlayer +# --------------------------------------------------------------------------- # +@pytest.fixture +def movie_player(qapp): + """A MoviePlayer with no media loaded. + + It owns an audio output device; stop it before the QApplication goes away. + """ + from playback.player import MoviePlayer + + player = MoviePlayer() + + yield player + + player.timer.stop() + player.audio_player.stop() + qapp.processEvents() + + +def test_movie_speed_scales_the_playback_clock(movie_player): + player = movie_player + player.playback_offset = 10.0 + player.is_playing = False + + # Paused: the clock is just the stored offset, whatever the speed. + player.speed = 2.0 + assert player.current_playback_time() == 10.0 + + +def test_movie_set_speed_reanchors_the_clock(movie_player, monkeypatch): + player = movie_player + player.playback_offset = 5.0 + player.is_playing = True + + # Pretend 2 seconds of wall clock have passed at 1x. + monkeypatch.setattr(player.elapsed_timer, "elapsed", lambda: 2000) + monkeypatch.setattr(player.elapsed_timer, "restart", lambda: 0) + assert player.current_playback_time() == 7.0 + + player.set_speed(2.0) + + # The 2 seconds already played must stay banked at 1x. If the multiplier + # were applied retroactively the position would jump from 7.0 to 9.0. + assert player.playback_offset == 7.0 + assert player.speed == 2.0 + + +def test_movie_set_speed_is_a_noop_at_the_same_value(movie_player, monkeypatch): + player = movie_player + player.playback_offset = 3.0 + player.is_playing = True + monkeypatch.setattr(player.elapsed_timer, "elapsed", lambda: 1000) + + player.set_speed(1.0) # already 1.0 + + # No re-anchor, so the offset is untouched. + assert player.playback_offset == 3.0 + + +def test_movie_audio_is_dropped_off_real_time(movie_player): + """Un-resampled audio against a scaled clock drifts; drop it instead.""" + + class _Frame: + time = 0.0 + + player = movie_player + player.audio_queue.append(_Frame()) + player.audio_queue.append(_Frame()) + + written = list() + player.audio_player.write = lambda frame: written.append(frame) + player.audio_player.can_accept_frame = lambda frame: True + + player.speed = 2.0 + player.play_audio(current_time=10.0) + + assert written == [] + assert len(player.audio_queue) == 0 # drained, not left to back up + + +def test_movie_audio_still_plays_at_real_time(movie_player): + class _Frame: + time = 0.0 + + player = movie_player + frame = _Frame() + player.audio_queue.append(frame) + + written = list() + player.audio_player.write = lambda value: written.append(value) + player.audio_player.can_accept_frame = lambda value: True + + player.speed = 1.0 + player.play_audio(current_time=10.0) + + assert written == [frame] diff --git a/widgets/__init__.py b/widgets/__init__.py index 5b6b8fe..689dded 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -33,6 +33,8 @@ import constants from utils import timecode + +from playback import speed as speedmath from utils import notescsv from PySide6 import QtGui @@ -128,6 +130,7 @@ def __init__(self, parent=None, **kwargs): # During playlist playback this loops the whole edit, never one clip. self.loop_enabled = False self.loop_mode = "off" + self.playback_speed = constants.DEFAULT_PLAYBACK_SPEED self._playlist_loading = False self.playlist_entries = list() self.playlist_entry_index = -1 @@ -321,6 +324,9 @@ def setupUi(self): self.viewframe.timelineToolbarLayout.trigger_timeline.connect(self.trigger_timeline) self.viewframe.timelineToolbarLayout.fps_chanaged.connect(self.update_fps) self.viewframe.timelineToolbarLayout.volume_changed.connect(self.player.volume_changed) + self.viewframe.timelineToolbarLayout.speed_changed.connect( + self.set_playback_speed + ) # Keyboard Shortcuts # Play / Pause self.playShortcut = QtGui.QShortcut(QtGui.QKeySequence("Space"), self) @@ -525,6 +531,36 @@ def setup_review_chrome(self): loop_mode_menu.addAction(action) self.loopModeActions[mode] = action + speed_menu = playback_menu.addMenu("Speed") + self.speedActionGroup = QtGui.QActionGroup(self) + self.speedActionGroup.setExclusive(True) + self.speedActions = {} + for value in constants.PLAYBACK_SPEEDS: + action = QtGui.QAction(speedmath.label_for(value), self, checkable=True) + action.setData(float(value)) + action.setChecked(value == constants.DEFAULT_PLAYBACK_SPEED) + action.triggered.connect( + lambda _checked=False, selected=value: self.set_playback_speed(selected) + ) + self.speedActionGroup.addAction(action) + speed_menu.addAction(action) + self.speedActions[float(value)] = action + + self.actionSpeedDown = QtGui.QAction("Slower", self) + self.actionSpeedDown.setShortcut(QtGui.QKeySequence("Ctrl+[")) + self.actionSpeedDown.triggered.connect(lambda: self.step_playback_speed(-1)) + playback_menu.addAction(self.actionSpeedDown) + self.actionSpeedUp = QtGui.QAction("Faster", self) + self.actionSpeedUp.setShortcut(QtGui.QKeySequence("Ctrl+]")) + self.actionSpeedUp.triggered.connect(lambda: self.step_playback_speed(1)) + playback_menu.addAction(self.actionSpeedUp) + self.actionSpeedReset = QtGui.QAction("Normal Speed", self) + self.actionSpeedReset.setShortcut(QtGui.QKeySequence("Ctrl+\\")) + self.actionSpeedReset.triggered.connect( + lambda: self.set_playback_speed(constants.DEFAULT_PLAYBACK_SPEED) + ) + playback_menu.addAction(self.actionSpeedReset) + self.actionCompare = QtGui.QAction("Compare Selected A/B", self) self.actionCompare.setIcon(NamePixmapIcon("display")) self.actionCompare.triggered.connect(self.playlistWidget.request_compare) @@ -2195,6 +2231,60 @@ def set_loop_mode(self, mode): message = f"Playback mode: {label}" self.statusBar().showMessage(message, 4500) + def set_playback_speed(self, value): + """Set the playback speed multiplier on both players. + + Speed can be changed from the timeline combobox, the Playback > Speed + submenu, or the Ctrl+[ / Ctrl+] shortcuts, so every control is synced + here without letting the signals loop back on each other. + """ + + value = speedmath.normalize(value) + self.playback_speed = value + + self.player.set_speed(value) + if self.compare_active: + self.compare_player.set_speed(value) + + combobox = getattr( + getattr( + getattr(self, "viewframe", None), "timelineToolbarLayout", None + ), + "speedCombobox", + None, + ) + if combobox is not None: + combobox.setValue(value) + + for speed_value, action in getattr(self, "speedActions", dict()).items(): + selected = speed_value == value + if action.isChecked() != selected: + blocker = QtCore.QSignalBlocker(action) + action.setChecked(selected) + del blocker + + label = speedmath.label_for(value) + if value == constants.DEFAULT_PLAYBACK_SPEED: + message = "Playback speed: {0}".format(label) + else: + # Audio is dropped off 1x rather than being resampled; say so once + # rather than letting the reviewer wonder why the sound vanished. + message = "Playback speed: {0} (audio silent off 1x)".format(label) + self.statusBar().showMessage(message, 4500) + + def step_playback_speed(self, direction): + """Move to the next/previous preset speed (Ctrl+] / Ctrl+[).""" + speeds = list(constants.PLAYBACK_SPEEDS) + + current = getattr(self, "playback_speed", constants.DEFAULT_PLAYBACK_SPEED) + + # The current speed may have come from somewhere other than the presets, + # so land on the nearest preset before stepping off it. + nearest = min(range(len(speeds)), key=lambda i: abs(speeds[i] - current)) + index = max(0, min(len(speeds) - 1, nearest + (1 if direction > 0 else -1))) + + self.set_playback_speed(speeds[index]) + def set_gamma_check(self, enabled): """Toggle temporary Y-drag gamma inspection in the viewer.""" enabled = bool(enabled) diff --git a/widgets/comboboxs.py b/widgets/comboboxs.py index 2f9271f..b3d4a22 100644 --- a/widgets/comboboxs.py +++ b/widgets/comboboxs.py @@ -51,6 +51,8 @@ from PySide6 import QtCore from PySide6 import QtWidgets +from playback import speed as speedmath + from widgets.styles import Font from widgets.styles import WaitCursor @@ -203,6 +205,81 @@ def findByKey(self, value, key=None): return result +class SpeedCombobox(QtWidgets.QComboBox): + """ + Playback speed multiplier selector. + + Signals: + speed_changed(float): + Emits the selected multiplier (1.0 is real time). + + Example: + >>> combobox.speed_changed.connect(callback) + """ + + speed_changed = QtCore.Signal(float) + + def __init__(self, parent=None, **kwargs): + """ + Initialize the playback speed combobox. + + Args: + parent (QtWidgets.QWidget): + Parent widget. + """ + + super(SpeedCombobox, self).__init__(parent) + + self.setToolTip("Playback speed (audio is silent off 1x)") + + # Transparent styling, matching the FPS selector beside it. + self.setStyleSheet("QComboBox {background: transparent; border: none;}") + + for value in constants.PLAYBACK_SPEEDS: + self.addItem(speedmath.label_for(value), float(value)) + + self.setValue(constants.DEFAULT_PLAYBACK_SPEED) + + self.currentIndexChanged.connect(self.indexChange) + + def indexChange(self, index): + """ + Handle a speed selection change. + + Args: + index (int): + Current combobox index. + """ + + value = self.itemData(index) + + if value is None: + return + + self.speed_changed.emit(float(value)) + + def setValue(self, value): + """ + Select *value* without re-emitting speed_changed. + + Used to reflect a speed set from elsewhere (menu, shortcut) back into + the combobox without looping the signal straight back out again. + + Args: + value (float): + Speed multiplier to select. + """ + + index = self.findData(float(value)) + + if index < 0: + return + + blocker = QtCore.QSignalBlocker(self) + self.setCurrentIndex(index) + del blocker + + class FbsCombobox(ContextCombobox): """ Frames-per-second selection combobox, provides playback FPS selection using predefined constants. diff --git a/widgets/viewer.py b/widgets/viewer.py index 5153a5d..edd2b29 100644 --- a/widgets/viewer.py +++ b/widgets/viewer.py @@ -170,6 +170,7 @@ from widgets.comboboxs import FbsCombobox from widgets.comboboxs import AovsCombobox +from widgets.comboboxs import SpeedCombobox from widgets.timeline import TimelineWidget @@ -985,6 +986,9 @@ class TimelineToolbarLayout(HorizontalLayout): # Signal emitted when volume value changes volume_changed = QtCore.Signal(float) + # Signal emitted when the playback speed multiplier changes + speed_changed = QtCore.Signal(float) + def __init__(self, parent, *args, **kwargs): """ Initialize timeline toolbar layout. @@ -1021,6 +1025,11 @@ def setupUi(self): self.fpsCombobox.fps_changed.connect(self.update_fps) self.addWidget(self.fpsCombobox) + # Playback speed multiplier + self.speedCombobox = SpeedCombobox(None) + self.speedCombobox.speed_changed.connect(self.speed_changed.emit) + self.addWidget(self.speedCombobox) + # Loop playback button self.loopButton = LoopButton( None, tooltip="Loop the timeline (Ctrl+L)", width=32, height=32