diff --git a/playback/player.py b/playback/player.py index 2f249c6..4484a7b 100644 --- a/playback/player.py +++ b/playback/player.py @@ -64,6 +64,7 @@ from playback import loopmode from playback import speed +from playback import proxy from collections import deque @@ -531,19 +532,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, - ) - self.cache.max_size = ( - constants.VL_SEQUENCE_2K_CACHE_FRAMES - if proxy_pixels >= 1920 * 1080 - else constants.VL_FRAME_CACHE_MAX_SIZE + # Reset Cache. Cache depth follows the proxy size: Full 4K/8K frames + # must not retain the same count as a small 720p review proxy. + proxy_width, proxy_height = proxy.fit( + self.reader.width, self.reader.height, even=False ) + self.cache.max_size = proxy.frame_capacity(proxy_width, proxy_height) self.cache.clear() self.cache_changed.emit([]) @@ -1495,14 +1489,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..715ce4c --- /dev/null +++ b/playback/proxy.py @@ -0,0 +1,166 @@ +"""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" + +# Approximate upper bound for decoded RGBA frames held in the sequence cache. +# Capacity is derived from the selected proxy size so Full 4K/8K cannot retain +# dozens of huge frames and exhaust the review workstation's memory. +SEQUENCE_CACHE_BUDGET_BYTES = 512 * 1024 * 1024 +MIN_SEQUENCE_CACHE_FRAMES = 2 +MAX_SEQUENCE_CACHE_FRAMES = 200 + +_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]) + + +def frame_capacity(width, height, bytes_per_pixel=4): + """Return a memory-bounded sequence cache depth for a decoded frame size.""" + try: + frame_bytes = max(1, int(width)) * max(1, int(height)) * max( + 1, int(bytes_per_pixel) + ) + except (TypeError, ValueError): + return MIN_SEQUENCE_CACHE_FRAMES + + capacity = SEQUENCE_CACHE_BUDGET_BYTES // frame_bytes + return max( + MIN_SEQUENCE_CACHE_FRAMES, + min(MAX_SEQUENCE_CACHE_FRAMES, int(capacity)), + ) + + +if __name__ == "__main__": + pass diff --git a/playback/reader.py b/playback/reader.py index 3c20423..17ab607 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 ) @@ -908,6 +907,10 @@ def get_frame(self, current_frame, aov="rgb", ocio_processor=None): def _preview_cache_path(self, source_path, aov, ocio_processor): """Return a persistent display-proxy key for one source frame.""" + # Full Resolution is an inspection mode. Do not silently substitute a + # lossy JPEG preview when the user is checking grain or edge detail. + if self.review_proxy and not proxy.enabled(): + return None try: stat = os.stat(source_path) except OSError: @@ -925,8 +928,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..3c4cc68 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,166 @@ +"""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 + + +def test_full_resolution_bypasses_lossy_jpeg_preview_cache(tmp_path): + from playback.reader import SequenceReader + + source = tmp_path / "plate.1001.exr" + source.write_bytes(b"only the path is needed") + reader = SequenceReader.__new__(SequenceReader) + reader.review_proxy = True + + proxy.set_level("full") + + assert reader._preview_cache_path(str(source), "rgb", None) is None + + +def test_sequence_cache_depth_is_bounded_by_frame_memory(): + at_720p = proxy.frame_capacity(1280, 720) + at_4k = proxy.frame_capacity(3840, 2160) + at_8k = proxy.frame_capacity(7680, 4320) + + assert at_720p > at_4k > at_8k + assert at_8k >= proxy.MIN_SEQUENCE_CACHE_FRAMES + assert at_720p <= proxy.MAX_SEQUENCE_CACHE_FRAMES diff --git a/widgets/__init__.py b/widgets/__init__.py index cb560fc..95d5808 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -35,6 +35,7 @@ from utils import timecode from playback import speed as speedmath +from playback import proxy from utils import notescsv from PySide6 import QtGui @@ -560,6 +561,20 @@ def setup_review_chrome(self): lambda: self.set_playback_speed(constants.DEFAULT_PLAYBACK_SPEED) ) playback_menu.addAction(self.actionSpeedReset) + 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")) @@ -2290,6 +2305,41 @@ def step_playback_speed(self, direction): self.set_playback_speed(speeds[index]) + def set_proxy_level(self, key): + """Set the display-proxy resolution and reload the current source. + + The open reader, frame cache and on-disk preview cache all hold frames + at the old proxy size, so reopen the source at the reviewer's current + frame after changing the level. + """ + + 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 + + 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)