From ca0af7fb5fe73f981d21982d6a792e0e3e757904 Mon Sep 17 00:00:00 2001 From: Eric Levy Date: Tue, 14 Jul 2026 14:12:26 -0400 Subject: [PATCH] Add playback speed multiplier Playback > Speed, a timeline combobox, and Ctrl+[ / Ctrl+] / Ctrl+\ select 0.25x through 4x. The two players keep time differently, so speed applies differently to each, and playback/speed.py holds the arithmetic so it can be tested on its own: - SequencePlayer is timer-driven: it fires every 1000/fps ms and steps one frame. Speed scales that interval, so every frame is still shown, just sooner or later. A running timer is re-armed immediately rather than waiting for the next tick. - MoviePlayer is clock-driven: it reads a monotonic elapsed time and presents whichever decoded frame is due. Speed scales the elapsed clock, so more (or fewer) frames fall due per tick. Presentation stays timestamp-driven, so frames still appear in order and none are decoded twice. Changing speed mid-playback re-anchors the movie clock first: it banks the position already reached at the old speed and restarts the elapsed timer, so the multiplier applies only from that point. Without this the whole elapsed span is rescaled retroactively and playback jumps. Audio is dropped at any speed other than 1x. The samples decode at their native rate, so submitting them against a scaled video clock drifts steadily out of sync, and playing them faster without resampling shifts the pitch. Off-speed playback is silent, which is what a reviewer expects when they shuttle, and it keeps A/V sync honest. The status bar says so. An unusable fps yields no interval rather than dividing by zero, and a junk speed falls back to 1x rather than stopping playback. 23 tests. Note SequencePlayer starts a decode QThread in its constructor and only reset() shuts it down; the test fixtures do that, or the process dies at exit. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb --- constants/__init__.py | 7 ++ playback/player.py | 99 +++++++++++++++- playback/speed.py | 76 ++++++++++++ tests/test_speed.py | 265 ++++++++++++++++++++++++++++++++++++++++++ widgets/__init__.py | 90 ++++++++++++++ widgets/comboboxs.py | 77 ++++++++++++ widgets/viewer.py | 9 ++ 7 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 playback/speed.py create mode 100644 tests/test_speed.py 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