diff --git a/playback/player.py b/playback/player.py index 1f2b071..557ae8a 100644 --- a/playback/player.py +++ b/playback/player.py @@ -63,6 +63,7 @@ import constants from playback import loopmode +from playback import proxy from collections import deque @@ -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 @@ -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, diff --git a/playback/proxy.py b/playback/proxy.py new file mode 100644 index 0000000..939884d --- /dev/null +++ b/playback/proxy.py @@ -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 diff --git a/playback/reader.py b/playback/reader.py index 3c20423..e3b8e8c 100644 --- a/playback/reader.py +++ b/playback/reader.py @@ -47,6 +47,9 @@ import utils import constants + +from playback import proxy + from ocio import apply_cpu_processor @@ -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: @@ -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 ) @@ -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() diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..453b5ad --- /dev/null +++ b/tests/test_proxy.py @@ -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 diff --git a/widgets/__init__.py b/widgets/__init__.py index 5b6b8fe..75923da 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -33,6 +33,8 @@ import constants from utils import timecode + +from playback import proxy from utils import notescsv from PySide6 import QtGui @@ -525,6 +527,21 @@ def setup_review_chrome(self): loop_mode_menu.addAction(action) self.loopModeActions[mode] = action + proxy_menu = playback_menu.addMenu("Proxy Resolution") + self.proxyActionGroup = QtGui.QActionGroup(self) + self.proxyActionGroup.setExclusive(True) + self.proxyActions = {} + for key, label, _limits in proxy.levels(): + action = QtGui.QAction(label, self, checkable=True) + action.setData(key) + action.setChecked(key == proxy.current_level()) + action.triggered.connect( + lambda _checked=False, selected=key: self.set_proxy_level(selected) + ) + self.proxyActionGroup.addAction(action) + proxy_menu.addAction(action) + self.proxyActions[key] = action + self.actionCompare = QtGui.QAction("Compare Selected A/B", self) self.actionCompare.setIcon(NamePixmapIcon("display")) self.actionCompare.triggered.connect(self.playlistWidget.request_compare) @@ -2195,6 +2212,45 @@ def set_loop_mode(self, mode): message = f"Playback mode: {label}" self.statusBar().showMessage(message, 4500) + def set_proxy_level(self, key): + """Set the display-proxy resolution and reload the current source. + + The open reader, the frame cache and the on-disk preview cache all hold + frames at the old proxy size, so the level cannot simply be swapped + under a running player -- the source is reopened at the frame the + reviewer is already sitting on. + """ + + applied = proxy.set_level(key) + + for level, action in getattr(self, "proxyActions", dict()).items(): + selected = level == applied + if action.isChecked() != selected: + blocker = QtCore.QSignalBlocker(action) + action.setChecked(selected) + del blocker + + label = proxy.label_for(applied) + + source = self.current_source_filepath + if not source: + self.statusBar().showMessage(f"Proxy resolution: {label}", 4500) + return + + # Reopen on the frame the reviewer is looking at, not back at frame one. + frame = self.viewframe.timeline.current_frame + + if self.player.player is not None: + self.player.player.pause() + self.viewframe.timelineToolbarLayout.playPauseButton.switch(False) + + if self.openMedia(source, add_to_playlist=False): + self.seek(frame) + + self.statusBar().showMessage( + f"Proxy resolution: {label} (source reloaded)", 4500 + ) + def set_gamma_check(self, enabled): """Toggle temporary Y-drag gamma inspection in the viewer.""" enabled = bool(enabled)