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
25 changes: 10 additions & 15 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 proxy

from collections import deque

Expand Down Expand Up @@ -513,14 +514,12 @@ def load(self, path):
self.end_frame = self.start_frame + (self.frame_count)
self.current_aov = "rgb"

# Reset Cache
proxy_pixels = min(
self.reader.width,
constants.VL_SEQUENCE_PROXY_MAX_WIDTH,
) * min(
self.reader.height,
constants.VL_SEQUENCE_PROXY_MAX_HEIGHT,
# Reset Cache. Cache depth follows the proxy size: bigger review frames
# mean fewer of them fit in the same memory budget.
proxy_width, proxy_height = proxy.fit(
self.reader.width, self.reader.height, even=False
)
proxy_pixels = proxy_width * proxy_height
self.cache.max_size = (
constants.VL_SEQUENCE_2K_CACHE_FRAMES
if proxy_pixels >= 1920 * 1080
Expand Down Expand Up @@ -1444,14 +1443,10 @@ def display_video_frame(self, frame):
# memory than converting the full 4K frame and resizing in the widget.
source_width = frame.width
source_height = frame.height
scale = min(
1.0,
constants.VL_VIDEO_PROXY_MAX_WIDTH / source_width,
constants.VL_VIDEO_PROXY_MAX_HEIGHT / source_height,
)
if scale < 1.0:
proxy_width = max(2, int(source_width * scale) // 2 * 2)
proxy_height = max(2, int(source_height * scale) // 2 * 2)
if proxy.scale_for(source_width, source_height) < 1.0:
# Even dimensions are mandatory here: yuv420 subsamples chroma by
# two, so an odd proxy size is not representable.
proxy_width, proxy_height = proxy.fit(source_width, source_height)
frame = frame.reformat(
width=proxy_width,
height=proxy_height,
Expand Down
143 changes: 143 additions & 0 deletions playback/proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Display-proxy resolution: which size frames are decoded to for review.

FrameDeck decodes 4K/8K sources down to a display proxy so playback stays
interactive. The source file and its timeline metadata are never touched --
only what the viewer decodes and caches.

The proxy level was fixed at 2K. It is now selectable, because the right answer
depends on the machine and the job: a supervisor on a laptop wants 720p to hold
real-time on an 8K plate, while someone checking grain or edge detail needs the
full-resolution frame and will accept the slower playback.

The level is process-wide state rather than a constructor argument, because the
readers, the frame cache and the on-disk preview cache all need to agree on it,
and they are built in different places. ``cache_token`` is folded into the
preview-cache key so proxies of different sizes can never collide there.
"""

from __future__ import absolute_import

# key, menu label, (max_width, max_height) -- None means decode at full size.
PROXY_LEVELS = (
("full", "Full Resolution", None),
("2k", "2K (2048 x 1152)", (2048, 1152)),
("1080", "1080p (1920 x 1080)", (1920, 1080)),
("720", "720p (1280 x 720)", (1280, 720)),
)

DEFAULT_LEVEL = "2k"

_LIMITS = {key: limits for key, _label, limits in PROXY_LEVELS}
_LABELS = {key: label for key, label, _limits in PROXY_LEVELS}

# Active level for this process.
_current = DEFAULT_LEVEL


def levels():
"""Return the selectable proxy levels as (key, label, limits) tuples."""
return PROXY_LEVELS


def current_level():
"""Return the active proxy level key."""
return _current


def set_level(key):
"""Set the active proxy level. Unknown keys fall back to the default.

Returns:
str: The level actually applied.
"""
global _current

_current = key if key in _LIMITS else DEFAULT_LEVEL
return _current


def reset():
"""Restore the default proxy level (used by tests)."""
return set_level(DEFAULT_LEVEL)


def label_for(key=None):
"""Return the menu label for a level key."""
return _LABELS.get(key or _current, _LABELS[DEFAULT_LEVEL])


def limits(key=None):
"""Return (max_width, max_height) for a level, or None at full resolution."""
return _LIMITS.get(key or _current, _LIMITS[DEFAULT_LEVEL])


def enabled(key=None):
"""True when the level actually downscales anything."""
return limits(key) is not None


def scale_for(width, height, key=None):
"""Return the factor that fits (width, height) inside the proxy limits.

Never upscales: a source already smaller than the limit returns 1.0, as does
full-resolution mode.
"""
bounds = limits(key)
if not bounds:
return 1.0

try:
source_width = max(1, int(width))
source_height = max(1, int(height))
except (TypeError, ValueError):
return 1.0

return min(
1.0,
bounds[0] / float(source_width),
bounds[1] / float(source_height),
)


def fit(width, height, key=None, even=True):
"""Return the proxy pixel size for a source of (width, height).

Args:
even (bool):
Round down to even dimensions. Required for the movie path -- yuv420
chroma is subsampled by two, so an odd proxy size is not encodable.
"""
scale = scale_for(width, height, key)

if scale >= 1.0:
target_width, target_height = int(width), int(height)
else:
target_width = int(int(width) * scale)
target_height = int(int(height) * scale)

if even:
target_width = max(2, target_width // 2 * 2)
target_height = max(2, target_height // 2 * 2)
else:
target_width = max(1, target_width)
target_height = max(1, target_height)

return target_width, target_height


def cache_token(key=None):
"""Return the preview-cache fingerprint for a level.

Folded into the on-disk preview cache key so a frame cached at 720p is never
served back to a viewer asking for 2K.
"""
bounds = limits(key)

if not bounds:
return "full"

return "{0}x{1}".format(bounds[0], bounds[1])


if __name__ == "__main__":
pass
19 changes: 9 additions & 10 deletions playback/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@

import utils
import constants

from playback import proxy

from ocio import apply_cpu_processor


Expand Down Expand Up @@ -795,9 +798,9 @@ def get_frame(self, current_frame, aov="rgb", ocio_processor=None):
# renders can then avoid decompressing the full 4K/8K level entirely.
mip_level = 0
spec = input_file.spec(0, 0)
while self.review_proxy and (
spec.width > constants.VL_SEQUENCE_PROXY_MAX_WIDTH
or spec.height > constants.VL_SEQUENCE_PROXY_MAX_HEIGHT
proxy_limits = proxy.limits() if self.review_proxy else None
while proxy_limits and (
spec.width > proxy_limits[0] or spec.height > proxy_limits[1]
):
candidate = input_file.spec(0, mip_level + 1)
if candidate.width <= 0 or candidate.height <= 0:
Expand Down Expand Up @@ -854,11 +857,7 @@ def get_frame(self, current_frame, aov="rgb", ocio_processor=None):
# display transform. This cuts 4K/8K OCIO cost and bounds each cached
# review frame to roughly 7 MB at 2048x1152.
scale = (
min(
1.0,
constants.VL_SEQUENCE_PROXY_MAX_WIDTH / max(1, image.shape[1]),
constants.VL_SEQUENCE_PROXY_MAX_HEIGHT / max(1, image.shape[0]),
)
proxy.scale_for(image.shape[1], image.shape[0])
if self.review_proxy
else 1.0
)
Expand Down Expand Up @@ -925,8 +924,8 @@ def _preview_cache_path(self, source_path, aov, ocio_processor):
str(stat.st_mtime_ns),
str(aov),
str(color_key),
f"{constants.VL_SEQUENCE_PROXY_MAX_WIDTH}x"
f"{constants.VL_SEQUENCE_PROXY_MAX_HEIGHT}",
# Keeps a 720p frame from being served to a viewer asking for 2K.
proxy.cache_token(),
)
)
digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
Expand Down
143 changes: 143 additions & 0 deletions tests/test_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Tests for the selectable display-proxy resolution."""

import pytest

from playback import proxy


@pytest.fixture(autouse=True)
def _restore_default_level():
"""Proxy level is process-wide state; never leak it between tests."""
yield
proxy.reset()


# --------------------------------------------------------------------------- #
# Levels
# --------------------------------------------------------------------------- #
def test_default_is_2k_matching_the_previous_hardcoded_behaviour():
assert proxy.current_level() == "2k"
assert proxy.limits() == (2048, 1152)


def test_levels_are_selectable():
assert proxy.set_level("720") == "720"
assert proxy.limits() == (1280, 720)
assert proxy.label_for() == "720p (1280 x 720)"


def test_unknown_level_falls_back_to_the_default():
assert proxy.set_level("8k") == "2k"
assert proxy.set_level(None) == "2k"


def test_full_resolution_disables_the_proxy():
proxy.set_level("full")

assert proxy.limits() is None
assert proxy.enabled() is False
assert proxy.scale_for(4096, 2160) == 1.0


# --------------------------------------------------------------------------- #
# scale_for
# --------------------------------------------------------------------------- #
def test_4k_is_scaled_down_to_the_proxy_bound():
proxy.set_level("2k")

# The tighter of the two bounds wins. For DCI 4K the width binds
# (2048/4096 = 0.5) ahead of the height (1152/2160 = 0.53), so the frame
# lands inside both rather than overshooting the width to satisfy height.
assert proxy.scale_for(4096, 2160) == pytest.approx(0.5)
assert proxy.fit(4096, 2160) == (2048, 1080)


def test_a_source_smaller_than_the_bound_is_never_upscaled():
proxy.set_level("2k")

assert proxy.scale_for(1280, 720) == 1.0


def test_scale_survives_junk_dimensions():
assert proxy.scale_for(None, None) == 1.0
assert proxy.scale_for(0, 0) == pytest.approx(1.0)


# --------------------------------------------------------------------------- #
# fit
# --------------------------------------------------------------------------- #
def test_fit_returns_even_dimensions_for_the_movie_path():
proxy.set_level("1080")

width, height = proxy.fit(3841, 2161)

# yuv420 subsamples chroma by two: an odd proxy size is not representable.
assert width % 2 == 0
assert height % 2 == 0
assert width <= 1920 and height <= 1080


def test_fit_at_full_resolution_returns_the_source_size():
proxy.set_level("full")

assert proxy.fit(1920, 1080) == (1920, 1080)


def test_fit_can_keep_odd_dimensions_for_the_sequence_path():
proxy.set_level("720")

width, height = proxy.fit(1281, 721, even=False)

assert width <= 1280 and height <= 720
assert width >= 1 and height >= 1


def test_fit_never_collapses_to_zero():
proxy.set_level("720")

assert proxy.fit(1, 1) == (2, 2) # even mode floors at 2


# --------------------------------------------------------------------------- #
# Cache token
# --------------------------------------------------------------------------- #
def test_each_level_has_a_distinct_cache_token():
tokens = set()
for key, _label, _limits in proxy.levels():
proxy.set_level(key)
tokens.add(proxy.cache_token())

# A shared token would let a frame cached at 720p be served to a viewer
# asking for 2K -- the reviewer would silently get the wrong resolution.
assert len(tokens) == len(proxy.levels())


def test_cache_token_names_the_bound():
proxy.set_level("2k")
assert proxy.cache_token() == "2048x1152"

proxy.set_level("full")
assert proxy.cache_token() == "full"


def test_preview_cache_path_changes_with_the_proxy_level(tmp_path, monkeypatch):
"""The on-disk preview cache must not collide across proxy levels."""
from playback.reader import SequenceReader

source = tmp_path / "plate.1001.exr"
source.write_bytes(b"not a real exr, only its stat is read")

reader = SequenceReader.__new__(SequenceReader)
reader.review_proxy = True
reader.auto_color_enabled = False
reader.auto_color_processor = None
reader.auto_input_color_space = None

proxy.set_level("2k")
at_2k = reader._preview_cache_path(str(source), "rgb", None)

proxy.set_level("720")
at_720 = reader._preview_cache_path(str(source), "rgb", None)

assert at_2k is not None and at_720 is not None
assert at_2k != at_720
Loading
Loading