From 9fb1e429014c88a4a629b4d439399821e9d4a08b Mon Sep 17 00:00:00 2001 From: Bernard Laberge Date: Thu, 11 Jun 2026 14:46:10 -0400 Subject: [PATCH] feat: SG-43687: Automatic color profile setup with rvio parity Signed-off-by: Bernard Laberge --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 50 +++ .../RvCommon/RvCommon/DesktopVideoDevice.h | 2 +- src/lib/ip/OCIONodes/OCIOIPNode.cpp | 11 +- src/lib/ip/OCIONodes/OCIONodes/OCIOIPNode.h | 1 + .../color_profile_setup/CMakeLists.txt | 11 + .../rv-packages/color_profile_setup/PACKAGE | 46 +++ .../color_profile_setup.py | 377 ++++++++++++++++++ 7 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 src/plugins/rv-packages/color_profile_setup/CMakeLists.txt create mode 100644 src/plugins/rv-packages/color_profile_setup/PACKAGE create mode 100644 src/plugins/rv-packages/color_profile_setup/color_profile_setup.py diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 2e2740c5c..2448f04a5 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -13,6 +13,11 @@ #include #endif +#ifdef PLATFORM_DARWIN +#include +#include +#endif + #include #include #include @@ -916,6 +921,51 @@ namespace Rv } #endif +#ifdef PLATFORM_DARWIN + TwkApp::VideoDevice::ColorProfile DesktopVideoDevice::colorProfile() const + { + // + // Get the display's color sync profile + // + + CGDirectDisplayID displayIDs[20]; + uint32_t displayCount = 0; + CGGetOnlineDisplayList(20, displayIDs, &displayCount); + + if (m_screen < 0 || m_screen >= static_cast(displayCount)) + { + m_colorProfile = ColorProfile(); + return m_colorProfile; + } + + CGDirectDisplayID cgScreen = displayIDs[m_screen]; + + if (ColorSyncProfileRef iccRef = ColorSyncProfileCreateWithDisplayID(cgScreen)) + { + m_colorProfile.type = ICCProfile; + + CFStringRef desc = ColorSyncProfileCopyDescriptionString(iccRef); + CFIndex n = CFStringGetLength(desc); + std::vector buffer(n * 4 + 1); + CFStringGetCString(desc, &buffer.front(), buffer.size(), kCFStringEncodingUTF8); + m_colorProfile.description = &buffer.front(); + + CFURLRef url = ColorSyncProfileGetURL(iccRef, NULL); + CFStringRef urlstr = CFURLGetString(url); + buffer.resize(CFStringGetLength(urlstr) * 4 + 1); + CFStringGetCString(urlstr, &buffer.front(), buffer.size(), kCFStringEncodingUTF8); + + m_colorProfile.url = &buffer.front(); + } + else + { + m_colorProfile = ColorProfile(); + } + + return m_colorProfile; + } +#endif + std::vector DesktopVideoDevice::createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice) { std::vector devices; diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index 394804930..d176a45c4 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -247,7 +247,7 @@ namespace Rv private: void addDataFormatAtDepth(size_t depth, DesktopStereoMode m); -#ifdef PLATFORM_WINDOWS +#if defined(PLATFORM_WINDOWS) || defined(PLATFORM_DARWIN) virtual ColorProfile colorProfile() const; #endif diff --git a/src/lib/ip/OCIONodes/OCIOIPNode.cpp b/src/lib/ip/OCIONodes/OCIOIPNode.cpp index 113cea70f..c935bfc8c 100644 --- a/src/lib/ip/OCIONodes/OCIOIPNode.cpp +++ b/src/lib/ip/OCIONodes/OCIOIPNode.cpp @@ -233,6 +233,8 @@ namespace IPCore updateContext(); updateFunction(); + + m_initialized = true; } void OCIOIPNode::updateContext() @@ -519,7 +521,10 @@ namespace IPCore boost::hash string_hash; string inName = stringProp("ocio.inColorSpace", m_state->linear); - if (inName.empty()) + // synlinearize/syndisplay build their pipeline from file/URL + // transforms, so an empty input color space is valid for those modes. + const bool needsInColorSpace = ociofunction != "synlinearize" && ociofunction != "syndisplay"; + if (inName.empty() && needsInColorSpace) return; try @@ -603,6 +608,8 @@ namespace IPCore string inTransformURL = stringProp("inTransform.url", ""); if (inTransformURL.empty() && (!m_inTransformData || m_inTransformData->size() == 0)) { + if (!m_initialized) + return; TWK_THROW_EXC_STREAM("Either inTransform.url or inTransform.data property " "needs to be set for synlinearize function"); } @@ -648,6 +655,8 @@ namespace IPCore const string outTransformURL = stringProp("outTransform.url", ""); if (outTransformURL.empty()) { + if (!m_initialized) + return; TWK_THROW_EXC_STREAM("outTransform.url property needs to " "be set for syndisplay function"); } diff --git a/src/lib/ip/OCIONodes/OCIONodes/OCIOIPNode.h b/src/lib/ip/OCIONodes/OCIONodes/OCIOIPNode.h index bf16e53ee..18271da8a 100644 --- a/src/lib/ip/OCIONodes/OCIONodes/OCIOIPNode.h +++ b/src/lib/ip/OCIONodes/OCIONodes/OCIOIPNode.h @@ -72,6 +72,7 @@ namespace IPCore std::vector m_3DLUTs; OCIOState* m_state{nullptr}; bool m_useRawConfig{false}; + bool m_initialized{false}; // synlinearize/syndisplay functions StringProperty* m_inTransformURL{nullptr}; diff --git a/src/plugins/rv-packages/color_profile_setup/CMakeLists.txt b/src/plugins/rv-packages/color_profile_setup/CMakeLists.txt new file mode 100644 index 000000000..c9c9d18dc --- /dev/null +++ b/src/plugins/rv-packages/color_profile_setup/CMakeLists.txt @@ -0,0 +1,11 @@ +# +# Copyright (C) 2026 Autodesk, Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# + +SET(_target + "color_profile_setup" +) + +RV_STAGE(TYPE "RVPKG" TARGET ${_target}) diff --git a/src/plugins/rv-packages/color_profile_setup/PACKAGE b/src/plugins/rv-packages/color_profile_setup/PACKAGE new file mode 100644 index 000000000..4f056afb5 --- /dev/null +++ b/src/plugins/rv-packages/color_profile_setup/PACKAGE @@ -0,0 +1,46 @@ +package: Color Profile Setup +author: Autodesk, Inc. +organization: Autodesk, Inc. +version: 1.0 +requires: '' +rv: 4.0 +openrv: 1.0.0 +optional: true +system: true +hidden: false + +modes: + - file: color_profile_setup.py + menu: '' + shortcut: '' + event: '' + load: immediate + +description: | +

