Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
99 changes: 93 additions & 6 deletions playback/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import constants

from playback import loopmode
from playback import speed

from collections import deque

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
76 changes: 76 additions & 0 deletions playback/speed.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading