From ab2e7189bee6e4f736db7bb0a959e61339a4a420 Mon Sep 17 00:00:00 2001 From: Eric Levy Date: Tue, 14 Jul 2026 14:44:08 -0400 Subject: [PATCH] Add user-selectable proxy resolution Playback > Proxy Resolution picks Full / 2K / 1080p / 720p. The proxy level was fixed at 2K; 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 frame and will accept slower playback. playback/proxy.py owns the level and the resolution maths. The level is process-wide rather than a constructor argument because the readers, the frame cache and the on-disk preview cache all have to agree on it, and they are built in different places. - The preview-cache key already carried the proxy dimensions, so it now carries the level token instead. Every level has a distinct token, which is what stops a frame cached at 720p being served back to a viewer asking for 2K. There is a test that fails if two levels ever collide. - The movie path still rounds to even dimensions: yuv420 subsamples chroma by two, so an odd proxy size is not representable. The sequence path does not need that constraint. - Sequence cache depth follows the proxy size, since bigger review frames mean fewer of them fit in the same memory budget. - Changing level reloads the current source at the frame the reviewer is sitting on. The open reader and both caches hold frames at the old size, so the level cannot be swapped under a running player. Full resolution disables the proxy entirely rather than scaling by 1.0, so the EXR mip-level shortcut is skipped too. 14 tests. Verified against a real decoded 3840x2160 clip: it lands at 2048x1152 at 2K, 1280x720 at 720p, and 3840x2160 at Full. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb --- playback/player.py | 25 ++++---- playback/proxy.py | 143 ++++++++++++++++++++++++++++++++++++++++++++ playback/reader.py | 19 +++--- tests/test_proxy.py | 143 ++++++++++++++++++++++++++++++++++++++++++++ widgets/__init__.py | 56 +++++++++++++++++ 5 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 playback/proxy.py create mode 100644 tests/test_proxy.py 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)