+ Automatically inserts the SYNLinearize (source linearization) and + SYNDisplay (display color management) OCIO nodes based on embedded ICC + color profiles and the display's system profile, replacing the manual, + key-bound script that previously had to be run by hand. +

+ +

+ Source media carrying an embedded ICC profile has its + RVLinearizePipelineGroup switched to a SYNLinearize node whose + inTransform.data is set to the profile blob. Each RVDisplayGroup whose + device has a system ICC profile has its display pipeline switched to a + SYNDisplay node pointed at that profile. +

+ +

+ For rvio parity, the display transform is baked into the RVOutputGroup(s) + at session-write time (RVDisplayGroup is not persisted, and rvio renders + through the output group), so an exported session renders identically in + rvio. +

+ +

+ The feature is opt-in and self-contained: enable it via the + "Automatic Color Profile Setup" checkbox under the package's menu. The + setting is stored in the package's own preferences and is off by default. +

diff --git a/src/plugins/rv-packages/color_profile_setup/color_profile_setup.py b/src/plugins/rv-packages/color_profile_setup/color_profile_setup.py new file mode 100644 index 000000000..5c06c0fb0 --- /dev/null +++ b/src/plugins/rv-packages/color_profile_setup/color_profile_setup.py @@ -0,0 +1,377 @@ +# +# Copyright (C) 2026 Autodesk, Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +from rv import rvtypes, commands + +import platform +import re +from six.moves.urllib.parse import urlparse + +# +# Per-pipeline-group snapshot of the "pipeline.nodes" value taken just before +# SYN nodes were inserted, keyed by the pipeline group node name. Used to +# restore the original pipeline when the feature is disabled. +# +DEFAULT_PIPE = {} + +# +# The native RV defaults, used as the restore target when we are reloading a +# session whose pipeline is already SYN managed (so the persisted SYN pipeline +# is not mistaken for the user's "default" pipeline). +# +DEFAULT_RV_PIPE = { + "RVLinearizePipelineGroup": ["RVLinearize", "RVLensWarp"], + "RVDisplayPipelineGroup": ["RVDisplayColor"], +} + +# Source data attribute that holds an embedded ICC profile blob. +ICC_DATA_KEY = "ColorSpace/ICC/Data" + +# Self-contained, package-owned preference (off by default). Stored in this +# package's own QSettings group so the feature needs no entry in RV's +# Preferences dialog. +SETTINGS_GROUP = "color_profile_setup" +SETTINGS_KEY = "enabled" + + +def groupMemberOfType(node, memberType): + for n in commands.nodesInGroup(node): + if commands.nodeType(n) == memberType: + return n + return None + + +def normalizeProfileURL(url): + # + # device.systemProfileURL is a file:// URL. Convert it to a filesystem + # path the same way source_setup.py does for ICCDisplayTransform. + # + path = urlparse(url.replace("%20", " ")).path + if "windows" in platform.platform().lower() and re.match("^/.:.*$", path): + path = path[1:] + return path + + +class ColorProfileSetupMode(rvtypes.MinorMode): + """ + Automatically inserts the SYNLinearize and SYNDisplay OCIO nodes that + previously had to be inserted by hand via a key-bound script. + + Source side: + When media carries an embedded ICC profile (a data attribute), the + source's RVLinearizePipelineGroup is switched to a single SYNLinearize + node whose inTransform.data is set to the profile blob. Because the + source group is persisted to .rv, this carries over to rvio for free. + + Display side: + For each RVDisplayGroup whose device has a system ICC profile, the + display pipeline is switched to a single SYNDisplay node whose + outTransform.url points at the profile. + + rvio parity: + RVDisplayGroup is NOT written to a session file and rvio renders + through RVOutputGroup instead. So at session-write time the display + SYNDisplay transform is baked into every RVOutputGroup's display + pipeline (which IS persisted), then reverted after the write so the + interactive graph is left unchanged. + + The feature is opt-in and self-contained: it is controlled by the + "Automatic Color Profile Setup" checkbox in this package's own menu, + backed by a package-owned preference (off by default). No entry is added + to RV's Preferences dialog. + + ORDERING: sort key "source_setup", ordering 20 -- runs after the base + source_setup (0) and ocio_source_setup (10). + """ + + # + # Source side + # + + def _iccBlob(self, source): + try: + medias = commands.getStringProperty("%s.media.movie" % source) + media = medias[0] if medias else "" + data = commands.sourceDataAttributes(source, media) + except Exception: + return None + + if not data: + return None + + dataDict = dict((name, blob) for (name, blob) in data) + blob = dataDict.get(ICC_DATA_KEY) + if blob is None: + # + # Fall back to the first data attribute. This mirrors the original + # manually-bound script, which used attrs[0] regardless of name. + # + blob = data[0][1] + if blob is None or len(blob) == 0: + return None + return blob + + def useSourceSYN(self, source): + linPipe = groupMemberOfType(commands.nodeGroup(source), "RVLinearizePipelineGroup") + if linPipe is None: + return + + blob = self._iccBlob(source) + if blob is None: + # No embedded ICC profile: leave / restore the default pipeline. + self.disableSourceSYN(source) + return + + current = commands.getStringProperty(linPipe + ".pipeline.nodes") + if linPipe not in DEFAULT_PIPE: + if "SYNLinearize" in current: + DEFAULT_PIPE[linPipe] = DEFAULT_RV_PIPE["RVLinearizePipelineGroup"] + else: + DEFAULT_PIPE[linPipe] = current + + # Match the original script: the linearize pipeline becomes just + # SYNLinearize. + if current != ["SYNLinearize"]: + commands.setStringProperty(linPipe + ".pipeline.nodes", ["SYNLinearize"], True) + + syn = groupMemberOfType(linPipe, "SYNLinearize") + if syn is not None: + commands.setByteProperty(syn + ".inTransform.data", blob, True) + print("INFO: SYNLinearize applied to %s" % source) + commands.redraw() + + def disableSourceSYN(self, source): + linPipe = groupMemberOfType(commands.nodeGroup(source), "RVLinearizePipelineGroup") + if linPipe is None or linPipe not in DEFAULT_PIPE: + return + current = commands.getStringProperty(linPipe + ".pipeline.nodes") + if current == DEFAULT_PIPE[linPipe]: + return + commands.setStringProperty(linPipe + ".pipeline.nodes", DEFAULT_PIPE[linPipe], True) + commands.redraw() + + # + # Display side + # + + def useDisplaySYN(self, group): + url = commands.getStringProperty(group + ".device.systemProfileURL") + if not url or url[0] == "": + self.disableDisplaySYN(group) + return + + dpipe = groupMemberOfType(group, "RVDisplayPipelineGroup") + if dpipe is None: + return + + path = normalizeProfileURL(url[0]) + current = commands.getStringProperty(dpipe + ".pipeline.nodes") + + # Already configured with this profile: nothing to do. + if current == ["SYNDisplay"]: + syn = groupMemberOfType(dpipe, "SYNDisplay") + if syn is not None and commands.getStringProperty(syn + ".outTransform.url") == [path]: + return + + if dpipe not in DEFAULT_PIPE: + if "SYNDisplay" in current: + DEFAULT_PIPE[dpipe] = DEFAULT_RV_PIPE["RVDisplayPipelineGroup"] + else: + DEFAULT_PIPE[dpipe] = current + + if current != ["SYNDisplay"]: + commands.setStringProperty(dpipe + ".pipeline.nodes", ["SYNDisplay"], True) + + syn = groupMemberOfType(dpipe, "SYNDisplay") + if syn is not None: + commands.setStringProperty(syn + ".outTransform.url", [path], True) + print("INFO: SYNDisplay applied to %s" % group) + commands.redraw() + + def disableDisplaySYN(self, group): + dpipe = groupMemberOfType(group, "RVDisplayPipelineGroup") + if dpipe is None or dpipe not in DEFAULT_PIPE: + return + current = commands.getStringProperty(dpipe + ".pipeline.nodes") + if current == DEFAULT_PIPE[dpipe]: + return + commands.setStringProperty(dpipe + ".pipeline.nodes", DEFAULT_PIPE[dpipe], True) + commands.redraw() + + # + # Whole-graph helpers + # + + def _sources(self): + result = [] + for t in ("RVFileSource", "RVImageSource"): + result += commands.nodesOfType(t) + return result + + def _applyDisplays(self): + for group in commands.nodesOfType("RVDisplayGroup"): + self.useDisplaySYN(group) + + def applyAll(self): + for source in self._sources(): + self.useSourceSYN(source) + self._applyDisplays() + + def restoreAll(self): + for source in self._sources(): + self.disableSourceSYN(source) + for group in commands.nodesOfType("RVDisplayGroup"): + self.disableDisplaySYN(group) + + def _currentDisplayProfilePath(self): + # + # Pick a display profile to bake into the output group(s). Prefer the + # first display with a non-empty system profile. + # + for group in commands.nodesOfType("RVDisplayGroup"): + url = commands.getStringProperty(group + ".device.systemProfileURL") + if url and url[0] != "": + return normalizeProfileURL(url[0]) + return None + + # + # Event handlers + # + + def sourceSetup(self, event): + event.reject() # allow other source-group-complete handlers to run + if not self._enabled: + return + group = event.contents().split(";;")[0] + fileSource = groupMemberOfType(group, "RVFileSource") + imageSource = groupMemberOfType(group, "RVImageSource") + source = fileSource if imageSource is None else imageSource + if source is not None: + self.useSourceSYN(source) + # A source's embedded profile does not drive the display; the display + # is configured from its own device system profile. + self._applyDisplays() + + def checkForDisplayGroup(self, event): + event.reject() + if not self._enabled: + return + try: + node = event.contents() + if commands.nodeType(node) == "RVDisplayGroup": + self.useDisplaySYN(node) + except Exception: + pass + + def beforeSessionRead(self, event): + event.reject() + self._readingSession = True + + def afterSessionRead(self, event): + event.reject() + self._readingSession = False + if self._enabled: + self._applyDisplays() + + def beforeSessionWrite(self, event): + # + # rvio parity: bake the display SYNDisplay transform into every output + # group so the exported .rv reproduces it under rvio (RVDisplayGroup is + # not persisted; RVOutputGroup is, and rvio renders through it). + # + event.reject() + self._bakedOutputGroups = {} + if not self._enabled: + return + path = self._currentDisplayProfilePath() + if path is None: + return + for og in commands.nodesOfType("RVOutputGroup"): + dpipe = groupMemberOfType(og, "RVDisplayPipelineGroup") + if dpipe is None: + continue + self._bakedOutputGroups[dpipe] = commands.getStringProperty(dpipe + ".pipeline.nodes") + if "SYNDisplay" not in self._bakedOutputGroups[dpipe]: + commands.setStringProperty(dpipe + ".pipeline.nodes", ["SYNDisplay"], True) + syn = groupMemberOfType(dpipe, "SYNDisplay") + if syn is not None: + commands.setStringProperty(syn + ".outTransform.url", [path], True) + print("INFO: SYNDisplay baked into %s for rvio parity" % og) + + def afterSessionWrite(self, event): + # + # Revert the output groups to their pre-write state so the parity bake + # does not alter the live interactive graph. + # + event.reject() + for dpipe, nodes in self._bakedOutputGroups.items(): + try: + current = commands.getStringProperty(dpipe + ".pipeline.nodes") + if current != nodes: + commands.setStringProperty(dpipe + ".pipeline.nodes", nodes, True) + except Exception: + pass + self._bakedOutputGroups = {} + + # + # Menu toggle (self-contained, package-owned preference) + # + + def toggleEnabled(self, event): + self._enabled = not self._enabled + commands.writeSettings(SETTINGS_GROUP, SETTINGS_KEY, self._enabled) + if self._enabled: + self.applyAll() + else: + self.restoreAll() + commands.redraw() + + def enabledState(self): + return commands.CheckedMenuState if self._enabled else commands.UncheckedMenuState + + def _buildMenu(self): + return [ + ( + "Color", + [ + ( + "Automatic Color Profile Setup", + self.toggleEnabled, + None, + self.enabledState, + ) + ], + ) + ] + + def __init__(self): + rvtypes.MinorMode.__init__(self) + + self._enabled = bool(commands.readSettings(SETTINGS_GROUP, SETTINGS_KEY, False)) + self._readingSession = False + self._bakedOutputGroups = {} + + self.init( + "color-profile-setup", + None, + [ + ("source-group-complete", self.sourceSetup, "Color and Geometry Management"), + ("before-session-read", self.beforeSessionRead, ""), + ("after-session-read", self.afterSessionRead, ""), + ("graph-new-node", self.checkForDisplayGroup, ""), + ("graph-node-inputs-changed", self.checkForDisplayGroup, ""), + ("before-session-write", self.beforeSessionWrite, ""), + ("before-session-write-copy", self.beforeSessionWrite, ""), + ("after-session-write", self.afterSessionWrite, ""), + ("after-session-write-copy", self.afterSessionWrite, ""), + ], + self._buildMenu(), + "source_setup", + 20, + ) # "source_setup" key shared with source_setup and ocio_source_setup + + +def createMode(): + return ColorProfileSetupMode()