diff --git a/CHANGES.md b/CHANGES.md index 27d1ab1c65..802cf31f5b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,9 @@ Release 3.2 (target: Sept 2026?) -- compared to 3.1 - *oiiotool*: New `--nchannels` flag to specify the number of output channels, for parity with maketx. [#5198](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5198) (by Danny Greenstein) (3.2.0.3, 3.1.14.0) - *oiiotool*: Commands taking offsets or geometry arguments now accept commas as alternative separators (e.g., `X,Y` or `WxH,X,Y` in addition to the X11-style `+X+Y` form). This affects `--create`, `--crop`, `--cut`, `--fit`, `--fullsize`, `--origin`, `--originoffset`, `--paste`, `--pattern`, `--printstats`, `--resize`. [#5209](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5209) (3.2.0.3, 3.1.14.0) - *oiiotool*: Be more cautious about implicit promotion to float when `--autocc` is used alongside explicit color space names. [#5192](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5192) (3.2.0.3, 3.1.14.0) + - *oiiotool*: New `--colorspacesearch` flag prints the color spaces in the active config whose derivable characteristics match a partial description, without needing to know the config's naming conventions. Each hint axis is a comma-separated list of terms via the modifiers `chromaticities=` (shorthand `chrm=`), `transfer_function=`, `encoding=`, `image_state=`, with inclusion toggles `include_inactive=`, `include_context_sensitive=`, `include_complex=`, `authored_encoding_only=`; colon-bearing terms may be passed as quoted modifier values. [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) + - *oiiotool*: New `--colorwriteplan FORMAT` flag prints, without writing any file, the color metadata OIIO would write for the current top image to the named format -- per signal: the verdict (write/derive/suppress/omit), the value, and which policy layer decided it (builtin default, global attribute, per-spec attribute, explicit metadata, or format incapability). [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) + - *oiiotool*: New `--colorinfo COLORSPACES` flag prints, for each color space in a comma-separated list (or for the current top image's color space if the list is empty), the characterization info available from the active config -- image state, color interop ID, encoding, range, and cached derived facts -- one row per field with an available/derived/unavailable/uncomputed marker. It is a tool-facing diagnostic and does no new probing or derivation. [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) * *Command line utilities*: - *iv*: Flip, rotate and save image [#5003](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5003) (by Valery Angelique) (3.2.0.0, 3.1.11.0) - *iconvert*: Allow `-o outfile` for output file designation, for parity with oiiotool syntax. [#5173](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5173) (3.2.0.3, 3.1.14.0) @@ -54,6 +57,10 @@ Release 3.2 (target: Sept 2026?) -- compared to 3.1 - *jpeg-xl*: CICP read and write support for JPEG-XL [#4968](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/4968) (by Brecht Van Lommel) (3.2.0.0, 3.1.9.0) - *jpeg-xl*: ICC read and write for JPEG-XL files (issue 4649) [#4905](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/4905) (by shanesmith-dwa) (3.0.14.0, 3.2.0.0) - *color mgmt*: For OCIO built-in configs, replace the default file rules with more sensible ones that avoid spurious matches (e.g., no longer assumes all `.exr` files use ACES2065-1 primaries). [#5194](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5194) (3.2.0.3, 3.1.14.0) + - *color mgmt*: Map the CICP Rec.709 primaries + sRGB-transfer tuple to display-referred sRGB (`srgb_rec709_display`) rather than the scene-referred entry, so CICP-tagged reads resolve to the intended display encoding. [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) + - *color mgmt*: `ColorConfig` color and display conversions now bridge across differing OCIO configs through the `aces_interchange` role, so a source space known only to a foreign config can be reconciled into the active config. When the two configs are not color-interoperable and OCIO strict parsing is off, the conversion falls back to a pass-through that leaves pixels untouched and keeps the honest source color-space tag instead of mistagging the result with the requested destination; a once-per-config interoperability warning is emitted (debug-gated). [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) + - *ImageBufAlgo* **behavior change**: the color-aware operations (`colorconvert`, `ociolook`, `ociodisplay`, `ociofiletransform`) now automatically maintain the output's color metadata as a best-effort convenience. When the resulting space is known, the operation updates `oiio:ColorSpace`, maintains the new cheap current-state descriptor attributes `oiio:ColorSpace:state` / `:encoding` / `:range` / `:equality_id` (each present only when its value is actually known -- updated or erased, never guessed or derived), and removes stale file-provenance attributes (`colorInteropID`, `CICP`, `ICCProfile`, `chromaticities`, `oiio:Gamma`, `acesImageContainerFlag`) that described the pre-operation source. This scrub now applies uniformly whether the source space was named explicitly or inferred from metadata (previously only the inferred-source path scrubbed), so callers should set metadata overrides after the last color operation. `ociofiletransform` with an arbitrary LUT now erases the color-space verdict and related metadata entirely (the resulting space cannot be known and absence is the honest answer), unless the file's name identifies the result space via the config's file rules, which keeps its longstanding tagging behavior. `ociolook` and `ociodisplay` gain the same untagged-source inference `colorconvert` already had, and space-preserving no-ops (data spaces, cross-config pass-throughs) now leave still-true metadata untouched. The deliberate unknown-marker family (`ocio:unknown` / `oiio:unknown` / `error:unknown`) is always honored and never scrubbed. Automatic tracking is documented as a convenience, not a contract: tracking gaps are not errors, but under a strict resolution scope an operation whose source cannot be resolved now errors (via the usual `has_error()` convention) instead of silently guessing, honoring a config-declared `error:unknown` catch space when the config also enables strict parsing. [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) + - *color mgmt* **behavior enrichment**: `ColorConfig::set_colorspace()` (and the `ImageSpec::set_colorspace()` / free-function conveniences that route through it) now applies the same two-bucket color-metadata hygiene as the color-aware `ImageBufAlgo` operations. Asserting a different color space than the spec already claims scrubs every now-contradicting file-provenance attribute (`colorInteropID`, `CICP`, `chromaticities`, `ICCProfile`, `oiio:Gamma`, `acesImageContainerFlag`, plus the longstanding `Exif:ColorSpace` / `tiff:*` invalidations), and maintains -- update-or-erase from the cheap characterization, never guessed or derived -- any `oiio:ColorSpace:state` / `:encoding` / `:range` / `:equality_id` current-state descriptors the spec carries. An empty name now erases all of it (verdict, facts, and descriptors: assume nothing). First tagging of an untagged spec and re-assertion of the current space keep their previous behavior exactly, so read paths that derive the tag from just-read metadata are unchanged. [#XXXX](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/XXXX) (3.2.0.4) * Other notable new feature: - *tiff*: Support for GPS metadata fields (latitude, longitude, altitude, timestamp, and related EXIF GPS fields) when using libTIFF 4.2+. [#5050](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5050) (3.2.0.1, 3.1.12.0) - *heif*: Add IOProxy support for both input and output [#5017](https://github.com/AcademySoftwareFoundation/OpenImageIO/pull/5017) (by Brecht Van Lommel) (3.2.0.0, 3.1.10.0) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d863678c5..32caa78517 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -223,7 +223,17 @@ if (IGNORE_HOMEBREWED_DEPS) ) endforeach () + # CMAKE_IGNORE_PATH only blocks exact directories; config packages + # (e.g. /lib/cmake/Imath) are found by prefix search and slip + # through. CMAKE_IGNORE_PREFIX_PATH (CMake 3.23+) blocks the whole + # prefix, and dependency_utils forwards it to local dependency child + # builds so they cannot resolve a different Homebrew copy than the + # parent (e.g. Homebrew Imath 3.2 inside a local OpenEXR build while + # OIIO links the local-dist Imath -- a mangled-symbol link failure). + list (APPEND CMAKE_IGNORE_PREFIX_PATH ${HOMEBREW_PREFIXES}) + message (STATUS "CMAKE_IGNORE_PATH: ${CMAKE_IGNORE_PATH}") + message (STATUS "CMAKE_IGNORE_PREFIX_PATH: ${CMAKE_IGNORE_PREFIX_PATH}") endif () diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e7f259218..0ab3850609 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -332,6 +332,20 @@ clang-format is installed on your local machine, and just run make clang-format +**clang-format must be older than version 18.** It is an optional dev +dependency, but a version-sensitive one: clang-format's output is not stable +across major releases, so a newer binary will reformat the entire tree in a +way the CI check then rejects. CI pins clang-format 17, and the build +configuration enforces the same bound — if the clang-format it finds is 18 or +newer, it prints a warning and disables the `clang-format` target rather than +let you reformat the tree incorrectly. + +If your system clang-format is too new, either install a 17.x build and point +the configuration at it with `CLANG_FORMAT_EXE_HINT=/path/to/its/bin`, or run +the pinned version directly without installing anything: + + uvx clang-format@17.0.6 --style=file -i + and it will automatically reformat your code according to the configuration file found in the `.clang-format` file at the root directory of the OIIO source code checkout. diff --git a/pyproject.toml b/pyproject.toml index 3b0a8fa0cc..5f107fa59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,10 @@ CXXFLAGS = "-Wno-error=stringop-overflow -Wno-pragmas" SKBUILD_CMAKE_BUILD_TYPE = "MinSizeRel" # FIXME: Getting build problems when using WebP 1.6.0, so hold it back WebP_BUILD_VERSION = "1.5.0" +# The commit hash pin in src/cmake/build_WebP.cmake is for v1.6.0; override +# it to the v1.5.0 commit to match the held-back version above, else the +# tag-verification check fails the wheel build. +WebP_GIT_COMMIT = "a4d7a715337ded4451fec90ff8ce79728e04126c" [tool.cibuildwheel.windows.environment] SKBUILD_CMAKE_BUILD_TYPE = "MinSizeRel" diff --git a/src/cmake/compiler.cmake b/src/cmake/compiler.cmake index 4d2e010cc4..83680d20ed 100644 --- a/src/cmake/compiler.cmake +++ b/src/cmake/compiler.cmake @@ -630,8 +630,46 @@ if (PROJECT_IS_TOP_LEVEL) NO_DEFAULT_PATH DOC "Path to clang-format executable") find_program (CLANG_FORMAT_EXE NAMES clang-format bin/clang-format) + # clang-format is an OPTIONAL dev dependency, but a version-sensitive one: + # its output is not stable across major releases, so a newer binary will + # happily reformat the whole tree in a way the CI check then rejects. The + # CI matrix pins clang-format-17 (see CLANG_FORMAT_EXE dance in ci.yml), so + # accept only < 18 and otherwise decline to wire the target -- reformatting + # with the wrong version is strictly worse than not reformatting at all. + set (CLANG_FORMAT_USABLE OFF) if (CLANG_FORMAT_EXE) - message (STATUS "clang-format found: ${CLANG_FORMAT_EXE}") + set (CLANG_FORMAT_USABLE ON) + execute_process (COMMAND "${CLANG_FORMAT_EXE}" --version + OUTPUT_VARIABLE CLANG_FORMAT_VERSION_OUTPUT + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _cf_result) + if (_cf_result EQUAL 0 AND + CLANG_FORMAT_VERSION_OUTPUT MATCHES "([0-9]+)\\.[0-9]+\\.[0-9]+") + set (CLANG_FORMAT_VERSION_MAJOR ${CMAKE_MATCH_1}) + else () + set (CLANG_FORMAT_VERSION_MAJOR "") + endif () + if (NOT CLANG_FORMAT_VERSION_MAJOR) + message (STATUS "clang-format found (${CLANG_FORMAT_EXE}) but its " + "version could not be determined -- skipping the " + "clang-format target.") + set (CLANG_FORMAT_USABLE OFF) + elseif (CLANG_FORMAT_VERSION_MAJOR GREATER_EQUAL 18) + message (WARNING + "clang-format ${CLANG_FORMAT_VERSION_MAJOR} found at " + "${CLANG_FORMAT_EXE}, but this project requires " + "clang-format < 18 (CI pins 17). Its output differs from " + "17's, so 'make clang-format' would reformat the tree in " + "a way CI rejects. The 'clang-format' target is disabled. " + "Install a 17.x binary and point CLANG_FORMAT_EXE_HINT at " + "it, or run the pinned version directly with no install: " + "uvx clang-format@17.0.6 --style=file -i ") + set (CLANG_FORMAT_USABLE OFF) + endif () + endif () + if (CLANG_FORMAT_USABLE) + message (STATUS "clang-format found: ${CLANG_FORMAT_EXE} " + "(version ${CLANG_FORMAT_VERSION_MAJOR})") # Start with the list of files to include when formatting... file (GLOB_RECURSE FILES_TO_FORMAT ${CLANG_FORMAT_INCLUDES}) # ... then process any list of excludes we are given @@ -644,7 +682,7 @@ if (PROJECT_IS_TOP_LEVEL) DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) add_custom_target (clang-format COMMAND "${CLANG_FORMAT_EXE}" -i -style=file ${FILES_TO_FORMAT} ) - else () + elseif (NOT CLANG_FORMAT_EXE) message (STATUS "clang-format not found.") endif () endif () diff --git a/src/cmake/dependency_utils.cmake b/src/cmake/dependency_utils.cmake index 879580a46b..454ba9abfe 100644 --- a/src/cmake/dependency_utils.cmake +++ b/src/cmake/dependency_utils.cmake @@ -727,10 +727,15 @@ macro (build_dependency_with_cmake pkgname) # Make sure to inherit CMAKE_IGNORE_PATH set(_pkg_CMAKE_ARGS ${_pkg_CMAKE_ARGS} ${_pkg_CMAKE_ARGS}) - if (CMAKE_IGNORE_PATH) - string(REPLACE ";" "\\;" CMAKE_IGNORE_PATH_ESCAPED "${CMAKE_IGNORE_PATH}") - list(APPEND _pkg_CMAKE_ARGS "-DCMAKE_IGNORE_PATH=${CMAKE_IGNORE_PATH_ESCAPED}") - endif() + # CMAKE_IGNORE_PREFIX_PATH too: unlike CMAKE_IGNORE_PATH it also blocks + # config-package prefix search, so child builds cannot resolve a system + # (e.g. Homebrew) copy of a dependency the parent build is ignoring. + foreach (_ignore_var IN ITEMS CMAKE_IGNORE_PATH CMAKE_IGNORE_PREFIX_PATH) + if (${_ignore_var}) + string(REPLACE ";" "\\;" _ignore_escaped "${${_ignore_var}}") + list(APPEND _pkg_CMAKE_ARGS "-D${_ignore_var}=${_ignore_escaped}") + endif() + endforeach() # Pass along any CMAKE_MSVC_RUNTIME_LIBRARY if (WIN32 AND CMAKE_MSVC_RUNTIME_LIBRARY) diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index f59a027809..2c0506dec4 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -267,6 +267,8 @@ macro (oiio_add_all_tests) set (nanobind_python_test_suffix ".nanobind") if (OIIO_BUILD_PYTHON_PYBIND11) oiio_add_tests ( + colorinfo + colorspacesearch docs-examples-python python-colorconfig python-deep @@ -279,6 +281,8 @@ macro (oiio_add_all_tests) python-roi python-texturesys python-typedesc + sourceprovenance + cicp-write-strip filters ENVIRONMENT "${_pybind_tests_pythonpath}" ) @@ -302,8 +306,22 @@ macro (oiio_add_all_tests) endif () oiio_add_tests (oiiotool-color + color-interop-convert FOUNDVAR OpenColorIO_FOUND) + # These color tests drive oiiotool through POSIX-shell constructs + # (per-command `env VAR=` prefixes, single-quoted echo, grep pipelines) + # that cmd.exe cannot run; skip them on Windows until they are + # rewritten portably. The behavior they exercise is platform-neutral. + if (NOT WIN32) + oiio_add_tests (oiiotool-colorwriteplan) + oiio_add_tests (oiiotool-colorpolicy-config + oiiotool-colorroundtrip + oiiotool-colorverbose + oiiotool-colorprofile + FOUNDVAR OpenColorIO_FOUND) + endif () + # Tests to run with HWY enabled. # Remember to add tests here as hwy enabled IBA functions are added oiio_add_tests ( oiiotool diff --git a/src/doc/colorinterop.rst b/src/doc/colorinterop.rst new file mode 100644 index 0000000000..9b31d908c8 --- /dev/null +++ b/src/doc/colorinterop.rst @@ -0,0 +1,567 @@ +.. + Copyright Contributors to the OpenImageIO project. + SPDX-License-Identifier: CC-BY-4.0 + + +.. _chap-colorinterop: + +Color interop IDs and CICP +########################## + +This chapter describes how OpenImageIO identifies color spaces using +*color interop IDs* and *CICP* codes, and how individual file format +plugins read and write that information today. + +A **color interop ID** is a short, stable text token that names a color +space in a way meant to be portable across applications and vendors, +independent of any particular color management configuration. The tokens +used by OpenImageIO come from the Academy Software Foundation's `Color +Interop Forum `_, +which publishes recommendations for identifying color spaces consistently +across tools and pipelines. + +**CICP** ("Coding-independent code points") is a numeric encoding of color +space information defined by `ITU-T H.273 +`_. It is a 4-tuple of small integers +--- color primaries, transfer characteristics, matrix coefficients, and a +full/narrow range flag --- and is the mechanism several image and video +container formats use natively to record color space metadata. + +See also the general description of the `"oiio:ColorSpace"` and `"CICP"` +attributes in :ref:`sec-metadata-color`. + + + +Color interop IDs in OpenImageIO +================================= + +`ColorConfig` provides three methods for moving between color space names, +color interop IDs, and CICP codes: + +.. code-block:: + + // Find color interop ID for the given colorspace name (color space, + // alias, or role). Returns "" if not found. + string_view get_color_interop_id(string_view colorspace) const; + + // Find color interop ID corresponding to the CICP code. + // Returns "" if not found. + string_view get_color_interop_id(const int cicp[4]) const; + + // Find CICP code corresponding to the colorspace. + // Returns an empty span if not found. + cspan get_cicp(string_view colorspace) const; + +`get_color_interop_id(string_view)` first asks the active OCIO config for +its own interop ID for the resolved color space (this requires OCIO >= +2.5, which added `ColorSpace::getInteropID()`), and only if that isn't +available does it fall back to a built-in table of color interop IDs +maintained inside OpenImageIO. This means that with a sufficiently recent +OCIO config that annotates its color spaces with interop IDs, the config's +own answer takes precedence over OpenImageIO's built-in table. + +`get_color_interop_id(const int cicp[4])` and `get_cicp(string_view)` only +consult the built-in table; they do not consult the OCIO config. + +Color space name lookups are case-insensitive, and the name may be any +color space, alias, or role that OpenImageIO can relate to one of the +built-in interop ID tokens by name, alias, or its cheap color space +classification. `get_color_interop_id()` is deliberately an inexpensive +lookup: it never probes transforms or builds color processors. The full +derivation (which can additionally identify a space by comparing its +transform values against the built-in registry identities, or generate a +config-local ID) runs internally at write-planning time when a file is +written. + + +Built-in color interop ID / CICP table +======================================= + +OpenImageIO ships a built-in table pairing color interop ID tokens with +their CICP correspondence, where one exists. It is transcribed below, +faithful to the order and content of the table in OpenImageIO's source +(`color_ocio.cpp`). Scene-referred interop IDs are listed first, and +display-referred ones follow. Some interop IDs describe color spaces (or +non-color-space states such as "data" and "unknown") that cannot be +expressed as CICP at all, and have no CICP entry. + +CICP columns give the numeric code for each of primaries, transfer +characteristics, and matrix coefficients (the range flag is always "Full" +for every table entry that has a CICP mapping). + +.. list-table:: Scene-referred color interop IDs + :widths: 22 14 20 20 24 + :header-rows: 1 + + * - Color interop ID + - Primaries + - Transfer + - Matrix + - Notes + * - ``lin_ap1_scene`` + - — + - — + - — + - No CICP mapping. + * - ``lin_ap0_scene`` + - — + - — + - — + - No CICP mapping. ACES2065-1 (AP0), used for the ACES Container (see + below). + * - ``lin_rec709_scene`` + - Rec709 (1) + - Linear (8) + - BT709 (1) + - + * - ``lin_p3d65_scene`` + - P3D65 (12) + - Linear (8) + - BT709 (1) + - + * - ``lin_rec2020_scene`` + - Rec2020 (9) + - Linear (8) + - Rec2020_CL (10) + - + * - ``lin_adobergb_scene`` + - — + - — + - — + - No CICP mapping (no CICP code for Adobe RGB primaries). + * - ``lin_ciexyzd65_scene`` + - XYZD65 (10) + - Linear (8) + - Unspecified (2) + - + * - ``srgb_rec709_scene`` + - Rec709 (1) + - sRGB (13) + - BT709 (1) + - + * - ``g22_rec709_scene`` + - Rec709 (1) + - Gamma22 (4) + - BT709 (1) + - + * - ``g18_rec709_scene`` + - — + - — + - — + - No CICP mapping. + * - ``srgb_ap1_scene`` + - — + - — + - — + - No CICP mapping. + * - ``g22_ap1_scene`` + - — + - — + - — + - No CICP mapping. + * - ``srgb_p3d65_scene`` + - P3D65 (12) + - sRGB (13) + - BT709 (1) + - + * - ``g22_adobergb_scene`` + - — + - — + - — + - No CICP mapping (no CICP code for Adobe RGB primaries). + * - ``data`` + - — + - — + - — + - Not a color space; marks pixel data that is not meant to be color + managed. + * - ``unknown`` + - — + - — + - — + - Marks pixel data whose color space is not known. A user's + explicitly-set ``colorInteropID`` attribute of ``unknown`` is written + verbatim (the author's bytes are never rewritten). A config that + itself declares a space unknown (an ``interop_id`` of ``unknown``, or + a color space *named* ``unknown`` with no contradicting + ``interop_id``) also writes a bare ``unknown``: OpenImageIO tracks + *why* a space is unknown using internal namespaced markers, but those + are private to OpenImageIO and are never written to a file; only this + registered token is. An undeterminable color space omits the + attribute entirely. + +.. list-table:: Display-referred color interop IDs + :widths: 22 14 20 20 24 + :header-rows: 1 + + * - Color interop ID + - Primaries + - Transfer + - Matrix + - Notes + * - ``srgb_rec709_display`` + - Rec709 (1) + - sRGB (13) + - BT709 (1) + - + * - ``g24_rec709_display`` + - Rec709 (1) + - BT709 (1) + - BT709 (1) + - + * - ``srgb_p3d65_display`` + - P3D65 (12) + - sRGB (13) + - BT709 (1) + - + * - ``srgbe_p3d65_display`` + - P3D65 (12) + - sRGB (13) + - BT709 (1) + - + * - ``pq_p3d65_display`` + - P3D65 (12) + - PQ (16) + - Rec2020_NCL (9) + - + * - ``pq_rec2020_display`` + - Rec2020 (9) + - PQ (16) + - Rec2020_NCL (9) + - + * - ``hlg_rec2020_display`` + - Rec2020 (9) + - HLG (18) + - Rec2020_NCL (9) + - + * - ``g22_rec709_display`` + - — + - — + - — + - No CICP mapping, by deliberate choice: OpenImageIO's source notes + that this is left unmapped "to keep previous behavior unchanged, as + Gamma 2.2 display is more likely meant to be written as sRGB"; on + read, the scene-referred interop ID is used instead. + * - ``g22_adobergb_display`` + - — + - — + - — + - No CICP mapping (no CICP code for Adobe RGB primaries). + * - ``g26_p3d65_display`` + - P3D65 (12) + - Gamma26 (17) + - BT709 (1) + - + * - ``g26_xyzd65_display`` + - XYZD65 (10) + - Gamma26 (17) + - Unspecified (2) + - + * - ``pq_xyzd65_display`` + - XYZD65 (10) + - PQ (16) + - Unspecified (2) + - + +.. note:: + + `get_color_interop_id(const int cicp[4])` only matches on the + *primaries* and *transfer* fields of the CICP tuple --- matrix + coefficients and the range flag are not part of the lookup key, and it + returns the first table entry (in the order shown above) whose + primaries and transfer match. Several distinct interop IDs share the + same (primaries, transfer) pair by design or coincidence: + + - ``srgb_rec709_scene`` and ``srgb_rec709_display`` both correspond to + (Rec709, sRGB); the scene-referred entry, being listed first, is + what a CICP-to-interop-ID lookup returns. + - ``srgb_p3d65_scene``, ``srgb_p3d65_display``, and + ``srgbe_p3d65_display`` all correspond to (P3D65, sRGB); again the + scene-referred entry is returned. + + This is intentional: scene-referred entries are ordered first in the + table specifically "so they are the default in automatic conversion + from CICP to interop ID." + + +Format plugin support +====================== + +The following describes color interop ID and CICP handling as currently +implemented in OpenImageIO's format plugins. + +OpenEXR +------- + +The OpenEXR plugin reads and writes a string attribute literally named +`colorInteropID` (not `oiio:ColorInteropID`). + +- On read, if the file has an `acesImageContainerFlag` attribute set to 1, + `"oiio:ColorSpace"` is set to `lin_ap0_scene`. Otherwise, if a + `colorInteropID` attribute is present, its value is used to set + `"oiio:ColorSpace"`. +- On write, if no `colorInteropID` attribute is already present on the + spec, one is derived automatically from `"oiio:ColorSpace"` by the + write-planning derivation cascade (declared config `interop_id`, + registry identity match, built-in table, config-local ID) and attached + to the file (only if a matching interop ID is found). +- The `openexr:ACESContainerPolicy` output configuration attribute (`none`, + `strict`, or `relaxed`) can additionally force a file into ACES + Container form: it sets the ACES AP0 `chromaticities`, sets + `colorInteropID` to `lin_ap0_scene`, and (in `strict` mode, only if the + spec already qualifies, or in `relaxed` mode regardless) sets + `acesImageContainerFlag` to 1. See :ref:`sec-bundledplugins-openexr` for + the full configuration attribute reference. + +OpenEXR does not natively support CICP; there is no `"CICP"` attribute +handling in the OpenEXR plugin. + +PNG +--- + +The PNG plugin's `feature("cicp")` query reports true when built against a +libpng new enough to support the `cICP` chunk (gated behind the compile-time +`PNG_cICP_SUPPORTED` macro). However, this only reflects libpng's +capability: the plugin does not currently read or write the `cICP` chunk, +and has no `"CICP"` attribute handling at all. + +HEIF/HEIC/AVIF +-------------- + +The HEIF plugin's CICP support requires libheif >= 1.9.0 (gated behind +`LIBHEIF_HAVE_VERSION(1, 9, 0)`; `feature("cicp")` reflects this). + +- On read, the NCLX color profile is queried from the image handle (not + the decoded image, since the two can differ) via libheif's C API. If an + NCLX profile is present and not entirely "unspecified", its four values + are stored as `"CICP"`, and `"oiio:ColorSpace"` is set from + `ColorConfig::get_color_interop_id()` if a match is found. +- On write, an explicit `"CICP"` attribute takes priority; otherwise CICP + is derived from `"oiio:ColorSpace"` via `ColorConfig::get_cicp()`. If + present, the values are used to populate an NCLX color profile attached + to the encoded image. + +JPEG XL +------- + +The JPEG XL plugin (`jpegxl.imageio`) supports only a subset of CICP, +constrained by what `JxlColorEncoding` can represent; there is no +compile-time version gate for this support. + +- On read, CICP is derived from the decoded `JxlColorEncoding` only when + the color encoding does not use custom primaries, a custom white point, + or a gamma transfer function. The matrix coefficients value is always + recorded as 0 (RGB) and the range flag is always recorded as full range, + since JPEG XL's internal representation doesn't carry those independent + of the primaries/transfer function. As with the other formats, + `"oiio:ColorSpace"` is then set via `ColorConfig::get_color_interop_id()` + if a match is found. +- On write, an explicit `"CICP"` attribute takes priority; otherwise CICP + is derived from `"oiio:ColorSpace"` via `ColorConfig::get_cicp()` (only + if color space metadata wasn't already written some other way). Only + primaries in {sRGB, BT.2100, P3} and transfer functions in {BT.709, + unknown, linear, sRGB, PQ, DCI, HLG} are supported for writing; if the + CICP tuple names an unsupported primary or transfer function (such as a + gamma or custom code point), no color encoding is set on the JPEG XL + output at all. + +There is no `colorInteropID` string-attribute round-trip in the PNG, +HEIF, or JPEG XL plugins; all three only work in terms of `"CICP"` plus +`ColorConfig`'s interop ID / CICP conversion. + + +Color policy +============ + +The rules that turn file color metadata into an `"oiio:ColorSpace"` on +read, and decide which color signals a writer emits, are collectively the +*color policy*. The policy is not hard-wired: it is driven by the ambient +OCIO configuration and, layered on top, by explicit settings. + +The ambient config drives I/O +----------------------------- + +Reads and writes consult the ambient OCIO configuration -- the one named +by the ``$OCIO`` environment variable (or otherwise the current config). +A configuration author can therefore make policy decisions *in the config +itself*, and every OIIO reader and writer honors them without any change +to the I/O call. + +If OpenImageIO is built without OCIO support, or no configuration is +active, there is nothing to consult and behavior is exactly as it has +always been -- the built-in defaults, unchanged. This is the +no-color-management opt-out: with no config, no config-declared policy +can apply. + +Config-declared policy +---------------------- + +An OCIO configuration already lets its author attach arbitrary custom +key/value pairs to each file rule. OCIO round-trips those keys byte-for- +byte and other applications ignore them, so they are a ready-made, +author-owned channel for a config to declare its own color policy. OIIO +reads the keys whose names begin with ``oiio:`` and applies them. + +Policy is carried on a *profile* rule: a file rule whose regex is ``$^`` +(so it never matches a real file -- it exists only to hold policy) and +whose custom keys are the policy settings. The reserved profile +``oiio:default`` is the config author's hook for adjusting OIIO's own +defaults: + +.. code-block:: yaml + + file_rules: + - ! + name: oiio:default + colorspace: raw_data + regex: "$^" + custom: + oiio:colorpolicy:read:cicp_state: scene + oiio:colorpolicy:write:cicp: never + - ! {name: Default, colorspace: raw_data} + +With this config active, a state-ambiguous CICP tuple read from a file +resolves to its scene-referred interpretation instead of the default +display-referred one, and writers suppress the CICP signal they would +otherwise emit -- with no OIIO attribute set anywhere. A config that +declares no ``oiio:`` keys changes nothing. + +Naming +------ + +Every profile name is ``oiio:``-prefixed; there is no bare ``default`` +namespace (which also avoids colliding with OCIO's own catch-all +``Default`` rule). ``oiio:default`` adjusts OIIO's shipped defaults. +Named profiles follow the pattern ``oiio::`` -- for +example ``oiio:blender:textures`` -- so an application declares its own +policy bundles without claiming the bare ``oiio:`` prefix, which is +reserved for the standard. An unknown ``oiio:`` key, or an unrecognized +value for a known key, is ignored (warn-once); a malformed configuration +never breaks resolution -- policy reading is best-effort and always falls +through to the built-in default. + +Precedence +---------- + +Several sources may express an opinion about the same policy key. They +resolve into one snapshot per call, weakest to strongest: + +.. list-table:: Color-policy precedence, weakest to strongest + :widths: 6 36 58 + :header-rows: 1 + + * - Layer + - Name + - Authored by + * - 1 + - Built-in defaults + - OpenImageIO, at ship time. + * - 2 + - The active config's ``oiio:default`` profile + - Config author. + * - 3 + - Selected profiles (composable selection; see below) + - User / application, for the session. + * - 4 + - Global individual keys (``OIIO::attribute``) + - User / application, for the session. + * - 5 + - The config file rule matching *this* file + - Config author, per file pattern. + * - 6 + - Per-call arguments on this open / write call + - The caller, now. + +The non-intuitive rung is that layer 5 beats layer 4: a rule that matches +``*.png`` overrides a user's "global" per-key attribute for PNG files. +The rationale is CSS-specificity -- the more specific selector wins +regardless of who authored it -- and the escape hatch is the per-call +argument (layer 6), which always wins. + +Composable profile selection +---------------------------- + +Layer 3 selects *active profiles* through two entry points that compose: +the ``OPENIMAGEIO_COLORPOLICY`` environment variable is the base, and the +global attribute ``oiio:colorpolicy:profile`` (set with `OIIO::attribute`) +composes on top -- the more explicit, programmatic entry point wins, so an +attribute entry can subtract what the environment variable added. + +A selection is a comma-separated list of entries, each optionally prefixed +with ``+`` (add, the default) or ``-`` (remove). An entry beginning with +``read:`` or ``write:`` addresses a single policy key, optionally with an +``=value`` (for example ``+read:cicp_state=scene``); any other entry names +a whole profile -- a config rule name, such as ``oiio:blender:textures`` +-- whose declared ``oiio:`` keys are merged (or, with ``-``, removed). +Selecting a profile the config does not declare is a graceful no-op. + +.. code-block:: shell + + # Select a config-declared profile, then drop one of its keys: + OPENIMAGEIO_COLORPOLICY="oiio:blender:textures,-read:cicp_state" + +Selected profiles compose over the config's ``oiio:default`` baseline +(layer 2) and sit below the global individual keys (layer 4), so an +absolute per-key `OIIO::attribute` still overrides a selected profile. + +Per-format round-trip +--------------------- + +Not every format round-trips color identity 1:1, but each format's +behavior is predictable. Writing a tagged image and reading it back +yields: + +.. list-table:: Per-format color-identity round-trip + :widths: 12 88 + :header-rows: 1 + + * - Format + - What survives a write / read round-trip + * - OpenEXR + - ``colorInteropID`` is preserved (native slot). CICP is never + written -- even an explicit ``"CICP"`` tuple is stripped, since + EXR has no CICP convention. + * - PNG + - A display identity is carried as a ``cICP`` chunk and read back as + ``"CICP"``, resolving to the display space. It round-trips as + CICP, not as ``colorInteropID``. + * - TIFF + - No color identity is carried: a round-tripped TIFF reads back + untagged. Lossy but predictable. + * - JPEG + - No color identity is carried; the reader's fixed sRGB assumption + resolves every JPEG to ``srgb_rec709_scene`` regardless of the + source tag. + +Verbose and forced write emission +--------------------------------- + +Two integer policy keys expand what writers emit beyond the minimal +default: + +- ``oiio:colorpolicy:write:force_interop_id`` makes a format with no + native ``colorInteropID`` slot carry the derived interop ID anyway, as + an auxiliary attribute: an XMP packet for TIFF, JPEG, and JPEG XL, a + ``tEXt`` chunk for PNG, and a header keyword for FITS. +- ``oiio:colorpolicy:write:verbose`` emits the redundant signal set + alongside the interop ID instead of minimizing it: an author-supplied + ``chromaticities`` attribute is kept rather than suppressed as + redundant, chromaticities are otherwise derived from the space's + reserved gamut, and a gamma value is derived when (and only when) the + space's transfer is a pure power law (a ``g18``/``g22``/``g24``/``g26`` + token); a non-power-law transfer (sRGB piecewise, PQ, log) never gets a + guessed gamma, so the emitted signals stay consistent with the space. + +The ``oiio:broadcast`` delivery profile +--------------------------------------- + +The broadcast delivery mapping is keyed by +``oiio:colorpolicy:write:broadcast`` and is conventionally carried on a +config-declared profile named ``oiio:broadcast``, selected via the +composable profile selection above. When active, P3-D65 display content +is routed into the broadcast delivery container at write time: the CICP +tuple signals Rec.2020 encoding primaries (code 9) with narrow (limited) +range and RGB matrix coefficients, the transfer code is carried over from +the source space's own CICP (falling back to BT.1886), and the true P3 +gamut volume is carried in mDCV mastering-display metadata. The pixels +are *not* re-gamut'd to Rec.2020 -- only the container signaling changes. diff --git a/src/doc/imagebufalgo.rst b/src/doc/imagebufalgo.rst index 6ac43d6fc7..826fdfb620 100644 --- a/src/doc/imagebufalgo.rst +++ b/src/doc/imagebufalgo.rst @@ -2961,6 +2961,16 @@ are often constructed from them: Color space conversion ====================== +The color-aware operations below automatically maintain the output's color +metadata as a best-effort convenience (not a guarantee): the resulting +`"oiio:ColorSpace"` and its current-state descriptors are updated when the +resulting space is known, erased when it cannot be known, and left alone by +space-preserving operations -- while stale file-provenance attributes +(`"colorInteropID"`, `"CICP"`, `"ICCProfile"`, `"chromaticities"`, +`"oiio:Gamma"`) are removed by any operation that changes the color space, +whether the source was named explicitly or inferred. Set metadata overrides +after the last color operation. + .. doxygengroup:: colorconvert .. diff --git a/src/doc/index.rst b/src/doc/index.rst index 54f81cc4e8..d84f7fe714 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -77,6 +77,7 @@ OpenImageIO |version| :maxdepth: 2 stdmetadata + colorinterop glossary diff --git a/src/doc/oiiotool.rst b/src/doc/oiiotool.rst index ce6fd570c2..8fa27bab9e 100644 --- a/src/doc/oiiotool.rst +++ b/src/doc/oiiotool.rst @@ -1849,6 +1849,60 @@ Writing images image. (The default is given by whether or not the `-a` option was used.) +.. option:: --colorwriteplan + + Prints, without writing any file, the color metadata OIIO would write + for the current (top) image if it were output to a file of the named + format (e.g. ``png``, ``openexr``) -- and why. The report has one row + per color signal (CICP, chromaticities, gamma, ICC profile, color + interop ID, mastering display volume) giving the verdict (``write`` an + author-supplied value verbatim, ``derive`` a value from the color + space, ``suppress`` by policy, or ``omit``), the value that would be + written, and which layer decided it: the builtin default behavior, a + global ``oiio:colorpolicy:write:*`` attribute, a per-spec attribute on + the image, explicit metadata present on the image, or the format being + incapable of carrying the signal. + + Example:: + + oiiotool input.exr --colorwriteplan png + +.. option:: --colorreadplan + + Prints how the current (top) image's color metadata *was* resolved on + read -- the read-side twin of ``--colorwriteplan``. The report lists each + reconciliation rule the resolver tried, in precedence order (explicit + assignment, ACES container, file rules, color interop ID, CICP, ICC + profile, chromaticities, gamma, ...), its outcome (``matched``, + ``missed``, ``inapplicable``, or ``invalid``), and the candidate or reason + it recorded, followed by the final resolved color space and the rule that + decided it. No file is written. + + Example:: + + oiiotool input.png --colorreadplan + +.. option:: --colorinfo + + Prints the characterization information the color config can supply + *cheaply* for each color space in the comma-separated list (each entry + may be a color space name, role, alias, or color interop ID). If the + list is empty (``""``), the current (top) image's color space is + reported instead. The report has one row per field -- image state, + color interop ID, encoding, range, equality ID, chromaticities, + transfer function -- giving a marker (``available``, ``derived``, + ``unavailable``, or ``uncomputed``) and the value. This command never + probes transforms or derives missing information: a field no prior + query has derived simply prints as ``uncomputed``, and a field that was + attempted but has no usable value prints as ``unavailable`` (for + example, ``range`` is never guessed). An unknown name in the list is + reported as an error through the usual error convention. + + Example:: + + oiiotool --colorinfo "srgb_tx,ACEScg" + oiiotool input.exr --colorinfo "" + .. option:: --colorcount r1,g1,b1,...:r2,g2,b2,...:... Given a list of colors separated by colons or semicolons, where each @@ -4529,6 +4583,54 @@ will be printed with the command `oiiotool --colorconfiginfo`. This command was added in OIIO 2.4.6. +.. option:: --colorspacesearch + + Print to the console the names of the color spaces in the active + configuration whose derivable characteristics match a partial + description, one per line. This lets you ask "which color spaces in this + config have the Rec.709 gamut but are not scene-linear?" without knowing + the config's naming conventions. Each of the four hint axes is given as a + comma-separated list of terms through an appended modifier: + + - `chromaticities=` *terms*, `transfer_function=` *terms*, + `encoding=` *terms*, `image_state=` *terms* : + + The chromaticities (gamut), transfer-function, encoding, and + image-state axes. `chrm=` is an accepted shorthand for + `chromaticities=`, echoing PNG's `cHRM` chunk. A term + is matched by default; a leading `-` excludes proven matches; a leading + `~` keeps only spaces proven to have the opposite property; a backslash + escapes a leading operator. A returned space passes every axis that was + given. A term may be a color space name or alias, a known color interop + ID, or an axis-specific form (a gamut component such as `rec709`, a + transfer/curve name, an encoding name, or the image state `scene`, + `display`, or `all`). + + - `include_inactive=` *val*, `include_context_sensitive=` *val*, + `include_complex=` *val* : + + When nonzero, also consider (respectively) the config's inactive color + spaces, its context-sensitive spaces, and complex (non-simple) spaces + whose transforms are inspected exhaustively. All default to 0. + + - `authored_encoding_only=` *val* : + + When nonzero, the encoding axis matches only encodings authored in the + config. By default a candidate also matches through the encoding of + its interop-identity twin — both alongside an authored encoding and in + place of a missing one — so a LUT space tagged with a theatrical + interop ID but authored `sdr-video` matches searches for both + `sdr-video` and `sdr-cinema`. Defaults to 0. + + A term may not contain a comma. A term containing a colon (for example + the color interop ID namespaces `custom:*`, `icc:*`, or + `:local:*`) must be given as a quoted modifier value, since an + unquoted `:` is :program:`oiiotool`'s own option delimiter (remember + that the quotes themselves must survive your shell). Examples:: + + oiiotool --colorspacesearch:chromaticities=rec709:encoding=-scene-linear + oiiotool '--colorspacesearch:transfer_function="custom:acme:supercurve"' + .. option:: --colorconfig Instruct :program:`oiiotool` to read an OCIO configuration from a custom @@ -4573,6 +4675,36 @@ will be printed with the command `oiiotool --colorconfiginfo`. `:subimages=` *indices-or-names* Include/exclude subimages (see :ref:`sec-oiiotool-subimage-modifier`). +Cross-config color space names +------------------------------- + +If `--colorconvert` (or `--ociodisplay`) names a color space that your +current OCIO configuration does not define, but the name is one of a small +set of common, well-known color space identities that OpenImageIO +recognizes, the conversion is not necessarily a hard error. As long as your +configuration declares an `aces_interchange` role (directly, or via a scene +color space OIIO can identify as one of those well-known identities), +OpenImageIO can relate your configuration to the missing name through that +shared identity and complete the conversion. The same applies when the +*source* of an `--ociodisplay` transform is one of those well-known +identities but your display/view are local. In both cases, nothing about a +purely local conversion (both endpoints already defined by your +configuration) is affected. + +OCIO's own `strictparsing` configuration setting governs what happens when +this cannot be done: with strict parsing on, an unresolvable name is a hard +error exactly as before. With strict parsing off, OpenImageIO instead warns +and passes the image through unchanged, so a batch job does not stop over +one unresolved name. + +None of this prints anything to the console by default. If a conversion had +to be routed this way, or fell back to a pass-through, the reason is +recorded as an error string on the color configuration (surfaced through the +normal `oiiotool` error path on failure) and narrated on OpenImageIO's debug +channel (`OPENIMAGEIO_DEBUG=1`). A configuration that cannot be related to +any well-known identity at all is noted once per configuration on the debug +channel the first time a cross-config conversion is attempted against it. + .. option:: --tocolorspace Replace the current image with a new image whose pixels are transformed diff --git a/src/doc/pythonbindings.rst b/src/doc/pythonbindings.rst index 46c6ffe836..b223e58ec8 100644 --- a/src/doc/pythonbindings.rst +++ b/src/doc/pythonbindings.rst @@ -721,7 +721,12 @@ Section :ref:`sec-ImageSpec`, is replicated for Python. .. py:method:: bool ImageSpec.set_colorspace (name) Set metadata to indicate the presumed color space `name`, or clear all - such metadata if `name` is the empty string. + such metadata if `name` is the empty string. Asserting a different + space than the spec already claims also scrubs now-contradicting color + metadata (`colorInteropID`, `CICP`, `chromaticities`, `ICCProfile`, + gamma) and updates or erases any `oiio:ColorSpace:*` current-state + descriptors present, matching the hygiene the color-aware ImageBufAlgo + operations perform. This function was added in version 2.5. @@ -4001,6 +4006,23 @@ is provided for minimal color support. .. TODO: The documentation for this class is incomplete. +.. py:method:: resolve (name) + + Turn `name` -- a color space name, alias, role, OIIO-understood universal + name (like `"sRGB"`), or a recognized Color Interop ID form -- into a + canonical color space name of this config. + + If no syntactic tier recognizes the name, return `name` unchanged + (OpenImageIO's longstanding behavior). + + Example: + + .. code-block:: python + + colorconfig = oiio.ColorConfig() + canonical = colorconfig.resolve("acescg") + + .. py:method:: get_cicp (colorspace) Find CICP code corresponding to the colorspace. @@ -4114,7 +4136,11 @@ details. .. py:method:: set_colorspace (spec, name) Set the metadata of the `spec` to presume that color space is `name` (or - to assume nothing about the color space if `name` is empty). + to assume nothing about the color space if `name` is empty), applying + the same two-bucket metadata hygiene as `ImageSpec.set_colorspace`: + changing an existing claim scrubs now-contradicting color metadata and + updates or erases any `oiio:ColorSpace:*` current-state descriptors + present. Example: @@ -4461,5 +4487,3 @@ Alternatively, if you prefer using `uv `_, you .. code-block:: bash uv run --with jupyter jupyter lab - - diff --git a/src/doc/stdmetadata.rst b/src/doc/stdmetadata.rst index 70a3fd6e2f..3cd825a17e 100644 --- a/src/doc/stdmetadata.rst +++ b/src/doc/stdmetadata.rst @@ -158,6 +158,39 @@ Color information pixel values are known to be scene-linear and using facility-default color primaries as defined by the OpenColorIO configuration. +.. option:: "oiio:ColorSpace:state" : string + "oiio:ColorSpace:encoding" : string + "oiio:ColorSpace:range" : string + "oiio:ColorSpace:equality_id" : string + + Current-state descriptors of the color channels, maintained + automatically (best-effort) by the color-aware `ImageBufAlgo` + operations to describe the pixels as they now are: + + - `"oiio:ColorSpace:state"` : `"scene"` or `"display"` referred. + - `"oiio:ColorSpace:encoding"` : the color space's encoding, as + authored in (or derived for) the OpenColorIO config (e.g. + `"scene-linear"`, `"sdr-video"`). + - `"oiio:ColorSpace:range"` : `"full"` or `"narrow"` pixel value range. + Range describes pixel state: an operation that explicitly expands or + compresses range sets it, a range-preserving operation retains it, + and an ordinary color-space conversion never invents it. + - `"oiio:ColorSpace:equality_id"` : the mathematical-identity id + computed by a prior characterization of the color space, if one is + cached. A disparity between this and `"oiio:ColorSpace"` indicates + the metadata was changed by hand or resolution has not yet run. + + Each descriptor is present only when its value is actually known: + color-aware operations update a descriptor when the destination + space supplies a value and erase it otherwise -- values are never + guessed. These attributes describe current state and therefore + survive color operations (updated, not scrubbed), unlike + file-provenance attributes (`"colorInteropID"`, `"CICP"`, + `"ICCProfile"`, `"chromaticities"`, `"oiio:Gamma"`, + `"acesImageContainerFlag"`), which describe what a source file + claimed and are removed by any operation that changes the color + space. Set metadata overrides after the last color operation. + .. option:: "oiio:BorderColor" : float[nchannels] The color presumed to be filling any parts of the display/full image @@ -175,6 +208,9 @@ Color information - `[2]` : matrix coefficients - `[3]` : full range flag + See :ref:`chap-colorinterop` for how CICP relates to color interop IDs + and how individual format plugins read and write it. + .. option:: "ICCProfile" : uint8[] "ICCProfile:...various..." : ...various types... @@ -211,6 +247,28 @@ Disk file format info/hints unassociated with the color (i.e., color not "premultiplied" by the alpha coverage value). +.. option:: "oiio:SourceFormat" : string + + The name of the file format plugin (as returned by + `ImageInput::format_name()`) that read the pixels, e.g. `"openexr"` or + `"png"`. Deposited by the reader so that this information survives + `ImageBufAlgo` operations and `ImageCache` round-trips -- unlike + `ImageBuf::file_format_name()`, which only answers for the `ImageBuf` + instance that did the reading. Contains no private information, but + like other internal `"oiio:*"` metadata it is (currently) suppressed + by output plugins by default, so it does not yet round-trip through a + write; a future write-policy attribute may offer to preserve it. + +.. option:: "oiio:SourcePath" : string + + The filename or path that was opened to read the pixels. Deposited by + the reader for the same reason as `"oiio:SourceFormat"`, but unlike that + attribute, this one can reveal an absolute local filesystem path. It is + **not** written to output files by default (suppressed the same way + other internal `"oiio:*"` metadata is), so that writing an image never + silently leaks the path of a file that contributed to it. A future + write-policy attribute may offer an opt-in to preserve it. + .. option:: "planarconfig" : string `contig` indicates that the file has contiguous pixels (RGB RGB RGB...), diff --git a/src/fits.imageio/fitsoutput.cpp b/src/fits.imageio/fitsoutput.cpp index 9c74215b15..3b5c4ded08 100644 --- a/src/fits.imageio/fitsoutput.cpp +++ b/src/fits.imageio/fitsoutput.cpp @@ -4,6 +4,7 @@ #include +#include "color_pvt.h" #include "fits_pvt.h" OIIO_PLUGIN_NAMESPACE_BEGIN @@ -166,6 +167,13 @@ FitsOutput::create_fits_header(void) std::string header; create_basic_header(header); + // Feature 1 (spec 09): FITS has no native colorInteropID slot. Under the + // force_interop_id policy, stamp the derived id so the keyword loop below + // carries it; otherwise strip it so the file stays untagged. Must run + // BEFORE the loop -- every STRING attribute becomes a header card, so an + // authored id would otherwise leak past write:interop_id=never. + pvt::apply_forced_interop_id(m_spec, "fits", m_filename); + //we add all keywords stored in ImageSpec to the FITS file for (size_t i = 0; i < m_spec.extra_attribs.size(); ++i) { std::string keyname = m_spec.extra_attribs[i].name().string(); diff --git a/src/ico.imageio/icoinput.cpp b/src/ico.imageio/icoinput.cpp index f25740415d..2c60de03b5 100644 --- a/src/ico.imageio/icoinput.cpp +++ b/src/ico.imageio/icoinput.cpp @@ -241,7 +241,8 @@ ICOInput::seek_subimage(int subimage, int miplevel) png_set_sig_bytes(m_png, 8); // already read 8 bytes bool ok = PNG_pvt::read_info(m_png, m_info, m_bpp, m_color_type, - m_interlace_type, m_bg, m_spec, true); + m_interlace_type, m_bg, m_spec, true, + nullptr); if (!ok || m_err || !check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 4 })) { return false; diff --git a/src/include/OpenImageIO/color.h b/src/include/OpenImageIO/color.h index e12b39292a..6ba79b6adb 100644 --- a/src/include/OpenImageIO/color.h +++ b/src/include/OpenImageIO/color.h @@ -4,6 +4,11 @@ #pragma once +#include +#ifdef OIIO_INTERNAL +# include +# include +#endif #include #include @@ -74,6 +79,299 @@ using ColorProcessorHandle = std::shared_ptr; /// NOTE: ColorConfig(s) and ColorProcessor(s) are potentially heavy-weight. /// Their construction / destruction should be kept to a minimum. +#ifdef OIIO_INTERNAL +namespace pvt { +// Access shim letting the internal color-space classification test hooks +// (see color_pvt.h) reach ColorConfig's private implementation. Only +// visible when building OIIO itself (same pattern as deepdata.h); the +// installed public header exposes no new symbol. +struct ColorConfigClassificationPeek; +} // namespace pvt +#endif + + +#ifdef OIIO_INTERNAL + +/// Options controlling ColorConfig::find_color_spaces(). The four hint axes +/// are passed separately; this bundles the search-scope toggles and the +/// per-call context. Default-constructed options give the default search +/// (active, simple, context-invariant spaces; encoding axis may use the +/// interop-identity twin). +/// +/// @version 3.2 +struct ColorSpaceSearchOptions { + /// Also consider the config's inactive color spaces. + bool include_inactive = false; + /// Also consider spaces whose transforms depend on context variables. + bool include_context_sensitive = false; + /// Also consider complex (non-simple) spaces by inspecting their + /// authored and realized transforms. + bool include_complex = false; + /// Limit the encoding axis to encodings explicitly authored in the + /// config -- no inference through a space's interop-identity twin. + bool authored_encoding_only = false; + /// OCIO context-variable overrides, scoped to the one call. + std::map context; + /// Name of a config-declared color policy profile to interpret this + /// call under. Accepted and currently ignored; honored when the policy + /// layer lands. (3.2.0) + std::string profile; + /// Inline color-policy overrides for this call, in the + /// `oiio:colorpolicy:*` grammar. These are policies, NOT OCIO context + /// variables -- `context` above remains the context mechanism. + /// Accepted and currently ignored; honored when the policy layer + /// lands. (3.2.0) + std::string policies; +}; + + +/// The individually queryable fields of a ColorSpaceInfo record, for the +/// per-field computed()/available()/derived() cost-visibility queries. +/// +/// @version 3.2 +enum class ColorSpaceInfoField : uint32_t { + EqualityID, + ColorInteropID, + Encoding, + ImageState, + Range, + Chromaticities, + TransferFunction, +}; + + +/// Semantic classification of a color space's transfer function, as +/// reported by ColorSpaceInfo::transfer_function_kind(). +/// +/// @version 3.2 +enum class ColorTransferFunctionKind : uint8_t { + Undetermined, ///< not determined (or not yet attempted) + Linear, ///< linear/identity curve + Named, ///< a recognized named family (see transfer_function()) + Sampled, ///< successfully sampled behavior, but no known family +}; + + +/// Per-call context for color-space characterization queries +/// (ColorConfig::get_color_space_info). Default construction uses the +/// ColorConfig's current context. +/// +/// @version 3.2 +struct ColorSpaceInfoOptions { + /// OCIO context-variable overrides, scoped to the one call. + std::map context; + /// Name of a config-declared color policy profile to interpret this + /// call under. Accepted and currently ignored; honored when the policy + /// layer lands. (3.2.0) + std::string profile; + /// Inline color-policy overrides for this call, in the + /// `oiio:colorpolicy:*` grammar. These are policies, NOT OCIO context + /// variables -- `context` above remains the context mechanism. + /// Accepted and currently ignored; honored when the policy layer + /// lands. (3.2.0) + std::string policies; +}; + + +/// Options controlling ColorConfig::serialize(). +/// +/// @version 3.2 +struct ColorConfigSerializeOptions { + /// Serialize the interoperability-repaired in-memory copy of the + /// config (the one cross-config conversions actually route through) + /// instead of the original: evidence of what is in memory, not what + /// was on disk. May trigger the lazy interoperability bootstrap. + bool interopified = false; +}; + + +/// Options describing how ColorConfig::evolve() should modify the copy it +/// returns. Default-constructed options request a plain copy. +/// +/// @version 3.2 +struct ColorConfigEvolveOptions { + /// If non-empty, the evolved config's working directory (used by OCIO + /// to resolve relative FileTransform sources and search paths, and + /// required for archiving). + std::string working_dir; + /// Default-value overrides for the config's context variables + /// (OCIO environment vars), as key -> value. + std::map context; + /// Start from the ORIGINAL config this one was first constructed with, + /// discarding the modifications of any previous evolve() steps, before + /// applying the fields above. + bool reset = false; +}; + + +/// Options controlling ColorConfig::archive(). +/// +/// @version 3.2 +struct ColorConfigArchiveOptions { + /// If non-empty, archive as if the config's working directory were this + /// directory (OCIO gathers the LUT files under the working directory + /// into the archive). Required when the config has no working directory + /// of its own (e.g. one built by ColorConfig::from_text without one). + std::string working_dir; + /// Archive the interoperability-repaired in-memory copy of the config + /// instead of the original. May trigger the lazy interop bootstrap. + bool interopified = false; +}; + + +/// Options controlling ColorConfig::get_debug_info(). There are no options +/// yet; the struct exists so future report selectors can be added without +/// changing the method signature. It has no other caller, so it belongs +/// with get_debug_info() and the two types below -- if that method ever +/// moves, this moves with it rather than being stranded here empty. +/// +/// @version 3.2 +struct ColorConfigDebugInfoOptions {}; + + +/// State of a config's scene-interchange discovery, as reported by +/// ColorConfigDebugInfo::interchange_state. `Pending` is a distinct, +/// load-bearing value: querying the debug info never triggers the lazy +/// discovery, so a discovery that has not run yet reports as pending +/// rather than as a negative result. +/// +/// @version 3.2 +enum class ColorInterchangeState : uint8_t { + Pending, ///< discovery has not run (querying does not trigger it) + Interoperable, ///< a scene interchange space was identified + NotFound, ///< discovery ran and identified none +}; + + +/// A config's identity and cache state, as returned by +/// ColorConfig::get_debug_info(), for diagnostics and bug reports. Every +/// field is formatted from existing internal state: constructing this +/// never triggers lazy work. +/// +/// @version 3.2 +struct OIIO_API ColorConfigDebugInfo { + /// OpenImageIO version string. + std::string oiio_version; + /// OpenColorIO version string (empty if OCIO is unavailable). + std::string ocio_version; + /// This config's name (see ColorConfig::configname()). + std::string config_name; + /// The config's structural cache identity -- context excluded. + std::string structural_cache_id; + /// OCIO's cache id for the config, with the context folded in. + std::string cache_id; + /// Data version of the built-in interop identities registry. + std::string registry_data_version; + + /// Whether a scene interchange space has been identified for this + /// config, or whether that discovery has simply not run yet. + ColorInterchangeState interchange_state = ColorInterchangeState::Pending; + /// The identified scene interchange space name; empty unless + /// `interchange_state` is `Interoperable`. + std::string interchange_name; + + /// Per-cache-layer entry counts, keyed by layer name (e.g. + /// "color processors", "fingerprints", "characterizations"). A map, + /// not one fixed field per layer, deliberately: cache layers come and + /// go, and the CONTENTS of a map are not ABI, whereas a field per + /// layer would make every future cache change an ABI break. Treat the + /// key set as informational, not as a contract. + std::map cache_entries; + + /// Render all of the above as a human-readable multi-line report, so a + /// bug report has one thing to paste. The exact text is informational + /// and may change between versions -- display it, don't parse it. + std::string to_string() const; +}; + + +/// Options controlling ColorConfig::clear_caches(). There are no options +/// yet; the struct exists so future selectors can be added without +/// changing the method signature. +/// +/// @version 3.2 +struct ColorConfigClearCachesOptions {}; + + +/// Immutable snapshot of the computed characterization information for one +/// resolved color space, as returned by ColorConfig::get_color_space_info(). +/// Accessor views remain valid for this object's lifetime. Copies are cheap +/// (shared immutable state). A later, more complete characterization updates +/// the information seen by future queries; it never mutates a snapshot +/// already held by a caller. +/// +/// Each field carries per-field cost visibility: `computed(field)` reports +/// whether determination of the field has been attempted at all, +/// `available(field)` whether the attempt produced a usable value, and +/// `derived(field)` whether that value required behavioral derivation +/// (probing transforms) rather than direct config/registry inspection. A +/// computed-but-unavailable field is a stable negative result, not an error. +/// +/// @version 3.2 +class OIIO_API ColorSpaceInfo { +public: + ColorSpaceInfo(); + ~ColorSpaceInfo(); + ColorSpaceInfo(const ColorSpaceInfo&); + ColorSpaceInfo(ColorSpaceInfo&&) noexcept; + ColorSpaceInfo& operator=(const ColorSpaceInfo&); + ColorSpaceInfo& operator=(ColorSpaceInfo&&) noexcept; + + /// False only for the default object or a failed/unknown query. + OIIO_NODISCARD bool valid() const noexcept; + + /// Canonical local color-space name. Empty when !valid(). + OIIO_NODISCARD string_view name() const noexcept; + + /// Mathematical identity determined by fingerprint equivalence. + /// It deliberately ignores authored interop_id and name coincidence. + OIIO_NODISCARD string_view equality_id() const noexcept; + + /// Authoritative/write Color Interop ID. Declaration-first semantics. + OIIO_NODISCARD string_view color_interop_id() const noexcept; + + /// Effective encoding: authored value first, otherwise a derived + /// interop-counterpart value. + OIIO_NODISCARD string_view encoding() const noexcept; + + /// "scene" or "display"; empty when undetermined. + OIIO_NODISCARD string_view image_state() const noexcept; + + /// "full" or "narrow"; empty when not intrinsic/determinable. Range + /// describes pixel state and is never guessed from a color-space name. + OIIO_NODISCARD string_view range() const noexcept; + + /// Eight floats in Rx,Ry,Gx,Gy,Bx,By,Wx,Wy order, or an empty span. + OIIO_NODISCARD cspan chromaticities() const noexcept; + + OIIO_NODISCARD ColorTransferFunctionKind + transfer_function_kind() const noexcept; + + /// Normalized family such as "srgb" or "g24"; empty for an + /// undetermined or sampled-but-unnamed transfer function. + OIIO_NODISCARD string_view transfer_function() const noexcept; + + /// Whether determination of this field has been attempted. + OIIO_NODISCARD bool computed(ColorSpaceInfoField field) const noexcept; + + /// Whether the attempted field has a usable value. + OIIO_NODISCARD bool available(ColorSpaceInfoField field) const noexcept; + + /// Whether the published value required behavioral derivation rather + /// than direct config/registry inspection. + OIIO_NODISCARD bool derived(ColorSpaceInfoField field) const noexcept; + +private: + class Impl; + std::shared_ptr m_impl; + + explicit ColorSpaceInfo(std::shared_ptr); + friend class ColorConfig; +}; + +#endif // OIIO_INTERNAL + + class OIIO_API ColorConfig { public: /// Construct a ColorConfig using the named OCIO configuration file, @@ -86,6 +384,12 @@ class OIIO_API ColorConfig { ~ColorConfig(); +#ifdef OIIO_INTERNAL + // Internal move support for the from-memory factories. + ColorConfig(ColorConfig&& other) noexcept; + ColorConfig& operator=(ColorConfig&& other) noexcept; +#endif + /// Reset the config to the named OCIO configuration file, or if /// filename is empty, to the current color configuration specified /// by env variable $OCIO. Return true for success, false if there @@ -409,10 +713,50 @@ class OIIO_API ColorConfig { /// Turn the name, which could be a color space, an alias, a role, or /// an OIIO-understood universal name (like "sRGB") into a canonical - /// color space name. If the name is not recognized, return "". + /// color space name. + /// + /// When a direct color space / role / alias lookup does not recognize the + /// name, resolve() additionally understands several syntactic forms of a + /// Color Interop ID (see the Color Interop Forum recommendation "An ID for + /// Color Interop", https://github.com/AcademySoftwareFoundation/ColorInterop/wiki), + /// tried in this order: + /// - a namespaced id (e.g. "studio:acescg") is retried with one leading + /// namespace stripped; + /// - a ":local:" id resolves against this config's own + /// color space names/aliases when "" matches this config's name; + /// - an id equal to a color space's explicit `interop_id` attribute, or + /// to it with exactly one side's namespace stripped (OCIO 2.5+). This + /// tier is an OpenImageIO extension, not part of the recommendation's + /// search: the recommendation states that the `interop_id` attribute + /// is *not* used when searching a config, because the same id may + /// legally appear on several color spaces and the config author + /// expresses precedence through aliases. This tier runs only after + /// the name/alias tiers above have all missed, so it never overrides + /// that precedence -- it only makes an id reachable that no alias + /// claimed; + /// - the utility token "data" (and, as an OpenImageIO extension not + /// defined by the CIF recommendation, "bypass") resolves to a ranked + /// data color space (while "unknown" only matches a literal color + /// space name/alias). + /// If none of these recognize the name, the name is returned unchanged. OIIO_NODISCARD string_view resolve(string_view name) const; - /// Are the two color space names/aliases/roles equivalent? +#ifdef OIIO_INTERNAL + /// Like resolve(name), but a name that no tier recognizes returns + /// `failover` instead of the name unchanged. Passing an empty failover + /// therefore distinguishes "resolved" from "not recognized", which the + /// 1-arg overload's historical passthrough cannot. The returned view is + /// either a view of long-lived config/registry storage (a hit) or the + /// caller's own `failover` (a miss). + /// + /// @version 3.2 + OIIO_NODISCARD string_view resolve(string_view name, + string_view failover) const; +#endif + + /// Are the two color space names/aliases/roles equivalent? Each name is + /// resolve()d first, so color interop IDs and aliases participate on either + /// side. OIIO_NODISCARD bool equivalent(string_view color_space, string_view other_color_space) const; @@ -422,7 +766,19 @@ class OIIO_API ColorConfig { /// @version 3.1 OIIO_NODISCARD cspan get_cicp(string_view colorspace) const; - /// Find color interop ID for the given colorspace. + /// Find the Color Interop ID for the given colorspace (see the Color + /// Interop Forum recommendation "An ID for Color Interop", + /// https://github.com/AcademySoftwareFoundation/ColorInterop/wiki). This + /// is a CHEAP lookup: it returns an author-declared `interop_id` + /// attribute on the resolved space (unconditionally authoritative, OCIO + /// 2.5+; a data space with no declared token yields "data"), else a + /// name/alias match against the built-in id/CICP table, else the empty + /// string. It never probes transforms, builds processors, or + /// manufactures an id -- an unidentified space is simply "" here, never + /// a guessed default. The full (expensive) derivation cascade -- + /// fingerprint equivalence against the built-in registry identities and + /// config-local id generation -- runs at write-planning time when a file + /// is written, not inside this getter. /// Returns empty string if not found. /// /// @version 3.1 @@ -435,22 +791,317 @@ class OIIO_API ColorConfig { /// @version 3.1 OIIO_NODISCARD string_view get_color_interop_id(const int cicp[4]) const; +#ifdef OIIO_INTERNAL + /// Search the config for color spaces matching a partial color-space + /// characterization, and return their names ordered deterministically by + /// (context-invariant, active, simple, name). + /// + /// Each of the four hint axes -- `chromaticities`, `transfer_function`, + /// `encoding`, and `image_state` -- is a list of terms; an empty axis is + /// unconstrained. Within an axis a term is by default an *include*, a + /// leading `-` makes it an *exclude*, and a leading `~` makes it an + /// *inverse* (match only spaces proven to have the opposite property); a + /// backslash escapes a leading operator (`\-`, `\~`, `\\`). A returned + /// space passes every non-empty axis. Each term is resolved with the + /// precedence: exact local color space name or alias, then known interop + /// ID, then an axis-specific fallback (a gamut component fragment such as + /// `rec709`; a named transform or transfer triple; a literal encoding; the + /// image state `scene`, `display`, or `all`). A candidate whose property + /// cannot be derived is treated as *unknown*, never an error. A malformed + /// term or an unresolvable hint is reported through the usual ColorConfig + /// error convention (has_error() / geterror()) -- before any candidate is + /// examined -- and the search returns an empty list. This method does not + /// throw. + /// + /// By default the search considers the config's active, simple color + /// spaces. `options.include_inactive` also considers inactive spaces; + /// `options.include_context_sensitive` also considers spaces whose + /// transforms depend on context variables; `options.include_complex` also + /// considers complex (non-simple) spaces by inspecting their authored and + /// realized transforms. `options.context` applies OCIO context-variable + /// overrides scoped to this call only. + /// + /// On the encoding axis a candidate characterizes as its authored + /// encoding attribute and, additionally, as the encoding of its + /// interop-identity twin (so a LUT space tagged with a theatrical + /// interop ID but authored `sdr-video` matches searches for both + /// `sdr-video` and `sdr-cinema`); a candidate with no authored encoding + /// adopts the twin's outright. `options.authored_encoding_only` limits + /// the encoding axis to authored attributes only. + /// + /// Current limitations (each a documented behavior, not a defect): + /// probe-derived chromaticities assume a single D65 + Bradford hypothesis, + /// so a non-D65 or log-curve space whose gamut is not in the reserved + /// chromaticity table may resolve as unknown; registry-side gamuts are + /// taken from that reserved table only, so a gamut absent from it (e.g. + /// `ciexyzd65`) is not yet resolvable. Interop IDs that contain a colon + /// (such as `custom:*` or `icc:*`) must be quoted when passed through the + /// `oiiotool --colorspacesearch` flag, whose modifier syntax otherwise + /// treats `:` as its option delimiter. + /// + /// @version 3.2 + OIIO_NODISCARD std::vector + find_color_spaces(cspan chromaticities = {}, + cspan transfer_function = {}, + cspan encoding = {}, + cspan image_state = {}, + const ColorSpaceSearchOptions& options = {}) const; + /// Retrieve the characterization information OIIO can supply CHEAPLY for + /// the named color space (which may be a name, role, alias, or Color + /// Interop ID): the canonical local name, the image state, the cheap + /// Color Interop ID subset (declared attribute or built-in table match, + /// exactly what get_color_interop_id() returns), the authored encoding, + /// and the intrinsic range when explicitly known. Any characterization + /// facts a previous, more expensive query already derived and cached + /// (equality ID, chromaticities, transfer function, derived encoding) + /// are merged into the returned snapshot; this method itself performs + /// only direct or cached work -- it never probes transforms, builds a + /// processor, or computes a fingerprint, and it never silently derives + /// a missing field. Uncached derivable fields simply report + /// `computed(field) == false`. + /// + /// For an unknown or unresolvable name, the returned object has + /// `valid() == false` and an error is reported through the usual + /// has_error()/geterror() convention. This method does not throw. + /// + /// @version 3.2 + OIIO_NODISCARD ColorSpaceInfo + get_color_space_info(string_view color_space, + const ColorSpaceInfoOptions& options = {}) const; + + /// Batch version of get_color_space_info(): one record per requested + /// name, in input order (duplicates included). Every requested name is + /// validated before any record is built; if any input is invalid, one + /// indexed error (e.g. `get_color_space_infos[3]: unknown color space + /// "..."`) is reported through has_error()/geterror() and an empty + /// vector is returned. An empty input span is an empty batch, not "all + /// spaces". This method does not throw. + /// + /// The batch spelling is deliberately distinct (plural, matching the + /// Python binding) rather than an overload: span's one-element + /// converting constructor would otherwise make a `std::string` lvalue + /// argument ambiguous between the scalar and batch forms. + /// + /// @version 3.2 + OIIO_NODISCARD std::vector + get_color_space_infos(cspan color_spaces, + const ColorSpaceInfoOptions& options = {}) const; + + /// Derive the complete characterization of the named color space (which + /// may be a name, role, alias, or Color Interop ID): every field of the + /// returned ColorSpaceInfo has been attempted, by full derivation where + /// direct inspection does not answer -- fingerprint equivalence for the + /// equality ID, the full interop-ID derivation cascade, the + /// interop-counterpart encoding fallback, chromaticity probing, and + /// transfer-function characterization. This is the EXPENSIVE + /// counterpart of get_color_space_info(): it may build OCIO processors + /// and probe transforms. Completed derivations (successful and + /// negative) are cached, so later queries -- including the cheap getter + /// -- see the derived facts without recomputing them; records already + /// held by callers are immutable snapshots and are not affected. + /// + /// "Complete" does not mean every field is available: an + /// uncharacterizable field reports `computed(field) == true` with + /// `available(field) == false`, a stable negative result rather than an + /// error. Range in particular is supplied only when intrinsic to a + /// registered identity or otherwise explicitly known -- it is never + /// guessed from a color-space name. + /// + /// For an unknown or unresolvable name, the returned object has + /// `valid() == false` and an error is reported through the usual + /// has_error()/geterror() convention. This method does not throw. + /// + /// @version 3.2 + OIIO_NODISCARD ColorSpaceInfo + derive_color_space_info(string_view color_space, + const ColorSpaceInfoOptions& options = {}) const; + + /// Batch version of derive_color_space_info(): one record per requested + /// name, in input order (duplicates included). Every requested name is + /// validated before any record is derived; if any input is invalid, one + /// indexed error (e.g. `derive_color_space_infos[3]: unknown color + /// space "..."`) is reported through has_error()/geterror() and an + /// empty vector is returned. Per-field derivation failure remains an + /// unavailable field, never a failed batch. An empty input span is an + /// empty batch, not "all spaces". This method does not throw. (The + /// batch name is plural for the same overload-ambiguity reason as + /// get_color_space_infos().) + /// + /// @version 3.2 + OIIO_NODISCARD std::vector + derive_color_space_infos(cspan color_spaces, + const ColorSpaceInfoOptions& options = {}) const; + + /// The canonical Color Interop Forum IDs declared by OpenImageIO's + /// built-in interop identities registry (see the CIF recommendation + /// "An ID for Color Interop", + /// https://github.com/AcademySoftwareFoundation/ColorInterop/wiki), in + /// deterministic (sorted) registry order. Each is usable anywhere a + /// `string_view` CIID is accepted -- as the argument to resolve() or + /// equivalent(), or compared against get_color_interop_id()'s return + /// value. + /// + /// Static because the builtin IDs come from the embedded registry, not + /// from any config: there is no instance to consult. The returned + /// storage has process lifetime, so the span stays valid and repeated + /// calls return the same data. + /// + /// This is registry *data*, not an exhaustive ID grammar: raw strings + /// remain first-class for the ids no finite set can enumerate + /// (local/custom/icc/user-namespaced). + /// + /// @version 3.2 + OIIO_NODISCARD static cspan get_builtin_interop_ids(); + + /// Convenience alias so callers may spell the options type + /// `ColorConfig::SerializeOptions`. + using SerializeOptions = ColorConfigSerializeOptions; + + /// Return the config serialized as OCIO YAML text (a wrapper around + /// OCIO's Config::serialize()). This is the text of the IN-MEMORY config + /// object -- including any construction-time fix-ups OIIO applied -- not + /// a copy of the file it was loaded from. With + /// `options.interopified = true`, serialize the interoperability-repaired + /// in-memory copy instead. On failure (no usable config, or an OCIO + /// serialization error), return an empty string and report the error + /// through the usual has_error()/geterror() convention. This method does + /// not throw. + /// + /// @version 3.2 + OIIO_NODISCARD std::string + serialize(const SerializeOptions& options = {}) const; + + /// Construct a ColorConfig from the OCIO YAML text of a config held in + /// memory (a wrapper around OCIO's Config::CreateFromStream), rather + /// than from a file. `working_dir`, if non-empty, sets the config's + /// working directory, which OCIO uses to resolve relative FileTransform + /// sources and search paths (CreateFromStream itself sets none) and + /// which archive() requires. Like the file constructor, this does not + /// throw: on failure the returned object reports the problem through + /// the usual has_error()/geterror() convention. + /// + /// @version 3.2 + OIIO_NODISCARD static ColorConfig from_text(string_view config_text, + string_view working_dir = ""); + + /// Convenience alias so callers may spell the options type + /// `ColorConfig::EvolveOptions`. + using EvolveOptions = ColorConfigEvolveOptions; + + /// Return a NEW ColorConfig that is a copy of this one with the + /// modifications described by `options` applied -- the public face of + /// the copy-on-modify contract: this config is frozen and is never + /// mutated; the evolved instance is an independent config with its own + /// caches. `options.context` overrides context-variable defaults (which + /// changes the config's structural cache identity, since the overrides + /// serialize with it); `options.working_dir` re-points runtime file + /// resolution (working directory is runtime state OCIO does not fold + /// into the structural cache id); `options.reset` starts from the + /// ORIGINAL config this one was first constructed with before applying + /// the other fields, so an evolve chain can always get back to its + /// root. On failure the returned config reports the problem through + /// the usual has_error()/geterror() convention. This method does not + /// throw. + /// + /// @version 3.2 + OIIO_NODISCARD ColorConfig evolve(const EvolveOptions& options = {}) const; + + /// Convenience alias so callers may spell the options type + /// `ColorConfig::ArchiveOptions`. + using ArchiveOptions = ColorConfigArchiveOptions; + + /// Archive the config and the LUT files it depends on into `filename` + /// as an OCIO config archive (a wrapper around OCIO's + /// Config::archive(); the conventional extension is `.ocioz`, readable + /// wherever OCIO configs are accepted, including the ColorConfig + /// filename constructor). It is the IN-MEMORY config object that is + /// archived, together with every candidate LUT file under the working + /// directory; `options.working_dir` overrides the working directory for + /// the one archive operation, and `options.interopified` archives the + /// interoperability-repaired in-memory copy instead. Return true on + /// success; on failure (no usable config, a config OCIO deems + /// unarchivable, or an I/O error) return false and report the error + /// through the usual has_error()/geterror() convention. This method + /// does not throw. + /// + /// @version 3.2 + OIIO_NODISCARD_ERROR bool archive(string_view filename, + const ArchiveOptions& options = {}) const; + + /// Convenience alias so callers may spell the options type + /// `ColorConfig::DebugInfoOptions`. + using DebugInfoOptions = ColorConfigDebugInfoOptions; + + /// Return this config's identity and cache state, for diagnostics and + /// bug reports: the OpenImageIO and OpenColorIO versions, the config's + /// name and cache identities, the interoperability (interchange + /// discovery) state, the built-in interop registry data version, and + /// per-layer cache entry counts. This reads existing internal state + /// only: it never triggers lazy work, so a discovery that has not yet + /// run reports as `ColorInterchangeState::Pending`. + /// `ColorConfigDebugInfo::to_string()` renders the same + /// human-readable report for pasting into a bug report. `options` is + /// reserved for future report selectors. + /// + /// @version 3.2 + OIIO_NODISCARD ColorConfigDebugInfo + get_debug_info(const DebugInfoOptions& options = {}) const; + + /// Convenience alias so callers may spell the options type + /// `ColorConfig::ClearCachesOptions`. + using ClearCachesOptions = ColorConfigClearCachesOptions; + + /// Drop cached derived state for this config: its per-instance color + /// processor cache, and the entries scoped to this config's cache + /// identity in the process-global fingerprint and characterization + /// memo caches. Clearing is semantics-free -- every cache repopulates + /// on demand -- so the only observable effects are memory and + /// recompute time (get_debug_info() reports the entry counts). Shared + /// process data not scoped to this config (e.g. the built-in interop + /// registry) is unaffected. `options` is reserved for future + /// selectors. + /// + /// @version 3.2 + void clear_caches(const ClearCachesOptions& options = {}) const; +#endif + /// Return a filename or other identifier for the config we're using. OIIO_NODISCARD std::string configname() const; /// Set the spec's metadata to presume that color space is `name` (or to /// assume nothing about the color space if `name` is empty). The core - /// operation is to set the "oiio:ColorSpace" attribute, but it also removes - /// or alters several other attributes that may hint color space in ways that - /// might be contradictory or no longer true. + /// operation is to set the "oiio:ColorSpace" attribute, and the + /// surrounding metadata maintenance follows the two-bucket color + /// metadata hygiene: /// - /// @version 3.0 + /// - Asserting a different space than the spec already claims scrubs + /// the *file-provenance facts* that described the old claim + /// (`colorInteropID`, `CICP`, `chromaticities`, `ICCProfile`, + /// `oiio:Gamma`, `acesImageContainerFlag` -- the deliberate + /// `*:unknown` marker family excepted), and maintains any + /// *current-state descriptors* the spec carries + /// (`oiio:ColorSpace:state` / `:encoding` / `:range` / + /// `:equality_id`): each is updated from the cheap characterization + /// of the new space when available, erased when not -- never + /// guessed, never derived by this call. + /// - An empty `name` erases everything: the verdict, the provenance + /// facts, and the descriptors (absence semantics -- assume nothing). + /// - First tagging (no previous claim) leaves the provenance facts in + /// place: at read time the claim is routinely derived from those + /// very facts, which are evidence for it, not contradictions. + /// - Re-asserting the space the spec already claims is a no-op. + /// + /// A few format-specific hints that may contradict the new claim are + /// also removed in all cases (`Exif:ColorSpace` unless `name` is + /// sRGB-equivalent, `tiff:ColorSpace`, `tiff:PhotometricInterpretation`). + /// + /// @version 3.0 (two-bucket hygiene since 3.2) void set_colorspace(ImageSpec& spec, string_view name) const; /// Set the spec's metadata to reflect Rec709 color primaries and the given - /// gamma. The core operation is to set the "oiio:ColorSpace" attribute, but - /// it also removes or alters several other attributes that may hint color - /// space in ways that might be contradictory or no longer true. + /// gamma. The core operation is to set the "oiio:ColorSpace" attribute + /// (via set_colorspace(), whose metadata hygiene applies), and + /// additionally record the given gamma as "oiio:Gamma". /// /// @version 3.0 void set_colorspace_rec709_gamma(ImageSpec& spec, float gamma) const; @@ -473,9 +1124,18 @@ class OIIO_API ColorConfig { ColorConfig(const ColorConfig&) = delete; ColorConfig& operator=(const ColorConfig&) = delete; + // Tag for the internal allocate-but-don't-initialize constructor used + // by the from-memory factories (from_text, evolve). + struct UninitTag {}; + explicit ColorConfig(UninitTag); + class Impl; std::unique_ptr m_impl; Impl* getImpl() const { return m_impl.get(); } + +#ifdef OIIO_INTERNAL + friend struct pvt::ColorConfigClassificationPeek; +#endif }; OIIO_NAMESPACE_3_1_END diff --git a/src/include/OpenImageIO/imageio.h b/src/include/OpenImageIO/imageio.h index ef318c7e00..7a2218e050 100644 --- a/src/include/OpenImageIO/imageio.h +++ b/src/include/OpenImageIO/imageio.h @@ -891,9 +891,15 @@ class OIIO_API ImageSpec { /// Set the metadata to presume that color space is `name` (or to assume /// nothing about the color space if `name` is empty). The core operation - /// is to set the "oiio:ColorSpace" attribute, but it also removes or - /// alters several other attributes that may hint color space in ways that - /// might be contradictory or no longer true. + /// is to set the "oiio:ColorSpace" attribute; the surrounding metadata + /// maintenance routes through the process-default color config's + /// `ColorConfig::set_colorspace()`, which applies the two-bucket color + /// metadata hygiene (see its documentation): changing an existing claim + /// scrubs the now-stale file-provenance facts (colorInteropID, CICP, + /// chromaticities, ICC profile, gamma) and updates-or-erases any + /// `oiio:ColorSpace:*` current-state descriptors present; an empty + /// `name` erases all color metadata. This convenience method + /// additionally removes a `CICP` attribute in all cases. /// /// @version 2.5 void set_colorspace(string_view name); @@ -4310,10 +4316,14 @@ inline string_view get_string_attribute (string_view name, /// Set the metadata of the `spec` to presume that color space is `name` (or /// to assume nothing about the color space if `name` is empty). The core -/// operation is to set the "oiio:ColorSpace" attribute, but it also removes -/// or alters several other attributes that may hint color space in ways that -/// might be contradictory or no longer true. This uses the current default -/// color config to adjudicate color space name equivalencies. +/// operation is to set the "oiio:ColorSpace" attribute, applying the +/// two-bucket color metadata hygiene documented on +/// `ColorConfig::set_colorspace()`: changing an existing claim scrubs the +/// now-stale file-provenance facts (colorInteropID, CICP, chromaticities, +/// ICC profile, gamma) and updates-or-erases any `oiio:ColorSpace:*` +/// current-state descriptors present; an empty `name` erases all color +/// metadata. This uses the current default color config to adjudicate color +/// space name equivalencies. /// /// @version 3.0 OIIO_API void set_colorspace(ImageSpec& spec, string_view name); diff --git a/src/include/OpenImageIO/nsversions.h b/src/include/OpenImageIO/nsversions.h index 51ae50777f..0760420fde 100644 --- a/src/include/OpenImageIO/nsversions.h +++ b/src/include/OpenImageIO/nsversions.h @@ -11,6 +11,8 @@ # error "oiioversion.h must always be included before nsversions.h" #endif +#include // fixed-width underlying types of forward-declared enums + // Establish the namespaces. // @@ -162,6 +164,15 @@ OIIO_NAMESPACE_3_1_BEGIN // libOpenImageIO_Util class ArgParse; class ColorConfig; +#ifdef OIIO_INTERNAL +struct ColorSpaceSearchOptions; +class ColorSpaceInfo; +struct ColorSpaceInfoOptions; +enum class ColorSpaceInfoField : uint32_t; +enum class ColorTransferFunctionKind : uint8_t; +enum class ColorInterchangeState : uint8_t; +struct ColorConfigDebugInfo; +#endif class ColorProcessor; class ErrorHandler; class Filter1D; @@ -208,6 +219,15 @@ OIIO_NAMESPACE_BEGIN // libOpenImageIO_Util using v3_1::ArgParse; using v3_1::ColorConfig; +#ifdef OIIO_INTERNAL +using v3_1::ColorSpaceSearchOptions; +using v3_1::ColorSpaceInfo; +using v3_1::ColorSpaceInfoOptions; +using v3_1::ColorSpaceInfoField; +using v3_1::ColorTransferFunctionKind; +using v3_1::ColorInterchangeState; +using v3_1::ColorConfigDebugInfo; +#endif using v3_1::ColorProcessor; using v3_1::ErrorHandler; using v3_1::Filter1D; diff --git a/src/include/color_pvt.h b/src/include/color_pvt.h new file mode 100644 index 0000000000..aa2b25e25d --- /dev/null +++ b/src/include/color_pvt.h @@ -0,0 +1,1553 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + + +/// \file +/// Internal (library-private) declarations for the color-interop domain: +/// the read-side color-metadata resolver, the write-side plan, the color +/// policy snapshots, the interop-id grammar, the pure characterization +/// primitives (curve family, chromaticities, transfer signatures), ICC +/// byte-level inspection, mastering-volume derivation, and the internal / +/// test-only hooks into ColorConfig's classification, fingerprint, +/// interoperability, and search machinery. Split out of imageio_pvt.h. +/// Deliberately OCIO-free so format plugins and unit tests (built without +/// OpenColorIO include paths) can include it; the OCIO-typed internals +/// shared by the color_*.cpp translation units live in +/// src/libOpenImageIO/color_ocio_pvt.h. Not installed. + + +#ifndef OPENIMAGEIO_COLOR_PVT_H +#define OPENIMAGEIO_COLOR_PVT_H + +#include +#include +#include +#include +#include + +#include +#include + + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +/// Number of color spaces in OIIO's built-in interop identities config -- +/// a small OCIO config OIIO ships with (compiled in) that defines color +/// spaces for the CIF-published interop identities OIIO knows how to +/// reliably recognize and relate in other OCIO configs. The config is +/// parsed on first call and the result reused for the life of the +/// process. Returns 0 if OCIO support is unavailable or the embedded +/// config failed to parse. For internal/test use only. +OIIO_API int +interop_identities_config_size(); + +/// True if OIIO's built-in interop identities config (see +/// interop_identities_config_size) resolves `interop_id` -- i.e. the config +/// has a color space reachable by that name or alias. Returns false if OCIO +/// support is unavailable, the config failed to build, or the id is empty or +/// unknown. For internal/test use only. +OIIO_API bool +interop_identities_config_resolves(string_view interop_id); + +/// True if the internal copy_config() helper preserves a config's explicit +/// default view transform name across an editable copy. OCIO < 2.3.1's +/// createEditableCopy() drops it; copy_config() restores it so the cross-config +/// display bridge does not shadow a config's own default view transform. The +/// probe builds a two-view-transform config whose explicit default is the +/// non-first one (OCIO's implicit default is the first), copies it, and checks +/// the name survived. Vacuously true when OCIO support is unavailable. For +/// internal/test use only. +OIIO_API bool +copy_config_preserves_default_view_transform(); + +/// The `name:` of every color space declared in OIIO's built-in interop +/// identities config (see interop_identities_config_size), in the config's +/// own enumeration order. By construction, each entry's `name:` equals its +/// `interop_id:` in the source config -- this does not include entries only +/// reachable as an alias. Empty if OCIO support is unavailable or the +/// embedded config failed to parse. For internal/test use only. +OIIO_API std::vector +interop_identities_config_names(); + +/// Every distinct `interop_id:` value declared in the EMBEDDED interop +/// identities config source (the compiled-in OCIO-2.3 YAML the registry is +/// built from), sorted -- regardless of which composite config +/// interop_identities_config_names() reports at the linked OCIO version +/// (with OCIO >= 2.5 that composite is the studio config plus OIIO's +/// additions, whose declared names are not the canonical id set). This is +/// the canonical CIID set, gathered by an `interop_id:` token scan of the +/// source file; it backs the internal ColorConfig::get_builtin_interop_ids() +/// facade, and a unit test asserts that accessor stays an exact-set match +/// for it. For internal/test use only. +OIIO_API std::vector +embedded_interop_identities_ids(); + +/// The `interop_id` string of every entry in the internal legacy static +/// CICP/interop-id table (color_ocio.cpp's `color_interop_ids[]` -- the +/// syntactic-fallback tier `ColorConfig::get_color_interop_id` consults, and +/// the table `ColorConfig::get_cicp` shares), in table order. Every entry +/// other than the "unknown" utility token is expected to spell a registry +/// id exactly, i.e. this is a subset of +/// embedded_interop_identities_ids() plus that one utility token. For +/// internal/test use only -- lets a test assert the two haven't drifted +/// apart. +OIIO_API std::vector +legacy_interop_id_table_names(); + +/// The full write-side Color Interop ID derivation cascade for `colorspace`: +/// 1. an author-declared `interop_id` attribute on the resolved space +/// (verbatim, unconditionally authoritative; a data space with no +/// declared token yields "data"); +/// 2. definitional equivalence (by fingerprint) to a built-in registry +/// identity, yielding THAT identity's id; +/// 3. the legacy static id/CICP table by name/alias equivalence; +/// 4. a config-local id ":local:" when this config is named +/// and the query resolves to a (sanitization-unique) real space; +/// 5. otherwise empty -- never a guessed default. +/// This is the EXPENSIVE path (step 2 can build the registry index and OCIO +/// processors; step 4 manufactures an id) and is consumed by the +/// characterization engine's derive tier -- through which the write planner +/// (plan_color_metadata) and the public derive verbs receive it -- and +/// directly by tests as the cascade's oracle. The public +/// ColorConfig::get_color_interop_id() performs only the cheap subset +/// (steps 1 and 3). The returned view is stable for the process lifetime. +/// For internal/test use only; a public wrapper can follow with its in-tree +/// consumer. +OIIO_API string_view +derive_color_interop_id(const ColorConfig& config, string_view colorspace); + + +// --------------------------------------------------------------------------- +// Curve-family normalization -- reduce a transfer-function ("curve") named +// transform's name to a reference-space-agnostic family so two spaces sharing +// a transfer curve compare equal regardless of the state suffix the name +// carries (`_tx` pass-through / bare mirror in current configs; legacy +// `_scene` / `_display` still supported). Pure, stateless, no OCIO. For +// internal/test use only. +// --------------------------------------------------------------------------- + +/// Family token for a curve name: strip a leading `crv_`, then strip at most +/// one trailing state suffix (`_scene`, `_display`, or `_tx`). A name that is +/// a bare suffix (`"_tx"`) or is empty passes through unchanged. E.g. +/// `crv_g24_tx`, `crv_g24`, and `crv_g24_display` all yield `g24`. +OIIO_API std::string +family_token(string_view name); + +/// Like family_token but keeps the `crv_` prefix -- for comparing two matched +/// catalog names for family equality (`crv_srgb_tx` == `crv_srgb`). +OIIO_API std::string +family_name(string_view name); + +/// True if `name` is the pass-through variant of a curve family (ends with +/// `_scene` or `_tx`, and is strictly longer than that suffix). +OIIO_API bool +curve_is_passthrough(string_view name); + +/// True if `name` is the mirror variant of a curve family: it ends with the +/// legacy `_display` suffix, OR its `_tx` pass-through twin is present in +/// `catalog_names` (the current suffixless-mirror convention). +OIIO_API bool +curve_is_mirror(string_view name, cspan catalog_names); + + +// --------------------------------------------------------------------------- +// Chromaticity math -- pure, config-free primitives for the chromaticity axis +// of color-space search by characterization. A Chromaticities is four (x, y) +// pairs in R, G, B, W order. Rounding is the only place numerical fuzz is +// absorbed; once coordinates are rounded, equality is coordinate-exact, so +// callers compare a Chromaticities with plain `==` / std::find (std::array +// gives that for free -- no dedicated compare helper). For internal/test use +// only. +// --------------------------------------------------------------------------- + +using Chromaticities = std::array, 4>; + +/// Round a chromaticity coordinate to 6 decimals, then snap to the nearest +/// coarser 5/4/3/2-digit grid if within 2e-7 (finest grid within tolerance +/// wins). Absorbs OCIO chromaticity floats like 0.329999998 -> 0.33 so that +/// downstream equality can be exact. +OIIO_API double +round_chromaticity_coord(double value); + +/// Reserved (R,G,B,W) primaries for an interop id that names a well-known +/// gamut, probed as an `__` substring of the lowered id (a complete +/// gamut component, not an arbitrary fragment), first match wins. `adobergb` +/// matches only on the exact id or an `_adobergb_` token. Empty when no +/// reserved gamut token is present (single-hypothesis / table-only: gamuts +/// absent from the table, e.g. `ciexyzd65`, are not resolved here). +OIIO_API std::optional +reserved_chromaticities_for_id(string_view interop_id); + +/// Derive chromaticities from the four AP0-anchored RGB probes (pure R, G, B, +/// W as a flat 12-element span, in that order) that the caller has pushed +/// through colorspace -> AP0 interchange. Applies the Bradford-adapted +/// AP0->XYZ(D65) matrix, solves each probe for (x, y) with rounding, and +/// snaps an equal-energy white to exact (1/3, 1/3). Empty if the span is not +/// 12 long or a probe is degenerate (non-finite / near-zero sum). Single +/// hypothesis (D65 + Bradford); the whitepoint/CAT sweep is a follow-on. +OIIO_API std::optional +chromaticities_from_ap0_probes(cspan ap0_rgb); + + +// --------------------------------------------------------------------------- +// Transfer-signature axis -- pure, config-free primitives for the +// transfer-function axis of color-space search by characterization. A +// candidate's transfer property is the triple { identity, family, signature }: +// whether the curve is linear/identity, its reference-state-agnostic family +// key (from the curve-family normalization above), and, for non-identity +// curves, a behavioral signature probed on the neutral axis. Matching follows +// a fixed order -- identity, then family, then signature. The numerical work +// (normalized slopes, per-encoding slope tolerance, white-gain tie-break) is +// pure: a caller runs a CPU processor over tf_probe_axis() and hands the +// outputs here, so nothing in this section needs a live config. For +// internal/test use only. +// --------------------------------------------------------------------------- + +/// Behavioral transfer-function signature of a color space: channel-averaged +/// outputs of a fixed set of neutral-axis probes in the encode direction +/// (linear anchor -> color space), plus the adjacent slopes normalized by the +/// 0.18->0.50 anchor slope. `encoding` (the effective OCIO encoding) selects +/// the slope tolerance; `family` is the transfer-family key; `is_linear` is +/// the measured 64x-ratio linearity verdict. +struct TransferFunctionSignature { + std::vector slopes; ///< adjacent slopes, 0.18->0.50 normalized + std::vector values; ///< channel-averaged probe outputs + std::string encoding; ///< effective OCIO encoding of the space + std::string family; ///< transfer-family key (see family_token) + bool is_linear = false; ///< measured linearity (64x ratio check) +}; + +/// Per-candidate transfer property. "Unknown" -- none of the members carry a +/// verdict (known() is false) -- makes an include term miss, a `~` term +/// reject, and a `-` term preserve, in the three-valued axis evaluation. +struct TransferProperty { + bool identity = false; ///< linear/identity curve + std::string family; ///< "" when unidentified + std::optional signature; + + bool known() const + { + return identity || !family.empty() || signature.has_value(); + } +}; + +/// A resolved transfer-function hint: the property a hint term denotes, as one +/// or more of identity / family / candidate signatures. (The search-term mode +/// -- include / exclude / inverse -- is layered on separately by the search +/// core; this struct carries only the resolved value.) +struct TransferHint { + bool identity = false; ///< hint denotes a linear/identity curve + std::string family; ///< curve-family key ("" when unidentified) + std::vector signatures; +}; + +/// The fixed neutral-axis probe abscissae the signature is built from: the 10 +/// discriminating points, followed by the (dark, bright) scaled-linearity +/// pair. A caller pushes each value as R=G=B through a CPU processor in the +/// encode direction and hands the 12 channel-averaged outputs to +/// tf_signature_from_probes(). +OIIO_API cspan +tf_probe_axis(); + +/// Per-encoding slope tolerance: 0.05 for `log`, 0.1 for `hdr-video`, else +/// 0.02 (`sdr-video` and default). Wider for log/HDR because those curves +/// vary more across their slope profiles. +OIIO_API double +tf_slope_tolerance(string_view encoding); + +/// True when the curve clips superwhite: the last two probe outputs (the 1.0 +/// and 1.1 points) coincide. +OIIO_API bool +tf_clips_superwhite(cspan values); + +/// Adjacent slopes of a 10-probe run, normalized by the 0.18->0.50 anchor +/// slope. Empty when `values` is not a full probe run (size 10) or the anchor +/// slope is degenerate (flat). +OIIO_API std::vector +tf_normalized_slopes(cspan values); + +/// Build a signature from the 12 channel-averaged outputs of tf_probe_axis() +/// (10 discriminating probes + dark + bright). `encoding` and `family` are the +/// caller's to fill afterward (they depend on the source config). nullopt on a +/// short span or a degenerate (flat) anchor slope. +OIIO_API std::optional +tf_signature_from_probes(cspan probe_outputs); + +/// Tolerance-compare two probed signatures: per-encoding slope tolerance with +/// clip masking (index 0 always masked; last index masked when either side +/// clips superwhite; at least 80% of compared slopes must agree), then a +/// white-gain tie-break at the 1.0 probe that keeps a headroom-scaled curve +/// distinct from its unscaled twin. +OIIO_API bool +transfer_signatures_match(const TransferFunctionSignature& a, + const TransferFunctionSignature& b); + +/// Does a resolved transfer hint match a candidate's transfer property, in +/// the identity -> family -> signature order: identity-vs-identity wins; else +/// if both families are known, family equality decides (behavior families beat +/// signature comparison); else a probed-signature tolerance compare against +/// any of the hint's signatures. An unknown candidate property never matches. +OIIO_API bool +transfer_hint_matches(const TransferHint& hint, + const TransferProperty& property); + + +// --------------------------------------------------------------------------- +// ICC profile identification primitives -- cheap, OCIO-free byte-level +// inspection of an embedded ICC profile blob (e.g. the "ICCProfile" spec +// attribute), used by the color-interop ICC identification path. +// --------------------------------------------------------------------------- + +/// Whether `iccdata` is structurally an ICC profile: at least 132 bytes +/// (fixed header + tag count) with the mandatory 'acsp' signature at byte +/// offset 36. This is the sole gate separating "an ICC profile" from +/// arbitrary bytes; deeper malformations are handled downstream. +OIIO_API bool +is_icc_profile(cspan iccdata); + +/// Process-local content identifier for an ICC profile, as lowercase hex: +/// XXH64 over the raw, unmodified profile bytes (16 hex chars). Returns +/// the empty string when `iccdata` is not an ICC profile (is_icc_profile). +/// Deliberately byte-exact: a v4 profile's embedded Profile ID field +/// (bytes 84-99, ICC.1:2022 section 7.2.18) is NOT consulted -- it is +/// creator-written and can be stale or forged, so two different blobs +/// could share one embedded ID and collide as cache identity. The +/// identifier is used for internal cache keys and "icc:" synthetic +/// tokens only -- it never leaves the process and must not be written as +/// portable metadata. (If that need ever arises, switch to recomputing the +/// ICC-mandated normalized MD5 rather than trusting the embedded field.) +OIIO_API std::string +icc_profile_identifier(cspan iccdata); + +/// Read an ICC.1:2022 `cicpTag` from a v4 profile into `cicp` as +/// { color_primaries, transfer_characteristics, matrix_coefficients, +/// video_full_range_flag } (ITU-T H.273 code points). Returns false -- +/// without touching `cicp` -- when the profile is not ICC, not v4, has no +/// cicp tag, or the tag is malformed (wrong size, non-zero reserved bytes, +/// out-of-bounds offsets, or a range flag greater than 1). Never throws. +OIIO_API bool +icc_embedded_cicp(cspan iccdata, int cicp[4]); + +/// Result of identify_icc_profile(). The three shapes: +/// - id empty, decodable false: the bytes are not an ICC profile at all +/// (failed is_icc_profile) -- invalid input, not a color answer. +/// - id == "icc:", decodable false: structurally an ICC +/// profile, but OCIO's matrix/TRC reader cannot decode it (cLUT/AToB +/// transform). The token names the profile without asserting any +/// colorimetry; callers should let weaker color hints win. +/// - id non-empty, decodable true: the decoded profile either matched a +/// built-in registry identity (id is the caller-local space name when +/// the caller's config resolves the identity, else the canonical +/// interop id) or matched nothing (id is the bare "icc:" +/// token). +struct IccIdentifyResult { + std::string id; + bool decodable = false; +}; + +/// Identify the color space of an embedded ICC profile blob as a color +/// interop ID, by decoding it through OCIO's matrix/TRC ICC reader inside +/// a throwaway in-memory probe config and fingerprinting the decoded +/// transform against the built-in interop identities registry. `config` is +/// only consulted to prefer a caller-local resolution of a matched +/// identity (ColorConfig::resolve); identification itself runs entirely +/// against the process-global registry. Never throws. +OIIO_API IccIdentifyResult +identify_icc_profile(const ColorConfig& config, cspan iccdata); + + +// --------------------------------------------------------------------------- +// Mastering display volume (SMPTE ST 2086) derivation. +// --------------------------------------------------------------------------- + +/// A mastering display colour volume: the reference monitor a picture +/// graded through a (display, view) pair was mastered on. Describes the +/// MASTERING MONITOR, not the container encoding (that is CICP territory). +struct MasteringDisplayVolume { + /// Limiting-gamut primaries + whitepoint as CIE xy, in R, G, B, W + /// order. + float primaries[4][2] = {}; + /// Peak luminance, cd/m^2 (snapped to the nominal mastering targets). + double max_luminance = 0.0; + /// Minimum luminance, cd/m^2. Probe-honest (may be exactly 0.0); wire + /// encoders wanting the conventional 0.0001 floor clamp at encode time. + double min_luminance = 0.0; + /// Provenance: the matched ACES-OUTPUT style, the DISPLAY builtin + /// style, or the interop id that supplied the decode; empty for a + /// plain interchange probe. + std::string style; +}; + +/// Derive the ST 2086 mastering display volume for a (display, view) pair +/// of `config`, via a five-tier first-hit-wins ladder: ACES-OUTPUT style +/// table, then a shared numeric probe over three decode constructions +/// (display-interchange CST / inverse DISPLAY builtin / registry-identity +/// decode), then no record. Empty display/view resolve to the config +/// defaults. Returns false -- leaving `volume` untouched by contract of +/// interest -- when no tier fires (unresolvable display/view, an untagged +/// unmatched LUT-only view, or a config with no display interchange to +/// probe): the ladder honestly yields nothing rather than guess. Content +/// light levels (MaxCLL/MaxFALL) are out of scope by design -- they need a +/// pixel scan, not a transform inspection. Never throws. +OIIO_API bool +derive_mastering_volume(const ColorConfig& config, string_view display, + string_view view, MasteringDisplayVolume& volume); + + +// --------------------------------------------------------------------------- +// Color-space classification -- how ColorConfig internally classifies a +// color space for interop matching (the "simple" transform allowlist and +// related properties). For internal/test use only. +// --------------------------------------------------------------------------- + +/// Classification bit flags for a color space. These mirror the internal +/// CSInfo classification bits exactly (a static_assert in color_ocio.cpp +/// keeps them in sync), so a color_space_analysis_flags() result can be +/// tested against them. +enum ColorSpaceAnalysis { + ColorSpaceIsData = 64, // "isdata: true" in the config + ColorSpaceIsUnique = 128, // has OCIO category "is-unique" + ColorSpaceShouldSkipMatching = 256, // never a matching candidate + ColorSpaceHasComplexTransform = 512, // rejected by the simple allowlist + ColorSpaceIsSimple = 1024, // member of the simple set + ColorSpaceIsContextInvariant = 2048, // no context vars affect it +}; + +/// Compute (lazily, on first request for this config) and return the interop +/// classification flags (see ColorSpaceAnalysis) for the color space `name` +/// in `config`. `active`, if non-null, receives whether the space is part of +/// the config's active colorspace enumeration. Returns 0 for unknown names. +/// For internal/test use only. +OIIO_API int +color_space_analysis_flags(const ColorConfig& config, string_view name, + bool* active = nullptr); + +/// Whether the lazy classification pass has already run for `name` in +/// `config`. Does NOT trigger classification -- used to verify that +/// constructing a ColorConfig does no classification work. Returns false for +/// unknown names. For internal/test use only. +OIIO_API bool +color_space_analyzed(const ColorConfig& config, string_view name); + + +// --------------------------------------------------------------------------- +// Color space fingerprints -- the probe-based numeric signature ColorConfig +// computes for a color space so equivalent spaces can be recognized by value +// rather than by name. For internal/test use only. +// --------------------------------------------------------------------------- + +/// A color space fingerprint: the floats produced by transforming a fixed +/// probe from the reference role to the color space, tagged with which +/// reference kind (scene vs display) selected the probe. `values` is empty +/// when the space is unknown or cannot be probed. +struct ColorSpaceFingerprint { + int reference_kind = 0; ///< OCIO ReferenceSpaceType (0 scene, 1 display) + std::vector values; ///< probe floats; empty if not computable + bool computed() const { return !values.empty(); } +}; + +/// Compute the color space fingerprint for `name` in `config`. Probing runs on +/// a lazily built, processor-cache-disabled copy of the config and is +/// byte-reproducible across builds. Returns an empty fingerprint +/// (values.empty()) when the space is unknown or cannot be probed. For +/// internal/test use only. +OIIO_API ColorSpaceFingerprint +color_space_fingerprint(const ColorConfig& config, string_view name); + +/// Whether two color space fingerprints denote the same color space under the +/// exact, tolerance-gated identity comparison: reference kinds must match, +/// vector lengths must match, and every identity-probe float must agree within +/// the fingerprint tolerance (the trailing linearity probes are excluded). For +/// internal/test use only. +OIIO_API bool +color_space_fingerprints_match(const ColorSpaceFingerprint& a, + const ColorSpaceFingerprint& b); + +/// The names of `config`'s "simple" color spaces that were successfully +/// fingerprinted, in the deterministic sorted order the engine iterates them +/// (reusing the classification simple-space cache). For internal/test use only. +OIIO_API std::vector +color_space_fingerprint_order(const ColorConfig& config); + + +// --------------------------------------------------------------------------- +// Color space fingerprint cache -- a process-global flyweight cache of the +// fingerprints above, keyed on (structural config cache id, context cache id, +// color space name) with a context-invariant bucket collapse so a +// context-invariant space is fingerprinted once and shared across every context +// of the same structural config. Content-addressed (no invalidation): a changed +// config or context yields new keys and orphans the old entries. For +// internal/test use only. +// --------------------------------------------------------------------------- + +/// Return the fingerprint for `name` in `config`, from the process-global +/// fingerprint cache. A cache hit is a cheap read; a miss computes the +/// fingerprint (outside the cache lock) and publishes it first-writer-wins. +/// Returns an empty fingerprint when the space is unknown or cannot be probed. +/// For internal/test use only. +OIIO_API ColorSpaceFingerprint +color_space_fingerprint_cached(const ColorConfig& config, string_view name); + +/// Fingerprint every "simple" color space in `config` and publish each into the +/// process-global fingerprint cache (the bulk "warm" pass). Returns how many +/// were fingerprinted. For internal/test use only. +OIIO_API size_t +color_space_fingerprint_warm(const ColorConfig& config); + +/// The number of entries currently in the process-global fingerprint cache. +/// For internal/test use only. +OIIO_API size_t +color_space_fingerprint_cache_size(); + +/// Empty the process-global fingerprint cache. For test/debug reset only -- +/// never needed in steady state, since keys are content-addressed. For +/// internal/test use only. +OIIO_API void +color_space_fingerprint_cache_reset(); + + +// --------------------------------------------------------------------------- +// Config interoperability check -- whether a config resolves a scene-referred +// interchange space (ACES2065-1 / the aces_interchange role) that cross-config +// color features anchor on, and the in-memory "interopified" repair copy built +// for configs that don't. Computed lazily on first query; constructing a +// ColorConfig runs none of it. For internal/test use only. +// --------------------------------------------------------------------------- + +/// Whether `config` is color-interoperable: it resolves a scene interchange +/// space by role, a well-known ACES2065-1 alias, or builtin identification. +/// Triggers the lazy interop bootstrap. For internal/test use only. +OIIO_API bool +color_config_is_interoperable(const ColorConfig& config); + +/// The name of the scene interchange color space `config` resolves, or empty +/// if it is not interoperable. Triggers the lazy interop bootstrap. For +/// internal/test use only. +OIIO_API std::string +color_config_interchange_name(const ColorConfig& config); + +/// Whether the lazy interop bootstrap has already run for `config`. Does NOT +/// trigger it -- used to verify that constructing a ColorConfig does no interop +/// work. For internal/test use only. +OIIO_API bool +color_config_interop_computed(const ColorConfig& config); + +/// Whether `config` emitted the once-per-config "not color-interoperable" +/// warning (true only for the config instance that first warned for a given +/// config structure). Triggers the lazy interop bootstrap. For internal/test +/// use only. +OIIO_API bool +color_config_interop_warned(const ColorConfig& config); + +/// Whether the interopified (repaired, in-memory) copy of `config` resolves a +/// scene interchange -- true even for non-interoperable configs once repaired. +/// Triggers the lazy interop bootstrap. For internal/test use only. +OIIO_API bool +color_config_interopified_resolves_scene_interchange(const ColorConfig& config); + +/// Whether the interopified copy of `config` has its OCIO processor cache +/// disabled (as the one-shot probe path requires). Triggers the lazy interop +/// bootstrap. For internal/test use only. +OIIO_API bool +color_config_interopified_cache_off(const ColorConfig& config); + +/// Exercise the central cross-config processor chokepoint: build a processor +/// from `src_name` in `src_config` to `dst_name` in `dst_config` through the +/// configs' shared OCIO interchange roles, apply it to the 3-channel `probe` +/// pixel, and return the transformed floats. A non-empty context_key/value +/// pair drives the chokepoint's context-aware overload. On failure returns an +/// empty vector and sets the error on `dst_config` (retrievable via +/// dst_config.geterror()); the chokepoint never throws. Comparisons should use +/// probe-pixel agreement (abs 1e-6/channel), not byte-identical processors. For +/// internal/test use only. +OIIO_API std::vector +cross_config_probe(const ColorConfig& src_config, string_view src_name, + const ColorConfig& dst_config, string_view dst_name, + cspan probe, string_view context_key = {}, + string_view context_value = {}); + +/// Route `local_name` in `config`'s in-memory interop-repaired copy to +/// `registry_name` in the built-in interop identities config through the pvt +/// cross-config chokepoint, and apply the result to the 3-channel `probe`. +/// This is the reference the public ColorConfig::createColorProcessor bridge +/// path should reproduce (probe-pixel agreement, abs 1e-6/channel). Returns an +/// empty vector if the route can't be built. For internal/test use only. +OIIO_API std::vector +identities_route_probe(const ColorConfig& config, string_view local_name, + string_view registry_name, cspan probe); + +/// Route `registry_name` in the built-in interop identities config into +/// `config`'s in-memory interop-repaired copy display/view (`display`, `view`) +/// through the pvt cross-config display-view chokepoint, and apply the result +/// to the 3-channel `probe`. This is the reference the public +/// ColorConfig::createDisplayTransform cross-config bridge path should reproduce +/// (probe-pixel agreement, abs 1e-6/channel). Returns an empty vector if the +/// route can't be built. For internal/test use only. +OIIO_API std::vector +identities_display_route_probe(const ColorConfig& config, + string_view registry_name, string_view display, + string_view view, cspan probe); + +/// Apply the interopified copy's own cie_xyz_d65_interchange -> `scene_name` +/// processor to the 3-channel `probe`. Used to assert the synthesized display +/// interchange is COLORIMETRIC: feeding XYZ-D65 white must land on the scene +/// space's white, matrix-only (no tonescale). Returns an empty vector if the +/// copy resolves no display interchange or the processor can't be built. For +/// internal/test use only. +OIIO_API std::vector +interopified_display_interchange_probe(const ColorConfig& config, + string_view scene_name, + cspan probe); + + +// --------------------------------------------------------------------------- +// Color interop ID grammar and sanitization -- the ID grammar and +// sanitization rules from the CIF recommendation "An ID for Color Interop" +// (Annexes B and C): +// https://github.com/AcademySoftwareFoundation/ColorInterop/wiki +// Pure, stateless functions; no OCIO dependency. +// --------------------------------------------------------------------------- + +enum class InteropIdForm { + INVALID, + BASE, // base + INNER_BASE, // inner:base + OUTER_INNER_BASE, // outer:inner:base (an "inner" of "local" is the + // reserved local-namespace form one layer up; the + // grammar itself does not special-case it) + OUTER_BLANK_BASE, // outer::base (blank inner) +}; + +// Parsed segments of a color interop ID. `form` is INVALID unless `id` +// matched one of the 4 legal forms; only the segments implied by `form` +// are populated. +struct InteropIdParts { + InteropIdForm form = InteropIdForm::INVALID; + std::string outer; + std::string inner; + std::string base; +}; + +// Parse and validate a color interop ID against the CIF Annex B grammar +// (0, 1, or 2 colons; 3+ is always invalid; every present segment must be +// a non-empty id-token, except the blank inner of the `outer::base` form). +OIIO_API InteropIdParts +parse_interop_id(const std::string& id); + +// True if `id` matches one of the 4 legal interop ID forms. Never folds +// case or sanitizes -- an id containing uppercase or non-ASCII characters +// is simply invalid; only sanitize_id_token() repairs those. +OIIO_API bool +is_valid_interop_id(const std::string& id); + +// Sanitize an arbitrary string into a valid interop id-token per CIF +// Annex C: fold A-Z to lowercase, remap a fixed set of punctuation, +// collapse each non-ASCII code point (however many UTF-8 bytes) to a +// single '^', and replace anything else unmapped with '*'. +OIIO_API std::string +sanitize_id_token(const std::string& token); + +// Substring of `id` after the first colon (the separator is consumed). +// Unchanged if `id` has no colon. Not itself validated as an id -- used +// as an intermediate search key, e.g. strip_leftmost_namespace( +// "my-studio::srgb") == ":srgb" (the leading colon of the blank inner is +// retained, so the result is not itself "srgb"). +OIIO_API std::string +strip_leftmost_namespace(const std::string& id); + +// True for the 3 reserved, case-sensitive utility tokens that name a +// color state without a registry lookup: "data", "unknown", "bypass". +OIIO_API bool +is_utility_interop_id(const std::string& id); + +// The marker vocabulary a colorInteropID value can carry beyond an ordinary +// definite id claim: the reserved utility tokens ("data" / "unknown" / +// "bypass", case-sensitive) and the deliberate unknown-marker family +// ("ocio:unknown" / "oiio:unknown" / "error:unknown", case-insensitive) -- +// config-declared unknownness, OIIO's synthetic isData/NoOp treatment +// marker, and the strict-resolution-failure signal, respectively. +// Everything else (including an empty string) classifies as Definite. +enum class InteropMarker { + Definite, //< an ordinary definite id claim (or empty) + UtilityData, //< "data" + UtilityBypass, //< "bypass" + BareUnknown, //< "unknown" + OcioUnknown, //< "ocio:unknown" -- config-declared unknown + OiioUnknown, //< "oiio:unknown" -- synthetic isData/NoOp treatment + ErrorUnknown, //< "error:unknown" -- strict-resolution failure +}; + +// The one classifier for that vocabulary: every site that branches on what +// a marker MEANS consults this instead of re-testing strings (what a caller +// DOES about it stays per-caller). For internal/test use only. +OIIO_API InteropMarker +classify_interop_marker(string_view id); + +// True iff `id` classifies as one of the deliberate unknown-marker family +// (ocio:unknown / oiio:unknown / error:unknown) -- the markers resolution +// and scrubbing honor: never scrubbed, never inferred over. +OIIO_API bool +is_unknown_marker(string_view id); + + +// --------------------------------------------------------------------------- +// Color-space search by characterization -- find a config's color spaces whose +// derivable characteristics (gamut / transfer curve / encoding / image state) +// satisfy a set of partial-characterization hints. The two pieces below are +// the pure, config-free crown of the search: the per-term grammar parse and +// the three-valued (match / known-different / unknown) axis combination. Both +// unit-test without a live OCIO config; the config-driving walk that resolves +// hints and probes candidates lives in color_ocio.cpp (it needs the config). +// For internal/test use only. +// --------------------------------------------------------------------------- + +/// A search hint term is `include` by default; a leading `-` makes it +/// `exclude` (subtract proven matches), a leading `~` makes it `inverse` +/// (select only proven *differences*). A leading `\` escapes an operator so it +/// is part of the name. +enum class SearchTermMode { include, exclude, inverse }; + +/// Split a raw hint term into (mode, value). A backslash escapes exactly `-`, +/// `~`, or `\` (the operator character becomes part of the value). Throws +/// std::invalid_argument on a bare operator (`"-"`), a dangling escape +/// (`"\"`), or an invalid escape (`"\foo"`). An empty input yields an empty +/// value (the caller skips it). +OIIO_API std::pair +parse_search_term(string_view raw); + +/// Three-valued axis combination -- the heart of the search. `modes` and +/// `term_matches` are parallel (one entry per resolved term on this axis); +/// `term_matches[i]` is whether term i matches the candidate's property, and +/// `property_known` is whether the candidate's property could be derived at +/// all ("unknown" when false). An empty axis accepts everything. Include ∪ +/// inverse select; an exclusion-only axis starts from the full universe; +/// exclusion always wins last. Inverse selects only *known* differences +/// (unknown is rejected), while exclusion preserves unknowns -- this is the +/// `~` vs `-` unknown-propagation split. The four per-axis evaluators in the +/// search walk all route through this one function. +OIIO_API bool +three_valued_axis(cspan modes, + cspan term_matches, bool property_known); + +/// The internal option set for a characterization search. Each of the four +/// hint axes is a list of grammar terms (empty = that axis is unconstrained). +/// `include_active` is on by default and has no public counterpart -- the +/// public API always searches active spaces; only this internal form can turn +/// them off. `context` is a per-call set of OCIO context variable overrides, +/// scoped to the one query. +struct FindColorSpacesOptions { + std::vector chromaticities; + std::vector transfer_functions; + std::vector encodings; + std::vector image_states; + bool include_active = true; + bool include_inactive = false; + bool include_context_sensitive = false; + bool exhaustive = false; + // strict limits encoding characterization to explicitly authored + // encoding attributes. The default additionally lets a candidate match + // through the encoding of its interop-identity twin — both as the + // fallback for an unset attribute and as a second acceptable value + // alongside an authored one. Hint-by-example resolution always reads + // the named space's own effective encoding. + bool strict = false; + std::map context; +}; + +/// Find the color spaces in `config` whose derivable characteristics satisfy +/// every non-empty hint axis of `options`, under the three-valued filter and +/// the visibility/eligibility gates. Every hint term is resolved up front +/// (an unresolvable hint throws std::invalid_argument before any candidate is +/// examined); a candidate whose property cannot be derived is tolerated as +/// "unknown". Results are returned in a deterministic order, +/// (context-invariant, active, simple, name). For internal/test use only. +OIIO_API std::vector +find_color_spaces(const ColorConfig& config, + const FindColorSpacesOptions& options); + + +// --------------------------------------------------------------------------- +// Field-selective color-space characterization engine -- the one internal +// entry the internal get/derive characterization facade and (in a later +// convergence) the search walk adapt to. characterize_color_space() always +// performs the cheap direct-fact pass (canonical name, image state, cheap +// interop-id subset, authored encoding, intrinsic range) and merges any +// previously cached derived facts; `requested_fields` selects which fields +// are additionally attempted by FULL derivation (fingerprint equality, +// chromaticity probe, transfer probe, the interop-id derivation cascade, +// the interop-counterpart encoding fallback). Derivation attempts -- +// successful or not -- are published to a process-global characterization +// cache keyed by (structural config cache id, effective context cache id, +// canonical space name) with the context-invariant bucket collapse the +// fingerprint cache uses, so an unprobeable space is never retried on every +// query. For internal/test use only. +// --------------------------------------------------------------------------- + +/// Bitmask of characterization fields to attempt full derivation for. +/// `None` is the cheap/direct-only pass (what the internal +/// ColorConfig::get_color_space_info() requests); `All` is the complete +/// derivation the internal derive facade requests; the search walk +/// requests only the fields its non-empty axes need. +enum class CharacterizationField : uint32_t { + None = 0, + EqualityID = 1u << 0, + ColorInteropID = 1u << 1, + Encoding = 1u << 2, + ImageState = 1u << 3, + Range = 1u << 4, + Chromaticities = 1u << 5, + TransferFunction = 1u << 6, + All = 0x7f, +}; + +constexpr CharacterizationField +operator|(CharacterizationField a, CharacterizationField b) +{ + return CharacterizationField(uint32_t(a) | uint32_t(b)); +} +constexpr bool +operator&(CharacterizationField a, CharacterizationField b) +{ + return (uint32_t(a) & uint32_t(b)) != 0; +} + +/// One characterization record: the value slots plus the per-field +/// computed / available / derived tri-state (bitmasks of +/// CharacterizationField). This is the payload behind the public opaque +/// ColorSpaceInfo. An empty `name` denotes an invalid record (unknown or +/// unresolvable query). +struct CharacterizationRecord { + std::string name; ///< canonical local name; empty = invalid + uint32_t computed_mask = 0; ///< fields whose determination was attempted + uint32_t available_mask = 0; ///< attempted fields with a usable value + uint32_t derived_mask = 0; ///< values that required behavioral derivation + /// Fields whose FULL derivation tier has been attempted (internal + /// bookkeeping, not part of the public tri-state): distinguishes a + /// cheap direct attempt from a full one, so an unsuccessful derivation + /// is cached as settled and never retried. + uint32_t full_attempt_mask = 0; + std::string equality_id; + std::string color_interop_id; + std::string encoding; + std::string image_state; ///< "scene" / "display" / empty + std::string range; ///< "full" / "narrow" / empty; never guessed + std::vector chromaticities; ///< 8 floats (RGBW xy) or empty + ColorTransferFunctionKind transfer_kind + = ColorTransferFunctionKind::Undetermined; + std::string transfer_function; ///< normalized family, or empty + + // Internal search payload -- the raw evidence the search walk's + // three-valued matching consumes, cached alongside the public-facing + // values above but never exposed through ColorSpaceInfo: + // double-precision rounded chromaticities (the float vector above is a + // lossy public copy; exact-== matching needs the doubles), the probed + // transfer signature, and the conservative ambient-context linearity + // verdict (distinct from transfer_kind: a measured-linear signature + // yields kind Linear without setting this). + std::optional chromaticities_xy; + std::optional transfer_signature; + bool transfer_identity = false; + + bool valid() const { return !name.empty(); } + bool computed(CharacterizationField f) const + { + return (computed_mask & uint32_t(f)) != 0; + } + bool available(CharacterizationField f) const + { + return (available_mask & uint32_t(f)) != 0; + } + bool derived(CharacterizationField f) const + { + return (derived_mask & uint32_t(f)) != 0; + } + bool full_attempted(CharacterizationField f) const + { + return (full_attempt_mask & uint32_t(f)) != 0; + } +}; + +/// Characterize `color_space` (a name, role, alias, or interop ID) in +/// `config`: cheap direct facts always, cached derived facts merged, +/// `requested_fields` additionally derived (and the attempts published to +/// the shared characterization cache). `context` is a per-call set of OCIO +/// context variable overrides scoped to this query. Returns an invalid +/// record (empty name) for an unknown/unresolvable query. Never throws. +OIIO_API CharacterizationRecord +characterize_color_space( + const ColorConfig& config, string_view color_space, + CharacterizationField requested_fields = CharacterizationField::None, + const std::map& context = {}); + +/// The number of entries currently in the process-global characterization +/// cache. For internal/test use only. +OIIO_API size_t +characterization_cache_size(); + +/// Empty the process-global characterization cache. For test/debug reset +/// only. For internal/test use only. +OIIO_API void +characterization_cache_reset(); + + +// Read-side color-metadata reconciliation -- the one audited precedence +// cascade that turns the raw color attributes a reader deposited (CICP, +// ICC blob, chromaticities, gamma, colorInteropID, an ACES-container flag) +// into a single resolved color space designation, so plugins stop +// hand-rolling their own precedence. Called once, centrally, in the +// ImageInput open path after the plugin deposits raw attributes. The +// resolution engine below is pure (a ColorConfig + these value types); the +// same engine drives resolve() and its diagnostic explain() trace. For +// internal/test use only. +// --------------------------------------------------------------------------- + +/// Which local assignments a resolved id is allowed to escape as. +enum class ColorResolutionScope { + Lenient, ///< a known interop id absent locally may still be returned + ConfigOnly, ///< the result must be a usable local assignment, else unknown + ExactState, ///< additionally bind scene/display referencing state +}; + +/// How scene/display candidates are ordered when substitution is allowed. +enum class ColorStatePreference { Auto, Scene, Display }; + +/// Whether (and where) OCIO FileRules sit in the cascade. Filenames may +/// influence resolution only through the two rungs this axis gates. +enum class ColorFileRules { Off, First, FallbackOnly }; + +/// Which raw color signals the read-side reconciler consults for a format +/// -- the read-direction mirror of ColorWriteCaps. Extraction populates +/// only the enabled signals; everything else stays absent (and its cascade +/// rule inapplicable). For internal/test use only. +struct ColorReadCaps { + bool aces_container = false; + bool interop_id = false; + bool cicp = false; + bool icc = false; + bool chromaticities = false; + bool gamma = false; + + /// Every signal enabled: the unrestricted spec->facts extraction the + /// scrubber, planners, and spec-side resolve use. + static ColorReadCaps all() + { + return { true, true, true, true, true, true }; + } +}; + +/// Immutable facts a reader read out of the asset. An absent field makes +/// its rule inapplicable; a present-but-unusable field misses and falls +/// through. Format-derived facts (png_srgb, aces_image_container) are +/// deposited by the plugin, never re-derived mid-cascade. +struct ColorMetadataFacts { + bool aces_image_container = false; + std::string color_interop_id; + std::vector icc_profile; + bool has_cicp = false; + int cicp[4] = { 0, 0, 0, 0 }; + bool has_chromaticities = false; + float chromaticities[8] = { 0, 0, 0, 0, 0, 0, 0, 0 }; + bool has_gamma = false; + float gamma = 0.0f; + bool png_srgb = false; +}; + +/// Per-call context: filenames touch resolution only here, via the two +/// FileRules-gated rungs; `format`/`filename` also drive scene-vs-display +/// source inference; `failover` is tried after everything metadata misses. +struct ColorCallContext { + std::string filename; + std::string format; + std::string failover; +}; + +/// A single locked snapshot of the whole read policy state, taken once per +/// call. The three typed axes plus the per-signal switches; the grammar, +/// names and defaults are owned by the color policy attribute spec +/// (`oiio:colorpolicy:read:*`). Every default reproduces main's behavior. +struct ColorReadPolicy { + ColorResolutionScope scope = ColorResolutionScope::Lenient; + ColorStatePreference state_pref = ColorStatePreference::Auto; + ColorFileRules file_rules = ColorFileRules::Off; + bool ignore_cicp_for_png = false; + bool ignore_sidecar = false; + /// The scene/display state preference applied specifically to a CICP + /// tuple's resolution (`oiio:colorpolicy:read:cicp_state`). A CICP tuple + /// is state-ambiguous; this axis governs which twin wins. Auto (the + /// default) reproduces main: the CICP rule falls back to `state_pref`, + /// and the registry's own display-biased mapping decides. It is a + /// one-shot governing a pending tuple -- resolve_pending_cicp consumes + /// the global key after it fires (spec 09, "Deferred resolution and + /// consume-once policy"). + ColorStatePreference cicp_state = ColorStatePreference::Auto; + /// When set, reconcile_color_metadata deposits the CICP tuple as a + /// *pending* resolution (marker attribute `oiio:cicp:pending`) instead of + /// eagerly committing a color space. The caller may then set `cicp_state` + /// and call resolve_pending_cicp. Default false = eager (main's behavior). + bool defer_cicp = false; + /// Whether an all-miss falls back to the config's Default Assignment. + /// Off reproduces main (a reader that determined nothing leaves the + /// spec's color space untouched); a later named policy turns it on. + bool apply_config_default = false; + + /// Read every `oiio:colorpolicy:read:*` value ONCE, under one lock, from + /// the global attribute table, optionally overridden by per-open config + /// hints. No mid-call re-reads. `config`, if non-null, additionally feeds + /// the config author's own declared policy (spec 09 FileRule custom keys) + /// in as a layer below the global attributes. + static OIIO_API ColorReadPolicy + snapshot(const ImageSpec* config_hints = nullptr, + const ColorConfig* config = nullptr, string_view filepath = {}); +}; + +/// The 13 cascade rules, in exact tested precedence order (CICP above ICC, +/// per the PNG spec's own chunk precedence: cICP > iCCP). +/// STRICT_PARSING is the terminal miss diagnostic, not a 14th source rule. +enum class ColorRule { + ExplicitAssignment, + AcesContainer, + FileRulesFirst, + ColorInteropID, + Cicp, + IccProfile, + PngSrgb, + ChromaticitiesAndGamma, + Chromaticities, + Gamma, + FileRulesFallback, + Failover, + ConfigDefault, + StrictParsing, +}; + +/// The outcome of visiting one rule. Inapplicable = the fact was absent. +enum class ColorRuleOutcome { Matched, Missed, Inapplicable, Invalid }; + +/// One in-flight trace step, recorded for every rule the engine visits. +struct ColorResolutionStep { + ColorRule rule = ColorRule::ConfigDefault; + ColorRuleOutcome outcome = ColorRuleOutcome::Inapplicable; + std::string candidate; + std::string resolved; + std::string reason; +}; + +/// The full trace produced by the engine. `resolved` is the winning color +/// space (empty if the cascade produced no assignment at all under the +/// active policy). `registered_synthetic` names the session-synthetic id a +/// lenient colorimetry/ICC match selected (id grammar only in this layer -- +/// no live endpoint is constructed). +struct ColorResolutionExplanation { + std::string resolved; + std::vector steps; + std::string registered_synthetic; + bool used_failover = false; + bool used_default = false; + + /// True iff the last MATCHED step is a genuine metadata source (not + /// failover, config-default, or the strict-parsing terminal). This is + /// the "did metadata actually decide this?" predicate. + OIIO_API bool has_genuine_metadata_match() const; +}; + +/// Run the audited cascade against `config` (may be null: rules that need a +/// config become inapplicable, and interop-id candidates fall back to the +/// built-in identity registry). Always returns the in-flight trace -- this +/// IS explain(); resolve() is just `.resolved`. `explicit_assignment`, if +/// non-empty, is a caller-forced color space that suppresses metadata rules. +OIIO_API ColorResolutionExplanation +resolve_color_metadata(const ColorConfig* config, + const std::string& explicit_assignment, + const ColorMetadataFacts& facts, + const ColorCallContext& ctx, + const ColorReadPolicy& policy); + +/// The ambient/current OCIO config that declares I/O metadata policy (spec +/// 09). Readers/writers pass this into ColorRead/WritePolicy::snapshot so a +/// config that DECLARES policy (via `oiio:` FileRule custom keys) drives real +/// reads/writes -- not just direct snapshot calls. Returns the process default +/// ColorConfig (backed by $OCIO), or nullptr when OCIO support is unavailable +/// (no-color-management opt-out: with no config to consult, snapshot behaves +/// exactly as a null-config snapshot did before). A config declaring no policy +/// keys changes nothing (no-surprise: empty keys => today's behavior). +OIIO_API const ColorConfig* +ambient_color_config(); + +/// The central read-side entry point. Extracts the facts a reader deposited +/// on `spec` -- narrowed to the signals `format_name`'s read caps declare +/// consulted (see color_read_caps_for_format) -- runs the cascade, and +/// stamps the resolved color space. With policy at its defaults this is +/// observably identical to the per-plugin precedence it replaces. Call once +/// in the ImageInput open path, passing the reader's format name. +OIIO_API void +reconcile_color_metadata(ImageSpec& spec, const ColorReadPolicy& policy, + string_view format_name = {}); + +/// Complete a *deferred* CICP resolution (spec 09, "Deferred resolution and +/// consume-once policy"). A reader that ran reconcile_color_metadata under a +/// `defer_cicp` policy left the CICP tuple pending (marker +/// `oiio:cicp:pending`) without committing a color space, giving the caller a +/// window to set `oiio:colorpolicy:read:cicp_state`. This resolves that +/// pending tuple under `policy` (whose `cicp_state` the caller just set), +/// stamps `oiio:ColorSpace`, and *consumes* both: it clears the pending +/// marker and resets the one-shot global `cicp_state` key so it does not +/// silently re-apply to the next file. No-op (returns false) when nothing is +/// pending. `config` scopes resolution; null = built-in registry (matching +/// the eager reconcile path). Returns true iff a color space was committed. +OIIO_API bool +resolve_pending_cicp(ImageSpec& spec, const ColorReadPolicy& policy, + const ColorConfig* config = nullptr); + +/// The one format-name -> consulted-read-signals table, the read-direction +/// mirror of color_write_caps_for_format. This is what +/// reconcile_color_metadata extracts through, so widening (or narrowing) a +/// format's consulted signals is a data edit here -- a per-format behavior +/// change, its own PR -- not new inline branching. Today every wired +/// reader's row (and the unknown-format default) is the same trio -- ACES +/// container flag, colorInteropID, CICP -- exactly the format-invariant +/// selection the reconciler's former inline extraction applied. For +/// internal/test use only. +OIIO_API ColorReadCaps +color_read_caps_for_format(string_view format_name); + +/// Extract the ColorMetadataFacts signals `spec` carries (ACES container +/// flag, colorInteropID, ICC profile blob, CICP, chromaticities, gamma), +/// narrowed to the signals `caps` enables. This is the ONE spec->facts +/// extraction: reconcile_color_metadata reads through it with the +/// per-format caps, and the unrestricted overload below is it with +/// ColorReadCaps::all(). +OIIO_API ColorMetadataFacts +color_facts_from_spec(const ImageSpec& spec, const ColorReadCaps& caps); + +/// Extract every ColorMetadataFacts signal `spec` carries: the +/// caps-narrowed extraction above with every signal enabled. +OIIO_API ColorMetadataFacts +color_facts_from_spec(const ImageSpec& spec); + +/// Spec-aware resolve(): run the audited cascade against the color hints +/// present on `spec` -- the same engine, entered from the ImageSpec / IBA +/// side. `config` scopes resolution to that config first, failing over to +/// the built-in identity registry exactly as the facts overload does (null +/// = registry-only answers). +OIIO_API ColorResolutionExplanation +resolve_color_metadata(const ColorConfig* config, const ImageSpec& spec, + const ColorCallContext& ctx, + const ColorReadPolicy& policy); + +/// Infer a usable source color space from the color hints on `spec`, for a +/// color operation whose caller supplied none: the spec resolve() above, +/// with session-synthetic answers (custom:/icc:) filtered out -- they name +/// no constructible color space in this round -- via a config-only retry +/// that lets an unusable signal miss and the next rung answer. Returns "" +/// when no hint yields a usable space (the caller keeps its default). +OIIO_API std::string +infer_color_space_from_spec(const ColorConfig* config, const ImageSpec& spec, + const ColorCallContext& ctx, + const ColorReadPolicy& policy); + +/// Post-operation provenance-fact scrub -- the file-provenance half of the +/// two-bucket rule. The facts a file deposited about the SOURCE +/// (colorInteropID, acesImageContainerFlag, ICCProfile, CICP, +/// chromaticities, oiio:Gamma) categorically no longer describe a buffer +/// after an identity-known color change: erase them all, no per-signal +/// re-resolution (the bucket is a static property of the attribute, not a +/// per-input verdict). The deliberate unknown-marker family (see +/// is_unknown_marker) survives -- those are treatment/error state, not +/// provenance. Current-state descriptors (the `oiio:ColorSpace:xxxx` +/// family) are the other bucket: retained and maintained by +/// ColorOperationHygiene, never scrubbed here. The caller asserts that a +/// color change actually happened; this function never touches the color +/// space itself. +OIIO_API void +scrub_color_metadata(ImageSpec& spec); + +/// Update-or-erase maintenance of the four cheap current-state descriptors +/// (`oiio:ColorSpace:state` / `:encoding` / `:range` / `:equality_id`) from +/// ColorConfig::get_color_space_info() -- the descriptor half of the +/// identity-known finish, shared by ColorOperationHygiene::finish() and +/// ColorConfig::set_colorspace(). Cheap get only: direct or previously +/// cached values update the sub-attribute, an unavailable value erases it +/// -- never guessed, never derived here (no fingerprint, no processor). +OIIO_API void +maintain_color_state_descriptors(ImageSpec& spec, const ColorConfig& config, + string_view color_space); + +/// Erase all four current-state descriptors (absence semantics -- the +/// "assume nothing" / identity-unknowable half). +OIIO_API void +erase_color_state_descriptors(ImageSpec& spec); + +/// How a color-aware ImageBufAlgo operation relates its output's color +/// space identity to its inputs -- the taxonomy that makes automatic +/// hygiene honest. +enum class ColorOperationIdentity { + Known, ///< the target space is named or derivable: full hygiene + Unknowable, ///< arbitrary transform: erase verdict, facts, descriptors + Preserved, ///< space-preserving or no-op: everything passes through +}; + +/// Automatic metadata hygiene around a color-aware ImageBufAlgo operation: +/// prepare() before the pixel math resolves the operation's source color +/// space (explicit argument -> spec attribute -> inference from the color +/// hints the spec carries -> lenient default) and applies the +/// unresolvable-source failure split; finish() after it maintains the +/// output spec per the operation's identity class. Automatic color-space +/// tracking is a best-effort CONVENIENCE, NOT A CONTRACT: tracking gaps +/// are not API errors, and both halves are explicit calls around the +/// operation -- no error-producing work ever runs in a destructor. +/// +/// finish() semantics per class (no-op unless the pixel math succeeded): +/// - Known: stamp the verdict with `target_color_space`, scrub stale +/// file-provenance facts (uniformly -- explicit and inferred sources +/// alike), and maintain the cheap current-state descriptors +/// `oiio:ColorSpace:state` / `:encoding` / `:range` / `:equality_id` +/// from ColorConfig::get_color_space_info(): direct or cached values +/// update the sub-attribute, unavailable values erase it -- never +/// guessed, never derived by this path (no chromaticities or transfer +/// information is requested). +/// - Unknowable: the resulting space cannot be known and users must not +/// expect it -- erase the verdict, the provenance facts, and the +/// descriptors (absence = could-not-determine; never "oiio:unknown", +/// which marks treatment, not ignorance). Deliberate unknown markers +/// survive here too. +/// - Preserved: re-stamp the verdict with `target_color_space` when the +/// operation names one (the honest no-op tag); facts and descriptors +/// pass through untouched. +class OIIO_API ColorOperationHygiene { +public: + /// Record the operation (finish() maintains `dst`'s spec) and resolve + /// its source color space, available afterwards from source(). An + /// explicit `from` (anything but empty/"current") passes through + /// verbatim; otherwise the source's tagged space, then inference from + /// its color hints, then the "scene_linear" default under the + /// (default) lenient read policy. Failure split, by consequence: an + /// unresolvable source is an error for pixel math (returns false, the + /// error set on `dst` per the usual has_error() convention) -- never a + /// config-default guess into a processor -- except that a + /// config-declared "error:unknown" catch space is honored under + /// effective-strict (non-lenient resolution scope AND the config's own + /// strictparsing). + bool prepare(const ImageBuf& src, ImageBuf& dst, const ColorConfig& config, + string_view from); + + /// The source-less form, for operations with no source-space concept + /// (e.g. ociofiletransform): records the operation only, never fails. + void prepare(const ImageBuf& src, ImageBuf& dst, const ColorConfig& config); + + /// The resolved source color space (valid after a successful + /// prepare()). + const std::string& source() const { return m_source; } + + /// The post-operation half; see the class comment for the per-class + /// semantics. Must be called explicitly after the pixel operation. + void finish(ColorOperationIdentity identity, string_view target_color_space, + bool pixels_succeeded); + +private: + const ColorConfig* m_config = nullptr; + ImageBuf* m_dst = nullptr; + ColorReadPolicy m_policy; + std::string m_source; +}; + +/// Dry-run preview (read-side twin of render_color_write_plan): render, as +/// plain aligned text, how `spec`'s color metadata resolves through the +/// audited read cascade -- each ColorRule visited in precedence order, its +/// outcome (matched / missed / inapplicable / invalid), the candidate/reason +/// it recorded, and the final resolved color space and the rule that decided +/// it. Re-runs the same engine reconcile_color_metadata uses, under the +/// default read policy; `config` null means the process default color config. +/// Writes no bytes and never mutates the spec. For internal/test use only. +OIIO_API std::string +render_color_read_plan(const ImageSpec& spec, + const ColorConfig* config = nullptr); + + +// --------------------------------------------------------------------------- +// Color-policy snapshot primitive -- the single-locked-snapshot mechanism +// shared by the read (reconcile) and write (plan) policy readers. One +// snapshot holds the shared policy lock for its whole lifetime, so every get +// below reads one consistent view of the oiio:colorpolicy:* attribute state +// with no mid-call re-lock (the read/write-back race the design forbids). +// Per-open / per-write config hints win over the global attribute table. For +// internal/test use only. +// --------------------------------------------------------------------------- +/// Which layer of the policy/metadata stack decided a planned signal (or, +/// for the first three values, supplied a policy setting). The snapshot +/// reports only the policy tiers (BuiltinDefault / GlobalAttribute / +/// PerSpecAttribute); the planner additionally attributes a field to the +/// author's explicit metadata or to the format's declared incapability. +enum class ColorPlanDecider { + BuiltinDefault, ///< no policy attribute set anywhere -- default behavior + ConfigDeclared, ///< a config `oiio:default`/profile policy key (layer 2/3) + GlobalAttribute, ///< a global `oiio:colorpolicy:*` attribute (layer 4) + MatchedRule, ///< the config file-rule matching this file (layer 5) + PerSpecAttribute, ///< a per-call config hint on the spec (layer 6) + ExplicitMetadata, ///< author-supplied metadata present on the spec + FormatIncapable, ///< the format cannot carry the signal +}; + +/// Config-declared policy keys (spec 09, RFC POC). Read the +/// `oiio:colorpolicy:*` custom key/value pairs an OCIO config author attached +/// to the FileRule named exactly `rule_name` (e.g. "oiio:default"), returned +/// name->value. The rule carries policy, not file matching (its regex never +/// matches). Empty when OCIO support is off, the config has no such rule, or +/// the rule has no custom keys. Never throws. For internal/test use only. +OIIO_API std::map +config_declared_policy_keys(const ColorConfig& config, string_view rule_name); + +/// Config-declared policy carried by the file rule that MATCHES `filepath` +/// (spec 09 layer 5, "matched-rule per-file opinions"): OCIO evaluates its +/// own file-rule patterns against the path and this returns the winning rule's +/// `oiio:colorpolicy:*` custom keys. Profile rules (regex `$^`) never match a +/// real path, so they are never returned here. Empty when OCIO support is off, +/// `filepath` is empty, or the matched rule has no custom keys. Never throws. +OIIO_API std::map +config_matched_rule_policy_keys(const ColorConfig& config, + string_view filepath); + +/// Feature 3 (spec 09): apply one composable layer-3 profile-selection +/// expression to `keys` (the accumulating layer-2/3 policy map). `selection` is +/// a comma-separated list; each entry is `[+|-]` where `` is +/// EITHER a whole profile name (a config rule name, e.g. `oiio:blender:textures` +/// -- looked up via config_declared_policy_keys) OR a single policy key +/// (`read:cicp_state`, optionally `=value`; the full attribute name is +/// `oiio:colorpolicy:`). No prefix / `+` adds a profile (its keys +/// cascade over earlier entries) or sets a key; `-` removes a profile (erases +/// its keys) or unsets a key. A target starting with `read:`/`write:` is a key, +/// otherwise a profile. An undefined profile contributes nothing (fall +/// through). Entries apply left to right so later ones override earlier; call +/// once for the env-var base then again for the composed-on-top attribute. For +/// internal/test use only. +OIIO_API void +apply_profile_selection(std::map& keys, + const ColorConfig& config, string_view selection); + +class OIIO_API ColorPolicySnapshot { +public: + /// `config`, if non-null, additionally exposes the config author's own + /// declared policy (spec 09): FileRule custom keys, read as layers BELOW + /// the per-call/global attribute tiers so those still win. `filepath`, if + /// non-empty, additionally consults the config file-rule that MATCHES it + /// (layer 5), which sits ABOVE the global attribute table (layer 4) but + /// below the per-call hints (layer 6) -- the documented CSS-specificity + /// rung (spec 09): a file-matching rule outranks a user's global key. + explicit ColorPolicySnapshot(const ImageSpec* hints = nullptr, + const ColorConfig* config = nullptr, + string_view filepath = {}); + /// String colorpolicy attribute, strongest tier first: per-call hint + /// (layer 6), matched-rule key (layer 5), global attribute (layer 4), + /// config default/profile key (layer 2/3), else "". `layer`, if non-null, + /// receives which tier supplied the value. + std::string get_string(const char* name, + ColorPlanDecider* layer = nullptr) const; + /// Int colorpolicy attribute, same tier order as get_string, else `dflt`. + int get_int(const char* name, int dflt) const; + +private: + const ImageSpec* m_hints; + std::lock_guard m_lock; // held for the snapshot's lifetime + // Config-declared policy keys (spec 09), merged weakest->strongest: + // the `oiio:default` profile (layer 2) then any active profiles selected + // via `oiio:colorpolicy:profile` (layer 3, later ones override). Populated + // in the ctor while the lock is held; empty when no config was given. + std::map m_config_keys; + // Layer 5: the keys of the config file-rule that matched this file's path. + // Consulted ABOVE the global attribute table, below the per-call hints. + std::map m_matched_keys; +}; + + +// --------------------------------------------------------------------------- +// Write-side color-metadata plan -- the central derivation writer plugins +// consume in place of hand-rolled emission. plan_color_metadata() turns the +// color space designation a spec carries into a per-signal plan: for each +// signal a format can emit, whether to write an author-supplied value, derive +// one from the color space, or suppress it. Couldn't-determine OMITS (no +// breadcrumb strings). Separately owned from the read-side reconciler above -- +// two modules sharing one oiio:colorpolicy:* namespace, deliberately not +// merged. For internal/test use only. +// --------------------------------------------------------------------------- + +/// What the plan says to do with one signal. +enum class ColorPlanAction { + Omit, ///< nothing determinable / format can't carry it -- emit nothing + Write, ///< an author-supplied value is present -- emit it verbatim + Derive, ///< OIIO derived the value from the color space -- emit it + Suppress, ///< a policy said "never" -- emit nothing even if determinable +}; + +/// One planned signal. Only the carrier the signal uses is populated: `str` +/// for an interop id, `ints` for a CICP tuple, `floats` for chromaticities, +/// `gamma` for a scalar. `emit()` is true iff the writer should put bytes down. +struct ColorPlanField { + ColorPlanAction action = ColorPlanAction::Omit; + /// Who decided `action`: format incapability, the author's explicit + /// metadata, or the policy tier (builtin / global / per-spec) that was + /// in force when the signal was resolved. + ColorPlanDecider decider = ColorPlanDecider::BuiltinDefault; + std::string str; + std::vector ints; + std::vector floats; + float gamma = 0.0f; + bool emit() const + { + return action == ColorPlanAction::Write + || action == ColorPlanAction::Derive; + } +}; + +/// Which signals a writer's format can carry. The plan only populates the +/// signals a writer declares supported; every other signal stays Omit. +struct ColorWriteCaps { + bool cicp = false; + bool chromaticities = false; + bool gamma = false; + bool icc = false; + bool interop_id = false; + bool mdcv = false; +}; + +/// The whole write plan -- each signal marked write / suppress / derive / omit. +/// `suppress_source_path` / `keep_source_format` carry the provenance write +/// rule: drop `oiio:SourcePath`, keep `oiio:SourceFormat`. +struct ColorMetadataPlan { + ColorPlanField cicp; + ColorPlanField chromaticities; + ColorPlanField gamma; + ColorPlanField icc; + ColorPlanField interop_id; + ColorPlanField mdcv; + bool suppress_source_path = true; + bool keep_source_format = true; +}; + +/// Per-signal write switch (spec-owned grammar `oiio:colorpolicy:write:*`). +enum class ColorSignalPolicy { Auto, Always, Never }; + +/// A single locked snapshot of the whole write policy state, taken once per +/// call via the shared ColorPolicySnapshot. Every default reproduces main's +/// write behavior; the grammar and names are owned by the color policy +/// attribute spec (`oiio:colorpolicy:write:*`). +struct ColorWritePolicy { + ColorSignalPolicy cicp = ColorSignalPolicy::Auto; + ColorSignalPolicy chromaticities = ColorSignalPolicy::Auto; + ColorSignalPolicy gamma = ColorSignalPolicy::Auto; + ColorSignalPolicy icc = ColorSignalPolicy::Auto; + ColorSignalPolicy interop_id = ColorSignalPolicy::Auto; + ColorSignalPolicy mdcv = ColorSignalPolicy::Auto; + // Which tier supplied each signal's policy above (any ColorPlanDecider + // tier), recorded by snapshot() so the plan can attribute its verdicts. + ColorPlanDecider cicp_layer = ColorPlanDecider::BuiltinDefault; + ColorPlanDecider chromaticities_layer = ColorPlanDecider::BuiltinDefault; + ColorPlanDecider gamma_layer = ColorPlanDecider::BuiltinDefault; + ColorPlanDecider icc_layer = ColorPlanDecider::BuiltinDefault; + ColorPlanDecider interop_id_layer = ColorPlanDecider::BuiltinDefault; + ColorPlanDecider mdcv_layer = ColorPlanDecider::BuiltinDefault; + // Feature 1 (spec 09): force the colorInteropID into formats with no + // native slot (TIFF/JPEG), emitted as an aux string attribute that + // round-trips via XMP. Default 0 keeps slotless formats untagged (the + // per-format round-trip contract); 1 opts in per config/profile/call. + bool force_interop_id = false; + // Feature 2 (spec 09): verbose/redundant emission. Default 0 emits the + // minimal consistent signal set for a space; 1 emits the FULL + // redundant-but-correct set (colorInteropID + CICP + chromaticities + + // gamma where each applies and is derivable), so every consumer finds a + // signal it understands. Blender opts this on; OIIO's builtin default is 0. + bool verbose = false; + // Feature B (spec 09): apply oiio:default's declared write-canonical space + // mappings. Default 0 preserves the source id; 1 canonicalizes on write + // (the locked mapping: g26_p3d65_display -> g26_xyzd65_display, the XYZ + // DCDM form -- gamma 2.6 + DCI white scaling, alias dcdm_xyzd65). Superseded + // by broadcast for P3 content. + bool canonicalize = false; + // Feature A (spec 09): oiio:broadcast delivery mapping. Default 0; 1 routes + // P3 display content into the broadcast container -- Rec.2020 encoding + // primaries signaled (CICP pri 9), narrow (limited) range, and the true P3 + // gamut carried in the MDCV mastering-display volume (not re-gamut'd). This + // supersedes the oiio:default canonicalize mapping for P3 content. + bool broadcast = false; + + /// Read every `oiio:colorpolicy:write:*` value ONCE, under one lock, from + /// the global attribute table, optionally overridden by per-write config + /// hints on the output spec. No mid-call re-reads. `config`, if non-null, + /// additionally feeds the config author's declared write policy (spec 09 + /// FileRule custom keys) in as a layer below the global attributes. + static OIIO_API ColorWritePolicy + snapshot(const ImageSpec* config_hints = nullptr, + const ColorConfig* config = nullptr, string_view filepath = {}); +}; + +/// Build the write plan for `spec` under `caps` and `policy`. `config` may be +/// null, in which case the process default color config is used (matching the +/// writers' historical name->id / name->CICP derivation). Pure derivation -- +/// never mutates the spec. For internal/test use only. +OIIO_API ColorMetadataPlan +plan_color_metadata(const ColorConfig* config, const ImageSpec& spec, + const ColorWriteCaps& caps, const ColorWritePolicy& policy); + +/// The one format-name -> declared-write-caps table. This is what each wired +/// writer plugin passes to plan_color_metadata (the plugins consume this +/// function, so the table cannot drift from the writers); a format with no +/// wired plan consumer returns all-false caps. Case-insensitive; "exr" is +/// accepted for "openexr". For internal/test use only. +OIIO_API ColorWriteCaps +color_write_caps_for_format(string_view format_name); + +/// Dry-run preview: render, as a plain aligned text table (one row per +/// signal, fixed row order, deterministic), the color-metadata write plan +/// OIIO would execute if `spec` were written to format `format_name` -- each +/// signal's verdict, the value that would be written, and which layer +/// decided it (see ColorPlanDecider). Pure: derives the same plan the writer +/// would (process-default color config, policy snapshot honoring per-spec +/// hints on `spec`) and writes no bytes. For internal/test use only. +OIIO_API std::string +render_color_write_plan(const ImageSpec& spec, string_view format_name); + +/// Feature 1 (spec 09): apply the `oiio:colorpolicy:write:force_interop_id` +/// policy for a slotless format (one whose write caps cannot natively carry a +/// colorInteropID, e.g. TIFF/JPEG). A slotless writer calls this after its +/// spec is finalized and before it emits generic/XMP attributes. When the +/// policy is set, derives the colorInteropID (if not already authored) and +/// stamps it as a plain string attribute so the writer's generic emission +/// (XMP) carries it; when unset, strips any colorInteropID so the format stays +/// untagged (matching the current per-format contract). No-op for formats with +/// a native interop-id slot -- those manage the id through their plan path. +/// `filepath`, if given, additionally consults the matched output-rule (layer +/// 5). For internal/writer-plugin use only. +OIIO_API void +apply_forced_interop_id(ImageSpec& spec, string_view format_name, + string_view filepath = {}); + +/// Build a ColorProcessor between two color spaces of OIIO's embedded interop +/// identities registry (NOT any user config). Used by the write-canonical pixel +/// conversion, whose mapped spaces are registry identities a user config need +/// not (correctly) define. Empty handle on failure or when the transform is a +/// no-op. For internal use only. +OIIO_API ColorProcessorHandle +interop_registry_processor(string_view from_id, string_view to_id); + +/// Feature B (spec 09), the reconciler write-shape: apply the config-declared +/// write-canonical PIXEL conversion to `buf` in place, retagging its +/// oiio:ColorSpace to the canonical target. The one locked mapping converts the +/// DCDM P3 form to the XYZ headroom form (g26_p3d65_display -> +/// g26_xyzd65_display: P3->XYZ primaries + DCI white headroom) through the +/// embedded interop registry -- a REAL colorimetric change, not a relabel, so +/// the emitted identity matches the pixels. Returns true iff pixels were +/// converted; a no-op (no mapping, unknown space, broadcast active, or registry +/// lacks the transform) leaves the buffer and its own truthful tag untouched. +/// `config` may be null (process default). The write path calls this on each +/// buffer before handing it to a format writer. For internal use only. +OIIO_API bool +apply_write_canonical_conversion(ImageBuf& buf, const ColorConfig* config, + string_view filepath = {}); +} // namespace pvt + + + +OIIO_NAMESPACE_END + +#endif // OPENIMAGEIO_COLOR_PVT_H diff --git a/src/include/imageio_pvt.h b/src/include/imageio_pvt.h index f47432868b..d5c9364853 100644 --- a/src/include/imageio_pvt.h +++ b/src/include/imageio_pvt.h @@ -10,6 +10,12 @@ #ifndef OPENIMAGEIO_IMAGEIO_PVT_H #define OPENIMAGEIO_IMAGEIO_PVT_H +#include +#include +#include +#include +#include + #include #include #include @@ -17,6 +23,7 @@ OIIO_NAMESPACE_BEGIN + // Note: Everything in pvt namespace is expected to be local to the library // and does not appear in exported headers that client software will see. // Therefore, it should all stay in the current namespace except where @@ -133,6 +140,20 @@ parallel_convert_from_float(const float* src, void* dst, size_t nvals, OIIO_API bool check_texture_metadata_sanity(ImageSpec& spec); +/// Deposit source-provenance attributes ("oiio:SourceFormat", +/// "oiio:SourcePath") onto a freshly-read ImageSpec. ImageBuf::name() and +/// ImageBuf::file_format_name() already answer this live for a directly +/// held ImageBuf, but that instance state doesn't survive IBA ops, +/// ImageCache round-trips, or serialization -- these spec attributes fill +/// exactly that gap. See stdmetadata.rst. +inline void +set_source_provenance(ImageSpec& spec, string_view format_name, + string_view filename) +{ + spec.attribute("oiio:SourceFormat", format_name); + spec.attribute("oiio:SourcePath", filename); +} + /// Get the timing report from log_time entries. OIIO_API std::string timing_report(); @@ -283,6 +304,7 @@ device_unified_malloc(size_t size); OIIO_API void device_free(void* mem); + } // namespace pvt diff --git a/src/jpeg.imageio/jpegoutput.cpp b/src/jpeg.imageio/jpegoutput.cpp index 39fea4f327..c1aaf21c11 100644 --- a/src/jpeg.imageio/jpegoutput.cpp +++ b/src/jpeg.imageio/jpegoutput.cpp @@ -12,6 +12,7 @@ #include #include +#include "color_pvt.h" #include "jpeg_pvt.h" OIIO_PLUGIN_NAMESPACE_BEGIN @@ -269,6 +270,11 @@ JpgOutput::open(const std::string& name, const ImageSpec& newspec, "srgb_rec709_scene")) m_spec.attribute("Exif:ColorSpace", 1); + // Feature 1 (spec 09): JPEG has no native colorInteropID slot. Under the + // force_interop_id policy, stamp the derived id so the XMP emission below + // carries it; otherwise strip it so the file stays untagged. + pvt::apply_forced_interop_id(m_spec, "jpeg", name); + // Write EXIF info std::vector exif; // Start the blob with "Exif" and two nulls. That's how it diff --git a/src/jpegxl.imageio/jxlinput.cpp b/src/jpegxl.imageio/jxlinput.cpp index 3ac507fe80..f9e5ad19f2 100644 --- a/src/jpegxl.imageio/jxlinput.cpp +++ b/src/jpegxl.imageio/jxlinput.cpp @@ -38,7 +38,10 @@ class JxlInput final : public ImageInput { const char* format_name(void) const override { return "jpegxl"; } int supports(string_view feature) const override { - return (feature == "exif" || feature == "ioproxy"); + // "cicp": this reader recovers a CICP tuple from the file's native + // colour-encoding field, so it declares the capability the same way + // the PNG and HEIF readers do. + return (feature == "exif" || feature == "ioproxy" || feature == "cicp"); } bool valid_file(Filesystem::IOProxy* ioproxy) const override; diff --git a/src/jpegxl.imageio/jxloutput.cpp b/src/jpegxl.imageio/jxloutput.cpp index 37fff33817..072f669ed5 100644 --- a/src/jpegxl.imageio/jxloutput.cpp +++ b/src/jpegxl.imageio/jxloutput.cpp @@ -12,6 +12,8 @@ #include #include +#include "color_pvt.h" + #include #include #include @@ -30,7 +32,12 @@ class JxlOutput final : public ImageOutput { { return (feature == "alpha" || feature == "nchannels" || feature == "exif" || feature == "ioproxy" - || feature == "tiles"); + || feature == "tiles" + // JPEG XL carries CICP in its native colour-encoding field + // (JxlColorEncoding), which this writer builds from the CICP + // attribute below. It must be declared, or ImageOutput's + // check_open() strips the attribute before we ever see it. + || feature == "cicp"); } bool open(const std::string& name, const ImageSpec& spec, OpenMode mode = Create) override; @@ -377,6 +384,13 @@ JxlOutput::save_metadata(ImageSpec& m_spec, JxlEncoderPtr& encoder) std::vector exif = { 0, 0, 0, 0 }; encode_exif(m_spec, exif); + // Feature 1 (spec 09): JPEG XL has no native colorInteropID slot. Under the + // force_interop_id policy, stamp the derived id so the XMP packet below + // carries it; otherwise strip it so the file stays untagged. Must run + // BEFORE encode_xmp -- xmp.cpp maps colorInteropID to aux:ColorInteropID, + // so an authored id would otherwise leak past write:interop_id=never. + pvt::apply_forced_interop_id(m_spec, "jpegxl", m_filename); + // Write XMP packet, if we have anything std::string xmp = encode_xmp(m_spec, true); diff --git a/src/libOpenImageIO/CMakeLists.txt b/src/libOpenImageIO/CMakeLists.txt index 2fa1c457c3..7c62e95f81 100644 --- a/src/libOpenImageIO/CMakeLists.txt +++ b/src/libOpenImageIO/CMakeLists.txt @@ -7,6 +7,28 @@ if (VERBOSE) endif () configure_file (buildopts.h.in "${CMAKE_BINARY_DIR}/include/buildopts.h" @ONLY) +# Compile the built-in interop identities config (an OCIO config OIIO +# ships with, similar in spirit to OCIO's own builtin configs) into a +# header as a hex byte array, so it can be parsed at runtime with no +# external file dependency. +set (INTEROP_IDENTITIES_CONFIG_PATH + "${CMAKE_CURRENT_SOURCE_DIR}/interop-identities-config.ocio") +# file(READ) does not create a configure-time dependency on its own, so an edit +# to the .ocio would otherwise leave the embedded hex (and the parsed registry) +# stale until the next manual reconfigure. Register it so CMake re-runs configure +# when the config changes. +set_property (DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${INTEROP_IDENTITIES_CONFIG_PATH}") +file (READ "${INTEROP_IDENTITIES_CONFIG_PATH}" INTEROP_HEX_CONTENTS HEX) +string (REGEX MATCHALL "([A-Za-z0-9][A-Za-z0-9])" INTEROP_SEPARATED_HEX + "${INTEROP_HEX_CONTENTS}") +list (JOIN INTEROP_SEPARATED_HEX ",0x" INTEROP_FORMATTED_HEX) +string (PREPEND INTEROP_FORMATTED_HEX "0x") +string (APPEND INTEROP_FORMATTED_HEX ",0x00") +set (INTEROP_IDENTITIES_CONFIG_HEX ${INTEROP_FORMATTED_HEX}) +configure_file (interop_identities_config.h.in + "${CMAKE_BINARY_DIR}/include/interop_identities_config.h" @ONLY) + file (GLOB libOpenImageIO_hdrs ../include/OpenImageIO/*.h) if (NOT USE_EXTERNAL_PUGIXML) @@ -50,6 +72,19 @@ set (libOpenImageIO_srcs imageoutput.cpp iptc.cpp xmp.cpp color_ocio.cpp + color_registry.cpp + color_fingerprint.cpp + color_crossconfig.cpp + color_icc_probe.cpp + color_search.cpp + color_characterization.cpp + interop_id.cpp + curve_family.cpp + chromaticity_match.cpp + transfer_signature.cpp + characterization_search.cpp + color_metadata_resolver.cpp + color_metadata_plan.cpp maketexture.cpp bluenoise.cpp printinfo.cpp @@ -201,7 +236,7 @@ if (CMAKE_UNITY_BUILD) # ${libOpenImageIO_srcs} deepdata.cpp exif.cpp exif-canon.cpp formatspec.cpp imagebuf.cpp imageinput.cpp imageio.cpp imageioplugin.cpp imageoutput.cpp - iptc.cpp xmp.cpp color_ocio.cpp maketexture.cpp bluenoise.cpp + iptc.cpp xmp.cpp color_ocio.cpp color_metadata_resolver.cpp color_metadata_plan.cpp maketexture.cpp bluenoise.cpp PROPERTIES UNITY_GROUP oiiolib) foreach (plugin_dir ${all_format_plugin_dirs} ../libtexture) @@ -250,12 +285,53 @@ if (OIIO_BUILD_TESTS AND BUILD_TESTING) FOLDER "Unit Tests" NO_INSTALL) add_test (unit_color ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/color_test) + fancy_add_executable (NAME curve_family_test SRC curve_family_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_curve_family ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/curve_family_test) + + fancy_add_executable (NAME chromaticity_match_test SRC chromaticity_match_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_chromaticity_match ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/chromaticity_match_test) + + fancy_add_executable (NAME transfer_signature_test SRC transfer_signature_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_transfer_signature ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/transfer_signature_test) + + fancy_add_executable (NAME characterization_search_test SRC characterization_search_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_characterization_search ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/characterization_search_test) + fancy_add_executable (NAME image_span_test SRC image_span_test.cpp LINK_LIBRARIES OpenImageIO Imath::Imath FOLDER "Unit Tests" NO_INSTALL) add_test (unit_image_span ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/image_span_test) set_tests_properties (unit_image_span PROPERTIES COST 10) + fancy_add_executable (NAME color_metadata_resolver_test + SRC color_metadata_resolver_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_color_metadata_resolver + ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/color_metadata_resolver_test) + + fancy_add_executable (NAME color_metadata_plan_test + SRC color_metadata_plan_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_color_metadata_plan + ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/color_metadata_plan_test) + + fancy_add_executable (NAME color_spec_resolve_test + SRC color_spec_resolve_test.cpp + LINK_LIBRARIES OpenImageIO + FOLDER "Unit Tests" NO_INSTALL) + add_test (unit_color_spec_resolve + ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/color_spec_resolve_test) + fancy_add_executable (NAME imagebuf_test SRC imagebuf_test.cpp LINK_LIBRARIES OpenImageIO FOLDER "Unit Tests" NO_INSTALL) diff --git a/src/libOpenImageIO/characterization_search.cpp b/src/libOpenImageIO/characterization_search.cpp new file mode 100644 index 0000000000..6648fd0a65 --- /dev/null +++ b/src/libOpenImageIO/characterization_search.cpp @@ -0,0 +1,106 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Pure, config-free primitives for color-space search by characterization: +// the per-term grammar parse and the three-valued (match / known-different / +// unknown) axis combination. These are the crown of the search that can be +// exercised without a live OCIO config; the hint resolution, candidate +// probing, and the bounded-exhaustive walk that call them live with the +// config in color_ocio.cpp. +// +// The three-valued table this encodes (per term): +// +// term mode property = match = known-different = unknown +// include (plain) select -- miss +// ~ inverse -- select reject +// - exclude reject -- preserve +// +// and the axis rules: an empty axis accepts everything; include ∪ inverse +// select; an exclusion-only axis starts from the full universe; exclusion +// always wins last. + +#include "color_pvt.h" +#include "imageio_pvt.h" + +#include + +#include +#include + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +std::pair +parse_search_term(string_view raw) +{ + if (raw.empty()) + return { SearchTermMode::include, {} }; + + SearchTermMode mode = SearchTermMode::include; + if (raw.front() == '-') { + mode = SearchTermMode::exclude; + raw.remove_prefix(1); + } else if (raw.front() == '~') { + mode = SearchTermMode::inverse; + raw.remove_prefix(1); + } + if (raw.empty()) + throw std::invalid_argument( + "color-space search hint may not be a bare operator"); + + std::string value(raw); + // A backslash escapes exactly one operator character ('-', '~', '\'), + // which then becomes part of the name. Everything else is invalid. + if (value.front() == '\\') { + if (value.size() == 1) + throw std::invalid_argument( + "color-space search hint has a dangling escape: " + + std::string(raw)); + if (value[1] == '-' || value[1] == '~' || value[1] == '\\') + value.erase(value.begin()); + else + throw std::invalid_argument( + "color-space search hint has an invalid escape: " + + std::string(raw)); + } + return { mode, std::move(value) }; +} + + +bool +three_valued_axis(cspan modes, + cspan term_matches, bool property_known) +{ + OIIO_DASSERT(modes.size() == term_matches.size()); + if (modes.empty()) + return true; // an empty axis is unconstrained + + // An exclusion-only axis starts from the full candidate universe; any + // include/inverse selector flips the start to "must be positively + // selected". + const bool has_selector + = std::any_of(modes.begin(), modes.end(), [](SearchTermMode m) { + return m != SearchTermMode::exclude; + }); + bool selected = !has_selector; + for (size_t i = 0, e = modes.size(); i < e; ++i) { + const bool matches = term_matches[i] != 0; + if (modes[i] == SearchTermMode::include && matches) + selected = true; + else if (modes[i] == SearchTermMode::inverse && property_known + && !matches) + selected = true; // inverse selects only *known* differences + } + // Exclusion always wins last -- a proven match on any exclude term drops + // the candidate, but an unknown property is preserved (never matches). + for (size_t i = 0, e = modes.size(); i < e; ++i) + if (modes[i] == SearchTermMode::exclude && term_matches[i] != 0) + return false; + return selected; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/characterization_search_test.cpp b/src/libOpenImageIO/characterization_search_test.cpp new file mode 100644 index 0000000000..33b3afc1ac --- /dev/null +++ b/src/libOpenImageIO/characterization_search_test.cpp @@ -0,0 +1,800 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for color-space search by characterization. Two layers: +// 1. The pure crown -- the term-grammar parse and the three-valued axis +// combination -- exercised with no config (parse_search_term, +// three_valued_axis). +// 2. The config-driven search -- pvt::find_color_spaces over small in-memory +// OCIO configs written to temp files -- exercising the universe/visibility +// gate, the `~` vs `-` unknown-propagation split, fail-fast hint +// resolution, escapes/sequences, and the exhaustive realize-clean + +// allowlist gate (which must NOT consult the fingerprint subsystem). + +#include +#include +#include +#include + +#include "color_pvt.h" +#include +#include +#include + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; + +namespace { + +template +bool +throws_invalid_argument(F&& f) +{ + try { + f(); + } catch (const std::invalid_argument&) { + return true; + } catch (...) { + return false; + } + return false; +} + + +// --------------------------------------------------------------------------- +// Layer 1: pure term grammar +// --------------------------------------------------------------------------- + +void +test_parse_search_term() +{ + using pvt::parse_search_term; + using Mode = pvt::SearchTermMode; + + auto plain = parse_search_term("scene-linear"); + OIIO_CHECK_ASSERT(plain.first == Mode::include); + OIIO_CHECK_EQUAL(plain.second, "scene-linear"); + + auto excl = parse_search_term("-scene-linear"); + OIIO_CHECK_ASSERT(excl.first == Mode::exclude); + OIIO_CHECK_EQUAL(excl.second, "scene-linear"); + + auto inv = parse_search_term("~scene-linear"); + OIIO_CHECK_ASSERT(inv.first == Mode::inverse); + OIIO_CHECK_EQUAL(inv.second, "scene-linear"); + + // Escaped operators become part of the name, keeping the term's own mode. + auto esc_dash = parse_search_term("\\-foo"); + OIIO_CHECK_ASSERT(esc_dash.first == Mode::include); + OIIO_CHECK_EQUAL(esc_dash.second, "-foo"); + + auto esc_tilde = parse_search_term("\\~foo"); + OIIO_CHECK_ASSERT(esc_tilde.first == Mode::include); + OIIO_CHECK_EQUAL(esc_tilde.second, "~foo"); + + // An operator may precede an escape: ~\~foo = inverse-match literal "~foo". + auto inv_esc = parse_search_term("~\\~foo"); + OIIO_CHECK_ASSERT(inv_esc.first == Mode::inverse); + OIIO_CHECK_EQUAL(inv_esc.second, "~foo"); + + auto esc_back = parse_search_term("\\\\foo"); + OIIO_CHECK_EQUAL(esc_back.second, "\\foo"); + + // Empty input yields an empty value (the caller skips it). + OIIO_CHECK_EQUAL(parse_search_term("").second, ""); + + // Bare operator, dangling escape, and invalid escape all throw. + OIIO_CHECK_ASSERT( + throws_invalid_argument([] { pvt::parse_search_term("-"); })); + OIIO_CHECK_ASSERT( + throws_invalid_argument([] { pvt::parse_search_term("~"); })); + OIIO_CHECK_ASSERT( + throws_invalid_argument([] { pvt::parse_search_term("\\"); })); + OIIO_CHECK_ASSERT( + throws_invalid_argument([] { pvt::parse_search_term("\\foo"); })); +} + + +// Small helper mirroring the axis-evaluator call in the search walk: build the +// parallel (modes, term_matches) spans and combine. +bool +axis(std::vector modes, std::vector matches, + bool known) +{ + return pvt::three_valued_axis(modes, matches, known); +} + +void +test_three_valued_axis() +{ + using Mode = pvt::SearchTermMode; + + // An empty axis is unconstrained. + OIIO_CHECK_EQUAL(axis({}, {}, false), true); + OIIO_CHECK_EQUAL(axis({}, {}, true), true); + + // --- The three-valued table, one include/inverse/exclude term at a time, + // against a matching / known-different / unknown property. --- + + // include: match selects; known-different misses; unknown misses. + OIIO_CHECK_EQUAL(axis({ Mode::include }, { 1 }, true), true); + OIIO_CHECK_EQUAL(axis({ Mode::include }, { 0 }, true), false); + OIIO_CHECK_EQUAL(axis({ Mode::include }, { 0 }, false), false); + + // ~ inverse: known-different selects; match misses; unknown is REJECTED. + OIIO_CHECK_EQUAL(axis({ Mode::inverse }, { 0 }, true), true); + OIIO_CHECK_EQUAL(axis({ Mode::inverse }, { 1 }, true), false); + OIIO_CHECK_EQUAL(axis({ Mode::inverse }, { 0 }, false), false); + + // - exclude: match is rejected; unknown is PRESERVED (kept). This is the + // crux of the `~` vs `-` split -- inverse rejects unknown, exclude keeps + // it. + OIIO_CHECK_EQUAL(axis({ Mode::exclude }, { 1 }, true), false); + OIIO_CHECK_EQUAL(axis({ Mode::exclude }, { 0 }, false), true); + OIIO_CHECK_EQUAL(axis({ Mode::exclude }, { 0 }, true), true); + + // Exclusion-only axis starts from the full universe (no selector needed). + OIIO_CHECK_EQUAL(axis({ Mode::exclude }, { 0 }, true), true); + + // Include ∪ inverse, then exclude subtracts. A candidate selected by an + // include term is still dropped by a matching exclude term (exclusion wins + // last). + OIIO_CHECK_EQUAL(axis({ Mode::include, Mode::exclude }, { 1, 1 }, true), + false); + OIIO_CHECK_EQUAL(axis({ Mode::include, Mode::exclude }, { 1, 0 }, true), + true); + + // Two includes: match on either selects. + OIIO_CHECK_EQUAL(axis({ Mode::include, Mode::include }, { 0, 1 }, true), + true); + OIIO_CHECK_EQUAL(axis({ Mode::include, Mode::include }, { 0, 0 }, true), + false); +} + +// --------------------------------------------------------------------------- +// Layer 2: config-driven search over small in-memory OCIO configs. +// --------------------------------------------------------------------------- + +// A tiny scratch directory that cleans itself up, for OCIO config/LUT files. +struct ScratchDir { + std::string path; + ScratchDir() + { + static std::atomic counter { 0 }; + path = Filesystem::temp_directory_path() + "/oiio_charsearch_" + + std::to_string(uintptr_t(this)) + "_" + + std::to_string(counter.fetch_add(1)); + Filesystem::create_directory(path); + } + ~ScratchDir() { Filesystem::remove_all(path); } + std::string write(string_view name, string_view contents) const + { + std::string p = path + "/" + std::string(name); + Filesystem::write_text_file(p, contents); + return p; + } +}; + + +ColorConfig +config_from_text(const ScratchDir& dir, string_view name, string_view text) +{ + return ColorConfig(dir.write(name, text)); +} + + +// The search-core fixture: an active simple space, an inactive simple space, a +// display simple space, a matrix space with no declared encoding (its encoding +// is *unknown*), and a data space (never a candidate). +constexpr const char* kSearchConfig = R"OCIO(ocio_profile_version: 2.1 +roles: + default: active_simple + scene_linear: active_simple +file_rules: + - ! {name: Default, colorspace: active_simple} +inactive_colorspaces: [inactive_simple] +colorspaces: + - ! + name: active_simple + encoding: scene-linear + - ! + name: inactive_simple + encoding: scene-linear + - ! + name: unknown_encoding + from_scene_reference: ! {matrix: [0.73, 0.02, 0.01, 0, 0.01, 0.91, 0.03, 0, 0.04, 0.02, 1.17, 0, 0, 0, 0, 1]} + - ! + name: data_space + isdata: true +display_colorspaces: + - ! + name: display_simple + encoding: display-linear +)OCIO"; + + +void +test_universe_and_visibility() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "search.ocio", kSearchConfig); + + pvt::FindColorSpacesOptions opt; + // Default universe: active/display simple spaces + the matrix space; the + // data space and inactive space are absent. + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector( + { "active_simple", "display_simple", "unknown_encoding" })); + + // include_inactive appends the inactive simple space. + opt.include_inactive = true; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector({ "active_simple", "display_simple", + "unknown_encoding", "inactive_simple" })); + + // Active off, inactive on: only the inactive space. + opt.include_active = false; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, opt) + == std::vector({ "inactive_simple" })); + + // Both off: empty (short-circuit). + opt.include_inactive = false; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, opt).empty()); + + // Determinism: an identical query returns a byte-identical ordered list. + pvt::FindColorSpacesOptions again; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, again) + == pvt::find_color_spaces(config, again)); +} + + +// An untagged, unencoded display space that IS g26_p3d65_display by value +// (same matrix + gamma as the registry definition): the fingerprint tier of +// pvt::derive_color_interop_id derives the identity, and the encoding axis +// adopts the twin's sdr-cinema outright. +constexpr const char* kUntaggedTheatricalConfig + = R"OCIO(ocio_profile_version: 2.3 +roles: + aces_interchange: reference + cie_xyz_d65_interchange: xyz + default: reference + scene_linear: reference +file_rules: + - ! {name: Default, colorspace: reference} +display_colorspaces: + - ! + name: xyz + encoding: display-linear + - ! + name: mystery_theatrical + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {value: 2.6, style: mirror, direction: inverse} +colorspaces: + - ! + name: reference + encoding: scene-linear +)OCIO"; + + +void +test_encoding_adopted_via_fingerprint() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "untagged.ocio", + kUntaggedTheatricalConfig); + + // No interop_id attribute, no authored encoding: the identity comes from + // the fingerprint tier and the encoding is adopted from the twin. + pvt::FindColorSpacesOptions opt; + opt.encodings = { "sdr-cinema" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, opt) + == std::vector({ "mystery_theatrical" })); + + // strict: no authored encoding means the property stays unknown. + opt.strict = true; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, opt).empty()); +} + + +// A space whose authored encoding (sdr-video) disagrees with its +// interop-identity twin's (g26_p3d65_display -> sdr-cinema): the encoding +// axis accepts both values unless strict. +constexpr const char* kTwinEncodingConfig = R"OCIO(ocio_profile_version: 2.3 +roles: + default: reference + scene_linear: reference +file_rules: + - ! {name: Default, colorspace: reference} +colorspaces: + - ! + name: reference + encoding: scene-linear + - ! + name: theatrical_output + encoding: sdr-video + interop_id: g26_p3d65_display + from_scene_reference: ! {value: 2.6, style: mirror, direction: inverse} + - ! + name: plain_video + encoding: sdr-video + from_scene_reference: ! {value: 2.2, style: mirror, direction: inverse} +)OCIO"; + + +void +test_encoding_twin_inference_and_strict() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "twin.ocio", + kTwinEncodingConfig); + + // OCIO < 2.5 drops the authored `interop_id:` key at parse, so the twin + // link (theatrical_output -> g26_p3d65_display -> sdr-cinema) never + // forms there and the inferred-encoding expectations below do not hold. + // Probe the loaded config rather than the OCIO version -- but anchor the + // probe at 2.5+, where a false result would mean authored ids silently + // stopped surfacing (a regression, not a capability gap). + const bool have_authored_id + = !config.get_color_interop_id("theatrical_output").empty(); + if (ColorConfig::OpenColorIO_version_hex() >= 0x02050000) + OIIO_CHECK_ASSERT(have_authored_id); + + // Inferred: authored sdr-video, but the g26_p3d65_display twin carries + // sdr-cinema -- the candidate matches both values. + pvt::FindColorSpacesOptions opt; + opt.encodings = { "sdr-cinema" }; + if (have_authored_id) + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, opt) + == std::vector({ "theatrical_output" })); + + opt.encodings = { "sdr-video" }; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector({ "plain_video", "theatrical_output" })); + + // Hint-by-example reads the named space's own effective encoding + // (sdr-video), not its twin's. + opt.encodings = { "theatrical_output" }; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector({ "plain_video", "theatrical_output" })); + + // Exclusion removes a proven (inferred) match; inverse requires a proven + // difference on every characterized value. Both depend on the twin's + // sdr-cinema being provable, hence the same authored-id gate. + if (have_authored_id) { + opt.encodings = { "-sdr-cinema" }; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector({ "plain_video", "reference" })); + opt.encodings = { "~sdr-cinema" }; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector({ "plain_video", "reference" })); + } + + // strict: authored attributes only -- the twin's encoding never enters. + opt.strict = true; + opt.encodings = { "sdr-cinema" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, opt).empty()); + opt.encodings = { "sdr-video" }; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, opt) + == std::vector({ "plain_video", "theatrical_output" })); +} + + +void +test_encoding_three_valued_split() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "search.ocio", kSearchConfig); + + // include: only the proven scene-linear space. + pvt::FindColorSpacesOptions inc; + inc.encodings = { "scene-linear" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, inc) + == std::vector({ "active_simple" })); + + // ~ inverse: only the space *proven different* (display_simple). The + // unknown-encoding matrix space is REJECTED (its encoding is unknown). + pvt::FindColorSpacesOptions inv; + inv.encodings = { "~scene-linear" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, inv) + == std::vector({ "display_simple" })); + + // - exclude: everything not *proven* scene-linear -- the unknown-encoding + // space is PRESERVED here (the crux of the `~` vs `-` split). + pvt::FindColorSpacesOptions exc; + exc.encodings = { "-scene-linear" }; + OIIO_CHECK_ASSERT( + pvt::find_color_spaces(config, exc) + == std::vector({ "display_simple", "unknown_encoding" })); + + // image-state axis: only the display space. + pvt::FindColorSpacesOptions disp; + disp.image_states = { "display" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, disp) + == std::vector({ "display_simple" })); + + // A bogus image-state hint fails fast. + pvt::FindColorSpacesOptions bogus; + bogus.image_states = { "bogus" }; + OIIO_CHECK_ASSERT(throws_invalid_argument( + [&] { pvt::find_color_spaces(config, bogus); })); + + // A bogus encoding hint fails fast. + pvt::FindColorSpacesOptions bad_enc; + bad_enc.encodings = { "no_such_encoding" }; + OIIO_CHECK_ASSERT(throws_invalid_argument( + [&] { pvt::find_color_spaces(config, bad_enc); })); + + // A chromaticity hint that is neither a local space, a known id, nor a + // complete gamut component fails fast (the incomplete "p3" fragment case). + pvt::FindColorSpacesOptions bad_chroma; + bad_chroma.chromaticities = { "p3" }; + OIIO_CHECK_ASSERT(throws_invalid_argument( + [&] { pvt::find_color_spaces(config, bad_chroma); })); +} + + +// Escapes + multi-term sequences: names that literally start with an operator. +constexpr const char* kEscapedNamesConfig = R"OCIO(ocio_profile_version: 2.1 +roles: + default: foo + scene_linear: foo +file_rules: + - ! {name: Default, colorspace: foo} +colorspaces: + - ! + name: foo + encoding: scene-linear + - ! + name: "-foo" + encoding: display-linear + - ! + name: "~foo" + encoding: log +)OCIO"; + + +void +test_escapes_and_sequences() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "escaped.ocio", + kEscapedNamesConfig); + + // An escaped operator is part of the encoding name literal. Here every + // space is simple, so the encoding hint selects by its declared encoding; + // "\-foo" resolves the literal encoding of the space named "-foo" + // (display-linear), selecting that space. The fixture spaces are identity + // transforms whose authored encodings contradict what fingerprinting + // infers (every one twins lin_ap0_scene); strict isolates the term + // grammar under test from twin-encoding inference. + pvt::FindColorSpacesOptions esc; + esc.strict = true; + esc.encodings = { "\\-foo" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, esc) + == std::vector({ "-foo" })); + + // A sequence: include two encodings, then exclude one. foo (scene-linear) + // survives; -foo (display-linear) is excluded. + pvt::FindColorSpacesOptions seq; + seq.strict = true; + seq.encodings = { "scene-linear", "display-linear", "-display-linear" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, seq) + == std::vector({ "foo" })); + + // An invalid escape fails fast. + pvt::FindColorSpacesOptions bad; + bad.encodings = { "\\foo" }; + OIIO_CHECK_ASSERT( + throws_invalid_argument([&] { pvt::find_color_spaces(config, bad); })); +} + + +// A non-simple (CDL) space is excluded from the default universe, but may still +// be *named* as a hint source. A custom curve NamedTransform matches +// behaviorally by signature. +constexpr const char* kComplexConfig = R"OCIO(ocio_profile_version: 2.1 +roles: + default: reference + aces_interchange: reference + scene_linear: reference +file_rules: + - ! {name: Default, colorspace: reference} +colorspaces: + - ! + name: reference + encoding: scene-linear + - ! + name: complex_space + encoding: scene-linear + from_scene_reference: ! {slope: [1, 1, 1], offset: [0, 0, 0], power: [1, 1, 1], saturation: 1} +)OCIO"; + + +void +test_non_simple_and_hint_source() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "complex.ocio", kComplexConfig); + + // The CDL space is not in the default universe. + const auto names = pvt::find_color_spaces(config, {}); + OIIO_CHECK_ASSERT(std::find(names.begin(), names.end(), "complex_space") + == names.end()); + + // ...but it can be a hint source: its (scene-linear) encoding selects the + // reference space. A hint source is not itself a result. + pvt::FindColorSpacesOptions hint; + hint.encodings = { "complex_space" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, hint) + == std::vector({ "reference" })); +} + + +// The exhaustive realize-clean + allowlist gate: a +// non-simple, file-backed color space whose realized ops all pass the atomic +// allowlist is admitted under exhaustive=true -- decided by realizing the +// processor and inspecting its ops, NOT by the fingerprint subsystem. +void +test_exhaustive_realize_clean_gate() +{ + ScratchDir dir; + // A trivial identity matrix CLF. Unlike .spi1d/.spimtx (which the default + // classification already accepts as simple), a .clf file transform is not + // simple by default -- so it exercises the exhaustive revisit path -- yet + // it is exhaustive-eligible and realizes to an allowlisted matrix op. + dir.write("identity.clf", R"( + + + +1 0 0 +0 1 0 +0 0 1 + + + +)"); + constexpr const char* cfg = R"OCIO(ocio_profile_version: 2.1 +roles: + default: reference + aces_interchange: reference + scene_linear: reference +file_rules: + - ! {name: Default, colorspace: reference} +colorspaces: + - ! + name: reference + encoding: scene-linear + - ! + name: clf_backed + from_scene_reference: ! {src: identity.clf} + - ! + name: cdl_backed + from_scene_reference: ! {slope: [1.1, 1, 1], offset: [0, 0, 0], power: [1, 1, 1], saturation: 1} +)OCIO"; + ColorConfig config = config_from_text(dir, "exhaustive.ocio", cfg); + + // Default: the .clf-backed space is not simple, and the CDL space is not + // simple, so both are absent. + const auto plain = pvt::find_color_spaces(config, {}); + OIIO_CHECK_ASSERT(std::find(plain.begin(), plain.end(), "clf_backed") + == plain.end()); + OIIO_CHECK_ASSERT(std::find(plain.begin(), plain.end(), "cdl_backed") + == plain.end()); + + // Exhaustive: the .clf-backed space realizes to an allowlisted matrix op + // and is admitted; the CDL space is rejected by the allowlist (no + // fingerprint consulted). This is the realize-clean + allowlist gate. + pvt::FindColorSpacesOptions ex; + ex.exhaustive = true; + const auto exhaustive = pvt::find_color_spaces(config, ex); + OIIO_CHECK_ASSERT( + std::find(exhaustive.begin(), exhaustive.end(), "clf_backed") + != exhaustive.end()); + OIIO_CHECK_ASSERT( + std::find(exhaustive.begin(), exhaustive.end(), "cdl_backed") + == exhaustive.end()); +} + + +// Transfer axis: identity `~` (proven non-linear) and a custom-curve +// NamedTransform matched behaviorally by signature. +constexpr const char* kCustomCurveConfig = R"OCIO(ocio_profile_version: 2.1 +roles: + default: reference + aces_interchange: reference + scene_linear: reference +file_rules: + - ! {name: Default, colorspace: reference} +named_transforms: + - ! + name: Odd 2.35 - Curve + aliases: [odd235_crv] + encoding: sdr-video + inverse_transform: ! {value: 2.35, style: pass_thru, direction: inverse} +colorspaces: + - ! + name: reference + encoding: scene-linear + - ! + name: odd_encoded + encoding: sdr-video + from_scene_reference: ! {value: 2.35, style: pass_thru, direction: inverse} +)OCIO"; + + +void +test_transfer_axis() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "curve.ocio", + kCustomCurveConfig); + + // A custom-curve NamedTransform with no registry identity matches the + // behaviorally equivalent color space by probed signature. + pvt::FindColorSpacesOptions curve; + curve.transfer_functions = { "Odd 2.35" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, curve) + == std::vector({ "odd_encoded" })); + + // ~ inverse against the linear reference: only the *proven non-linear* + // space is returned (the reference itself, being linear/identity, drops). + pvt::FindColorSpacesOptions inv; + inv.transfer_functions = { "~reference" }; + OIIO_CHECK_ASSERT(pvt::find_color_spaces(config, inv) + == std::vector({ "odd_encoded" })); +} + + +// A config with one context-sensitive space: ctx_space resolves $CTX at +// probe time, behaving as gamma_a (2.35 curve) or gamma_b (1.4 curve) +// depending on the context. The explicit per-call context override must be +// honored by every characterization probe. +constexpr const char* kContextConfig = R"OCIO(ocio_profile_version: 2.1 +environment: + CTX: gamma_a +roles: + default: ref + aces_interchange: ref + scene_linear: ref +file_rules: + - ! {name: Default, colorspace: ref} +colorspaces: + - ! + name: ref + encoding: scene-linear + - ! + name: gamma_a + encoding: sdr-video + from_scene_reference: ! {value: 2.35, style: pass_thru, direction: inverse} + - ! + name: gamma_b + encoding: sdr-video + from_scene_reference: ! {value: 1.4, style: pass_thru, direction: inverse} + - ! + name: ctx_space + encoding: sdr-video + to_scene_reference: ! {src: $CTX, dst: ref} +)OCIO"; + + +void +test_context_override() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "ctx.ocio", kContextConfig); + + auto contains = [](const std::vector& v, const char* name) { + return std::find(v.begin(), v.end(), name) != v.end(); + }; + + pvt::FindColorSpacesOptions opt; + opt.include_context_sensitive = true; + opt.transfer_functions = { "gamma_a" }; + + // Under CTX=gamma_a (also the config's ambient default), ctx_space + // behaves as gamma_a and matches the hint. + opt.context = { { "CTX", "gamma_a" } }; + const auto with_a = pvt::find_color_spaces(config, opt); + OIIO_CHECK_ASSERT(contains(with_a, "gamma_a")); + OIIO_CHECK_ASSERT(contains(with_a, "ctx_space")); + + // Under CTX=gamma_b the SAME space behaves as gamma_b: the probes must + // run under the explicit override, so ctx_space drops out of a gamma_a + // transfer query... + opt.context = { { "CTX", "gamma_b" } }; + const auto with_b = pvt::find_color_spaces(config, opt); + OIIO_CHECK_ASSERT(contains(with_b, "gamma_a")); + OIIO_CHECK_FALSE(contains(with_b, "ctx_space")); + + // ...and reappears in a gamma_b transfer query under the same override. + pvt::FindColorSpacesOptions opt_b; + opt_b.include_context_sensitive = true; + opt_b.transfer_functions = { "gamma_b" }; + opt_b.context = { { "CTX", "gamma_b" } }; + const auto with_b2 = pvt::find_color_spaces(config, opt_b); + OIIO_CHECK_ASSERT(contains(with_b2, "gamma_b")); + OIIO_CHECK_ASSERT(contains(with_b2, "ctx_space")); + + // Two explicit contexts, same query, different result sets. + OIIO_CHECK_ASSERT(with_a != with_b); +} + + +// The internal ColorConfig::find_color_spaces thin adapter: it must map +// ColorSpaceSearchOptions onto the internal option set and forward to the +// pvt core, always searching active spaces (there is no include_active +// toggle on the facade), and it must convert the core's fail-fast +// throw into the has_error()/geterror() convention (the facade never +// throws). +void +test_internal_adapter() +{ + ScratchDir dir; + ColorConfig config = config_from_text(dir, "search.ocio", kSearchConfig); + + // A hint through the public method returns the same result as the core. + std::vector enc = { "-scene-linear" }; + pvt::FindColorSpacesOptions core; + core.encodings = enc; + OIIO_CHECK_ASSERT(config.find_color_spaces({}, {}, enc, {}) + == pvt::find_color_spaces(config, core)); + + // Default (all axes empty) exposes the active/simple universe. + OIIO_CHECK_ASSERT( + config.find_color_spaces() + == std::vector( + { "active_simple", "display_simple", "unknown_encoding" })); + + // include_inactive flows through the adapter. + ColorSpaceSearchOptions inactive_opts; + inactive_opts.include_inactive = true; + OIIO_CHECK_ASSERT( + config.find_color_spaces({}, {}, {}, {}, inactive_opts) + == std::vector({ "active_simple", "display_simple", + "unknown_encoding", "inactive_simple" })); + + // The core's fail-fast throw on an unresolvable hint is converted to + // the error convention: empty result, has_error() set, never a throw. + std::vector bad = { "bogus" }; + std::vector r; + OIIO_CHECK_ASSERT(!throws_invalid_argument( + [&] { r = config.find_color_spaces({}, {}, {}, bad); })); + OIIO_CHECK_ASSERT(r.empty()); + OIIO_CHECK_ASSERT(config.has_error()); + OIIO_CHECK_ASSERT(!config.geterror().empty()); + OIIO_CHECK_ASSERT(!config.has_error()); // geterror() cleared it +} + +} // namespace + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_parse_search_term(); + test_three_valued_axis(); + test_universe_and_visibility(); + test_encoding_adopted_via_fingerprint(); + test_encoding_twin_inference_and_strict(); + test_encoding_three_valued_split(); + test_escapes_and_sequences(); + test_non_simple_and_hint_source(); + test_exhaustive_realize_clean_gate(); + test_transfer_axis(); + test_context_override(); + test_internal_adapter(); + return unit_test_failures; +} diff --git a/src/libOpenImageIO/chromaticity_match.cpp b/src/libOpenImageIO/chromaticity_match.cpp new file mode 100644 index 0000000000..2a45af9593 --- /dev/null +++ b/src/libOpenImageIO/chromaticity_match.cpp @@ -0,0 +1,155 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Pure chromaticity math for color-space search by characterization: the +// reserved-primaries table keyed by interop-id token, the AP0->XYZ(D65) +// matrix that turns a set of AP0-anchored RGB probes into chromaticity +// coordinates, and the coordinate rounding/snapping that is the *only* place +// numerical fuzz is absorbed. Once rounding lands, chromaticity equality is +// coordinate-exact -- a Chromaticities is a std::array, so callers compare +// with plain `==` / std::find (no epsilon, no dedicated compare helper). +// +// Everything here is pure and config-free: reserved_chromaticities_for_id +// takes an id string, and chromaticities_from_ap0_probes takes the already- +// probed AP0 RGB quartet, so both unit-test without a live OCIO config. The +// config-driving probe (colorspace -> interchange apply that produces those +// AP0 RGB values) lives with the search walk that consumes it. +// +// Single-hypothesis derivation: the matrix is Bradford-adapted from the ACES +// white to D65 only. Non-D65 / log-curve spaces and registry gamuts absent +// from the reserved table are not resolved here; the multi-hypothesis +// whitepoint/CAT sweep is a documented follow-on. + +#include "color_pvt.h" +#include "imageio_pvt.h" + +#include + +#include +#include +#include + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +double +round_chromaticity_coord(double value) +{ + // Round to 6 decimals, then snap to the nearest coarser 5/4/3/2-digit + // grid if it lands within snapTol. First (finest) grid within tolerance + // wins. This absorbs OCIO chromaticity floats that come back as e.g. + // 0.329999998; downstream equality is exact on the result. + const double rounded = std::round(value * 1.0e6) / 1.0e6; + static constexpr double snaptol = 2.0e-7; // 2 * 10^-(6+1) + for (int digits : { 5, 4, 3, 2 }) { + const double factor = std::pow(10.0, digits); + const double candidate = std::round(rounded * factor) / factor; + if (std::abs(rounded - candidate) <= snaptol) + return candidate; + } + return rounded; +} + + +std::optional +reserved_chromaticities_for_id(string_view interop_id) +{ + if (interop_id.empty()) + return {}; + + // Reserved (R,G,B,W) xy primaries for well-known interop-id tokens, per + // the CIF registry reserved tables. Probed as an "__" substring of + // the lowered id (so a token must be a *complete* gamut component, not an + // arbitrary fragment), first match wins -- table order is readability + // only. + static const std::pair reserved[] = { + // clang-format off + { "bmdwg5", {{ {{0.7177215, 0.3171181}}, {{0.228041, 0.861569}}, + {{0.1005841, -0.0820452}}, {{0.312717, 0.3290312}} }} }, + { "wg5", {{ {{0.7177215, 0.3171181}}, {{0.228041, 0.861569}}, + {{0.1005841, -0.0820452}}, {{0.312717, 0.3290312}} }} }, + { "sgamut3venice", {{ {{0.74046426, 0.27936437}}, {{0.08924115, 0.89380953}}, + {{0.11048824, -0.05257933}}, {{0.3127, 0.329}} }} }, + { "sgamut3cinevenice", {{ {{0.77590187, 0.27450239}}, {{0.1886829, 0.82868494}}, + {{0.10133738, -0.08918752}}, {{0.3127, 0.329}} }} }, + { "rec2020", {{ {{0.708, 0.292}}, {{0.170, 0.797}}, + {{0.131, 0.046}}, {{0.3127, 0.329}} }} }, + { "rec601pal", {{ {{0.64, 0.33}}, {{0.29, 0.60}}, + {{0.15, 0.06}}, {{0.3127, 0.329}} }} }, + { "rec601", {{ {{0.63, 0.34}}, {{0.31, 0.595}}, + {{0.155, 0.07}}, {{0.3127, 0.329}} }} }, + { "rec709", {{ {{0.64, 0.33}}, {{0.30, 0.60}}, + {{0.15, 0.06}}, {{0.3127, 0.329}} }} }, + { "p3d65", {{ {{0.68, 0.32}}, {{0.265, 0.69}}, + {{0.15, 0.06}}, {{0.3127, 0.329}} }} }, + { "ap0", {{ {{0.7347, 0.2653}}, {{0.0, 1.0}}, + {{0.0001, -0.077}}, {{0.32168, 0.33767}} }} }, + { "ap1", {{ {{0.713, 0.293}}, {{0.165, 0.830}}, + {{0.128, 0.044}}, {{0.32168, 0.33767}} }} }, + // clang-format on + }; + + const std::string lowered = Strutil::lower(interop_id); + for (const auto& [token, chroma] : reserved) { + const std::string probe = std::string("_") + token + "_"; + if (lowered.find(probe) != std::string::npos) + return chroma; + } + // AdobeRGB is matched only on the exact id or an "_adobergb_" token, never + // via the substring loop, because many ids carry "rgb" incidentally. + if (lowered == "adobergb" + || lowered.find("_adobergb_") != std::string::npos) + return Chromaticities { { { { 0.64, 0.33 } }, + { { 0.21, 0.71 } }, + { { 0.15, 0.06 } }, + { { 0.3127, 0.329 } } } }; + return {}; +} + + +std::optional +chromaticities_from_ap0_probes(cspan ap0_rgb) +{ + if (ap0_rgb.size() != 12) + return {}; + + // AP0 (ACES2065-1) RGB -> CIE XYZ, Bradford-adapted from the ACES white + // (0.32168, 0.33767) to D65 (XYZ 0.95045592705167, 1, 1.08905775075988). + // Hardcoded to match OCIO's builtin transform constants closely enough + // that probe-derived coordinates land exactly on reference values after + // round_chromaticity_coord. Single hypothesis: D65 whitepoint + Bradford + // CAT only -- the multi-hypothesis whitepoint/CAT sweep is a follow-on. + static const double kAp0ToXyzD65[3][3] = { + { 0.93827984927725538, -0.0044514458123613527, 0.016627523586776195 }, + { 0.33736889078783672, 0.72952156669026558, -0.0668904574781026 }, + { 0.0011739508496858876, -0.0037107064020525725, 1.0915945063122463 }, + }; + + // Four probes -- pure R, G, B, W pushed through colorspace -> AP0 + // interchange by the caller. A transfer curve that maps 0->0 and 1->1 + // drops out, and xy is scale-invariant, so pure gain is harmless. + Chromaticities out; + for (int i = 0; i < 4; ++i) { + const float* rgb = ap0_rgb.data() + i * 3; + double xyz[3]; + for (int r = 0; r < 3; ++r) + xyz[r] = kAp0ToXyzD65[r][0] * double(rgb[0]) + + kAp0ToXyzD65[r][1] * double(rgb[1]) + + kAp0ToXyzD65[r][2] * double(rgb[2]); + const double sum = xyz[0] + xyz[1] + xyz[2]; + if (!std::isfinite(sum) || std::abs(sum) < 1.0e-12) + return {}; + out[i] = { { round_chromaticity_coord(xyz[0] / sum), + round_chromaticity_coord(xyz[1] / sum) } }; + } + // An equal-energy white rounds to x == y; normalize to exact (1/3, 1/3). + if (out[3][0] == out[3][1]) + out[3] = { { 1.0 / 3.0, 1.0 / 3.0 } }; + return out; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/chromaticity_match_test.cpp b/src/libOpenImageIO/chromaticity_match_test.cpp new file mode 100644 index 0000000000..857fb55b19 --- /dev/null +++ b/src/libOpenImageIO/chromaticity_match_test.cpp @@ -0,0 +1,123 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for the pvt:: pure chromaticity math (reserved-primaries table, +// AP0->XYZ(D65) probe solve, coordinate rounding/snapping). Pure functions -- +// no config or OCIO needed. + +#include +#include +#include + +#include "color_pvt.h" + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; + + +static void +test_round_chromaticity_coord() +{ + // The single fuzz-absorbing step: round to 6 decimals, then snap to a + // coarser grid within 2e-7. 0.329999998 -> 0.330000 (6) -> snaps to 0.33. + OIIO_CHECK_EQUAL(pvt::round_chromaticity_coord(0.329999998), 0.33); + OIIO_CHECK_EQUAL(pvt::round_chromaticity_coord(0.3127), 0.3127); + OIIO_CHECK_EQUAL(pvt::round_chromaticity_coord(0.150000012), 0.15); + // No coarser grid within tol -> the 6-decimal rounding stands. + OIIO_CHECK_EQUAL(pvt::round_chromaticity_coord(0.734789), 0.734789); + // Exactly representable coarse values are unchanged. + OIIO_CHECK_EQUAL(pvt::round_chromaticity_coord(0.06), 0.06); +} + + +static void +test_reserved_chromaticities_for_id() +{ + // Probed as "__" against the lowered id: a full interop id + // resolves, but a bare or partial fragment does not. + auto ap1 = pvt::reserved_chromaticities_for_id("lin_ap1_scene"); + OIIO_CHECK_ASSERT(ap1.has_value()); + OIIO_CHECK_EQUAL((*ap1)[0][0], 0.713); + OIIO_CHECK_EQUAL((*ap1)[3][1], 0.33767); // ACES white y + + auto rec709 = pvt::reserved_chromaticities_for_id("srgb_rec709_display"); + OIIO_CHECK_ASSERT(rec709.has_value()); + OIIO_CHECK_EQUAL((*rec709)[1][1], 0.60); // green y + + // A bare component fragment is not a full id -> no "_token_" match. (The + // search axis resolves bare "ap1" through a separate component path.) + OIIO_CHECK_ASSERT(!pvt::reserved_chromaticities_for_id("ap1").has_value()); + OIIO_CHECK_ASSERT(!pvt::reserved_chromaticities_for_id("p3").has_value()); + OIIO_CHECK_ASSERT(!pvt::reserved_chromaticities_for_id("").has_value()); + + // AdobeRGB matches only exactly / via _adobergb_, never through the + // substring loop -- and a rec709 id must not accidentally hit it. + auto adobe = pvt::reserved_chromaticities_for_id("adobergb"); + OIIO_CHECK_ASSERT(adobe.has_value()); + OIIO_CHECK_EQUAL((*adobe)[1][0], 0.21); // green x, adobergb-specific + OIIO_CHECK_ASSERT( + pvt::reserved_chromaticities_for_id("g22_adobergb_display").has_value()); + auto plain709 = pvt::reserved_chromaticities_for_id("lin_rec709_scene"); + OIIO_CHECK_ASSERT(plain709.has_value()); + OIIO_CHECK_EQUAL((*plain709)[1][0], + 0.30); // rec709 green x, not adobergb's + + // Case-insensitive. + OIIO_CHECK_ASSERT( + pvt::reserved_chromaticities_for_id("LIN_AP1_SCENE").has_value()); +} + + +static void +test_chromaticities_from_ap0_probes() +{ + // AP0 white probe (1,1,1) through the D65-adapted matrix lands on D65 + // (0.3127, 0.329) after rounding; R/G/B probes carry the other three. + const std::vector unit { 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1 }; + auto c = pvt::chromaticities_from_ap0_probes(unit); + OIIO_CHECK_ASSERT(c.has_value()); + OIIO_CHECK_EQUAL((*c)[3][0], 0.3127); // white x + OIIO_CHECK_EQUAL((*c)[3][1], 0.329); // white y + OIIO_CHECK_EQUAL_THRESH((*c)[0][0], 0.734855, + 1e-6); // AP0 red x (D65-adapted) + + // Illuminant-E white probe (equal XYZ) rounds to x == y and is snapped to + // exact (1/3, 1/3). + const std::vector eq { 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 1.0540976150737138f, + 0.9674863678639096f, + 0.9182462840112028f }; + auto e = pvt::chromaticities_from_ap0_probes(eq); + OIIO_CHECK_ASSERT(e.has_value()); + OIIO_CHECK_EQUAL((*e)[3][0], 1.0 / 3.0); + OIIO_CHECK_EQUAL((*e)[3][1], 1.0 / 3.0); + + // Wrong span length and a degenerate (all-zero) probe both decline. + const std::vector shortspan { 1, 0, 0 }; + OIIO_CHECK_ASSERT( + !pvt::chromaticities_from_ap0_probes(shortspan).has_value()); + const std::vector zero(12, 0.0f); + OIIO_CHECK_ASSERT(!pvt::chromaticities_from_ap0_probes(zero).has_value()); +} + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_round_chromaticity_coord(); + test_reserved_chromaticities_for_id(); + test_chromaticities_from_ap0_probes(); + return unit_test_failures; +} diff --git a/src/libOpenImageIO/color_characterization.cpp b/src/libOpenImageIO/color_characterization.cpp new file mode 100644 index 0000000000..fdb1d15c04 --- /dev/null +++ b/src/libOpenImageIO/color_characterization.cpp @@ -0,0 +1,780 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// The field-selective color-space characterization engine +// (pvt::characterize_color_space), its process-global cache, and the public +// surface adapted to it: the opaque immutable ColorSpaceInfo record and +// ColorConfig::get_color_space_info (scalar and batch). See color_pvt.h for +// the engine contract. Split alongside color_search.cpp; see +// color_ocio_pvt.h for the shared internal declarations. + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "color_ocio_pvt.h" + + +// The engine touches ColorConfig::Impl, which lives in the ABI-versioned +// v3_1 namespace -- so the core below must too (same pattern as +// color_search.cpp). The OIIO_API pvt shims declared by color_pvt.h in the +// library's "current" namespace are defined at the end of this file and +// reach back here with explicit v3_1:: qualification. +OIIO_NAMESPACE_3_1_BEGIN + +namespace spvt = OIIO::pvt; + +namespace { + +using Field = spvt::CharacterizationField; + +// --------------------------------------------------------------------------- +// Process-global characterization cache. Keyed on (structural config cache +// id, effective context cache id, canonical space name) with the same +// context-invariant bucket collapse the fingerprint cache uses (see +// color_fingerprint.cpp). Values are whole CharacterizationRecord snapshots; +// publication is a field-wise first-writer-wins merge under the lock, and +// computation never happens while holding it. Content-addressed: a changed +// config or context yields new keys and orphans old entries, so +// ColorConfig::reset() needs no invalidation. +// --------------------------------------------------------------------------- + +std::mutex& +char_cache_mutex() +{ + static std::mutex m; + return m; +} + +std::unordered_map& +char_cache() +{ + static std::unordered_map cache; + return cache; +} + +// ponytail: clear-on-limit like the fingerprint cache; upgrade to LRU only +// if churny workloads show recompute cost in profiles. +constexpr size_t char_cache_max_entries = 8192; + +std::string +char_cache_key(const std::string& cfgId, const std::string& ctxId, + bool invariant, string_view name) +{ + return invariant ? Strutil::fmt::format("{}|invariant|{}", cfgId, name) + : Strutil::fmt::format("{}|{}|{}", ctxId, cfgId, name); +} + +// Copy field `f`'s value slot from `src` into `dst` and mirror its tri-state +// bits. The one field-copy primitive both merge directions use. +void +copy_field(spvt::CharacterizationRecord& dst, + const spvt::CharacterizationRecord& src, Field f) +{ + switch (f) { + case Field::EqualityID: dst.equality_id = src.equality_id; break; + case Field::ColorInteropID: + dst.color_interop_id = src.color_interop_id; + break; + case Field::Encoding: dst.encoding = src.encoding; break; + case Field::ImageState: dst.image_state = src.image_state; break; + case Field::Range: dst.range = src.range; break; + case Field::Chromaticities: + dst.chromaticities = src.chromaticities; + dst.chromaticities_xy = src.chromaticities_xy; + break; + case Field::TransferFunction: + dst.transfer_kind = src.transfer_kind; + dst.transfer_function = src.transfer_function; + dst.transfer_signature = src.transfer_signature; + dst.transfer_identity = src.transfer_identity; + break; + default: break; + } + const uint32_t bit = uint32_t(f); + dst.computed_mask |= (src.computed_mask & bit); + dst.available_mask = (dst.available_mask & ~bit) + | (src.available_mask & bit); + dst.derived_mask = (dst.derived_mask & ~bit) | (src.derived_mask & bit); +} + +const Field all_fields[] = { Field::EqualityID, Field::ColorInteropID, + Field::Encoding, Field::ImageState, + Field::Range, Field::Chromaticities, + Field::TransferFunction }; + +// Merge `src` into `dst`, field-wise: a field is copied when `dst` has not +// computed it, when `dst`'s attempt found no usable value and `src`'s did, +// or when `src` holds a behaviorally DERIVED value and `dst`'s is merely +// direct -- the derive tier may correct a cheap verdict (a mislabeled +// config's syntactic table match, outranked by the fingerprint cascade) and +// the correction must survive merging with a later fresh cheap pass. +// Established values of equal provenance are never overwritten +// (first-writer-wins per field). +void +merge_record(spvt::CharacterizationRecord& dst, + const spvt::CharacterizationRecord& src) +{ + for (Field f : all_fields) { + if (!src.computed(f)) + continue; + if (!dst.computed(f) || (!dst.available(f) && src.available(f)) + || (src.derived(f) && !dst.derived(f))) + copy_field(dst, src, f); + } + // Full-attempt bookkeeping accumulates regardless of which side's value + // won: once a field's full derivation has been attempted anywhere, it is + // settled and never retried. + dst.full_attempt_mask |= src.full_attempt_mask; +} + +// Set field `f`'s tri-state on `rec`: attempted always; usable and +// derivation provenance as reported. +void +mark(spvt::CharacterizationRecord& rec, Field f, bool is_available, + bool is_derived = false) +{ + const uint32_t bit = uint32_t(f); + rec.computed_mask |= bit; + if (is_available) + rec.available_mask |= bit; + if (is_derived) + rec.derived_mask |= bit; +} + +} // namespace + + + +spvt::CharacterizationRecord +characterize_color_space_impl(const ColorConfig& config, + string_view color_space, + uint32_t requested_fields, + const std::map& context) +{ + spvt::CharacterizationRecord rec; + auto* impl = pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || !impl->config_ || disable_ocio || color_space.empty()) + return rec; // invalid + + // Resolution uses the syntactic (fingerprint-free) subset, which returns + // empty on a genuine miss -- exactly the validity test the cheap + // contract needs, and never wakes the fingerprint engine. + std::string resolved(impl->resolve_syntactic(color_space)); + OCIO::ConstColorSpaceRcPtr cs; + if (!resolved.empty()) { + try { + cs = impl->config_->getColorSpace(resolved.c_str()); + } catch (...) { + cs = nullptr; + } + } + const bool is_data = cs && cs->isData(); + if (!cs) { + // The name lands on no space in the ACTIVE config, but a color + // interop ID is meaningful against ANY interoperable config through + // the registry and the legacy syntactic table (the point of the + // embedded registry -- the builtin configs bundled with OCIO < 2.4 + // lack the CIID aliases entirely), and the derive cascade never + // guesses. Fall through under the raw query name so the shared + // cache and the ColorInteropID derive tier below run as usual; the + // config-space-dependent facts stay unattempted. + if (!(requested_fields & uint32_t(Field::ColorInteropID))) + return rec; // nothing else is answerable without a config space + resolved = std::string(color_space); + rec.name = resolved; + } else { + rec.name = cs->getName() ? cs->getName() : resolved; + + // ---- Cheap direct facts (no processors, no probes). + + // Image state: the OCIO reference-space kind. A data space's + // reference kind is arbitrary, so its state is honestly + // undetermined. + if (!is_data) + rec.image_state = cs->getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY + ? "display" + : "scene"; + mark(rec, Field::ImageState, !rec.image_state.empty()); + + // Color Interop ID: the existing cheap declared/table subset. + rec.color_interop_id = std::string( + config.get_color_interop_id(resolved)); + mark(rec, Field::ColorInteropID, !rec.color_interop_id.empty()); + + // Encoding: the authored attribute only (the interop-counterpart + // fallback is a derivation, below). + if (cs->getEncoding() && cs->getEncoding()[0]) + rec.encoding = cs->getEncoding(); + mark(rec, Field::Encoding, !rec.encoding.empty()); + + // Range: supplied only when intrinsic to a registered identity or + // otherwise explicitly known -- nothing registers one today, so the + // attempt is a stable negative. Never guessed from a name (the + // static CICP table's range flag is a fixed encode-time convention, + // not per-space knowledge). + mark(rec, Field::Range, false); + } + + // ---- Cache scope. An unkeyable config skips the shared cache. + const std::string cfgId = get_config_cache_id(impl->config_); + OCIO::ConstContextRcPtr ctx; // null = the config's ambient context + if (!context.empty()) + ctx = make_context_with_overrides(impl->config_, context); + const std::string ctxId = ctx ? context_cache_id(ctx) + : impl->currentContextID(); + + // ---- Merge previously cached derived facts (a cheap read; checking + // both buckets avoids waking the classifier on the cheap path -- a + // space is only ever published under one of them). + if (!cfgId.empty()) { + std::lock_guard lock(char_cache_mutex()); + auto& cache = char_cache(); + for (bool invariant : { true, false }) { + auto it = cache.find( + char_cache_key(cfgId, ctxId, invariant, rec.name)); + if (it != cache.end()) { + merge_record(rec, it->second); + break; + } + } + } + + // ---- Requested derivations, for fields not already settled by the + // cache. All OCIO work happens here, outside the cache lock. Each + // attempt -- successful or not -- is recorded as computed, so the + // publication below caches negative results and an unprobeable space is + // not retried on every query. + bool attempted_derivation = false; + auto begin_full_attempt = [&](Field f) { + rec.full_attempt_mask |= uint32_t(f); + attempted_derivation = true; + }; + + if ((requested_fields & uint32_t(Field::EqualityID)) + && !rec.full_attempted(Field::EqualityID)) { + begin_full_attempt(Field::EqualityID); + std::string id; + if (!is_data) { + try { + id = std::string(impl->deriveRegistryInteropId(rec.name)); + } catch (...) { + } + } + rec.equality_id = id; + mark(rec, Field::EqualityID, !id.empty(), !id.empty()); + } + + if ((requested_fields & uint32_t(Field::ColorInteropID)) + && !rec.full_attempted(Field::ColorInteropID)) { + // The full declaration -> equality -> table -> generated-local + // sequence, run even when the cheap declared/table subset already + // answered: the equality (fingerprint) tier outranks a syntactic + // table match, so on a mislabeled config the cascade corrects the + // cheap verdict. This keeps the derive tier bit-exact with + // pvt::derive_color_interop_id, whose intentional consumer is the + // write planner. (On a well-formed config the two agree, and the + // cascade's declared tier still short-circuits before any + // fingerprint work.) + begin_full_attempt(Field::ColorInteropID); + std::string id; + try { + id = std::string(derive_color_interop_id_impl(config, resolved)); + } catch (...) { + } + if (!id.empty() && id != rec.color_interop_id) { + rec.color_interop_id = id; + mark(rec, Field::ColorInteropID, true, true); + } + } + + if ((requested_fields & uint32_t(Field::Encoding)) + && !rec.available(Field::Encoding) + && !rec.full_attempted(Field::Encoding)) { + // No authored (or cached) encoding: derive the interop-counterpart + // fallback. + begin_full_attempt(Field::Encoding); + std::string enc; + try { + enc = impl->effectiveEncoding(rec.name); + } catch (...) { + } + if (!enc.empty()) { + rec.encoding = enc; + mark(rec, Field::Encoding, true, true); + } + } + + if ((requested_fields & uint32_t(Field::Chromaticities)) + && !rec.full_attempted(Field::Chromaticities)) { + begin_full_attempt(Field::Chromaticities); + std::optional chr; + try { + chr = impl->deriveChromaticities(rec.name, ctx); + } catch (...) { + } + if (chr) { + rec.chromaticities_xy = chr; // exact doubles, for search's == + rec.chromaticities.reserve(8); + for (const auto& xy : *chr) { + rec.chromaticities.push_back(float(xy[0])); + rec.chromaticities.push_back(float(xy[1])); + } + } + mark(rec, Field::Chromaticities, chr.has_value(), chr.has_value()); + } + + if ((requested_fields & uint32_t(Field::TransferFunction)) + && !rec.full_attempted(Field::TransferFunction)) { + begin_full_attempt(Field::TransferFunction); + bool identity = false; + if (!is_data && !ctx) { + // Conservative identity shortcut, ambient context only: OCIO's + // isColorSpaceLinear() takes no context, so under a per-call + // context override the context-threaded signature probe below + // is the only honest linearity evidence. + try { + identity = impl->isColorSpaceLinear(rec.name); + } catch (...) { + } + } + rec.transfer_identity = identity; + if (identity) { + rec.transfer_kind = ColorTransferFunctionKind::Linear; + } else if (!is_data) { + std::optional sig; + try { + sig = impl->deriveTransferSignature(rec.name, ctx); + } catch (...) { + } + if (sig) { + rec.transfer_signature = sig; // raw evidence, for search + if (sig->is_linear) { + rec.transfer_kind = ColorTransferFunctionKind::Linear; + } else if (!sig->family.empty()) { + rec.transfer_kind = ColorTransferFunctionKind::Named; + rec.transfer_function = sig->family; + } else { + rec.transfer_kind = ColorTransferFunctionKind::Sampled; + } + } + } + const bool determined = rec.transfer_kind + != ColorTransferFunctionKind::Undetermined; + mark(rec, Field::TransferFunction, determined, determined); + } + + if ((requested_fields & uint32_t(Field::Range)) + && !rec.full_attempted(Field::Range)) { + // Range describes pixel state and may be supplied only by a genuine + // registry/CICP *registration* intrinsic to an identity. No such + // source exists yet: the static CICP table's range flag is hardwired + // Full for every row -- a fixed encode-time convention, not + // per-space knowledge -- so deriving from it would be exactly the + // guessed-"full" default the contract forbids. The full attempt is + // therefore a settled negative (cached, never retried) until a real + // registration source appears. + begin_full_attempt(Field::Range); + } + + // ---- Publish derivation attempts (immutable snapshot semantics: + // field-wise first-writer-wins merge under the lock; callers holding + // earlier records are unaffected). + if (attempted_derivation && !cfgId.empty()) { + // The bucket choice may classify the space (context-invariance); + // that is derive-path work, never reached by the cheap getter. + bool invariant = false; + try { + invariant = (impl->analysisFlags(rec.name) + & CSInfo::is_context_invariant) + != 0; + } catch (...) { + } + const std::string key = char_cache_key(cfgId, ctxId, invariant, + rec.name); + std::lock_guard lock(char_cache_mutex()); + auto& cache = char_cache(); + if (cache.size() >= char_cache_max_entries) + cache.clear(); + auto it = cache.find(key); + if (it == cache.end()) { + cache.emplace(key, rec); + } else { + spvt::CharacterizationRecord merged = it->second; + merge_record(merged, rec); + it->second = std::move(merged); + } + } + + // A name with no config space is only a valid subject if the interop-id + // cascade actually identified it; otherwise honor the original invalid + // contract (the negative attempt is still cached above, so repeats stay + // cheap). + if (!cs && rec.color_interop_id.empty()) + return spvt::CharacterizationRecord(); + + return rec; +} + + + +size_t +characterization_cache_size_impl() +{ + std::lock_guard lock(char_cache_mutex()); + return char_cache().size(); +} + + + +void +characterization_cache_reset_impl() +{ + std::lock_guard lock(char_cache_mutex()); + char_cache().clear(); +} + + + +void +characterization_cache_erase_config(string_view cfgId) +{ + if (cfgId.empty()) + return; + // Both key forms carry the structural config id as an exact segment: + // "|invariant|" or "||" (see + // char_cache_key). + std::lock_guard lock(char_cache_mutex()); + auto& cache = char_cache(); + for (auto it = cache.begin(); it != cache.end();) { + auto segs = Strutil::splitsv(it->first, "|"); + bool match = segs.size() >= 2 && (segs[0] == cfgId || segs[1] == cfgId); + it = match ? cache.erase(it) : std::next(it); + } +} + + + +// --------------------------------------------------------------------------- +// The public opaque record: a shared immutable CharacterizationRecord. +// Everything out of line; no inline function dereferences the PIMPL. +// --------------------------------------------------------------------------- + +class ColorSpaceInfo::Impl { +public: + spvt::CharacterizationRecord rec; + explicit Impl(spvt::CharacterizationRecord r) + : rec(std::move(r)) + { + } +}; + +namespace { + +// The public field enumerators are declared in the same order as the +// internal CharacterizationField bits, so the bit for public field `f` is +// 1 << int(f). +uint32_t +field_bit(ColorSpaceInfoField f) +{ + return uint32_t(1) << uint32_t(f); +} + +// The record a default-constructed (impl-less) ColorSpaceInfo reports. +const spvt::CharacterizationRecord& +empty_record() +{ + static const spvt::CharacterizationRecord empty; + return empty; +} + +} // namespace + + +ColorSpaceInfo::ColorSpaceInfo() = default; +ColorSpaceInfo::~ColorSpaceInfo() = default; +ColorSpaceInfo::ColorSpaceInfo(const ColorSpaceInfo&) = default; +ColorSpaceInfo::ColorSpaceInfo(ColorSpaceInfo&&) noexcept = default; +ColorSpaceInfo& +ColorSpaceInfo::operator=(const ColorSpaceInfo&) + = default; +ColorSpaceInfo& +ColorSpaceInfo::operator=(ColorSpaceInfo&&) noexcept + = default; + +ColorSpaceInfo::ColorSpaceInfo(std::shared_ptr impl) + : m_impl(std::move(impl)) +{ +} + +bool +ColorSpaceInfo::valid() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.valid(); +} + +string_view +ColorSpaceInfo::name() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.name; +} + +string_view +ColorSpaceInfo::equality_id() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.equality_id; +} + +string_view +ColorSpaceInfo::color_interop_id() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.color_interop_id; +} + +string_view +ColorSpaceInfo::encoding() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.encoding; +} + +string_view +ColorSpaceInfo::image_state() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.image_state; +} + +string_view +ColorSpaceInfo::range() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.range; +} + +cspan +ColorSpaceInfo::chromaticities() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.chromaticities; +} + +ColorTransferFunctionKind +ColorSpaceInfo::transfer_function_kind() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.transfer_kind; +} + +string_view +ColorSpaceInfo::transfer_function() const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return rec.transfer_function; +} + +bool +ColorSpaceInfo::computed(ColorSpaceInfoField field) const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return (rec.computed_mask & field_bit(field)) != 0; +} + +bool +ColorSpaceInfo::available(ColorSpaceInfoField field) const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return (rec.available_mask & field_bit(field)) != 0; +} + +bool +ColorSpaceInfo::derived(ColorSpaceInfoField field) const noexcept +{ + const auto& rec = m_impl ? m_impl->rec : empty_record(); + return (rec.derived_mask & field_bit(field)) != 0; +} + + + +// --------------------------------------------------------------------------- +// Public ColorConfig entry points: the CHEAP getter, scalar and batch. Both +// request no derivation fields from the engine (direct/cached work only) +// and convert engine misses into the class's has_error()/geterror() +// convention. Neither throws. +// --------------------------------------------------------------------------- + +ColorSpaceInfo +ColorConfig::get_color_space_info(string_view color_space, + const ColorSpaceInfoOptions& options) const +{ + try { + spvt::CharacterizationRecord rec = characterize_color_space_impl( + *this, color_space, uint32_t(spvt::CharacterizationField::None), + options.context); + if (!rec.valid()) { + getImpl()->error("get_color_space_info: unknown color space \"{}\"", + color_space); + return {}; + } + return ColorSpaceInfo( + std::make_shared(std::move(rec))); + } catch (const std::exception& e) { + getImpl()->error("get_color_space_info: {}", e.what()); + return {}; + } +} + + + +std::vector +ColorConfig::get_color_space_infos(cspan color_spaces, + const ColorSpaceInfoOptions& options) const +{ + try { + std::vector results; + results.reserve(color_spaces.size()); + // Every input is validated before any record is returned: one + // invalid name fails the whole batch with an indexed error. Batch + // order and duplicates are preserved. + for (size_t i = 0; i < size_t(color_spaces.size()); ++i) { + spvt::CharacterizationRecord rec = characterize_color_space_impl( + *this, color_spaces[i], + uint32_t(spvt::CharacterizationField::None), options.context); + if (!rec.valid()) { + getImpl()->error( + "get_color_space_infos[{}]: unknown color space \"{}\"", i, + color_spaces[i]); + return {}; + } + results.push_back(ColorSpaceInfo( + std::make_shared(std::move(rec)))); + } + return results; + } catch (const std::exception& e) { + getImpl()->error("get_color_space_infos: {}", e.what()); + return {}; + } +} + + + +// --------------------------------------------------------------------------- +// Public ColorConfig entry points: the DERIVE verbs, scalar and batch. Both +// request full derivation of every field from the engine (which publishes +// completed attempts -- successful and negative -- to the shared cache) and +// convert engine misses into the class's has_error()/geterror() convention. +// Neither throws. +// --------------------------------------------------------------------------- + +ColorSpaceInfo +ColorConfig::derive_color_space_info(string_view color_space, + const ColorSpaceInfoOptions& options) const +{ + try { + spvt::CharacterizationRecord rec = characterize_color_space_impl( + *this, color_space, uint32_t(spvt::CharacterizationField::All), + options.context); + if (!rec.valid()) { + getImpl()->error( + "derive_color_space_info: unknown color space \"{}\"", + color_space); + return {}; + } + return ColorSpaceInfo( + std::make_shared(std::move(rec))); + } catch (const std::exception& e) { + getImpl()->error("derive_color_space_info: {}", e.what()); + return {}; + } +} + + + +std::vector +ColorConfig::derive_color_space_infos(cspan color_spaces, + const ColorSpaceInfoOptions& options) const +{ + try { + // Resolve and validate EVERY requested name before deriving any + // record, so one bad input costs no processor work. The validation + // pass is the engine's cheap tier (no derivation requested). + for (size_t i = 0; i < size_t(color_spaces.size()); ++i) { + spvt::CharacterizationRecord probe = characterize_color_space_impl( + *this, color_spaces[i], + uint32_t(spvt::CharacterizationField::None), options.context); + if (!probe.valid()) { + getImpl()->error( + "derive_color_space_infos[{}]: unknown color space \"{}\"", + i, color_spaces[i]); + return {}; + } + } + std::vector results; + results.reserve(color_spaces.size()); + // Batch order and duplicates are preserved. Per-field derivation + // failure is an unavailable field on a valid record, never a failed + // batch. + for (const std::string& name : color_spaces) { + spvt::CharacterizationRecord rec = characterize_color_space_impl( + *this, name, uint32_t(spvt::CharacterizationField::All), + options.context); + results.push_back(ColorSpaceInfo( + std::make_shared(std::move(rec)))); + } + return results; + } catch (const std::exception& e) { + getImpl()->error("derive_color_space_infos: {}", e.what()); + return {}; + } +} + +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the engine above lives in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +CharacterizationRecord +characterize_color_space(const ColorConfig& config, string_view color_space, + CharacterizationField requested_fields, + const std::map& context) +{ + return v3_1::characterize_color_space_impl(config, color_space, + uint32_t(requested_fields), + context); +} + + +size_t +characterization_cache_size() +{ + return v3_1::characterization_cache_size_impl(); +} + + +void +characterization_cache_reset() +{ + v3_1::characterization_cache_reset_impl(); +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_crossconfig.cpp b/src/libOpenImageIO/color_crossconfig.cpp new file mode 100644 index 0000000000..e2f7289c54 --- /dev/null +++ b/src/libOpenImageIO/color_crossconfig.cpp @@ -0,0 +1,1301 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Interchange discovery, the interoperability assertion/bootstrap +// ("interopify") machinery, and the cross-config processor chokepoints and +// reconciliation routes. Split out of color_ocio.cpp; see color_ocio_pvt.h +// for the shared internal declarations. + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "color_ocio_pvt.h" + + + +// The built-in interop identities config and the interoperability +// assertion/bootstrap machinery below touch ColorConfig::Impl, which lives in +// the ABI-versioned v3_1 namespace -- so they must too. The OIIO_API pvt +// shims that expose them are declared (by color_pvt.h) in the library's +// "current" namespace and are defined further down in a separate +// OIIO_NAMESPACE_BEGIN block; those reach back here with explicit v3_1:: +// qualification. +OIIO_NAMESPACE_3_1_BEGIN + +////////////////////////////////////////////////////////////////////////// +// +// Interoperability assertion + in-memory bootstrap. +// +// A config is "color-interoperable" when it resolves a scene-referred +// interchange space (ACES2065-1 / the aces_interchange role) that cross-config +// color features anchor on. For configs that don't, we synthesize a repaired, +// PROCESSOR_CACHE_OFF *copy* ("interopified") that does -- the original config +// is never mutated. All of this runs lazily on the first interop query. The +// free helpers below live in the same anonymous namespace as +// build_interop_identities_config() (opened above); the Impl methods that use +// them are defined after it is closed. + +namespace { + +// Scene-referred interchange discovery aliases, tried in order. The role name +// is first so an explicit aces_interchange role always wins; the rest are the +// well-known ACES2065-1 / AP0 spellings different configs use. +static const std::array kSceneInterchangeAliases = { + OCIO::ROLE_INTERCHANGE_SCENE, + "aces2065-1", + "ACES: Linear - AP0", + "aces 2065-1", + "aces", + "aces20651", + "aces - aces2065-1", + "ap0ln", + "ap0", + "lin_ap0_scene", + "lin_ap0", + "ap0_linear", + "ap0_lin", + "linear ap0", + "linear - aces ap0", + "linear - ap0", +}; + +// Display-referred interchange discovery aliases (CIE-XYZ-D65), role first. +static const std::array kDisplayInterchangeAliases = { + OCIO::ROLE_INTERCHANGE_DISPLAY, + "CIE-XYZ-D65", + "CIE-XYZ D65", + "lin_ciexyzd65_display", +}; + +// Resolve `name` (a color space name, alias, or role) to a real color space +// name in `config`, or empty if it doesn't resolve. +std::string +try_canonical_name(const OCIO::ConstConfigRcPtr& config, const char* name) +{ + if (!name || !*name) + return {}; + try { + if (auto cs = config->getColorSpace(name)) + return cs->getName(); + const char* canonical = config->getCanonicalName(name); + if (canonical && *canonical) + if (auto cs = config->getColorSpace(canonical)) + return cs->getName(); + } catch (...) { + } + return {}; +} + +// Discover the scene interchange color space name: the alias list (role name +// first), then OCIO's builtin identification against OIIO's interop identities +// config. Returns empty if the config resolves no scene interchange. +// +// Version-dependent quality, not a defect: IdentifyBuiltinColorSpace's +// interchange-heuristics were reworked in OCIO 2.3.1 (#1913), so results on +// non-trivial configs can differ between 2.3.0 and 2.3.1+. Both are correct +// per their own version's heuristic; this is not version-gated here. +std::string +discover_scene_interchange(const OCIO::ConstConfigRcPtr& config) +{ + if (!config) + return {}; + for (const char* alias : kSceneInterchangeAliases) + if (std::string name = try_canonical_name(config, alias); !name.empty()) + return name; + if (auto ids = build_interop_identities_config()) { + try { + const char* id = OCIO::Config::IdentifyBuiltinColorSpace( + config, ids, OCIO::ROLE_INTERCHANGE_SCENE); + if (id && *id) + return id; + } catch (...) { + } + } + return {}; +} + +// Mirror of discover_scene_interchange for the display-referred side. +std::string +discover_display_interchange(const OCIO::ConstConfigRcPtr& config) +{ + if (!config) + return {}; + for (const char* alias : kDisplayInterchangeAliases) + if (std::string name = try_canonical_name(config, alias); !name.empty()) + return name; + if (auto ids = build_interop_identities_config()) { + try { + const char* id = OCIO::Config::IdentifyBuiltinColorSpace( + config, ids, OCIO::ROLE_INTERCHANGE_DISPLAY); + if (id && *id) + return id; + } catch (...) { + } + } + return {}; +} + +// The scene-referred identity (reference) space: a non-data scene space with +// neither a to- nor a from-reference transform. Returns its name, or empty. +std::string +find_scene_reference_identity(const OCIO::ConstConfigRcPtr& config) +{ + const int n = config->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_SCENE, + OCIO::COLORSPACE_ALL); + for (int i = 0; i < n; ++i) { + const char* name = config->getColorSpaceNameByIndex( + OCIO::SEARCH_REFERENCE_SPACE_SCENE, OCIO::COLORSPACE_ALL, i); + if (!name || !*name) + continue; + auto cs = config->getColorSpace(name); + if (!cs || cs->isData()) + continue; + const bool toRef = bool( + cs->getTransform(OCIO::COLORSPACE_DIR_TO_REFERENCE)); + const bool fromRef = bool( + cs->getTransform(OCIO::COLORSPACE_DIR_FROM_REFERENCE)); + if (!toRef && !fromRef) + return name; + } + return {}; +} + +// Bootstrap display-referred interchange on an editable copy: ensure a +// scene_reference role, then a ROLE_INTERCHANGE_DISPLAY space (synthesizing +// lin_ciexyzd65_display if the config carries none), and -- only if the config +// has no default view transform yet -- a scene_to_display_bridge chaining the +// scene reference to CIE-XYZ-D65. Touches only `editable`. +void +bootstrap_display_interchange(const OCIO::ConfigRcPtr& editable, + const std::string& interchangeScene) +{ + if (interchangeScene.empty()) + return; + + // Ensure a scene_reference role. + std::string sceneRefName = find_scene_reference_identity(editable); + if (!sceneRefName.empty()) { + try { + editable->setRole("scene_reference", sceneRefName.c_str()); + } catch (...) { + } + } + + // If the config already resolves a display interchange, bind the role and + // stop. + if (std::string existing = discover_display_interchange(editable); + !existing.empty()) { + try { + editable->setRole(OCIO::ROLE_INTERCHANGE_DISPLAY, existing.c_str()); + } catch (...) { + } + return; + } + + // Otherwise synthesize lin_ciexyzd65_display as the display reference. + try { + auto displayRef = OCIO::ColorSpace::Create( + OCIO::REFERENCE_SPACE_DISPLAY); + displayRef->setName("lin_ciexyzd65_display"); + displayRef->setEncoding("display-linear"); + editable->addColorSpace(displayRef); + editable->setRole(OCIO::ROLE_INTERCHANGE_DISPLAY, + "lin_ciexyzd65_display"); + editable->setRole("display_reference", "lin_ciexyzd65_display"); + } catch (...) { + return; + } + + // Build a scene_to_display_bridge view transform, but only if the config + // does not already declare a default one. + try { + const char* existingVt = editable->getDefaultViewTransformName(); + if (existingVt && *existingVt) + return; + + auto ap0ToXyz = OCIO::BuiltinTransform::Create(); + ap0ToXyz->setStyle("UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD"); + + OCIO::ConstTransformRcPtr bridge; + auto acesCS = editable->getColorSpace(interchangeScene.c_str()); + const bool acesIsSceneRef = acesCS + && !acesCS->getTransform( + OCIO::COLORSPACE_DIR_TO_REFERENCE); + if (acesIsSceneRef || sceneRefName == interchangeScene) { + // The scene reference IS the (positively identified) AP0 + // interchange space: use the builtin alone. + bridge = ap0ToXyz; + } else if (sceneRefName.empty()) { + // The reference has no nameable space to chain through and is + // not itself the identified interchange -- don't guess that it + // is AP0. Skip view-transform synthesis; the display interchange + // role is still set, so cross-config processors work for many + // spaces anyway. + return; + } else { + // Chain scene_reference -> aces_interchange, then AP0 -> XYZ-D65. + auto group = OCIO::GroupTransform::Create(); + auto proc = editable->getProcessor(sceneRefName.c_str(), + interchangeScene.c_str()); + group->appendTransform( + proc->getOptimizedProcessor(OCIO::OPTIMIZATION_DEFAULT) + ->createGroupTransform()); + group->appendTransform(ap0ToXyz); + bridge = group; + } + + auto vt = OCIO::ViewTransform::Create(OCIO::REFERENCE_SPACE_SCENE); + vt->setName("scene_to_display_bridge"); + vt->setTransform(bridge, OCIO::VIEWTRANSFORM_DIR_FROM_REFERENCE); + editable->addViewTransform(vt); + editable->setDefaultViewTransformName("scene_to_display_bridge"); + } catch (...) { + // View transform synthesis failed -- the display interchange role is + // still set, so cross-config processors work for many spaces anyway. + } +} + +// Warn at most once per STRUCTURAL config id across the process. Returns true +// only for the caller that first records `id` (which should emit the warning). +// spin_rw_mutex-guarded set, mirroring the learned-complex blacklist shape. +bool +note_interop_warning(const std::string& id) +{ + static spin_rw_mutex s_mutex; + static std::unordered_set s_warned; + { + spin_rw_read_lock lock(s_mutex); + if (s_warned.count(id)) + return false; + } + spin_rw_write_lock lock(s_mutex); + return s_warned.insert(id).second; +} + +} // namespace + +// The STRUCTURAL cache id: identifies the config's structure independent of +// any context (distinct from getCacheID(), which folds in the current +// context). Used as the memo/warn key so a context-invariant result is shared +// across every context a config is queried with. +std::string +get_config_cache_id(const OCIO::ConstConfigRcPtr& config) +{ + if (!config) + return {}; + try { + const char* id = config->getCacheID(OCIO::ConstContextRcPtr {}); + return id ? std::string(id) : std::string(); + } catch (...) { + return {}; + } +} + +// Return the "interopified" copy of `config`: a PROCESSOR_CACHE_OFF editable +// copy repaired to resolve a scene (and, where possible, display) interchange. +// Memoized process-wide by structural cache id (first-writer-wins, size +// bounded) so all ColorConfig instances of the same config structure share +// one copy. `config` itself is never mutated. Returns {} if OCIO can't +// build the copy. +OCIO::ConstConfigRcPtr +interopify_config(const OCIO::ConstConfigRcPtr& config) +{ + if (!config) + return {}; + const std::string key = get_config_cache_id(config); + + static spin_rw_mutex s_mutex; + static std::unordered_map s_memo; + if (!key.empty()) { + spin_rw_read_lock lock(s_mutex); + auto it = s_memo.find(key); + if (it != s_memo.end()) + return it->second; + } + + // Build the repaired copy OUTSIDE the lock. + OCIO::ConstConfigRcPtr result; + try { + OCIO::ConfigRcPtr editable = copy_config(config); + std::string interchange = discover_scene_interchange(editable); + if (!interchange.empty()) { + // Config carries the interchange space; bind the role to it if the + // role itself is absent. + if (!editable->getColorSpace(OCIO::ROLE_INTERCHANGE_SCENE)) + editable->setRole(OCIO::ROLE_INTERCHANGE_SCENE, + interchange.c_str()); + } + // Fail-don't-guess: when no scene interchange can be POSITIVELY + // identified (the aces_interchange role, a known alias/name match, + // or OCIO builtin identification -- all covered by + // discover_scene_interchange above), NO repair is attempted. A + // transformless scene reference could be linear Rec.709, a camera + // gamut, or any config-defined reference; fabricating an AP0 + // equivalence for it would produce numerically wrong cross-config + // transforms and fingerprints. The copy then resolves no scene + // interchange, the bridge gate stays closed, and cross-config / + // fingerprint queries fail cleanly with the existing + // "not color-interoperable" narration. + + // Ensure lin_ap0_scene resolves (as an alias of the interchange space) + // so the probe path's fallback lookups find it by that name. + if (!interchange.empty() && !editable->getColorSpace("lin_ap0_scene")) { + if (auto cs = editable->getColorSpace(interchange.c_str())) { + auto editableCS = cs->createEditableCopy(); + editableCS->addAlias("lin_ap0_scene"); + editable->addColorSpace(editableCS); + } + } + + bootstrap_display_interchange(editable, interchange); + + // Probe processors are one-shot (results are memoized upstream), so + // OCIO's per-config processor cache yields no hits here while adding + // mutex contention, a full-cache scan on every miss, and keeping every + // probe processor alive; disabling it is the documented fast path. + editable->setProcessorCacheFlags(OCIO::PROCESSOR_CACHE_OFF); + result = editable; + } catch (...) { + return {}; + } + + if (key.empty() || !result) + return result; + spin_rw_write_lock lock(s_mutex); + // Bound the memo: every entry pins a full editable OCIO config copy for + // the life of the process. Real workloads touch a handful of configs; + // if the cap is ever reached, dropping the memo costs only a rebuild + // for later instances (each ColorConfig::Impl keeps its own reference + // to the copy it obtained, so nothing in use is invalidated). + // ponytail: clear-on-limit; add LRU only if config-churny processes + // show rebuild cost. + if (s_memo.size() >= 16 && !s_memo.count(key)) + s_memo.clear(); + return s_memo.emplace(key, result).first->second; // existing on race +} + + + +// The cross-config chokepoint: the single wrapper over OCIO's two-config +// GetProcessorFromConfigs. Every cross-config color route funnels through +// here (color-space now; a display-view sibling with the explicit-interchange +// overload lands in a later slice), so the failure policy lives in exactly one +// place. Both configs must expose the interchange role GetProcessorFromConfigs +// needs (aces_interchange for scene-referred names, cie_xyz_d65_interchange +// for display-referred) -- that is the caller's obligation, satisfied by the +// interoperability bootstrap/repair above. With both contexts null the 4-arg +// overload is used (OCIO takes each config's current context); otherwise the +// context-aware overload runs, defaulting a missing side to that config's +// current context. On any OCIO failure the processor is null and `errmsg` is +// set from the exception -- never thrown across the boundary, never silent. +// +// Fast path: when both configs already carry the aces_interchange role (true +// by construction for the interopified analysis copy + the built-in identities +// config -- interopifiedResolvesSceneInterchange() is the caller's gate), pass +// "aces_interchange" explicitly as both interchange names. This skips OCIO's +// own interchange-role lookup/validation on every call and is materially +// faster; the role check below (Config::hasRole, not a name heuristic) is what +// makes it safe to take. Any config missing the role on either side falls +// through unchanged to the discovery form. +OCIO::ConstProcessorRcPtr +processor_from_configs(const OCIO::ConstConfigRcPtr& src_config, + string_view src_name, + const OCIO::ConstConfigRcPtr& dst_config, + string_view dst_name, std::string& errmsg, + const OCIO::ConstContextRcPtr& src_context, + const OCIO::ConstContextRcPtr& dst_context, + const char* interchange_role) +{ + errmsg.clear(); + if (!src_config || !dst_config) { + errmsg = "Cross-config processor requires two valid configs"; + return {}; + } + const std::string src(src_name); + const std::string dst(dst_name); + // `interchange_role` is the anchor both configs bridge through -- normally + // aces_interchange (scene), but cie_xyz_d65_interchange (display) when a + // display-referred endpoint prefers the display anchor (spec 10 B2). Both + // are colorimetric; the fast path only fires when BOTH configs carry the + // chosen role, otherwise OCIO's own discovery form runs. + const bool explicit_interchange = src_config->hasRole(interchange_role) + && dst_config->hasRole(interchange_role); + try { + if (!src_context && !dst_context) { + if (explicit_interchange) + return OCIO::Config::GetProcessorFromConfigs( + src_config, src.c_str(), interchange_role, dst_config, + dst.c_str(), interchange_role); + return OCIO::Config::GetProcessorFromConfigs(src_config, + src.c_str(), + dst_config, + dst.c_str()); + } + OCIO::ConstContextRcPtr sctx = src_context + ? src_context + : src_config->getCurrentContext(); + OCIO::ConstContextRcPtr dctx = dst_context + ? dst_context + : dst_config->getCurrentContext(); + if (explicit_interchange) + return OCIO::Config::GetProcessorFromConfigs( + sctx, src_config, src.c_str(), interchange_role, dctx, + dst_config, dst.c_str(), interchange_role); + return OCIO::Config::GetProcessorFromConfigs(sctx, src_config, + src.c_str(), dctx, + dst_config, dst.c_str()); + } catch (OCIO::Exception& e) { + errmsg = e.what(); + } catch (...) { + errmsg = "Unknown error in OpenColorIO GetProcessorFromConfigs"; + } + return {}; +} + +// Display-view sibling of the cross-config chokepoint: the single wrapper over +// OCIO's two-config display-view GetProcessorFromConfigs overload. Every +// cross-config display route funnels through here, so the failure policy lives +// in one place (as with processor_from_configs). This is the cross-config +// display bridge composition -- a scene-referred source in one config routed to +// a display/view in another -- which relies on both configs exposing the +// aces_interchange role OCIO auto-detects (the caller's obligation, satisfied +// by the interoperability bootstrap/repair). With both contexts null the 6-arg +// overload runs; otherwise the context-aware overload, defaulting a missing +// side to that config's current context. On any OCIO failure the processor is +// null and `errmsg` is set from the exception -- never thrown across the +// boundary, never silent. +// +// Version-dependent quality, not a defect: display-view data-space no-op +// semantics changed in OCIO 2.3.1 (#1896) -- a display/view that is itself a +// data space is a no-op transform on 2.3.1+, whereas 2.3.0 could produce a +// non-identity result in that case. No workaround here; document only. +// +// Fast path: same construction as processor_from_configs above, and the same +// role -- aces_interchange, not cie_xyz_d65_interchange. Every caller of this +// helper routes a scene-referred source (the identities config's ACES2065-1 +// identity space) into a display/view, and OCIO picks the interchange role +// from the SOURCE color space's reference type +// (Config::GetProcessorFromConfigs, display/view overload), so +// aces_interchange is what this route actually resolves through today; there +// is no display/XYZ-referred source in this chokepoint to guarantee +// cie_xyz_d65_interchange for. If a display-referred source ever routes +// through here, this fast path must gate on cie_xyz_d65_interchange instead +// (or fall back) for that case. +OCIO::ConstProcessorRcPtr +display_processor_from_configs(const OCIO::ConstConfigRcPtr& src_config, + string_view src_name, + const OCIO::ConstConfigRcPtr& dst_config, + string_view display, string_view view, + OCIO::TransformDirection direction, + std::string& errmsg, + const OCIO::ConstContextRcPtr& src_context, + const OCIO::ConstContextRcPtr& dst_context) +{ + errmsg.clear(); + if (!src_config || !dst_config) { + errmsg = "Cross-config display processor requires two valid configs"; + return {}; + } + const std::string src(src_name); + const std::string disp(display); + const std::string vw(view); + const bool explicit_interchange + = src_config->hasRole(OCIO::ROLE_INTERCHANGE_SCENE) + && dst_config->hasRole(OCIO::ROLE_INTERCHANGE_SCENE); + try { + if (!src_context && !dst_context) { + if (explicit_interchange) + return OCIO::Config::GetProcessorFromConfigs( + src_config, src.c_str(), OCIO::ROLE_INTERCHANGE_SCENE, + dst_config, disp.c_str(), vw.c_str(), + OCIO::ROLE_INTERCHANGE_SCENE, direction); + return OCIO::Config::GetProcessorFromConfigs(src_config, + src.c_str(), + dst_config, + disp.c_str(), + vw.c_str(), direction); + } + OCIO::ConstContextRcPtr sctx = src_context + ? src_context + : src_config->getCurrentContext(); + OCIO::ConstContextRcPtr dctx = dst_context + ? dst_context + : dst_config->getCurrentContext(); + if (explicit_interchange) + return OCIO::Config::GetProcessorFromConfigs( + sctx, src_config, src.c_str(), OCIO::ROLE_INTERCHANGE_SCENE, + dctx, dst_config, disp.c_str(), vw.c_str(), + OCIO::ROLE_INTERCHANGE_SCENE, direction); + return OCIO::Config::GetProcessorFromConfigs(sctx, src_config, + src.c_str(), dctx, + dst_config, disp.c_str(), + vw.c_str(), direction); + } catch (OCIO::Exception& e) { + errmsg = e.what(); + } catch (...) { + errmsg + = "Unknown error in OpenColorIO GetProcessorFromConfigs (display)"; + } + return {}; +} + + +void +ColorConfig::Impl::interop_bootstrap(InteropState& state) const +{ + state.is_interoperable = false; + state.interchange_colorspace.clear(); + if (!config_ || disable_builtin_configs) + return; + + std::string name = discover_scene_interchange(config_); + // Builtin config naming convention: ocio://default resolves the role name + // itself even when the alias list above discovers nothing. + if (name.empty() && Strutil::iequals(configname(), "ocio://default")) + name = OCIO::ROLE_INTERCHANGE_SCENE; + if (!name.empty()) { + state.interchange_colorspace = name; + state.is_interoperable = true; + } +} + + + +void +ColorConfig::Impl::ensure_interop() const +{ + { + spin_rw_read_lock lock(m_mutex); + if (m_interop_ready) + return; + } + + // Do all OCIO work OUTSIDE the lock (OCIO takes its own locks; a racing + // builder's work is simply discarded -- duplicate work is fine, blocking + // is not), then publish under the write lock. Same discipline as examine() + // and ensureProbeConfig(); ColorConfig construction never reaches here. + InteropState state; + interop_bootstrap(state); + if (config_ && !disable_ocio) + state.interopified = interopify_config(config_); + + // Non-interoperable configs warn. This is a WARNING, not an error: it is + // deliberately never written to the ColorConfig error string here. That + // string is a single overwrite-on-set slot shared per config (see + // error()/geterror() above), so setting it at bootstrap -- before any + // cross-config route is even attempted -- would make has_error() true for + // callers who never touch cross-config features, polluting otherwise- + // healthy same-config use and risking a spurious trip of the uncaught- + // error exit dump. Instead: an OIIO::debug line (attr/env-gated, never an + // unconditional stderr print -- R4), printed at most once per structural + // config id across the process, plus the composed message recorded + // in-memory on THIS Impl -- every Impl that finds itself non- + // interoperable composes and records its own `warned`/`warning_message`, + // regardless of which Impl (if any) won the process-global debug-line + // dedup claim below; the dedup only throttles the printed line, it must + // not decide whether this Impl's own observable reflects reality (a + // second ColorConfig wrapping the same structural config independently + // discovers it is non-interoperable and must report that). reconcile_ + // cross_config{,_display} compose their own why+how-to-fix error text + // only if/when a cross-config route is actually attempted and fails + // (unchanged by this slice). Skip when builtin configs are disabled -- + // we didn't actually assess interoperability in that case. + if (!state.is_interoperable && config_ && !disable_builtin_configs) { + state.warning_message = Strutil::fmt::format( + "OpenImageIO ColorConfig \"{}\" is not color-interoperable: " + "no scene interchange role (aces_interchange) could be found " + "or repaired. Cross-config color conversions and display " + "transforms are unavailable for this config -- OCIO strict " + "parsing will error on them, non-strict parsing will pass " + "them through unchanged. Add the aces_interchange role (and " + "a matching color space) to this config to enable " + "cross-config features.", + configname()); + state.warned = true; + const std::string key = get_config_cache_id(config_); + if (!key.empty() && note_interop_warning(key)) + Strutil::debug("{}\n", state.warning_message); + } + + spin_rw_write_lock lock(m_mutex); + if (!m_interop_ready) { + m_interop = std::move(state); + m_interop_ready = true; + } +} + + + +OCIO::ConstConfigRcPtr +ColorConfig::Impl::interopifiedConfig() const +{ + ensure_interop(); + spin_rw_read_lock lock(m_mutex); + return m_interop.interopified; +} + + + +// Reconcile a color conversion whose local resolution failed. Fires only when +// a requested name is a registry-known interop identity this config lacks; a +// locally-absent, registry-unknown name is a genuine unknown and is left to +// the caller's today's-error path -- only the foreign endpoint may come from +// the identities config, a name unknown to both configs is declined (this +// asymmetry is deliberate: it never masks a typo). The foreign endpoint is +// drawn from the built-in interop identities config; the local endpoint from +// this config's in-memory, repaired ("interopified") copy, which carries the +// interchange role GetProcessorFromConfigs bridges through. +// +// Returned-handle / errmsg contract (errmsg is always assigned): +// * bridged success -> non-null handle, errmsg empty. +// * lenient fallback -> non-null pass-through no-op handle, errmsg set to a +// continue-message (non-strict parsing: reconciliation could not build the +// transform, but the pipeline proceeds -- the oiiotool --colorconvert: +// strict=0 idiom, one layer down). +// * strict hard error -> null handle, errmsg set to a why + how-to-fix +// message (OCIO strict parsing restores today's hard-error behavior). +// * declined (feature N/A / builtin configs disabled) -> null handle, errmsg +// empty (caller keeps today's OCIO error). +// +// The gate consults the interoperability state (the repaired copy actually +// resolves a scene interchange), never bare name-presence. Every reconciliation +// it triggers emits a single, complete OIIO::debug narration; nothing is +// silent, and no OCIO exception crosses this boundary. +ColorProcessorHandle +ColorConfig::Impl::reconcile_cross_config(string_view src, string_view dst, + std::string& errmsg) const +{ + errmsg.clear(); + if (!config_ || disable_ocio || disable_builtin_configs) + return {}; + + OCIO::ConstConfigRcPtr ids = build_interop_identities_config(); + if (!ids) + return {}; + + // Classify each endpoint: does this config resolve it locally, and if not, + // is it a registry-known interop identity? A locally-absent, registry-known + // name is the "foreign" endpoint the bridge serves. + const std::string s(src), d(dst); + const std::string s_local = try_canonical_name(config_, s.c_str()); + const std::string d_local = try_canonical_name(config_, d.c_str()); + const std::string s_reg = s_local.empty() + ? try_canonical_name(ids, s.c_str()) + : std::string(); + const std::string d_reg = d_local.empty() + ? try_canonical_name(ids, d.c_str()) + : std::string(); + const bool src_foreign = s_local.empty() && !s_reg.empty(); + const bool dst_foreign = d_local.empty() && !d_reg.empty(); + + // Feature applies only when at least one endpoint is a registry-known + // identity this config lacks AND the other endpoint resolves locally (or is + // itself foreign). Any locally-absent, registry-unknown endpoint is a + // genuine unknown: decline, leaving today's error untouched. + if (!src_foreign && !dst_foreign) + return {}; + if (!src_foreign && s_local.empty()) + return {}; + if (!dst_foreign && d_local.empty()) + return {}; + + // OCIO strict parsing opts out of the lenient bridge entirely: preserve + // today's hard-error behavior exactly (the strict-facility user story). + bool strict = true; + try { + strict = config_->isStrictParsingEnabled(); + } catch (...) { + } + + const std::string foreign_name = src_foreign ? s : d; + + // Is the foreign endpoint a DISPLAY-referred registry identity (e.g. + // srgb_rec709_display)? Such a CIID can never have a local equivalent in a + // config that defines no display space, yet the registry lowers it + // colorimetrically to the scene reference through its own default view + // transform (scene_to_display_bridge). So it is not a "typo the user should + // fix by adding the space" the way a scene CIID is -- bridging it is the + // correct behavior, and it is exempt from the strict hard-error opt-out + // below. Scene CIIDs keep that opt-out unchanged. + auto foreign_is_display = [&ids](const std::string& reg_name) { + if (reg_name.empty()) + return false; + try { + auto cs = ids->getColorSpace(reg_name.c_str()); + return cs + && cs->getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY; + } catch (...) { + return false; + } + }; + const bool display_foreign = (src_foreign && foreign_is_display(s_reg)) + || (dst_foreign && foreign_is_display(d_reg)); + + if (strict && !display_foreign) { + // Strict mode never touches the interopify/repair machinery for a + // scene CIID -- the membership check above (against the already-built, + // memoized identities config) is all that is needed to compose the + // hard error, so skip interopifiedConfig()'s repair/bootstrap entirely. + // (A display-referred foreign CIID takes the bridge below instead: spec + // 10 B2 fallback routes it through the scene interchange.) + errmsg = Strutil::fmt::format( + "Could not reconcile color conversion \"{}\" -> \"{}\": OCIO " + "strict parsing is enabled. \"{}\" is a registry-known interop " + "identity this config does not define; add the aces_interchange " + "role (and a matching color space) to \"{}\", or use a color " + "space name this config defines.", + src, dst, foreign_name, configname()); + Strutil::debug("OpenImageIO ColorConfig(\"{}\"): {}\n", configname(), + errmsg); + return {}; + } + + // Gate on the interoperability state, not name-presence: only bridge when + // in-memory detection/repair produced a copy that resolves the scene + // interchange role GetProcessorFromConfigs needs. + OCIO::ConstConfigRcPtr bridge = interopifiedConfig(); + const bool gate_open = bridge && interopifiedResolvesSceneInterchange(); + + OCIO::ConstProcessorRcPtr proc; + std::string ocio_err; + if (gate_open) { + // The repaired copy is a superset of config_, so the local endpoint + // still resolves there; the foreign endpoint comes from the identities + // config. + OCIO::ConstConfigRcPtr src_cfg = src_foreign ? ids : bridge; + OCIO::ConstConfigRcPtr dst_cfg = dst_foreign ? ids : bridge; + std::string src_name = src_foreign + ? s_reg + : try_canonical_name(bridge, s.c_str()); + std::string dst_name = dst_foreign + ? d_reg + : try_canonical_name(bridge, d.c_str()); + if (src_name.empty()) + src_name = s; + if (dst_name.empty()) + dst_name = d; + + // Interchange selection (spec 10 B2): a display-referred foreign CIID + // PREFERS the display anchor (cie_xyz_d65_interchange) when both configs + // resolve it -- the registry always does, the interopified copy does + // when bootstrap_display_interchange synthesized it. The display anchor + // is the only one that reaches a display-referred TARGET in the user's + // config by a single colorimetric matrix (the display->display case); + // the scene anchor is what reaches a scene-referred target (and is the + // Step-1 route). Neither anchor is universal for a given config (a + // display-having config may lack a scene<->display view transform, a + // scene-only config lacks a display target), so try the preferred anchor + // then fall back to the other -- both are colorimetric, so whichever + // OCIO can build is correct. + bool prefer_display = false; + if (display_foreign) { + try { + prefer_display = ids->hasRole(OCIO::ROLE_INTERCHANGE_DISPLAY) + && bridge->hasRole( + OCIO::ROLE_INTERCHANGE_DISPLAY); + } catch (...) { + } + } + std::array roles + = prefer_display + ? std::array { OCIO::ROLE_INTERCHANGE_DISPLAY, + OCIO::ROLE_INTERCHANGE_SCENE } + : std::array { OCIO::ROLE_INTERCHANGE_SCENE, + OCIO::ROLE_INTERCHANGE_SCENE }; + const int n_roles = prefer_display ? 2 : 1; + for (int r = 0; r < n_roles && !proc; ++r) { + // R2(b)/R4(b): narrate every cross-config route as one complete msg. + Strutil::debug( + "OpenImageIO ColorConfig(\"{}\"): reconciling color conversion " + "across configs -- source \"{}\" ({}) -> destination \"{}\" ({}) " + "via {}\n", + configname(), src_name, + src_foreign ? "interop identities config" : "this config", + dst_name, + dst_foreign ? "interop identities config" : "this config", + roles[r]); + proc = processor_from_configs(src_cfg, src_name, dst_cfg, dst_name, + ocio_err, nullptr, nullptr, roles[r]); + } + if (proc) { + try { + return ColorProcessorHandle(new ColorProcessor_OCIO(proc)); + } catch (OCIO::Exception& e) { + ocio_err = e.what(); + } catch (...) { + ocio_err = "Unknown error constructing cross-config processor"; + } + } + } + + // Reconciliation did not complete: the gate is closed, or the bridge could + // not build the transform. (Strict parsing already returned above.) + // Compose one complete why + how-to-fix message (the ColorConfig error + // string is overwrite-on-set). + std::string why; + if (!gate_open) + why = Strutil::fmt::format( + "config \"{}\" is not color-interoperable (no scene interchange " + "role could be found or repaired)", + configname()); + else + why = ocio_err.empty() ? "the interop bridge could not build the " + "transform" + : ocio_err; + errmsg = Strutil::fmt::format( + "Could not reconcile color conversion \"{}\" -> \"{}\": {}. \"{}\" is a " + "registry-known interop identity this config does not define; add the " + "aces_interchange role (and a matching color space) to \"{}\", or use a " + "color space name this config defines.", + src, dst, why, foreign_name, configname()); + + // Lenient parsing: warn (debug) and continue with a pass-through no-op so + // the pipeline proceeds. The message is still recorded on the ColorConfig + // for callers that surface it. + Strutil::debug("OpenImageIO ColorConfig(\"{}\"): {} Continuing with a " + "pass-through (non-strict parsing).\n", + configname(), errmsg); + return ColorProcessorHandle( + new ColorProcessor_Matrix(Imath::M44f(), false)); +} + + + +// Display-view sibling of reconcile_cross_config, mirroring its strict/lenient/ +// narration contract exactly (one policy, two routes). The display and view are +// inherently local to this config; only the INPUT (source) color space can be a +// locally-absent, registry-known interop identity -- the "foreign" endpoint the +// bridge serves. The foreign source comes from the built-in interop identities +// config; the local display/view from this config's in-memory repaired copy, +// which carries the interchange role OCIO's display-view GetProcessorFromConfigs +// bridges through. +// +// Returned-handle / errmsg contract matches reconcile_cross_config: +// * bridged success -> non-null handle, errmsg empty. +// * lenient fallback -> non-null pass-through no-op handle, errmsg set to the +// continue-message (non-strict parsing). Critically, the prototype's silent +// setSrc(ROLE_SCENE_LINEAR) "continue anyway" is NOT taken here: an +// unbridgeable source stays untouched (pass-through), never reinterpreted as +// scene_linear. +// * strict hard error -> null handle, errmsg set to why + how-to-fix. +// * declined (input resolves locally, or is a genuine unknown, or feature +// N/A) -> null handle, errmsg empty (caller keeps today's OCIO error). +// +// TODO: the cross-config display route does not carry a looks override -- +// looks are config-local and the cross-config display bridge has none. A +// foreign source that also needs a looks override is not handled yet; add +// looks support to this route if/when a caller needs looks applied across +// configs. +ColorProcessorHandle +ColorConfig::Impl::reconcile_cross_config_display(string_view input, + string_view display, + string_view view, + bool inverse, + std::string& errmsg) const +{ + errmsg.clear(); + if (!config_ || disable_ocio || disable_builtin_configs) + return {}; + + OCIO::ConstConfigRcPtr ids = build_interop_identities_config(); + if (!ids) + return {}; + + // Only the input can be foreign. If it resolves locally, today's local + // display path already handles it -- decline. If it is locally absent but + // registry-unknown, it is a genuine unknown: decline, leaving today's error. + const std::string in(input); + const std::string in_local = try_canonical_name(config_, in.c_str()); + if (!in_local.empty()) + return {}; + const std::string in_reg = try_canonical_name(ids, in.c_str()); + if (in_reg.empty()) + return {}; + + // OCIO strict parsing opts out of the lenient bridge entirely (today's + // hard-error behavior), exactly as the color-space route does. + bool strict = true; + try { + strict = config_->isStrictParsingEnabled(); + } catch (...) { + } + + if (strict) { + // Strict mode never touches the interopify/repair machinery -- the + // membership check above (against the already-built, memoized + // identities config) is all that is needed to compose the hard + // error, so skip interopifiedConfig()'s repair/bootstrap entirely. + errmsg = Strutil::fmt::format( + "Could not reconcile display transform \"{}\" -> display \"{}\" " + "view \"{}\": OCIO strict parsing is enabled. \"{}\" is a " + "registry-known interop identity this config does not define; " + "add the aces_interchange role (and a matching color space) to " + "\"{}\", or use a color space name this config defines.", + input, display, view, input, configname()); + Strutil::debug("OpenImageIO ColorConfig(\"{}\"): {}\n", configname(), + errmsg); + return {}; + } + + // Gate on the interoperability state, not name-presence: only bridge when + // in-memory detection/repair produced a copy resolving the scene interchange + // role the display-view chokepoint needs. + OCIO::ConstConfigRcPtr bridge = interopifiedConfig(); + const bool gate_open = bridge && interopifiedResolvesSceneInterchange(); + + OCIO::ConstProcessorRcPtr proc; + std::string ocio_err; + if (gate_open) { + const OCIO::TransformDirection dir = inverse + ? OCIO::TRANSFORM_DIR_INVERSE + : OCIO::TRANSFORM_DIR_FORWARD; + + // R3/R4(b): narrate the cross-config display route as one complete + // message (say "display transform" so the route is identifiable). + Strutil::debug( + "OpenImageIO ColorConfig(\"{}\"): reconciling display transform " + "across configs -- source \"{}\" (interop identities config) -> " + "display \"{}\" view \"{}\" (this config)\n", + configname(), in_reg, display, view); + + proc = display_processor_from_configs(ids, in_reg, bridge, display, + view, dir, ocio_err); + if (proc) { + try { + return ColorProcessorHandle(new ColorProcessor_OCIO(proc)); + } catch (OCIO::Exception& e) { + ocio_err = e.what(); + } catch (...) { + ocio_err = "Unknown error constructing cross-config display " + "processor"; + } + } + } + + // Reconciliation did not complete: the gate is closed, or the bridge could + // not build the transform. (Strict parsing already returned above.) + // Compose one complete why + how-to-fix message (the ColorConfig error + // string is overwrite-on-set). + std::string why; + if (!gate_open) + why = Strutil::fmt::format( + "config \"{}\" is not color-interoperable (no scene interchange " + "role could be found or repaired)", + configname()); + else + why = ocio_err.empty() ? "the interop bridge could not build the " + "transform" + : ocio_err; + errmsg = Strutil::fmt::format( + "Could not reconcile display transform \"{}\" -> display \"{}\" view " + "\"{}\": {}. \"{}\" is a registry-known interop identity this config " + "does not define; add the aces_interchange role (and a matching color " + "space) to \"{}\", or use a color space name this config defines.", + input, display, view, why, input, configname()); + + // Lenient parsing: warn (debug) and continue with a pass-through no-op. + // On bridge failure, do NOT fall back to treating the input as + // scene_linear -- that silently reinterprets pixels; take the + // strict-aware error path instead. Here (non-strict), that means the + // pixels pass through unchanged, and the message is recorded on the + // ColorConfig for callers that surface it. + Strutil::debug("OpenImageIO ColorConfig(\"{}\"): {} Continuing with a " + "pass-through (non-strict parsing).\n", + configname(), errmsg); + return ColorProcessorHandle( + new ColorProcessor_Matrix(Imath::M44f(), false)); +} + + + +bool +ColorConfig::Impl::interopIsInteroperable() const +{ + ensure_interop(); + spin_rw_read_lock lock(m_mutex); + return m_interop.is_interoperable; +} + + + +std::string +ColorConfig::Impl::interopInterchangeName() const +{ + ensure_interop(); + spin_rw_read_lock lock(m_mutex); + return m_interop.interchange_colorspace; +} + + + +bool +ColorConfig::Impl::interopComputed() const +{ + spin_rw_read_lock lock(m_mutex); + return m_interop_ready; +} + + + +bool +ColorConfig::Impl::interopWarned() const +{ + ensure_interop(); + spin_rw_read_lock lock(m_mutex); + return m_interop.warned; +} + + + +bool +ColorConfig::Impl::interopifiedResolvesSceneInterchange() const +{ + ensure_interop(); + OCIO::ConstConfigRcPtr cfg; + { + spin_rw_read_lock lock(m_mutex); + cfg = m_interop.interopified; + } + if (!cfg) + return false; + try { + if (cfg->getColorSpace(OCIO::ROLE_INTERCHANGE_SCENE)) + return true; + const char* canon = cfg->getCanonicalName(OCIO::ROLE_INTERCHANGE_SCENE); + return canon && *canon; + } catch (...) { + return false; + } +} + + + +bool +ColorConfig::Impl::interopifiedCacheOff() const +{ + ensure_interop(); + OCIO::ConstConfigRcPtr cfg; + { + spin_rw_read_lock lock(m_mutex); + cfg = m_interop.interopified; + } + return cfg && cfg->getProcessorCacheFlags() == OCIO::PROCESSOR_CACHE_OFF; +} + +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the helpers above live in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + + +bool +color_config_is_interoperable(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->interopIsInteroperable() : false; +} + +std::string +color_config_interchange_name(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->interopInterchangeName() : std::string(); +} + +bool +color_config_interop_computed(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->interopComputed() : false; +} + +bool +color_config_interop_warned(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->interopWarned() : false; +} + +bool +color_config_interopified_resolves_scene_interchange(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->interopifiedResolvesSceneInterchange() : false; +} + +bool +color_config_interopified_cache_off(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->interopifiedCacheOff() : false; +} + + +std::vector +cross_config_probe(const ColorConfig& src_config, string_view src_name, + const ColorConfig& dst_config, string_view dst_name, + cspan probe, string_view context_key, + string_view context_value) +{ + std::vector out; + auto* src_impl = v3_1::pvt::ColorConfigClassificationPeek::impl(src_config); + auto* dst_impl = v3_1::pvt::ColorConfigClassificationPeek::impl(dst_config); + if (!src_impl || !dst_impl) + return out; + OCIO::ConstConfigRcPtr sc = src_impl->config_; + OCIO::ConstConfigRcPtr dc = dst_impl->config_; + if (!sc || !dc) { + dst_impl->error("Cross-config probe requires two OCIO-backed configs"); + return out; + } + if (probe.size() != 3) { + dst_impl->error("Cross-config probe expects a 3-channel pixel"); + return out; + } + + // A non-empty key/value pair drives the context-aware overload: set the var + // on both configs' current contexts, exercising the chokepoint's 6-arg path. + OCIO::ConstContextRcPtr sctx, dctx; + if (context_key.size() && context_value.size()) { + const std::string k(context_key), v(context_value); + auto se = sc->getCurrentContext()->createEditableCopy(); + se->setStringVar(k.c_str(), v.c_str()); + sctx = se; + auto de = dc->getCurrentContext()->createEditableCopy(); + de->setStringVar(k.c_str(), v.c_str()); + dctx = de; + } + + std::string err; + auto proc = v3_1::processor_from_configs(sc, src_name, dc, dst_name, err, + sctx, dctx); + if (!proc) { + dst_impl->error("{}", err); + return out; + } + // Probe-pixel comparison discipline (abs 1e-6/channel): apply the default + // CPU processor to the caller's pixel and hand back the transformed floats. + out.assign(probe.begin(), probe.end()); + try { + proc->getDefaultCPUProcessor()->applyRGB(out.data()); + } catch (OCIO::Exception& e) { + dst_impl->error("{}", e.what()); + out.clear(); + } + return out; +} + + +std::vector +identities_route_probe(const ColorConfig& config, string_view local_name, + string_view registry_name, cspan probe) +{ + std::vector out; + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || probe.size() != 3) + return out; + // Route the config's repaired copy's local endpoint to the identities + // config's registry endpoint through the same chokepoint the public + // createColorProcessor bridge path uses -- the reference it must reproduce. + OCIO::ConstConfigRcPtr bridge = impl->interopifiedConfig(); + OCIO::ConstConfigRcPtr ids = v3_1::build_interop_identities_config(); + if (!bridge || !ids) + return out; + std::string err; + auto proc = v3_1::processor_from_configs(bridge, local_name, ids, + registry_name, err); + if (!proc) + return out; + out.assign(probe.begin(), probe.end()); + try { + proc->getDefaultCPUProcessor()->applyRGB(out.data()); + } catch (OCIO::Exception&) { + out.clear(); + } + return out; +} + + +std::vector +identities_display_route_probe(const ColorConfig& config, + string_view registry_name, string_view display, + string_view view, cspan probe) +{ + std::vector out; + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || probe.size() != 3) + return out; + // Route the identities config's registry source into this config's repaired + // copy display/view through the same display-view chokepoint the public + // createDisplayTransform bridge path uses -- the reference it must reproduce. + OCIO::ConstConfigRcPtr bridge = impl->interopifiedConfig(); + OCIO::ConstConfigRcPtr ids = v3_1::build_interop_identities_config(); + if (!bridge || !ids) + return out; + std::string err; + auto proc = v3_1::display_processor_from_configs( + ids, registry_name, bridge, display, view, OCIO::TRANSFORM_DIR_FORWARD, + err); + if (!proc) + return out; + out.assign(probe.begin(), probe.end()); + try { + proc->getDefaultCPUProcessor()->applyRGB(out.data()); + } catch (OCIO::Exception&) { + out.clear(); + } + return out; +} + + +std::vector +interopified_display_interchange_probe(const ColorConfig& config, + string_view scene_name, + cspan probe) +{ + std::vector out; + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || probe.size() != 3) + return out; + OCIO::ConstConfigRcPtr bridge = impl->interopifiedConfig(); + if (!bridge) + return out; + OCIO::ConstProcessorRcPtr proc; + try { + // The synthesized display interchange -> a scene space, entirely within + // the interopified copy. Colorimetric by construction; the caller feeds + // XYZ-D65 white and asserts it lands on the scene space's white. + proc = bridge->getProcessor(OCIO::ROLE_INTERCHANGE_DISPLAY, + std::string(scene_name).c_str()); + } catch (OCIO::Exception&) { + return out; + } + if (!proc) + return out; + out.assign(probe.begin(), probe.end()); + try { + proc->getDefaultCPUProcessor()->applyRGB(out.data()); + } catch (OCIO::Exception&) { + out.clear(); + } + return out; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_fingerprint.cpp b/src/libOpenImageIO/color_fingerprint.cpp new file mode 100644 index 0000000000..1ad367d29b --- /dev/null +++ b/src/libOpenImageIO/color_fingerprint.cpp @@ -0,0 +1,736 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// The color space fingerprint engine: the calibrated probe protocol, the +// per-config probe state, the process-global flyweight fingerprint cache, +// and the registry-equivalence resolution built on them. Split out of +// color_ocio.cpp; see color_ocio_pvt.h for the shared internal declarations. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "color_ocio_pvt.h" + + +OIIO_NAMESPACE_3_1_BEGIN + +using namespace OCIO; + +////////////////////////////////////////////////////////////////////////// +// +// Color space fingerprints: transform a fixed probe from the reference role +// to a color space and compare the resulting floats to recognize equivalent +// spaces by value. The probe constants are calibrated -- do not retype. + +namespace { + +// The two directions a color space's transform can be authored in. If only the +// opposite direction is authored, use it inverted; if neither is (the literal +// reference space), an identity matrix stands in. +struct DirectionalTransform { + ConstTransformRcPtr transform; + TransformDirection direction = TRANSFORM_DIR_FORWARD; +}; + +DirectionalTransform +transform_for_direction(const ConstColorSpaceRcPtr& cs, + ColorSpaceDirection direction) +{ + if (auto t = cs->getTransform(direction)) + return { t, TRANSFORM_DIR_FORWARD }; + if (auto opp = cs->getTransform(ColorSpaceDirection(1 - int(direction)))) + return { opp, TRANSFORM_DIR_INVERSE }; + return { MatrixTransform::Create(), TRANSFORM_DIR_FORWARD }; +} + +} // namespace + +// Fingerprint probe protocol. +// +// Six RGBA identity pixels per reference-space kind (24 floats), transformed +// FROM the reference space TO each color space; the results are the identity +// fingerprint: +// Pixel 0-2: chromatic primaries covering the reference gamut triangle. +// Pixel 3: black (0,0,0), 50% alpha. +// Pixel 4: dark neutral (~18% grey). +// Pixel 5: diffuse white -- (1,1,1) for scene, D65 illuminant for display. +// A linearity quartet (pixels 6-13) is appended so the same probe can later +// derive per-space linearity, but it is excluded from equality matching. +// +// The calibrated probe is authored in the interchange reference primaries +// (ACES AP0 for scene, CIE-XYZ-D65 for display). initialize_probe_values first +// normalizes it into THIS config's reference space (in case the config's +// reference primaries differ) by applying the interchange space's +// to-reference transform, so fingerprints are comparable across configs. This +// slice assumes the config resolves the interchange role. +// +// Optimization is disabled (OPTIMIZATION_NONE) throughout so results are +// byte-for-byte reproducible across builds and platforms. +ProbeValues +initialize_probe_values(const ConstConfigRcPtr& config, + const ConstContextRcPtr& context) +{ + // clang-format off + std::vector acesVals = { + 0.408933127871f, + 0.106169822808f, + 0.027842572707f, + 0.0f, + 0.374615373650f, + 0.739417755017f, + 0.118862613721f, + 0.0f, + 0.171696591718f, + 0.104272268468f, + 0.786227391453f, + 0.0f, + 0.0f, + 0.0f, + 0.0f, + 0.5f, + 0.037018876439f, + 0.030827687576f, + 0.021641700645f, + 0.0f, + 1.0f, + 1.0f, + 1.0f, + 1.0f, + // Linearity quartet: four (dark, bright) pairs at 0.0625 / 4.0 -- + // neutral, R, G, B -- mirroring OCIO's isColorSpaceLinear probe. + 0.0625f, + 0.0625f, + 0.0625f, + 0.0f, + 4.0f, + 4.0f, + 4.0f, + 0.0f, + 0.0625f, + 0.0f, + 0.0f, + 0.0f, + 4.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0625f, + 0.0f, + 0.0f, + 0.0f, + 4.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0625f, + 0.0f, + 0.0f, + 0.0f, + 4.0f, + 0.0f, + }; + std::vector xyzVals = { + 0.383684057405f, + 0.213552088801f, + 0.030478901760f, + 0.0f, + 0.350178969169f, + 0.657853997550f, + 0.127445793983f, + 0.0f, + 0.173708304342f, + 0.081402847459f, + 0.858056140808f, + 0.0f, + 0.0f, + 0.0f, + 0.0f, + 0.5f, + 0.034956685913f, + 0.033530856964f, + 0.023553027375f, + 0.0f, + 0.950455927052f, + 1.0f, + 1.089057750760f, + 1.0f, + // Linearity quartet -- display-linear XYZ units. + 0.0625f, + 0.0625f, + 0.0625f, + 0.0f, + 4.0f, + 4.0f, + 4.0f, + 0.0f, + 0.0625f, + 0.0f, + 0.0f, + 0.0f, + 4.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0625f, + 0.0f, + 0.0f, + 0.0f, + 4.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0f, + 0.0625f, + 0.0f, + 0.0f, + 0.0f, + 4.0f, + 0.0f, + }; + // clang-format on + OIIO_DASSERT(acesVals.size() == size_t(kFingerprintProbePixels) * 4 + && xyzVals.size() == size_t(kFingerprintProbePixels) * 4); + + // Normalize the scene probe into this config's scene reference space. + try { + auto cs = config->getColorSpace(ROLE_INTERCHANGE_SCENE); + if (!cs) + cs = config->getColorSpace("ACES2065-1"); + if (!cs) + cs = config->getColorSpace("lin_ap0_scene"); + if (cs) { + auto toRef = transform_for_direction(cs, + COLORSPACE_DIR_TO_REFERENCE); + auto proc = config->getProcessor(context, toRef.transform, + toRef.direction); + if (!proc->isNoOp()) { + auto cpu = proc->getOptimizedCPUProcessor(OPTIMIZATION_NONE); + PackedImageDesc img(acesVals.data(), long(acesVals.size() / 4), + 1, 4); + cpu->apply(img); + } + } + } catch (...) { + } + + // Same for the display probe (CIE-XYZ-D65 reference), if the config has + // any display-referred spaces at all. + try { + if (config->getNumColorSpaces(SEARCH_REFERENCE_SPACE_DISPLAY, + COLORSPACE_ALL) + > 0) { + auto cs = config->getColorSpace(ROLE_INTERCHANGE_DISPLAY); + if (!cs) + cs = config->getColorSpace("CIE-XYZ-D65"); + if (!cs) + cs = config->getColorSpace("lin_ciexyzd65_display"); + if (cs) { + auto toRef + = transform_for_direction(cs, COLORSPACE_DIR_TO_REFERENCE); + auto proc = config->getProcessor(context, toRef.transform, + toRef.direction); + if (!proc->isNoOp()) { + auto cpu = proc->getOptimizedCPUProcessor( + OPTIMIZATION_NONE); + PackedImageDesc img(xyzVals.data(), + long(xyzVals.size() / 4), 1, 4); + cpu->apply(img); + } + } + } + } catch (...) { + } + + return { std::move(acesVals), std::move(xyzVals) }; +} + +// Transform the (reference-space) probe by the color space's from-reference +// transform; the resulting floats are its fingerprint. The space's reference +// kind (scene vs display) selects which probe set is used. +std::optional +compute_fingerprint(const ConstConfigRcPtr& config, + const ConstColorSpaceRcPtr& cs, + const ConstContextRcPtr& context, const ProbeValues& probes) +{ + if (!cs) + return std::nullopt; + try { + auto fromRef = transform_for_direction(cs, + COLORSPACE_DIR_FROM_REFERENCE); + auto proc = config->getProcessor(context, fromRef.transform, + fromRef.direction); + auto cpu = proc->getOptimizedCPUProcessor(OPTIMIZATION_NONE); + const int kind = int(cs->getReferenceSpaceType()); + std::vector values = kind == int(REFERENCE_SPACE_DISPLAY) + ? probes.display + : probes.scene; + PackedImageDesc img(values.data(), long(values.size() / 4), 1, 4); + cpu->apply(img); + return OIIO::pvt::ColorSpaceFingerprint { kind, std::move(values) }; + } catch (...) { + return std::nullopt; + } +} + +// Exact, tolerance-gated identity match. Reference kinds must match (a scene +// and a display space never compare equal), vector lengths must match, and +// every identity-probe float must agree within kFingerprintAbsTolerance. The +// first structural mismatch or first out-of-tolerance float returns false. The +// trailing linearity quartet (pixels 6-13) is excluded: its 4.0 inputs clamp +// differently through LUT-backed curves than through analytic ones (e.g. an +// ICC TRC table vs ExponentWithLinear), which would turn equivalent spaces +// into false mismatches. There is deliberately no best/closest scoring. +bool +fingerprints_match(const OIIO::pvt::ColorSpaceFingerprint& left, + const OIIO::pvt::ColorSpaceFingerprint& right) +{ + if (left.reference_kind != right.reference_kind) + return false; + if (left.values.size() != right.values.size()) + return false; + const size_t bound = std::min(right.values.size(), + size_t(kFingerprintBasePixels) * 4); + for (size_t i = 0; i < bound; ++i) + if (std::abs(left.values[i] - right.values[i]) + > kFingerprintAbsTolerance) + return false; + return true; +} + + +void +ColorConfig::Impl::ensureProbeConfig() const +{ + { + spin_rw_read_lock lock(m_mutex); + if (m_probe_ready) + return; + } + + // Probe through the interopified copy: it is already a PROCESSOR_CACHE_OFF + // editable copy of config_ repaired to resolve the scene (and, where + // possible, display) interchange, so fingerprinting works even for configs + // that don't natively carry the interchange role -- and there is no second + // editable copy to build. ensure_interop() builds it lazily and leaves + // config_ untouched. Normalize the probes OUTSIDE the lock (OCIO takes its + // own locks); publish under the write lock, letting a racing builder's work + // be discarded (flyweight: duplicate work allowed, blocking never). + ensure_interop(); + OCIO::ConstConfigRcPtr probe_config; + OCIO::ConstContextRcPtr probe_context; + ProbeValues probe_values; + { + spin_rw_read_lock lock(m_mutex); + probe_config = m_interop.interopified; + } + // Fail-don't-guess: the probe constants are AP0-calibrated, so they are + // only meaningful when the (repaired) copy POSITIVELY resolves the scene + // interchange to normalize against (see interopify_config). Without it, + // leave the probe config unset so fingerprints report "not computable" + // instead of silently assuming the config's reference is AP0. + if (probe_config && !interopifiedResolvesSceneInterchange()) + probe_config.reset(); + if (probe_config) { + try { + // Probe under THIS instance's current context, never the memoized + // interopified copy's: that copy is shared process-wide across + // all instances of the same structural config (first-writer-wins), + // so its captured context belongs to whichever instance built it + // first. The fingerprint cache keys entries by this instance's + // context id (fingerprint_cache_scope); the probe must run under + // exactly that context. + probe_context = config_ ? config_->getCurrentContext() + : probe_config->getCurrentContext(); + probe_values = initialize_probe_values(probe_config, probe_context); + } catch (...) { + probe_config.reset(); + } + } + + spin_rw_write_lock lock(m_mutex); + if (!m_probe_ready) { + m_probe_config = std::move(probe_config); + m_probe_context = std::move(probe_context); + m_probe_values = std::move(probe_values); + m_probe_ready = true; + } +} + + +std::optional +ColorConfig::Impl::computeFingerprint(string_view name) const +{ + ensureProbeConfig(); + + OCIO::ConstConfigRcPtr config; + OCIO::ConstContextRcPtr context; + ProbeValues probes; + { + spin_rw_read_lock lock(m_mutex); + config = m_probe_config; + context = m_probe_context; + probes = m_probe_values; + } + if (!config) + return std::nullopt; + auto cs = config->getColorSpace(std::string(name).c_str()); + return compute_fingerprint(config, cs, context, probes); +} + + +std::vector> +ColorConfig::Impl::fingerprintSimpleColorSpaces() const +{ + std::vector> out; + + // Iterate the classification's sorted simple-space cache (already sorted), + // so the fingerprinted order is deterministic. Gather it before touching + // the probe state's lock (getSimpleColorSpaces() takes m_mutex itself). + const std::vector& simple = getSimpleColorSpaces(); + ensureProbeConfig(); + + OCIO::ConstConfigRcPtr config; + OCIO::ConstContextRcPtr context; + ProbeValues probes; + { + spin_rw_read_lock lock(m_mutex); + config = m_probe_config; + context = m_probe_context; + probes = m_probe_values; + } + if (!config) + return out; + + out.reserve(simple.size()); + for (const auto& name : simple) { + auto cs = config->getColorSpace(name.c_str()); + auto fp = compute_fingerprint(config, cs, context, probes); + if (fp) + out.emplace_back(name, std::move(*fp)); + } + return out; +} + + +namespace { + +// Process-global flyweight color space fingerprint cache. Keyed on +// (structural config cache id, context cache id, color space name) with a +// context-invariant bucket collapse (see fingerprint_cache_key). Reuses OIIO's +// existing sharded concurrent map -- find_or_insert is exactly the +// first-writer-wins publish this needs, retrieve() is the cheap read-locked +// hit. Content-addressed: a changed config or context simply produces new keys, +// so stale entries orphan harmlessly. Retention is bounded by a hard cap +// (see fingerprint_cache_publish); there is no per-entry invalidation. +// clear() also exists for test/debug reset (see fingerprint_cache_reset). +using FingerprintCache + = unordered_map_concurrent; + +FingerprintCache& +fingerprint_cache() +{ + static FingerprintCache cache; + return cache; +} + +// Publish `fp` under `key` (first-writer-wins) with a hard size bound: the +// cache is content-addressed with no invalidation path, so a long-lived +// process that churns configs or contexts would otherwise accrete orphaned +// entries forever. On hitting the cap the whole cache is dropped and +// repopulated by subsequent queries -- fingerprints are cheap to recompute, +// so a full clear beats LRU bookkeeping here. +// ponytail: clear-on-limit; upgrade to LRU only if churny workloads show +// recompute cost in profiles. +constexpr size_t fingerprint_cache_max_entries = 8192; + +OIIO::pvt::ColorSpaceFingerprint +fingerprint_cache_publish(const std::string& key, + const OIIO::pvt::ColorSpaceFingerprint& fp) +{ + auto& cache = fingerprint_cache(); + if (cache.size() >= fingerprint_cache_max_entries) + cache.clear(); + auto result = cache.find_or_insert(key, fp); + return result.first->second; // the published value (possibly another + // thread's, on race) +} + +// Build the cache key for `name`. A context-invariant space collapses to a +// single per-structural-config bucket ("|invariant|"); every other +// space -- including one the classifier hasn't proven invariant, or doesn't +// know at all -- stays context-scoped ("||"), so nothing is +// ever shared that wasn't proven stable. The config component is the STRUCTURAL +// cache id (get_config_cache_id), never the context-folded getCacheID(), which +// is what makes the invariant collapse sound: the structural id doesn't change +// when only context vars do. +std::string +fingerprint_cache_key(const std::string& cfgId, const std::string& ctxId, + bool invariant, string_view name) +{ + return invariant ? Strutil::fmt::format("{}|invariant|{}", cfgId, name) + : Strutil::fmt::format("{}|{}|{}", ctxId, cfgId, name); +} + +// The structural config id + current-context id components of a cache key, read +// from `config`. Empty structural id means the config can't be keyed. +void +fingerprint_cache_scope(const OCIO::ConstConfigRcPtr& config, + std::string& cfgId, std::string& ctxId) +{ + cfgId = get_config_cache_id(config); + ctxId.clear(); + if (!config) + return; + try { + if (auto ctx = config->getCurrentContext()) + if (const char* id = ctx->getCacheID()) + ctxId = id; + } catch (...) { + } +} + +} // namespace + + +void +fingerprint_cache_erase_config(string_view cfgId) +{ + if (cfgId.empty()) + return; + // Both key forms carry the structural config id as an exact segment: + // "|invariant|" or "||" (see + // fingerprint_cache_key). Collect matching keys under the iteration's + // per-bin locks, then erase outside the iterator (erase re-takes the + // bin lock). Racing inserts may repopulate concurrently -- clearing is + // semantics-free, so that is harmless. + auto& cache = fingerprint_cache(); + std::vector doomed; + for (auto it = cache.begin(); it != cache.end(); ++it) { + auto segs = Strutil::splitsv(it->first, "|"); + if (segs.size() >= 2 && (segs[0] == cfgId || segs[1] == cfgId)) + doomed.push_back(it->first); + } + for (const auto& key : doomed) + cache.erase(key); +} + + +std::optional +ColorConfig::Impl::fingerprintCached(string_view name) +{ + // No config, or a config with no structural id, can't be keyed: fall back + // to a direct (uncached) compute rather than pollute the shared cache. + std::string cfgId, ctxId; + fingerprint_cache_scope(config_, cfgId, ctxId); + if (cfgId.empty()) + return computeFingerprint(name); + + // Classify first so the key builder knows whether this space is context- + // invariant (a shared bucket) or must stay context-scoped. analyze() is + // memoized and far cheaper than the fingerprint probe it guards. + const bool invariant = (analysisFlags(name) & CSInfo::is_context_invariant) + != 0; + const std::string key = fingerprint_cache_key(cfgId, ctxId, invariant, + name); + auto& cache = fingerprint_cache(); + + // Cheap read-locked hit. + OIIO::pvt::ColorSpaceFingerprint fp; + if (cache.retrieve(key, fp)) + return fp; + + // Miss: compute OUTSIDE the cache lock (never call OCIO while holding it), + // then publish first-writer-wins. A racing builder's entry wins and ours is + // discarded (flyweight: duplicate work allowed, blocking never). Failures + // are not cached, so a later query retries. + auto computed = computeFingerprint(name); + if (!computed) + return std::nullopt; + return fingerprint_cache_publish(key, *computed); +} + + +string_view +ColorConfig::Impl::resolve_registry_equivalence(string_view name) +{ + // Utility tokens (data/unknown/bypass) name a color STATE, not a color; + // they have no registry fingerprint and must never reach a fingerprint + // compare. (data/bypass were already offered to resolve_data_utility above; + // this also covers unknown and any residual data/bypass that found no + // space.) + if (OIIO::pvt::is_utility_interop_id(std::string(name))) + return {}; + + // Canonicalize the id through the registry and fetch its fingerprint. A miss + // here (an id that names no registry space, or a non-fingerprintable one) + // ends the tier -- resolve() falls through to the input-name passthrough. + const RegistryFingerprintIndex& index = registry_fingerprint_index(); + std::string canonical_id; + const OIIO::pvt::ColorSpaceFingerprint* registry_fp + = registry_fingerprint_for_id(index, name, canonical_id); + if (!registry_fp) + return {}; + + // Walk this config's simple spaces in the classification's sorted, + // deterministic order. For each: a cheap explicit-interop-id compare first + // (OCIO >= 2.5), then a tolerance-gated fingerprint match through the + // process-global cached path. First match in order wins; the returned view + // is backed by the persistent simple-space cache. Only names are returned. + for (const std::string& cs_name : getSimpleColorSpaces()) { +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + if (!canonical_id.empty() && config_ && !disable_ocio) { + try { + if (auto qcs = config_->getColorSpace(cs_name.c_str())) { + const char* qid = qcs->getInteropID(); + if (qid && *qid && canonical_id == qid) + return cs_name; + } + } catch (...) { + } + } +#endif + auto fp = fingerprintCached(cs_name); + if (fp && fingerprints_match(*fp, *registry_fp)) + return cs_name; + } + return {}; +} + + +string_view +ColorConfig::Impl::deriveRegistryInteropId(string_view resolved_name) +{ + if (resolved_name.empty()) + return {}; + // Gate (mirrors the read-side equivalence tier eligibility): a data + // space is already answered by step 1's utility sub-case; a config-unique + // space or one flagged skip-matching is never a fingerprint candidate. + const int flags = analysisFlags(resolved_name); + if (flags + & (CSInfo::is_data | CSInfo::is_unique | CSInfo::should_skip_matching)) + return {}; + // Fingerprint the query space through the process-global cached path, then + // match it against the built-in registry index. The returned id is the + // registry identity's own (process-global-stable) string, not this query's + // name. Lazy: this is the first place the write side touches the fingerprint + // engine or the registry index. + auto fp = fingerprintCached(resolved_name); + if (!fp) + return {}; + return registry_id_for_fingerprint(registry_fingerprint_index(), *fp); +} + + +std::size_t +ColorConfig::Impl::fingerprintWarm() +{ + std::string cfgId, ctxId; + fingerprint_cache_scope(config_, cfgId, ctxId); + if (cfgId.empty()) + return 0; + + // Compute every simple space's fingerprint OUTSIDE the cache lock (the bulk + // pass already iterates the sorted simple-space set deterministically and + // skips spaces that don't fingerprint), then publish each into the cache. + auto fingerprints = fingerprintSimpleColorSpaces(); + std::size_t count = 0; + for (auto& entry : fingerprints) { + const bool invariant + = (analysisFlags(entry.first) & CSInfo::is_context_invariant) != 0; + const std::string key = fingerprint_cache_key(cfgId, ctxId, invariant, + entry.first); + fingerprint_cache_publish(key, entry.second); + ++count; + } + return count; +} + +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the helpers above live in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + + +ColorSpaceFingerprint +color_space_fingerprint(const ColorConfig& config, string_view name) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl) + return {}; + auto fp = impl->computeFingerprint(name); + return fp ? std::move(*fp) : ColorSpaceFingerprint {}; +} + +bool +color_space_fingerprints_match(const ColorSpaceFingerprint& a, + const ColorSpaceFingerprint& b) +{ + return v3_1::fingerprints_match(a, b); +} + +std::vector +color_space_fingerprint_order(const ColorConfig& config) +{ + std::vector names; + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl) + return names; + auto fingerprints = impl->fingerprintSimpleColorSpaces(); + names.reserve(fingerprints.size()); + for (auto& entry : fingerprints) + names.push_back(std::move(entry.first)); + return names; +} + + +ColorSpaceFingerprint +color_space_fingerprint_cached(const ColorConfig& config, string_view name) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl) + return {}; + auto fp = impl->fingerprintCached(name); + return fp ? std::move(*fp) : ColorSpaceFingerprint {}; +} + +std::size_t +color_space_fingerprint_warm(const ColorConfig& config) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->fingerprintWarm() : 0; +} + +std::size_t +color_space_fingerprint_cache_size() +{ + return v3_1::fingerprint_cache().size(); +} + +void +color_space_fingerprint_cache_reset() +{ + v3_1::fingerprint_cache().clear(); +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_icc_probe.cpp b/src/libOpenImageIO/color_icc_probe.cpp new file mode 100644 index 0000000000..388668ed82 --- /dev/null +++ b/src/libOpenImageIO/color_icc_probe.cpp @@ -0,0 +1,618 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// ICC profile identification (decode an embedded ICC profile through OCIO's +// matrix/TRC reader and fingerprint it against the interop identities +// registry) and the mastering display volume (SMPTE ST 2086) derivation. +// Split out of color_ocio.cpp; see color_ocio_pvt.h for the shared internal +// declarations. + +#include +#include +#include +#include +#include +#include + +#include + +#include "color_ocio_pvt.h" + + + +// The built-in interop identities config and the interoperability +// assertion/bootstrap machinery below touch ColorConfig::Impl, which lives in +// the ABI-versioned v3_1 namespace -- so they must too. The OIIO_API pvt +// shims that expose them are declared (by color_pvt.h) in the library's +// "current" namespace and are defined further down in a separate +// OIIO_NAMESPACE_BEGIN block; those reach back here with explicit v3_1:: +// qualification. +OIIO_NAMESPACE_3_1_BEGIN + +// --------------------------------------------------------------------------- +// ICC profile identification -- decode an embedded ICC profile through +// OCIO's matrix/TRC ICC FileTransform reader inside a throwaway in-memory +// probe config, then fingerprint the decoded transform against the interop +// identities registry above. Mirror-inside: the reader's acceptance rules +// (colorant matrix + per-channel tone curves, hardcoded Bradford D50->D65 +// adaptation) are OCIO's, never re-derived here. +// --------------------------------------------------------------------------- + +namespace { + +// Serves the embedded ICC blob to OCIO's ICC FileTransform reader without +// touching disk. The probe config is built in code (never parsed), so +// getConfigData() is intentionally empty -- OCIO only consults the proxy +// for LUT/file reads once setConfigIOProxy is attached to an +// already-constructed config. The config carries exactly one file (the +// virtual profile name), which OCIO absolutizes against the config's +// search path before asking the proxy -- so serve the single blob for any +// non-null request rather than matching the (absolutized) filename. +class IccBlobProxy final : public OCIO::ConfigIOProxy { +public: + IccBlobProxy(cspan blob, std::string hash) + : m_blob(blob.begin(), blob.end()) + , m_hash(std::move(hash)) + { + } + + std::vector getLutData(const char* filepath) const override + { + if (filepath) + return m_blob; + throw OCIO::Exception("IccBlobProxy: unexpected LUT request"); + } + std::string getConfigData() const override { return {}; } + std::string getFastLutFileHash(const char* filepath) const override + { + return filepath ? m_hash : std::string(); + } + +private: + std::vector m_blob; + std::string m_hash; +}; + + +// Build the throwaway probe config for one ICC profile: a display-referred +// space "icc_probe" whose to_reference is a FileTransform on the profile +// set INVERSE (OCIO's ICC FileTransform forward maps reference(PCS) -> +// device; inverting makes to_reference DECODE device code values into the +// CIE-XYZ-D65 display reference), plus an identity "cie_xyz_d65" space +// carrying the display interchange role so the fingerprint probe values +// are already in the reference and normalization is a no-op. +// +// The per-profile virtual filename embeds the content identifier -- this +// is load-bearing, NOT cosmetic: OCIO's process-global GetFastFileHash +// cache (PathUtils.cpp) keys purely on the resolved filename and never +// re-consults the ConfigIOProxy, and a config's processor cache ID hashes +// its serialization (which embeds the src). A shared filename would make +// every probe config collide on both keys, handing back the FIRST +// profile's processor for every later profile in the process (e.g. an +// undecodable cLUT "decoding" as a previously-seen sRGB). A content-unique +// name keeps distinct profiles distinct. +OCIO::ConstConfigRcPtr +make_icc_probe_config(cspan iccdata) +{ + const std::string hash = OIIO::pvt::icc_profile_identifier(iccdata); + // Start from CreateRaw's minimal working config (current version, "raw" + // space + default role already valid). validate() rejects an empty + // search_path when a FileTransform is present, so set one -- the actual + // bytes come from the ConfigIOProxy, never this path. + auto cfg = OCIO::Config::CreateRaw()->createEditableCopy(); + cfg->setName("oiio-icc-probe"); + cfg->setSearchPath("."); + // Every processor built against the probe is a one-shot; a per-config + // processor cache would only add mutex serialization and retained + // processors. + cfg->setProcessorCacheFlags(OCIO::PROCESSOR_CACHE_OFF); + + auto xyz = OCIO::ColorSpace::Create(OCIO::REFERENCE_SPACE_DISPLAY); + xyz->setName("cie_xyz_d65"); + cfg->addColorSpace(xyz); + cfg->setRole(OCIO::ROLE_INTERCHANGE_DISPLAY, "cie_xyz_d65"); + + // OCIO validation requires a view transform whenever display-referred + // spaces exist; bridge scene AP0 <-> CIE-XYZ-D65 with the standard + // utility builtin (also wires the scene interchange, matching the + // registry topology). + auto ap0 = OCIO::ColorSpace::Create(OCIO::REFERENCE_SPACE_SCENE); + ap0->setName("lin_ap0"); + cfg->addColorSpace(ap0); + cfg->setRole(OCIO::ROLE_INTERCHANGE_SCENE, "lin_ap0"); + auto bridge = OCIO::ViewTransform::Create(OCIO::REFERENCE_SPACE_SCENE); + bridge->setName("scene_to_display_bridge"); + auto toxyz = OCIO::BuiltinTransform::Create(); + toxyz->setStyle("UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD"); + bridge->setTransform(toxyz, OCIO::VIEWTRANSFORM_DIR_FROM_REFERENCE); + cfg->addViewTransform(bridge); + cfg->setDefaultViewTransformName("scene_to_display_bridge"); + + auto probe = OCIO::ColorSpace::Create(OCIO::REFERENCE_SPACE_DISPLAY); + probe->setName("icc_probe"); + auto ft = OCIO::FileTransform::Create(); + ft->setSrc(("embedded_" + hash + ".icc").c_str()); + ft->setDirection(OCIO::TRANSFORM_DIR_INVERSE); + probe->setTransform(ft, OCIO::COLORSPACE_DIR_TO_REFERENCE); + cfg->addColorSpace(probe); + + cfg->setConfigIOProxy(std::make_shared(iccdata, hash)); + cfg->validate(); + return cfg; +} + +} // namespace + +// Core of pvt::identify_icc_profile() (the pvt shim at the end of this +// file forwards here). Identify-first: a profile that decodes and matches +// a registry identity yields that identity (resolved against the caller's +// config when possible); a decodable-but-unmatched profile yields the bare +// "icc:" token. There is deliberately NO session-synthetic +// registration on this branch -- nothing consumes a registered synthetic +// yet, so the token itself is the complete answer for the unmatched case. +OIIO::pvt::IccIdentifyResult +identify_icc_profile_impl(const ColorConfig& config, cspan iccdata) +{ + OIIO::pvt::IccIdentifyResult result; + if (!OIIO::pvt::is_icc_profile(iccdata)) + return result; // not ICC: empty id, decodable false + const std::string token = "icc:" + + OIIO::pvt::icc_profile_identifier(iccdata); + + // Decode-validate eagerly: undecodable profiles (cLUT/AToB -- OCIO's + // reader is matrix/TRC-only) throw when the processor is built. + OCIO::ConstConfigRcPtr probecfg; + try { + probecfg = make_icc_probe_config(iccdata); + probecfg->getProcessor("icc_probe", "cie_xyz_d65"); + } catch (...) { + result.id = token; + return result; // decodable stays false + } + result.decodable = true; + + // Fingerprint the decoded profile against the registry identities. The + // probe values are authored in CIE-XYZ-D65, which IS this config's + // display reference, so initialize_probe_values leaves them untouched. + std::string ciid; + try { + auto context = probecfg->getCurrentContext(); + ProbeValues probes = initialize_probe_values(probecfg, context); + if (auto fp = compute_fingerprint(probecfg, + probecfg->getColorSpace("icc_probe"), + context, probes)) + ciid = registry_id_for_fingerprint(registry_fingerprint_index(), + *fp); + } catch (...) { + } + if (ciid.empty()) { + result.id = token; // decodable, unmatched + return result; + } + // Prefer a caller-local resolution of the matched identity; fall back + // to the canonical interop id itself. + string_view local = config.resolve(ciid); + result.id = local.empty() ? ciid : std::string(local); + return result; +} + + +// --------------------------------------------------------------------------- +// Mastering display volume (SMPTE ST 2086) derivation -- the five-tier +// first-hit-wins ladder, ported from the proven POC: +// 1. ACES-OUTPUT builtin style table (nominal peak + limiting gamut) +// 2. display-interchange CST probe (view_transform-based views) +// 3. inverse DISPLAY-builtin probe (v1-style with an encoding tail) +// 4. registry-identity decode probe (v1-style pure-LUT with interop id) +// 5. no record (honestly yields nothing, never guesses) +// Tiers 2-4 share one numeric probe; they differ only in how the code->XYZ +// decode is built. +// --------------------------------------------------------------------------- + +namespace { + +// Reference chromaticities for the style table (R, G, B, W xy). +static const float kRec709xy[4][2] = { { 0.64f, 0.33f }, + { 0.30f, 0.60f }, + { 0.15f, 0.06f }, + { 0.3127f, 0.329f } }; +static const float kP3D65xy[4][2] = { { 0.68f, 0.32f }, + { 0.265f, 0.69f }, + { 0.15f, 0.06f }, + { 0.3127f, 0.329f } }; +static const float kRec2020xy[4][2] = { { 0.708f, 0.292f }, + { 0.170f, 0.797f }, + { 0.131f, 0.046f }, + { 0.3127f, 0.329f } }; + +// Recursive scan for a BuiltinTransform whose style begins with `prefix` in +// a transform tree (GroupTransform children included). ColorSpaceTransform +// indirection is intentionally not followed -- a style reachable only +// through a referenced space is invisible by design; callers fall through +// to their own next tier. `stop_at_first` returns on the first hit (the +// ACES-OUTPUT lookup); otherwise every child is scanned so `style_out` +// holds the LAST match (the DISPLAY-builtin encoding tail, where the tail +// -- not the first hit -- is what's wanted). +bool +find_builtin_style(const OCIO::ConstTransformRcPtr& transform, + const char* prefix, bool stop_at_first, + std::string& style_out) +{ + if (!transform) + return false; + if (transform->getTransformType() == OCIO::TRANSFORM_TYPE_BUILTIN) { + auto builtin = OCIO::DynamicPtrCast( + transform); + if (builtin && Strutil::starts_with(builtin->getStyle(), prefix)) { + style_out = builtin->getStyle(); + return true; + } + return false; + } + if (transform->getTransformType() == OCIO::TRANSFORM_TYPE_GROUP) { + auto group = OCIO::DynamicPtrCast( + transform); + if (!group) + return false; + bool found = false; + for (int i = 0; i < group->getNumTransforms(); ++i) { + if (find_builtin_style(group->getTransform(i), prefix, + stop_at_first, style_out)) { + found = true; + if (stop_at_first) + return true; + } + } + return found; + } + return false; +} + +// DISPLAY-builtin encoding tail prefix of a display encoding style. +static const char* kDisplayBuiltinPrefix = "DISPLAY - CIE-XYZ-D65_to_"; + +// Parse an ACES-OUTPUT builtin style into a mastering volume. Peak nits is +// the first "nit" token (ACES 1.1 styles carry a second mid-grey +// token -- "1000nit-15nit" -- which is not a mastering value); tokenless +// SDR styles fall back to their ACES-defined nominals (video 100, cinema +// 48). The limiting gamut comes from the P3lim / P3-D65 / REC2020[lim] / +// REC709[lim] token; whitepoint is D65, carried by the tables themselves. +// SDR-VIDEO without a gamut token is Rec.709 by definition. Styles with no +// resolvable gamut (e.g. DCI-white cinema sims) return false and fall +// through to the numeric probe. +bool +mastering_volume_from_style(const std::string& style, + OIIO::pvt::MasteringDisplayVolume& volume) +{ + if (!Strutil::starts_with(style, "ACES-OUTPUT")) + return false; + double peak = 0.0; + const auto nitpos = style.find("nit"); + if (nitpos != std::string::npos) { + size_t begin = nitpos; + while (begin > 0 + && (isdigit(static_cast(style[begin - 1])) + || style[begin - 1] == '.')) + --begin; + if (begin == nitpos) + return false; + peak = Strutil::stod(style.substr(begin, nitpos - begin)); + } else if (style.find("SDR-VIDEO") != std::string::npos) { + peak = 100.0; + } else if (style.find("SDR-CINEMA") != std::string::npos) { + peak = 48.0; + } else { + return false; + } + const float(*gamut)[2] = nullptr; + if (style.find("P3lim") != std::string::npos + || style.find("P3-D65") != std::string::npos) + gamut = kP3D65xy; + else if (style.find("REC2020") != std::string::npos) + gamut = kRec2020xy; + else if (style.find("REC709") != std::string::npos + || style.find("SDR-VIDEO") != std::string::npos) + gamut = kRec709xy; + if (!gamut) + return false; + memcpy(volume.primaries, gamut, sizeof(volume.primaries)); + volume.max_luminance = peak; + // min stays 0.0 (what a code-0 probe reports for PQ and Rec.1886 + // alike); wire encoders wanting the conventional 0.0001 cd/m^2 floor + // clamp at encode time. + volume.min_luminance = 0.0; + volume.style = style; + return true; +} + +// Cinema predicate for a "DISPLAY - CIE-XYZ-D65_to_*" builtin style -- +// classify by the encoding FAMILY that leads the style suffix, never by +// DCI/DCDM device tokens anywhere in the string. Gamma-2.6 theatrical +// encodings (G2.6-P3-DCI-BFD, G2.6-P3-D60-BFD, G2.6-P3-D65, and DCDM's +// gamma 2.6 with its baked 48/52.37 scale) decode interchange Y relative +// to the 48 cd/m^2 projector calibration white. ST2084/PQ decodes to +// absolute nits/100, and the video encodings (sRGB, G2.2/REC.1886, +// DisplayP3, HLG) are relative to the 100 cd/m^2 video white. Device +// tokens are unreliable: the "DCDM" in ST2084-DCDM-D65 names the XYZ +// container, not a 48-nit convention, and G2.6-P3-D60-BFD carries no +// DCI/DCDM token at all. +bool +is_cinema_display_builtin(const std::string& style) +{ + if (!Strutil::starts_with(style, kDisplayBuiltinPrefix)) + return false; + string_view suffix = string_view(style).substr( + strlen(kDisplayBuiltinPrefix)); + return Strutil::starts_with(suffix, "G2.6-P3-") + || Strutil::starts_with(suffix, "DCDM-"); +} + +// Cinema classification from an interop identity: the registry twin's +// encoding is the authority (identity-first; the style-suffix predicate +// above is the structural fallback). "sdr-cinema" marks the gamma-2.6 +// theatrical encodings (48 cd/m^2 calibration white); "hdr-cinema" marks +// PQ cinema masters, which decode to absolute nits/100 like all PQ and so +// anchor at 100. Linear theatrical spaces stay "display-linear" in the +// registry, so the p3dci gamut token still decides those. Returns -1 when +// the identity has no registry twin -- the caller falls back to whatever +// structural evidence it holds; else 0/1. +int +cinema_from_identity(string_view interop_id) +{ + if (interop_id.empty()) + return -1; + const RegistryFingerprintIndex& index = registry_fingerprint_index(); + if (!index.config) + return -1; + OCIO::ConstColorSpaceRcPtr twin; + try { + twin = index.config->getColorSpace(std::string(interop_id).c_str()); + } catch (...) { + return -1; + } + if (!twin) + return -1; + const char* enc = twin->getEncoding(); + string_view encoding(enc ? enc : ""); + if (Strutil::iequals(encoding, "sdr-cinema")) + return 1; + if (Strutil::iequals(encoding, "display-linear") + && Strutil::icontains(interop_id, "p3dci")) + return 1; // e.g. lin_p3dci_display: linear feed to a 48-nit projector + return 0; // known twin with a video or PQ (absolute) encoding +} + +// Snap a probed peak to the nearest nominal mastering target. mDCV wants +// the nominal (an ACES 1.1 1000-nit tonescale saturates at ~991.48 through +// the probe), so anything within the 2% window snaps; first match wins. +double +snap_nominal_nits(double nits) +{ + static const double kNominal[] = { 48, 100, 108, 203, 300, 500, + 600, 1000, 2000, 4000, 10000 }; + for (double nominal : kNominal) + if (std::abs(nits - nominal) / nominal < 0.02) + return nominal; + return nits; +} + +} // namespace + +// Core of pvt::derive_mastering_volume() (the pvt shim at the end of this +// file forwards here). +bool +derive_mastering_volume_impl(const ColorConfig& config, string_view display, + string_view view, + OIIO::pvt::MasteringDisplayVolume& volume) +{ + auto* impl = pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || !impl->config_) + return false; + const OCIO::ConstConfigRcPtr cfg = impl->config_; + + std::string disp(display); + std::string vw(view); + try { + if (disp.empty()) + disp = cfg->getDefaultDisplay(); + if (disp.empty()) + return false; + if (vw.empty()) + vw = cfg->getDefaultView(disp.c_str()); + if (vw.empty()) + return false; + + std::string style; // ACES-OUTPUT style, if any + std::string display_encoding_tail; // v1-style DISPLAY builtin tail + std::string output_space; // v1-style output space name + const char* vtname = cfg->getDisplayViewTransformName(disp.c_str(), + vw.c_str()); + const bool vt_based = vtname && vtname[0]; + if (vt_based) { + if (auto vt = cfg->getViewTransform(vtname)) { + if (!find_builtin_style( + vt->getTransform(OCIO::VIEWTRANSFORM_DIR_FROM_REFERENCE), + "ACES-OUTPUT", true, style)) + find_builtin_style( + vt->getTransform(OCIO::VIEWTRANSFORM_DIR_TO_REFERENCE), + "ACES-OUTPUT", true, style); + } + } else { + const char* csname = cfg->getDisplayViewColorSpaceName(disp.c_str(), + vw.c_str()); + if (csname && csname[0]) { + output_space = csname; + if (auto cs = cfg->getColorSpace(csname)) { + auto fromref = cs->getTransform( + OCIO::COLORSPACE_DIR_FROM_REFERENCE); + auto toref = cs->getTransform( + OCIO::COLORSPACE_DIR_TO_REFERENCE); + if (!find_builtin_style(fromref, "ACES-OUTPUT", true, style)) + find_builtin_style(toref, "ACES-OUTPUT", true, style); + find_builtin_style(fromref, kDisplayBuiltinPrefix, false, + display_encoding_tail); + if (display_encoding_tail.empty()) + find_builtin_style(toref, kDisplayBuiltinPrefix, false, + display_encoding_tail); + } + } + } + + // Tier 1: the style table carries the NOMINAL peak (1000 where the + // probe sees the tonescale asymptote 991.48) and the LIMITING gamut + // (the probe can only see the encoding gamut). + if (mastering_volume_from_style(style, volume)) + return true; + + // Tiers 2-4: build the code->XYZ decode, then run the shared + // numeric probe. Cinema anchoring (48 vs 100 cd/m^2) is decided per + // construction, from the evidence each has -- identity-first via + // the registry twin's encoding, structural style-family fallback. + OCIO::ConstCPUProcessorRcPtr decode; + bool cinema = false; + auto context = cfg->getCurrentContext(); + if (vt_based) { + // Tier 2: CST from the display colorspace to the display + // interchange role. + const char* dcsname + = cfg->getDisplayViewColorSpaceName(disp.c_str(), vw.c_str()); + if (!dcsname || !dcsname[0]) + return false; + std::string tail; + if (auto dcs = cfg->getColorSpace(dcsname)) { + find_builtin_style(dcs->getTransform( + OCIO::COLORSPACE_DIR_FROM_REFERENCE), + kDisplayBuiltinPrefix, false, tail); + if (tail.empty()) + find_builtin_style(dcs->getTransform( + OCIO::COLORSPACE_DIR_TO_REFERENCE), + kDisplayBuiltinPrefix, false, tail); + } + const int idcinema = cinema_from_identity( + derive_color_interop_id_impl(config, dcsname)); + cinema = idcinema >= 0 ? (idcinema > 0) + : is_cinema_display_builtin(tail); + auto cst = OCIO::ColorSpaceTransform::Create(); + cst->setSrc(dcsname); + cst->setDst(OCIO::ROLE_INTERCHANGE_DISPLAY); + decode = cfg->getProcessor(context, cst, + OCIO::TRANSFORM_DIR_FORWARD) + ->getDefaultCPUProcessor(); + // Provenance: the unparseable ACES style tier 1 found, if any. + } else if (!display_encoding_tail.empty()) { + // Tier 3: the LAST DISPLAY builtin in the output space's chain, + // instantiated INVERSE. A CST is NOT used here -- the v1 output + // space is scene-referred and a CST to the interchange would + // re-apply a view transform. + auto builtin = OCIO::BuiltinTransform::Create(); + builtin->setStyle(display_encoding_tail.c_str()); + builtin->setDirection(OCIO::TRANSFORM_DIR_INVERSE); + decode = cfg->getProcessor(context, builtin, + OCIO::TRANSFORM_DIR_FORWARD) + ->getDefaultCPUProcessor(); + style = display_encoding_tail; // provenance: the encoding tail + cinema = is_cinema_display_builtin(display_encoding_tail); + } else { + // Tier 4: registry-identity decode. The identity must be + // display-referred -- a scene-referred identity cannot anchor + // display luminance. + if (output_space.empty()) + return false; + string_view interop_id = derive_color_interop_id_impl(config, + output_space); + const RegistryFingerprintIndex& index = registry_fingerprint_index(); + if (interop_id.empty() || !index.config) + return false; + auto registrycs = index.config->getColorSpace( + std::string(interop_id).c_str()); + if (!registrycs + || registrycs->getReferenceSpaceType() + != OCIO::REFERENCE_SPACE_DISPLAY) + return false; + decode = index.config + ->getProcessor(registrycs->getName(), + OCIO::ROLE_INTERCHANGE_DISPLAY) + ->getDefaultCPUProcessor(); + style = interop_id; // provenance: the identity + cinema = cinema_from_identity(interop_id) > 0; + } + + // The shared probe. Luminance: achromatic 1e5 drive through the + // view (saturates any tonescale), decoded at CIE-XYZ-D65 where + // Y = nits/anchor; the anchor is 100 (video, PQ absolute) or 48 + // (gamma-2.6 theatrical projector calibration white). Peak is + // capped at the 10000 PQ container ceiling and snapped to the + // nominal targets; black is probe-honest (no 0.0001 floor). + auto dvt = OCIO::DisplayViewTransform::Create(); + dvt->setSrc(OCIO::ROLE_SCENE_LINEAR); + dvt->setDisplay(disp.c_str()); + dvt->setView(vw.c_str()); + auto dvtcpu = cfg->getProcessor(context, dvt, + OCIO::TRANSFORM_DIR_FORWARD) + ->getDefaultCPUProcessor(); + + float peakrgb[3] = { 1e5f, 1e5f, 1e5f }; + dvtcpu->applyRGB(peakrgb); + decode->applyRGB(peakrgb); + float blackrgb[3] = { 0.0f, 0.0f, 0.0f }; + dvtcpu->applyRGB(blackrgb); + decode->applyRGB(blackrgb); + const double anchor = cinema ? 48.0 : 100.0; + volume.max_luminance = snap_nominal_nits( + std::min(double(peakrgb[1]) * anchor, 10000.0)); + volume.min_luminance = std::max(double(blackrgb[1]) * anchor, 0.0); + + // Primaries: decode the four basis vectors R,G,B,W to XYZ and + // convert to xy. Invariant to the per-channel TRC (a basis vector's + // xy is independent of the curve), so exact for matrix+TRC + // encodings; reports the ENCODING gamut (hull-fitting a custom + // view's true limiting gamut is a known follow-up). + static const float kBasis[4][3] = { { 1.0f, 0.0f, 0.0f }, + { 0.0f, 1.0f, 0.0f }, + { 0.0f, 0.0f, 1.0f }, + { 1.0f, 1.0f, 1.0f } }; + for (int i = 0; i < 4; ++i) { + float xyz[3] = { kBasis[i][0], kBasis[i][1], kBasis[i][2] }; + decode->applyRGB(xyz); + const double sum = double(xyz[0]) + double(xyz[1]) + double(xyz[2]); + volume.primaries[i][0] = sum != 0.0 ? float(xyz[0] / sum) : 0.0f; + volume.primaries[i][1] = sum != 0.0 ? float(xyz[1] / sum) : 0.0f; + } + volume.style = style; // provenance: builtin style or interop id + return true; + } catch (...) { + // No display interchange role, unresolvable scene source, etc. -- + // the volume is not derivable from this config. + return false; + } +} + +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the helpers above live in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + + +IccIdentifyResult +identify_icc_profile(const ColorConfig& config, cspan iccdata) +{ + return v3_1::identify_icc_profile_impl(config, iccdata); +} + +bool +derive_mastering_volume(const ColorConfig& config, string_view display, + string_view view, MasteringDisplayVolume& volume) +{ + return v3_1::derive_mastering_volume_impl(config, display, view, volume); +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_metadata_plan.cpp b/src/libOpenImageIO/color_metadata_plan.cpp new file mode 100644 index 0000000000..1d4aa0dccc --- /dev/null +++ b/src/libOpenImageIO/color_metadata_plan.cpp @@ -0,0 +1,719 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// One central write-side color-metadata derivation, replacing the per-plugin +// "figure out which color attributes to emit for this color space" code that +// every writer used to hand-roll. A writer now declares which signals its +// format can carry and consumes the computed plan; all write-side policy +// (the never-guess omission rule, the provenance suppression rule, the +// id -> CICP table lookup) lives here, and name -> interop id derivation is +// consumed from the shared characterization engine (color_characterization +// .cpp) rather than derived privately. +// +// This is the write-side twin of the read-side reconciler and is deliberately +// kept a separate module: read and write share one policy namespace but no +// engine. The only thing shared is the single-locked-snapshot primitive +// (ColorPolicySnapshot), which both policy readers route through so the lock +// discipline is defined once. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "color_pvt.h" +#include "imageio_pvt.h" + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +// The one process-global lock behind every colorpolicy snapshot (read and +// write). A snapshot holds it for its lifetime; all gets happen inside, so a +// call reads a single consistent view with no mid-call re-lock. +static std::mutex& +color_policy_mutex() +{ + static std::mutex m; + return m; +} + +void +apply_profile_selection(std::map& keys, + const ColorConfig& config, string_view selection) +{ + for (string_view raw : Strutil::splitsv(selection, ",")) { + std::string entry(Strutil::strip(raw)); + if (entry.empty()) + continue; + bool remove = false; + if (entry.front() == '+') { + entry.erase(0, 1); + } else if (entry.front() == '-') { + remove = true; + entry.erase(0, 1); + } + entry = Strutil::strip(entry); + if (entry.empty()) + continue; + + // A target on the policy axis (`read:...`/`write:...`) is a single + // key; anything else is a whole profile name (a config rule name). + if (Strutil::starts_with(entry, "read:") + || Strutil::starts_with(entry, "write:")) { + std::string key = entry, value; + if (auto eq = entry.find('='); eq != std::string::npos) { + key = entry.substr(0, eq); + value = entry.substr(eq + 1); + } + const std::string full = "oiio:colorpolicy:" + key; + if (remove) + keys.erase(full); + else + keys[full] = value; // set (value may be empty for a bare +key) + } else { + // Profile: merge (or erase) its declared keys. An undefined profile + // yields no keys -- a graceful fall-through (spec 09). + const auto profile_keys = config_declared_policy_keys(config, + entry); + for (const auto& kv : profile_keys) { + if (remove) + keys.erase(kv.first); + else + keys[kv.first] = kv.second; // cascades over earlier entries + } + } + } +} + + +ColorPolicySnapshot::ColorPolicySnapshot(const ImageSpec* hints, + const ColorConfig* config, + string_view filepath) + : m_hints(hints) + , m_lock(color_policy_mutex()) +{ + // Config-declared policy (spec 09): the config author's own opinions, + // read here under the same lock so the whole snapshot is one consistent + // view. Merged weakest->strongest into m_config_keys, which get_string / + // get_int consult as ONE layer BELOW the global attribute table (so an + // explicit OIIO::attribute still overrides -- ladder layer 4 > 2/3). + if (config) { + // Layer 2: the config's `oiio:default` profile -- its baseline + // alteration of OIIO's builtin defaults. + m_config_keys = config_declared_policy_keys(*config, "oiio:default"); + + // Layer 3 (spec 09): active profiles, a composable +/- selection. Two + // entry points compose over the layer-2 baseline just loaded: the env + // var OPENIMAGEIO_COLORPOLICY is the base, then the global attribute + // `oiio:colorpolicy:profile` composes on top (more explicit / + // programmatic wins, so an attribute `-entry` can subtract what the env + // var added). Each mutates m_config_keys, which get_string/get_int read + // BELOW the global individual-key table (layer 4), so an absolute + // per-key OIIO::attribute still overrides a selected profile. + apply_profile_selection(m_config_keys, *config, + Sysutil::getenv("OPENIMAGEIO_COLORPOLICY")); + std::string attrsel; + OIIO::getattribute("oiio:colorpolicy:profile", attrsel); + apply_profile_selection(m_config_keys, *config, attrsel); + + // Layer 5 (spec 09): the per-file opinions of the config file-rule that + // MATCHES this file's path. Kept separate from m_config_keys because it + // sits ABOVE the global attribute table (layer 4), not below it -- the + // documented CSS-specificity rung (a file-matching rule outranks a + // user's "absolute" global key; only the per-call hint, layer 6, wins). + if (!filepath.empty()) + m_matched_keys = config_matched_rule_policy_keys(*config, filepath); + } +} + +std::string +ColorPolicySnapshot::get_string(const char* name, ColorPlanDecider* layer) const +{ + if (m_hints) { + if (auto a = m_hints->find_attribute(name, TypeString)) { + if (layer) + *layer = ColorPlanDecider::PerSpecAttribute; + return a->get_ustring().string(); + } + } + // Layer 5: the matched file-rule's per-file key -- above the global table. + { + auto it = m_matched_keys.find(name); + if (it != m_matched_keys.end() && !it->second.empty()) { + if (layer) + *layer = ColorPlanDecider::MatchedRule; + return it->second; + } + } + std::string v; + if (OIIO::getattribute(name, v) && !v.empty()) { + if (layer) + *layer = ColorPlanDecider::GlobalAttribute; + return v; + } + // Below the global table: the config author's declared policy (spec 09). + auto it = m_config_keys.find(name); + if (it != m_config_keys.end() && !it->second.empty()) { + if (layer) + *layer = ColorPlanDecider::ConfigDeclared; + return it->second; + } + if (layer) + *layer = ColorPlanDecider::BuiltinDefault; + return {}; +} + +int +ColorPolicySnapshot::get_int(const char* name, int dflt) const +{ + if (m_hints) { + if (auto a = m_hints->find_attribute(name, TypeInt)) + return a->get_int(); + } + { + auto it = m_matched_keys.find(name); // layer 5, above the global table + if (it != m_matched_keys.end()) + return Strutil::from_string(it->second); + } + int v = dflt; + if (OIIO::getattribute(name, v)) + return v; + auto it = m_config_keys.find(name); + if (it != m_config_keys.end()) + return Strutil::from_string(it->second); + return dflt; +} + + +namespace { + + ColorSignalPolicy parse_signal(const std::string& v) + { + if (v == "always") + return ColorSignalPolicy::Always; + if (v == "never") + return ColorSignalPolicy::Never; + return ColorSignalPolicy::Auto; // "" or "auto" -- today's behavior + } + + // Resolve one signal to an action + value carrier. `explicit_present` is a + // value the author already put on the spec (emitted verbatim, Write); + // `derived` is what OIIO could derive from the color space ("" / empty == + // couldn't determine -> Omit, the never-guess rule). A "never" policy + // suppresses the signal outright; the format-capability gate is applied by + // the caller. + ColorPlanField plan_string_signal(ColorSignalPolicy pol, bool capable, + const std::string& explicit_present, + const std::string& derived) + { + ColorPlanField f; + if (!capable || pol == ColorSignalPolicy::Never) { + f.action = capable ? ColorPlanAction::Suppress + : ColorPlanAction::Omit; + return f; + } + if (!explicit_present.empty()) { + // Verbatim in all modes: the author's bytes are theirs. The marker + // collapse below is deliberately NOT applied here -- it governs what + // OIIO SYNTHESIZES, not what a user wrote. + f.action = ColorPlanAction::Write; + f.str = explicit_present; + } else if (!derived.empty()) { + // Writer boundary for the unknown-marker family (ADR-0020 Amendment + // 2). The markers are OIIO's INTERNAL taxonomy: they carry the *why* + // behind an unknown, and the `ocio` namespace in particular is + // reserved to the OpenColorIO project. A file gets the Color Interop + // Forum's registered vocabulary and nothing else, so a derived marker + // is translated on the way out: + // ocio:unknown, error:unknown -> bare "unknown". Nothing is silently + // dropped: "unknown" is the Forum's registered utility id for + // exactly this case, so the FACT survives and only OIIO's private + // reason for it is discarded. + // oiio:unknown -> omitted. It is a TREATMENT marker (synthetic + // isData/NoOp) that may legally coexist with a definite + // oiio:ColorSpace under the disparity rule, so it makes no + // identity claim at all -- and colorInteropID is an identity + // field. Emitting it there would be a category error. + // ponytail: nothing today synthesizes oiio:/error:unknown into an id + // (only ocio:unknown is minted, color_ocio.cpp), so those two arms are + // currently unreachable. They are stated anyway so the boundary is + // correct the day a derive path does produce them. + switch (classify_interop_marker(derived)) { + case InteropMarker::OiioUnknown: return f; // stays Omit + case InteropMarker::OcioUnknown: + case InteropMarker::ErrorUnknown: f.str = "unknown"; break; + default: f.str = derived; break; + } + f.action = ColorPlanAction::Derive; + } + return f; // else stays Omit + } + + // Feature 2 (spec 09): derive a display gamma from an interop id whose transfer + // is a *pure* power law, probed as a `g_` prefix token on the lowered + // id (g18/g22/g24/g26 -> 1.8/2.2/2.4/2.6). Returns 0 when the id names no pure + // power-law transfer (e.g. sRGB piecewise, log, PQ) -- verbose never guesses a + // gamma for a curve that is not a single exponent, so the emitted gAMA stays + // consistent with the space. Mirrors the pure-gamma cases the PNG writer + // already special-cases inline. + float gamma_from_id(string_view interop_id) + { + const std::string lo = Strutil::lower(interop_id); + static const std::pair table[] = { + { "g18_", 1.8f }, + { "g22_", 2.2f }, + { "g24_", 2.4f }, + { "g26_", 2.6f }, + }; + for (const auto& [tok, g] : table) + if (Strutil::starts_with(lo, tok)) + return g; + return 0.0f; + } + + // True when an interop id names P3-D65 gamut content (a `p3d65` gamut token). + bool is_p3d65_content(string_view interop_id) + { + return Strutil::lower(interop_id).find("p3d65") != std::string::npos; + } + + // Feature B (spec 09): oiio:default's declared write-canonical space mappings. + // The one locked mapping: g26_p3d65_display (the P3-primaries DCDM form) is + // canonicalized to g26_xyzd65_display -- a P3->XYZ primaries conversion WITHIN + // the DCI-white-scaled DCDM family (gamma 2.6 + DCI white headroom, alias + // dcdm_xyzd65), NOT a headroom change and NOT the P3-primaries form. Any other + // id passes through unchanged. See spec 09 "Write-canonical mapping in + // oiio:default". + std::string canonical_write_id(string_view interop_id) + { + if (interop_id == "g26_p3d65_display") + return "g26_xyzd65_display"; + return std::string(interop_id); + } + +} // namespace + + +ColorWritePolicy +ColorWritePolicy::snapshot(const ImageSpec* config_hints, + const ColorConfig* config, string_view filepath) +{ + ColorWritePolicy p; + ColorPolicySnapshot snap(config_hints, config, filepath); + + p.cicp = parse_signal( + snap.get_string("oiio:colorpolicy:write:cicp", &p.cicp_layer)); + p.chromaticities = parse_signal( + snap.get_string("oiio:colorpolicy:write:chromaticities", + &p.chromaticities_layer)); + p.gamma = parse_signal( + snap.get_string("oiio:colorpolicy:write:gamma", &p.gamma_layer)); + p.icc = parse_signal( + snap.get_string("oiio:colorpolicy:write:icc", &p.icc_layer)); + p.interop_id = parse_signal( + snap.get_string("oiio:colorpolicy:write:interop_id", + &p.interop_id_layer)); + p.mdcv = parse_signal( + snap.get_string("oiio:colorpolicy:write:mdcv", &p.mdcv_layer)); + + p.force_interop_id + = snap.get_int("oiio:colorpolicy:write:force_interop_id", 0) != 0; + p.verbose = snap.get_int("oiio:colorpolicy:write:verbose", 0) != 0; + p.canonicalize = snap.get_int("oiio:colorpolicy:write:canonicalize", 0) + != 0; + p.broadcast = snap.get_int("oiio:colorpolicy:write:broadcast", 0) != 0; + return p; +} + + +ColorMetadataPlan +plan_color_metadata(const ColorConfig* config, const ImageSpec& spec, + const ColorWriteCaps& caps, const ColorWritePolicy& policy) +{ + // A null config means "use the process default" -- the config the writers + // historically derived against. + const ColorConfig& cfg = config ? *config + : ColorConfig::default_colorconfig(); + const std::string colorspace = spec.get_string_attribute("oiio:ColorSpace"); + + ColorMetadataPlan plan; + + // One full derivation cascade (declared id, registry fingerprint match, + // legacy table, config-local id) feeds both derived signals below, + // consumed through the shared characterization engine's DERIVE tier -- + // the same cached records the internal derive_color_space_info facade and + // the search walk publish, so a space is characterized once, not per + // consumer. Write planning is the intentional home of the expensive + // derivation, so this requests the interop-id field's full cascade + // (never the cheap subset, which a mislabeled config's syntactic table + // match could fool). No other field is requested: every other plan + // signal consumes only authored metadata today. + std::string derived_id + = characterize_color_space(cfg, colorspace, + CharacterizationField::ColorInteropID) + .color_interop_id; + + // Feature B (spec 09): the config's oiio:default write-canonical mapping is + // NOT applied here as a tag-only relabel. Relabeling P3/straight-2.6 pixels + // with the id of the XYZ/DCI-headroom form (a colorimetric change) would be + // a factual mislabel. The mapping is now a REAL pixel conversion applied at + // the buffer-holding write stage (see apply_write_canonical_conversion, + // which converts the pixels through the embedded interop registry and + // retags), so by the time planning runs the space is already the canonical + // one and derived_id needs no remap. broadcast (layer 3 > 2) still routes P3 + // content into its own container below. + + // interop id: author's colorInteropID verbatim, else name -> interop id. + // Feature 1 (spec 09): force_interop_id makes a slotless format capable of + // carrying the id (emitted as an aux attribute), so a set policy flips the + // capability gate on for the interop-id signal specifically. + const bool interop_capable = caps.interop_id || policy.force_interop_id; + plan.interop_id + = plan_string_signal(policy.interop_id, interop_capable, + spec.get_string_attribute("colorInteropID"), + derived_id); + + // CICP: author's CICP int[4] verbatim, else name -> interop id -> CICP + // tuple (get_cicp on the derived id is a cheap table lookup). + if (caps.cicp && policy.cicp != ColorSignalPolicy::Never) { + int explicit_cicp[4]; + if (spec.getattribute("CICP", TypeDesc(TypeDesc::INT, 4), + explicit_cicp)) { + plan.cicp.action = ColorPlanAction::Write; + plan.cicp.ints.assign(explicit_cicp, explicit_cicp + 4); + } else if (policy.broadcast && is_p3d65_content(derived_id)) { + // Feature A (spec 09): P3 -> broadcast container. Rec.2020 encoding + // primaries are SIGNALED (CICP primaries code 9) and the range is + // narrow (limited, video_full_range_flag = 0); the transfer is + // carried from the source's own CICP, and the matrix is RGB (code 0, + // matching PNG's RGB constraint). The P3 gamut is NOT re-gamut'd to + // Rec.2020 -- its true volume is carried in the MDCV + // mastering-display metadata below. + // No-CICP transfer fallback is BT.1886 (code 1), the SDR broadcast + // EOTF -- NOT DCDM gamma-2.6 (code 17), whose 48/52.37 DCI headroom + // a broadcast decoder must not apply to non-cinema content. + cspan src = cfg.get_cicp(derived_id); + const int transfer = src.size() == 4 ? src[1] : 1; + plan.cicp.action = ColorPlanAction::Derive; + plan.cicp.ints = { 9, transfer, 0, 0 }; + } else { + cspan derived = derived_id.empty() ? cspan() + : cfg.get_cicp(derived_id); + if (derived.size() == 4) { + plan.cicp.action = ColorPlanAction::Derive; + plan.cicp.ints.assign(derived.begin(), derived.end()); + } + } + } else if (caps.cicp) { + plan.cicp.action = ColorPlanAction::Suppress; + } + + // Chromaticities: author's chromaticities float[8] verbatim. Minimally + // there is no name -> chromaticities derivation, so an unspecified one omits + // rather than guessing. Feature 2 (spec 09): under verbose, DERIVE cHRM from + // the space's reserved gamut (a consistent, table-driven value) so the full + // redundant set is emitted; verbose also makes the signal emittable for the + // plan-consuming formats (PNG cHRM, EXR chromaticities), whose static caps + // understate what they can carry. ponytail: verbose OR-ed into the gate + // because both plan consumers can carry cHRM; the writer still gates the + // actual emission on emit(). + const bool chrom_capable = caps.chromaticities || policy.verbose; + if (chrom_capable && policy.chromaticities != ColorSignalPolicy::Never) { + float chrm[8]; + if (spec.getattribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), + chrm)) { + plan.chromaticities.action = ColorPlanAction::Write; + plan.chromaticities.floats.assign(chrm, chrm + 8); + } else if (policy.verbose && !derived_id.empty()) { + if (auto c = reserved_chromaticities_for_id(derived_id)) { + plan.chromaticities.action = ColorPlanAction::Derive; + for (const auto& xy : *c) { // R,G,B,W (x,y) -> flat float[8] + plan.chromaticities.floats.push_back(float(xy[0])); + plan.chromaticities.floats.push_back(float(xy[1])); + } + } + } + } else if (chrom_capable) { + plan.chromaticities.action = ColorPlanAction::Suppress; + } + + // B5 (spec 07): once a colorInteropID is going to be emitted, the + // chromaticities attribute is redundant derivable metadata that drifts and + // contradicts -- suppress it regardless of whether the author supplied one. + // The one exception, an ST 2065-4 / ACES container that REQUIRES its AP0 + // chromaticities (B4), is enforced by the EXR writer that owns that + // container machinery, not here. Feature 2: verbose deliberately KEEPS the + // redundant chromaticities alongside the id (the whole point of verbose), + // so the B5 minimization is skipped when verbose is on. + if (plan.interop_id.emit() && !policy.verbose) + plan.chromaticities.action = ColorPlanAction::Suppress; + + // Gamma: author's gamma verbatim. Feature 2 (spec 09): under verbose, + // DERIVE gamma from a pure-power-law transfer token (gamma_from_id); a + // non-power-law space (sRGB piecewise, log, PQ) yields 0 and stays omitted + // so the emitted gAMA is never inconsistent with the curve. + const bool gamma_capable = caps.gamma || policy.verbose; + if (gamma_capable && policy.gamma != ColorSignalPolicy::Never) { + if (auto a = spec.find_attribute("oiio:Gamma", TypeFloat)) { + plan.gamma.action = ColorPlanAction::Write; + plan.gamma.gamma = a->get_float(); + } else if (policy.verbose) { + if (float g = gamma_from_id(derived_id); g > 0.0f) { + plan.gamma.action = ColorPlanAction::Derive; + plan.gamma.gamma = g; + } + } + } else if (gamma_capable) { + plan.gamma.action = ColorPlanAction::Suppress; + } + + // ICC: author's ICCProfile blob verbatim; no derivation (omit). + if (caps.icc && policy.icc != ColorSignalPolicy::Never) { + if (auto a = spec.find_attribute("ICCProfile")) { + plan.icc.action = ColorPlanAction::Write; + const unsigned char* p = reinterpret_cast( + a->data()); + plan.icc.ints.assign(p, p + a->type().size()); // raw bytes + } + } else if (caps.icc) { + plan.icc.action = ColorPlanAction::Suppress; + } + + // mDCV (mastering-display volume). No in-tree derivation in general, so it + // stays Omit. Feature A (spec 09): under broadcast, DERIVE the true P3(D65) + // gamut volume (R,G,B,W xy) the Rec.2020-signaled container is actually + // carrying, and make it emittable -- the plan consumers' static caps do not + // enable mDCV, so broadcast is OR-ed into the gate, mirroring the verbose + // cHRM/gAMA gates above. + // Note the asymmetry with the five signals above: author-supplied mDCV is + // NOT resolved here as an ExplicitMetadata Write. Authored `mdcv_*` + // ImageSpec attributes are honored directly in the format writer (see + // png_pvt.h), so this plan only carries the broadcast-derived volume. + // Consequence: --colorwriteplan reports mdcv as omit even when the file + // will carry author mDCV. ponytail: unify by reading `mdcv_*` into + // plan.mdcv when the reconciler write-shape is settled. + const bool mdcv_capable = caps.mdcv || policy.broadcast; + if (mdcv_capable && policy.mdcv != ColorSignalPolicy::Never) { + if (policy.broadcast && is_p3d65_content(derived_id)) { + if (auto c = reserved_chromaticities_for_id(derived_id)) { + plan.mdcv.action = ColorPlanAction::Derive; + for (const auto& xy : *c) { // R,G,B,W (x,y) -> flat float[8] + plan.mdcv.floats.push_back(float(xy[0])); + plan.mdcv.floats.push_back(float(xy[1])); + } + } + } + } else if (mdcv_capable) { + plan.mdcv.action = ColorPlanAction::Suppress; + } + + // Attribute each verdict: format incapability and the author's explicit + // metadata trump the policy tier; everything else was decided by + // whichever tier supplied the signal's policy. + auto decider = [](bool capable, ColorPlanAction action, + ColorPlanDecider policy_layer) { + if (!capable) + return ColorPlanDecider::FormatIncapable; + if (action == ColorPlanAction::Write) + return ColorPlanDecider::ExplicitMetadata; + return policy_layer; + }; + plan.cicp.decider = decider(caps.cicp, plan.cicp.action, policy.cicp_layer); + plan.chromaticities.decider = decider(chrom_capable, + plan.chromaticities.action, + policy.chromaticities_layer); + plan.gamma.decider = decider(gamma_capable, plan.gamma.action, + policy.gamma_layer); + plan.icc.decider = decider(caps.icc, plan.icc.action, policy.icc_layer); + plan.interop_id.decider = decider(interop_capable, plan.interop_id.action, + policy.interop_id_layer); + plan.mdcv.decider = decider(mdcv_capable, plan.mdcv.action, + policy.mdcv_layer); + + // Provenance write rule: drop oiio:SourcePath, keep oiio:SourceFormat. + plan.suppress_source_path = true; + plan.keep_source_format = true; + return plan; +} + + +ColorWriteCaps +color_write_caps_for_format(string_view format_name) +{ + ColorWriteCaps caps; + if (Strutil::iequals(format_name, "png")) { + caps.cicp = true; + } else if (Strutil::iequals(format_name, "openexr") + || Strutil::iequals(format_name, "exr")) { + caps.interop_id = true; + } + // mDCV (SMPTE ST 2086) format applicability, per oicio spec 34 "Format + // gate" -- png/heif/avif/jxl (+ mp4/mov via the master_display string) are + // mastering-capable; exr and tiff and jpeg have no native mDCV slot. Only + // PNG is wired to a file so far (png_pvt.h, via libpng png_set/get_mDCV); + // under the broadcast policy the plan OR-s mDCV in regardless of caps.mdcv + // (see plan_color_metadata), so caps.mdcv stays false here until a second + // format is wired. HEIF/AVIF (libheif) and JXL (libjxl) are follow-ons -- + // their mastering-display APIs need those libraries present at build time. + return caps; +} + + +namespace { + + const char* action_name(ColorPlanAction a) + { + switch (a) { + case ColorPlanAction::Write: return "write"; + case ColorPlanAction::Derive: return "derive"; + case ColorPlanAction::Suppress: return "suppress"; + default: return "omit"; + } + } + + const char* decider_name(ColorPlanDecider d) + { + switch (d) { + case ColorPlanDecider::ConfigDeclared: return "config declared"; + case ColorPlanDecider::GlobalAttribute: return "global attribute"; + case ColorPlanDecider::MatchedRule: return "matched rule"; + case ColorPlanDecider::PerSpecAttribute: return "per-spec attribute"; + case ColorPlanDecider::ExplicitMetadata: return "explicit metadata"; + case ColorPlanDecider::FormatIncapable: return "format incapable"; + default: return "builtin default"; + } + } + + // Render the one populated value carrier of a field ("-" when the plan says + // to emit nothing). ICC bytes are summarized, never dumped. + std::string field_value(const ColorPlanField& f, bool is_icc) + { + if (!f.emit()) + return "-"; + if (is_icc) + return Strutil::fmt::format("<{} bytes>", f.ints.size()); + if (!f.str.empty()) + return f.str; + if (f.ints.size()) + return Strutil::join(f.ints, "/"); + if (f.floats.size()) + return Strutil::join(f.floats, ","); + return Strutil::fmt::format("{:g}", f.gamma); + } + +} // namespace + + +std::string +render_color_write_plan(const ImageSpec& spec, string_view format_name) +{ + const ColorWriteCaps caps = color_write_caps_for_format(format_name); + // Preview the write plan under the ambient config's declared write policy + // (spec 09), the same as a real write would. --colorwriteplan takes a + // format, not an output path, so layer 5 (matched output-rule) does not + // apply here; layers 2/3 (config default/profiles) and 4/6 do. + const ColorMetadataPlan plan = plan_color_metadata( + nullptr, spec, caps, + ColorWritePolicy::snapshot(&spec, ambient_color_config())); + std::string out + = Strutil::fmt::format("Color write plan for format \"{}\":\n", + format_name); + auto row = [&](const char* signal, const ColorPlanField& f, + bool is_icc = false) { + out += Strutil::fmt::format(" {:<15} {:<9} {:<19} {}\n", signal, + action_name(f.action), + decider_name(f.decider), + field_value(f, is_icc)); + }; + row("cicp", plan.cicp); + row("chromaticities", plan.chromaticities); + row("gamma", plan.gamma); + row("icc", plan.icc, true); + row("interop_id", plan.interop_id); + row("mdcv", plan.mdcv); + return out; +} + + +bool +apply_write_canonical_conversion(ImageBuf& buf, const ColorConfig* config, + string_view filepath) +{ + // Feature B (spec 09), the reconciler write-shape: when the config's write + // policy maps this buffer's color space to a canonical target that is a + // real colorimetric change, CONVERT the pixels rather than merely retagging + // them. The one locked mapping is the DCDM P3->XYZ headroom conversion + // (g26_p3d65_display -> g26_xyzd65_display). Runs against the embedded + // interop registry (interop_registry_processor), then stamps oiio:ColorSpace + // to the target so the downstream metadata plan tags the pixels truthfully. + // Returns true iff pixels were converted. No-op (false) when no mapping + // applies, the space can't be characterized, or the registry lacks the + // transform -- in every no-op case the buffer keeps its own (truthful) tag. + const ColorConfig& cfg = config ? *config + : ColorConfig::default_colorconfig(); + const ColorWritePolicy policy + = ColorWritePolicy::snapshot(&buf.spec(), config, filepath); + if (!policy.canonicalize || policy.broadcast) + return false; + const std::string colorspace = buf.spec().get_string_attribute( + "oiio:ColorSpace"); + if (colorspace.empty()) + return false; + const std::string from + = characterize_color_space(cfg, colorspace, + CharacterizationField::ColorInteropID) + .color_interop_id; + const std::string to = canonical_write_id(from); + if (from.empty() || to == from) + return false; + ColorProcessorHandle proc = interop_registry_processor(from, to); + if (!proc) + return false; + // In-place; unpremult=false because these are display-encoded pixels, not + // premultiplied linear -- the mapping is a pure per-pixel curve/matrix. + if (!ImageBufAlgo::colorconvert(buf, buf, proc.get(), /*unpremult=*/false)) + return false; + buf.specmod().attribute("oiio:ColorSpace", to); + return true; +} + + +void +apply_forced_interop_id(ImageSpec& spec, string_view format_name, + string_view filepath) +{ + const ColorWriteCaps caps = color_write_caps_for_format(format_name); + if (caps.interop_id) + return; // native slot -- the format's own plan path owns the id + + const ColorConfig* config = ambient_color_config(); + const ColorWritePolicy policy = ColorWritePolicy::snapshot(&spec, config, + filepath); + if (!policy.force_interop_id) { + // Default contract: a slotless format carries no transport identity. + // Strip any authored/passthrough id so it stays untagged (the id would + // otherwise leak out through the writer's generic XMP emission). + spec.erase_attribute("colorInteropID"); + return; + } + // Forced: keep an already-authored id; otherwise derive one from the color + // space and stamp it so the writer's generic emission (XMP) carries it. + if (!spec.get_string_attribute("colorInteropID").empty()) + return; + const ColorMetadataPlan plan = plan_color_metadata(config, spec, caps, + policy); + if (plan.interop_id.emit() && is_valid_interop_id(plan.interop_id.str)) + spec.attribute("colorInteropID", plan.interop_id.str); +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_metadata_plan_test.cpp b/src/libOpenImageIO/color_metadata_plan_test.cpp new file mode 100644 index 0000000000..ecc1ee4f2d --- /dev/null +++ b/src/libOpenImageIO/color_metadata_plan_test.cpp @@ -0,0 +1,1096 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for the write-side color-metadata plan (pvt), driven directly +// through color_pvt.h. They exercise the write/suppress/derive/omit marking +// of each signal, the never-guess omission rule, the provenance suppression +// rule, and the OpenEXR writer's consumption of the plan. + +#include +#include +#include +#include + +#include "color_pvt.h" +#include +#include +#include + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; +using namespace OIIO::pvt; + + +// The same small config the read-side test uses: its identity spaces carry +// the interop-id / CICP metadata the derive-path vectors resolve against. +static std::string +write_test_config() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_cmp_test.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! + name: raw_data + isdata: true + aliases: [data] + - ! + name: lin_ap0_scene + - ! + name: lin_ap1_scene + - ! + name: srgb_rec709_scene + - ! + name: srgb_rec709_display + - ! + name: g24_rec709_display +)"; + f.close(); + return path; +} + + +// Feature 3 (spec 09): a config carrying named policy profiles for the +// layer-3 selection test. oiio:default declares a layer-2 key (write:verbose) +// distinct from the profile's keys, so a `-profile` removal is shown to leave +// the layer-2 baseline untouched. +static std::string +write_profile_config() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_cmp_profiles.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:write:verbose: "1"}} + - ! {name: oiio:blender:textures, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:read:cicp_state: scene, oiio:colorpolicy:read:display_to_scene: invert_view}} + - ! {name: Default, colorspace: raw_data} +colorspaces: + - ! + name: raw_data + isdata: true + aliases: [data] +)"; + f.close(); + return path; +} + + +// Feature 3 (spec 09): the composable +/- layer-3 profile selection. Exercises +// the parse/compose directly (apply_profile_selection), independent of any +// reader: profile activation, per-key subtract, direct key set, whole-profile +// subtract composed on top, and undefined-profile fall-through. +static void +test_profile_selection(const ColorConfig& config) +{ + using Keys = std::map; + const std::string CS = "oiio:colorpolicy:read:cicp_state"; + const std::string DTS = "oiio:colorpolicy:read:display_to_scene"; + const std::string VB = "oiio:colorpolicy:write:verbose"; + // The layer-2 baseline the snapshot ctor seeds before selection. + auto base = [&] { + return config_declared_policy_keys(config, "oiio:default"); + }; + + // (1) Selecting a profile activates its keys, cascading over layer 2. + { + Keys k = base(); + apply_profile_selection(k, config, "oiio:blender:textures"); + OIIO_CHECK_EQUAL(k[CS], "scene"); + OIIO_CHECK_EQUAL(k[DTS], "invert_view"); + OIIO_CHECK_EQUAL(k[VB], "1"); // layer-2 baseline preserved + } + // (2) Profile then -key drops just that one key; the profile stays. + { + Keys k = base(); + apply_profile_selection(k, config, + "oiio:blender:textures,-read:display_to_scene"); + OIIO_CHECK_EQUAL(k[CS], "scene"); + OIIO_CHECK_ASSERT(k.find(DTS) == k.end()); + } + // (3) +key=value sets a key directly (no profile). + { + Keys k = base(); + apply_profile_selection(k, config, "+read:cicp_state=scene"); + OIIO_CHECK_EQUAL(k[CS], "scene"); + OIIO_CHECK_EQUAL(k[VB], "1"); + } + // (4) Composition: the env-var base adds the profile, the attribute on top + // subtracts the whole profile -- its keys go, the layer-2 key remains. + { + Keys k = base(); + apply_profile_selection(k, config, + "oiio:blender:textures"); // env base + apply_profile_selection(k, config, "-oiio:blender:textures"); // attr + OIIO_CHECK_ASSERT(k.find(CS) == k.end()); + OIIO_CHECK_ASSERT(k.find(DTS) == k.end()); + OIIO_CHECK_EQUAL(k[VB], "1"); + } + // (5) An undefined profile contributes nothing (graceful fall-through). + { + Keys k = base(); + apply_profile_selection(k, config, "oiio:does:not:exist"); + OIIO_CHECK_EQUAL(k.size(), base().size()); + } +} + + +static ColorWriteCaps +all_caps() +{ + ColorWriteCaps c; + c.cicp = c.chromaticities = c.gamma = c.icc = c.interop_id = c.mdcv = true; + return c; +} + + +// An author-supplied colorInteropID is emitted verbatim (Write); an +// unspecified, underivable color space omits (never-guess). +static void +test_interop_id_write_and_omit() +{ + ColorWritePolicy pol; + + ImageSpec explicit_spec(4, 4, 3, TypeHalf); + explicit_spec.attribute("colorInteropID", "lin_adobergb_scene"); + auto p1 = plan_color_metadata(nullptr, explicit_spec, all_caps(), pol); + OIIO_CHECK_EQUAL(int(p1.interop_id.action), int(ColorPlanAction::Write)); + OIIO_CHECK_EQUAL(p1.interop_id.str, "lin_adobergb_scene"); + + ImageSpec blank(4, 4, 3, TypeHalf); + blank.attribute("oiio:ColorSpace", "not-a-real-color-space-xyzzy"); + auto p2 = plan_color_metadata(nullptr, blank, all_caps(), pol); + OIIO_CHECK_EQUAL(int(p2.interop_id.action), int(ColorPlanAction::Omit)); + OIIO_CHECK_ASSERT(!p2.interop_id.emit()); +} + + +// A capable signal under a "never" policy is Suppressed, not Written, even +// with an author value present. +static void +test_never_suppresses() +{ + ColorWritePolicy pol; + pol.interop_id = ColorSignalPolicy::Never; + + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + auto p = plan_color_metadata(nullptr, spec, all_caps(), pol); + OIIO_CHECK_EQUAL(int(p.interop_id.action), int(ColorPlanAction::Suppress)); + OIIO_CHECK_ASSERT(!p.interop_id.emit()); +} + + +// A signal the format cannot carry stays Omit regardless of the metadata. +static void +test_incapable_omits() +{ + ColorWritePolicy pol; + ColorWriteCaps caps; // nothing supported + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + auto p = plan_color_metadata(nullptr, spec, caps, pol); + OIIO_CHECK_EQUAL(int(p.interop_id.action), int(ColorPlanAction::Omit)); +} + + +// Author-supplied chromaticities / gamma are emitted verbatim; without an +// author value and with no in-tree deriver they omit (never-guess). +static void +test_explicit_chroma_and_gamma() +{ + ColorWritePolicy pol; + const float chrm[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.3290f }; + + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), chrm); + spec.attribute("oiio:Gamma", 2.2f); + auto p = plan_color_metadata(nullptr, spec, all_caps(), pol); + OIIO_CHECK_EQUAL(int(p.chromaticities.action), int(ColorPlanAction::Write)); + OIIO_CHECK_EQUAL(p.chromaticities.floats.size(), size_t(8)); + OIIO_CHECK_EQUAL(int(p.gamma.action), int(ColorPlanAction::Write)); + OIIO_CHECK_EQUAL(p.gamma.gamma, 2.2f); + + ImageSpec bare(4, 4, 3, TypeHalf); + auto p2 = plan_color_metadata(nullptr, bare, all_caps(), pol); + OIIO_CHECK_EQUAL(int(p2.chromaticities.action), int(ColorPlanAction::Omit)); + OIIO_CHECK_EQUAL(int(p2.gamma.action), int(ColorPlanAction::Omit)); +} + + +// Name -> interop id and name -> CICP derivation, exercised against the config +// (branching on what the config can actually resolve, so the vector is +// deterministic regardless of the config's coverage). +static void +test_derivation(const ColorConfig& config) +{ + ColorWritePolicy pol; + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("oiio:ColorSpace", "srgb_rec709_display"); + + auto p = plan_color_metadata(&config, spec, all_caps(), pol); + + // The plan's Derive path runs the full derivation cascade, so the + // expectation comes from pvt::derive_color_interop_id, not the cheap + // public lookup. + const std::string want_id( + derive_color_interop_id(config, "srgb_rec709_display")); + if (!want_id.empty()) { + OIIO_CHECK_EQUAL(int(p.interop_id.action), + int(ColorPlanAction::Derive)); + OIIO_CHECK_EQUAL(p.interop_id.str, want_id); + } else { + OIIO_CHECK_EQUAL(int(p.interop_id.action), int(ColorPlanAction::Omit)); + } + + cspan want_cicp = want_id.empty() ? cspan() + : config.get_cicp(want_id); + if (want_cicp.size() == 4) { + OIIO_CHECK_EQUAL(int(p.cicp.action), int(ColorPlanAction::Derive)); + OIIO_CHECK_EQUAL(p.cicp.ints.size(), size_t(4)); + } else { + OIIO_CHECK_EQUAL(int(p.cicp.action), int(ColorPlanAction::Omit)); + } +} + + +// A MISLABELED config: a space whose name table-matches one interop id +// ("srgb_rec709_scene") but whose math (an identity transform against the +// AP0 interchange anchor) fingerprints to a different registry identity +// ("lin_ap0_scene"). The cheap declared/table subset is fooled by the name; +// the full cascade's equality (fingerprint) tier outranks it. The planner's +// Derive verdict must be the full cascade's answer, reached through the +// shared characterization engine -- and the direct cascade, the engine's +// derive tier, the public derive verb, and the plan must all agree +// bit-exact (the round-2 cheap-first divergence, resolved). +static void +test_mislabeled_config_derivation() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + static const char* mislabeled_yaml = R"(ocio_profile_version: 2.1 +name: mislabeled_cfg +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: srgb_rec709_scene +)"; + const std::string path = Filesystem::temp_directory_path() + + "/oiio_cmp_mislabeled.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path, mislabeled_yaml)); + ColorConfig cc(path); + OIIO_CHECK_ASSERT(!cc.has_error()); + characterization_cache_reset(); + + // The cheap subset answers the syntactic table match... + OIIO_CHECK_EQUAL(cc.get_color_interop_id("srgb_rec709_scene"), + "srgb_rec709_scene"); + // ...but the full cascade's fingerprint tier outranks it. + const std::string cascade(derive_color_interop_id(cc, "srgb_rec709_scene")); + OIIO_CHECK_EQUAL(cascade, "lin_ap0_scene"); + + // The engine's derive tier (via the public derive verb) agrees with the + // cascade bit-exact, and reports the correction as a derived value. + ColorSpaceInfo info = cc.derive_color_space_info("srgb_rec709_scene"); + OIIO_CHECK_ASSERT(info.valid()); + OIIO_CHECK_EQUAL(info.color_interop_id(), cascade); + OIIO_CHECK_ASSERT(info.derived(ColorSpaceInfoField::ColorInteropID)); + + // The corrected verdict survives the cache merge with a later fresh + // cheap pass (the derived value outranks the table match). + ColorSpaceInfo cheap = cc.get_color_space_info("srgb_rec709_scene"); + OIIO_CHECK_EQUAL(cheap.color_interop_id(), cascade); + + // And the planner's Derive verdict is that same answer. + ColorWritePolicy pol; + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("oiio:ColorSpace", "srgb_rec709_scene"); + auto p = plan_color_metadata(&cc, spec, all_caps(), pol); + OIIO_CHECK_EQUAL(int(p.interop_id.action), int(ColorPlanAction::Derive)); + OIIO_CHECK_EQUAL(p.interop_id.str, cascade); + + characterization_cache_reset(); + Filesystem::remove(path); +} + + +// The global oiio:colorpolicy:* tier: a value set through OIIO::attribute() +// must round-trip through OIIO::getattribute(), be visible to the policy +// snapshot, and actually change writer behavior end to end (write a file, +// reopen it, observe the signal gone) -- not merely alter a plan object. +static void +test_global_policy_tier() +{ + // Storage round-trip, string and int. + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:interop_id", "never")); + std::string v; + OIIO_CHECK_ASSERT( + OIIO::getattribute("oiio:colorpolicy:write:interop_id", v)); + OIIO_CHECK_EQUAL(v, "never"); + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:read:ignore_cicp_for_png", 1)); + int iv = 0; + OIIO_CHECK_ASSERT( + OIIO::getattribute("oiio:colorpolicy:read:ignore_cicp_for_png", iv)); + OIIO_CHECK_EQUAL(iv, 1); + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:read:ignore_cicp_for_png", 0)); + + // The write-policy snapshot sees the global (no per-spec hints in play). + auto pol = ColorWritePolicy::snapshot(); + OIIO_CHECK_EQUAL(int(pol.interop_id), int(ColorSignalPolicy::Never)); + + // End to end: an EXR write that would otherwise DERIVE an interop id from + // the color space emits none while the global says never. Only meaningful + // when the default config can derive one -- guard like test_exr_consumption. + const std::string derivable( + derive_color_interop_id(ColorConfig::default_colorconfig(), + "lin_ap0_scene")); + if (!derivable.empty() && ImageOutput::create("exr")) { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_globalpolicy.exr"; + std::vector pix(4 * 4 * 3, 0.5f); + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("oiio:ColorSpace", "lin_ap0_scene"); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + ""); + in->close(); + } + Filesystem::remove(file); + } + + // Restore the default so later tests see auto behavior. + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:interop_id", "auto")); + pol = ColorWritePolicy::snapshot(); + OIIO_CHECK_EQUAL(int(pol.interop_id), int(ColorSignalPolicy::Auto)); +} + + +// Per-spec / per-open policy hints must reach every reconcile/plan call +// site: a hint on the output spec (write) or the open-config spec (read) +// overrides the global tier, on both PNG and EXR, read and write. +static void +test_policy_hint_plumbing() +{ + std::vector fpix(4 * 4 * 3, 0.5f); + std::vector upix(4 * 4 * 3, 128); + + // --- EXR write: per-spec hint overrides a global 'never'. ------------ + const std::string derivable( + derive_color_interop_id(ColorConfig::default_colorconfig(), + "lin_ap0_scene")); + if (!derivable.empty() && ImageOutput::create("exr")) { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_hints.exr"; + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:interop_id", "never")); + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("oiio:ColorSpace", "lin_ap0_scene"); + spec.attribute("oiio:colorpolicy:write:interop_id", "auto"); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, fpix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:interop_id", "auto")); + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + // The per-spec 'auto' beat the global 'never': the id derived. + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + derivable); + in->close(); + } + Filesystem::remove(file); + } + + // --- EXR read: per-open config hint overrides a global preference. --- + if (ImageOutput::create("exr")) { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_hints_read.exr"; + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "srgb_rec709_display"); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, fpix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + // Reader-parity guard: run the probes under BOTH EXR readers + // (openexr:core=0 -> OpenEXRInput, =1 -> OpenEXRCoreInput), so a + // policy wired into only one reader fails here regardless of which + // one the build defaults to. + int core_orig = 0; + OIIO::getattribute("openexr:core", core_orig); + for (int core : { 0, 1 }) { + OIIO_CHECK_ASSERT(OIIO::attribute("openexr:core", core)); + // Global tier: prefer the scene-state twin of the file's id. + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:read:state_preference", + "scene")); + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_EQUAL(in->spec().get_string_attribute( + "oiio:ColorSpace"), + "srgb_rec709_scene"); + in->close(); + } + // Per-open hint: put the display preference back for THIS open + // only. + ImageSpec config; + config.attribute("oiio:colorpolicy:read:state_preference", + "display"); + auto in2 = ImageInput::open(file, &config); + OIIO_CHECK_ASSERT(in2.get()); + if (in2) { + OIIO_CHECK_EQUAL(in2->spec().get_string_attribute( + "oiio:ColorSpace"), + "srgb_rec709_display"); + in2->close(); + } + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:read:state_preference", + "auto")); + } + OIIO::attribute("openexr:core", core_orig); + Filesystem::remove(file); + } + + // --- PNG: capability probe -- explicit CICP must round-trip at all + // (libpng without cICP support skips the PNG halves). ----------------- + const int cicp_srgb[4] = { 1, 13, 0, 1 }; + bool png_cicp_ok = false; + const std::string pngfile = Filesystem::temp_directory_path() + + "/oiio_cmp_hints.png"; + if (ImageOutput::create("png")) { + ImageSpec spec(4, 4, 3, TypeUInt8); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp_srgb); + auto o = ImageOutput::create(pngfile); + OIIO_CHECK_ASSERT(o && o->open(pngfile, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeUInt8, upix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(pngfile); + if (in) { + int got[4] = { -1, -1, -1, -1 }; + png_cicp_ok = in->spec().getattribute("CICP", + TypeDesc(TypeDesc::INT, 4), + got); + in->close(); + } + } + if (!png_cicp_ok) { + Strutil::print("PNG cICP unavailable; skipping PNG hint plumbing\n"); + Filesystem::remove(pngfile); + return; + } + + // --- PNG read: per-open config hint changes the CICP resolution. ----- + { + // No hint: the sRGB CICP tuple resolves display-referred. + auto in = ImageInput::open(pngfile); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_display"); + in->close(); + } + // Per-open hint: prefer the scene-state twin. + ImageSpec config; + config.attribute("oiio:colorpolicy:read:state_preference", "scene"); + auto in2 = ImageInput::open(pngfile, &config); + OIIO_CHECK_ASSERT(in2.get()); + if (in2) { + OIIO_CHECK_EQUAL(in2->spec().get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_scene"); + in2->close(); + } + } + + // --- PNG write: per-spec hint overrides a global 'never'. ------------ + { + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:cicp", "never")); + ImageSpec spec(4, 4, 3, TypeUInt8); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp_srgb); + spec.attribute("oiio:colorpolicy:write:cicp", "auto"); + auto o = ImageOutput::create(pngfile); + OIIO_CHECK_ASSERT(o && o->open(pngfile, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeUInt8, upix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:cicp", "auto")); + auto in = ImageInput::open(pngfile); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + int got[4] = { -1, -1, -1, -1 }; + // The per-spec 'auto' beat the global 'never': the chunk exists. + OIIO_CHECK_ASSERT( + in->spec().getattribute("CICP", TypeDesc(TypeDesc::INT, 4), + got)); + in->close(); + } + } + Filesystem::remove(pngfile); +} + + +// A Suppress verdict must be enforced at the WRITER boundary, not just in +// the plan object: an author-supplied value under a 'never' policy stays out +// of the written file even though the attribute sits on the spec (the EXR +// generic-metadata loop would otherwise emit it anyway). +static void +test_writer_level_suppress() +{ + // EXR: author colorInteropID + per-spec never -> reopened file carries + // no colorInteropID. + if (ImageOutput::create("exr")) { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_suppress.exr"; + std::vector pix(4 * 4 * 3, 0.5f); + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + spec.attribute("oiio:colorpolicy:write:interop_id", "never"); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_ASSERT( + !in->spec().find_attribute("colorInteropID", TypeString)); + in->close(); + } + Filesystem::remove(file); + } + + // PNG: author CICP + per-spec never -> reopened file carries no cICP + // chunk (the writer's emit gate already enforces this; guard it). + if (ImageOutput::create("png")) { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_suppress.png"; + std::vector pix(4 * 4 * 3, 128); + const int cicp[4] = { 1, 13, 0, 1 }; + ImageSpec spec(4, 4, 3, TypeUInt8); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + spec.attribute("oiio:colorpolicy:write:cicp", "never"); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeUInt8, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + int got[4] = { -1, -1, -1, -1 }; + OIIO_CHECK_ASSERT( + !in->spec().getattribute("CICP", TypeDesc(TypeDesc::INT, 4), + got)); + in->close(); + } + Filesystem::remove(file); + } +} + + +// Layer attribution: every planned field records who decided it -- format +// incapability, the author's explicit metadata, or the policy tier (builtin +// default / global attribute / per-spec attribute) that was in force. +static void +test_layer_attribution() +{ + // Builtin default (no policy set anywhere) and format incapability. + { + ImageSpec spec(4, 4, 3, TypeHalf); + auto p = plan_color_metadata(nullptr, spec, all_caps(), + ColorWritePolicy::snapshot()); + OIIO_CHECK_EQUAL(int(p.cicp.decider), + int(ColorPlanDecider::BuiltinDefault)); + ColorWriteCaps none; // nothing supported + auto p2 = plan_color_metadata(nullptr, spec, none, + ColorWritePolicy::snapshot()); + OIIO_CHECK_EQUAL(int(p2.cicp.decider), + int(ColorPlanDecider::FormatIncapable)); + OIIO_CHECK_EQUAL(int(p2.interop_id.decider), + int(ColorPlanDecider::FormatIncapable)); + } + + // Author-supplied metadata wins the attribution on a Write verdict. + { + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + auto p = plan_color_metadata(nullptr, spec, all_caps(), + ColorWritePolicy::snapshot()); + OIIO_CHECK_EQUAL(int(p.interop_id.decider), + int(ColorPlanDecider::ExplicitMetadata)); + } + + // A global attribute decides -- even an explicit value bows to its + // "never" -- and a per-spec hint outranks (and re-attributes) it. + { + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:interop_id", "never")); + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + auto p = plan_color_metadata(nullptr, spec, all_caps(), + ColorWritePolicy::snapshot(&spec)); + OIIO_CHECK_EQUAL(int(p.interop_id.action), + int(ColorPlanAction::Suppress)); + OIIO_CHECK_EQUAL(int(p.interop_id.decider), + int(ColorPlanDecider::GlobalAttribute)); + + spec.attribute("oiio:colorpolicy:write:interop_id", "auto"); + auto p2 = plan_color_metadata(nullptr, spec, all_caps(), + ColorWritePolicy::snapshot(&spec)); + OIIO_CHECK_EQUAL(int(p2.interop_id.action), + int(ColorPlanAction::Write)); + OIIO_CHECK_EQUAL(int(p2.interop_id.decider), + int(ColorPlanDecider::ExplicitMetadata)); + + spec.attribute("oiio:colorpolicy:write:interop_id", "never"); + auto p3 = plan_color_metadata(nullptr, spec, all_caps(), + ColorWritePolicy::snapshot(&spec)); + OIIO_CHECK_EQUAL(int(p3.interop_id.action), + int(ColorPlanAction::Suppress)); + OIIO_CHECK_EQUAL(int(p3.interop_id.decider), + int(ColorPlanDecider::PerSpecAttribute)); + // Restore to UNSET ("") -- not "auto", which would leave the global + // tier attributed for everything after us. + OIIO_CHECK_ASSERT( + OIIO::attribute("oiio:colorpolicy:write:interop_id", "")); + } + + // The name->caps table matches what the wired writers declare. + { + ColorWriteCaps png = color_write_caps_for_format("png"); + OIIO_CHECK_ASSERT(png.cicp && !png.interop_id && !png.icc); + ColorWriteCaps exr = color_write_caps_for_format("openexr"); + OIIO_CHECK_ASSERT(exr.interop_id && !exr.cicp); + ColorWriteCaps exr2 = color_write_caps_for_format("EXR"); + OIIO_CHECK_ASSERT(exr2.interop_id); + ColorWriteCaps none = color_write_caps_for_format("tiff"); + OIIO_CHECK_ASSERT(!none.cicp && !none.interop_id && !none.icc + && !none.chromaticities && !none.gamma && !none.mdcv); + } +} + + +// The provenance write rule: suppress oiio:SourcePath, keep oiio:SourceFormat. +static void +test_provenance_rule() +{ + ImageSpec spec(4, 4, 3, TypeHalf); + auto p = plan_color_metadata(nullptr, spec, all_caps(), ColorWritePolicy()); + OIIO_CHECK_ASSERT(p.suppress_source_path); + OIIO_CHECK_ASSERT(p.keep_source_format); +} + + +// The OpenEXR writer consumes the plan: an author-supplied id survives a +// write/read round-trip untouched, and a spec that only names a color space +// gets exactly the id the plan derives (or none, matching the default config). +static void +test_exr_consumption() +{ + auto out = ImageOutput::create("exr"); + if (!out) { + Strutil::print( + "EXR plugin unavailable; skipping consumption round-trip\n"); + return; + } + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_roundtrip.exr"; + std::vector pix(4 * 4 * 3, 0.5f); + + auto write = [&](const ImageSpec& spec) { + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + }; + auto read_id = [&]() -> std::string { + auto in = ImageInput::open(file); + if (!in) + return ""; + std::string id = in->spec().get_string_attribute("colorInteropID"); + in->close(); + return id; + }; + + // Author value is emitted verbatim. + { + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + write(spec); + OIIO_CHECK_EQUAL(read_id(), "lin_adobergb_scene"); + } + + // An explicitly-set attr of "unknown" is also verbatim -- in ALL modes. + // The author's bytes are sacred: OIIO never rewrites them into a + // namespaced marker (the "ocio:unknown" marker is derivation-only, for + // configs that themselves declare unknownness). + { + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "unknown"); + write(spec); + OIIO_CHECK_EQUAL(read_id(), "unknown"); + } + + // A color-space-only spec gets the plan's derived id (default config). + { + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("oiio:ColorSpace", "lin_ap0_scene"); + write(spec); + const std::string want( + derive_color_interop_id(ColorConfig::default_colorconfig(), + "lin_ap0_scene")); + OIIO_CHECK_EQUAL(read_id(), want); + } + + Filesystem::remove(file); +} + + +// B7 (spec 07): color identity is scoped to the whole file and emitted in +// the FIRST part's header only. A multi-part EXR written with a colorInteropID +// must carry the resolved id on part 0; a later part may carry only the "data" +// utility token, never a duplicated color identity. +static void +test_exr_multipart_first_part_only() +{ + if (!ImageOutput::create("exr")) { + Strutil::print("EXR plugin unavailable; skipping multi-part B7\n"); + return; + } + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_multipart.exr"; + std::vector pix(4 * 4 * 3, 0.5f); + + ImageSpec s0(4, 4, 3, TypeHalf); + s0.attribute("colorInteropID", "lin_adobergb_scene"); + s0.attribute("name", "part0"); + // A later part authored (wrongly) with the same color identity: B7 must + // strip it, since a later part may only ever carry "data". + ImageSpec s1(4, 4, 3, TypeHalf); + s1.attribute("colorInteropID", "lin_adobergb_scene"); + s1.attribute("name", "part1"); + ImageSpec specs[2] = { s0, s1 }; + + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->supports("multiimage")); + if (o && o->supports("multiimage")) { + OIIO_CHECK_ASSERT(o->open(file, 2, specs)); + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->open(file, specs[1], ImageOutput::AppendSubimage)); + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + // Part 0 keeps the color identity. + OIIO_CHECK_ASSERT(in->seek_subimage(0, 0)); + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + "lin_adobergb_scene"); + // Part 1 must NOT carry the duplicated identity (over-tagging). + OIIO_CHECK_ASSERT(in->seek_subimage(1, 0)); + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + ""); + in->close(); + } + } + Filesystem::remove(file); +} + + +// B5 (spec 07): writing a colorInteropID means NOT also writing +// chromaticities -- a stale/redundant chromaticities attribute is dropped. +// The B4 exception is an ST 2065-4 / ACES container, which must KEEP its +// required AP0 chromaticities. +static void +test_exr_chromaticities_dropped_and_aces_kept() +{ + if (!ImageOutput::create("exr")) { + Strutil::print("EXR plugin unavailable; skipping B5 chromaticities\n"); + return; + } + static const float ap0[8] = { 0.7347f, 0.2653f, 0.0f, 1.0f, + 0.0001f, -0.077f, 0.32168f, 0.33767f }; + // A plausible-but-stale non-AP0 chromaticities set (Rec.709 primaries). + static const float rec709[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.3290f }; + std::vector pix(4 * 4 * 3, 0.5f); + + // (a) colorInteropID + stale chromaticities, not an ACES container: the + // chromaticities must be dropped. + { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_b5_drop.exr"; + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), rec709); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + "lin_adobergb_scene"); + OIIO_CHECK_ASSERT(!in->spec().find_attribute("chromaticities")); + in->close(); + } + Filesystem::remove(file); + } + + // (b) ACES container (B4): the required AP0 chromaticities are kept + // alongside colorInteropID = lin_ap0_scene. + { + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_b5_aces.exr"; + ImageSpec spec(4, 4, 3, TypeHalf); + spec.channelnames = { "R", "G", "B" }; + spec.attribute("compression", "none"); + spec.attribute("acesImageContainerFlag", 1); + spec.attribute("colorInteropID", "lin_ap0_scene"); + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), ap0); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + "lin_ap0_scene"); + OIIO_CHECK_ASSERT(in->spec().find_attribute("chromaticities") + != nullptr); + in->close(); + } + Filesystem::remove(file); + } +} + + +// B9.1 (spec 07): an author-supplied colorInteropID must be grammar-valid +// (spec 01) or be omitted -- a malformed id is never written. +static void +test_exr_invalid_id_omitted() +{ + if (!ImageOutput::create("exr")) { + Strutil::print("EXR plugin unavailable; skipping B9.1 validation\n"); + return; + } + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_b91.exr"; + std::vector pix(4 * 4 * 3, 0.5f); + ImageSpec spec(4, 4, 3, TypeHalf); + // Uppercase + too many colons -> grammar-invalid per is_valid_interop_id. + spec.attribute("colorInteropID", "Totally Bogus:::Name"); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_ASSERT( + !in->spec().find_attribute("colorInteropID", TypeString)); + in->close(); + } + Filesystem::remove(file); +} + + +// ADR-0020 Amendment 2 -- the writer boundary for the unknown-marker family. +// The markers are OIIO's INTERNAL taxonomy (they carry the *why* behind an +// unknown, and `ocio:` is a namespace the Color Interop Forum reserves to the +// OpenColorIO project). A file gets the Forum's registered vocabulary and +// nothing else, so a DERIVED marker is translated on the way out: +// ocio:unknown / error:unknown -> bare "unknown" (the Forum's registered +// utility id for exactly this case: the fact persists, only OIIO's private +// reason for it is dropped) +// oiio:unknown -> omitted (a TREATMENT marker that makes no identity claim; +// colorInteropID is an identity field) +// DERIVED values only. An author's explicitly-set attribute is still emitted +// verbatim in all modes -- the contrast cases below pin that, so a future +// change that erodes the verbatim doctrine fails here. +static void +test_unknown_marker_write_boundary() +{ + // --- Derived path: a config that DECLARES unknownness (a space named + // "unknown") is the one thing that actually synthesizes a marker today. + const std::string path = Filesystem::temp_directory_path() + + "/oiio_cmp_declared_unknown.ocio"; + { + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: lin_ap1_scene + scene_linear: lin_ap1_scene +colorspaces: + - ! + name: lin_ap1_scene + - ! + name: unknown +)"; + } + ColorConfig cfg(path); + if (!cfg.has_error()) { + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("oiio:ColorSpace", "unknown"); + + // The derive side is UNCHANGED: ADR-0020 owns the internal spelling, + // and this gate deliberately does not touch it. + OIIO_CHECK_EQUAL(std::string(derive_color_interop_id(cfg, "unknown")), + "ocio:unknown"); + + // The write plan collapses it to the registered bare token. + auto p = plan_color_metadata(&cfg, spec, all_caps(), + ColorWritePolicy()); + OIIO_CHECK_EQUAL(int(p.interop_id.action), + int(ColorPlanAction::Derive)); + OIIO_CHECK_EQUAL(p.interop_id.str, "unknown"); + // The namespaced form must not survive into the plan at all. + OIIO_CHECK_ASSERT(p.interop_id.str.find(':') == std::string::npos); + } + Filesystem::remove(path); + + // --- Author-supplied contrast: verbatim, in all modes, bytes on disk. + if (!ImageOutput::create("exr")) { + Strutil::print("EXR plugin unavailable; skipping marker byte check\n"); + return; + } + const std::string file = Filesystem::temp_directory_path() + + "/oiio_cmp_marker.exr"; + std::vector pix(4 * 4 * 3, 0.5f); + for (const char* marker : + { "ocio:unknown", "oiio:unknown", "error:unknown", "unknown" }) { + ImageSpec spec(4, 4, 3, TypeHalf); + spec.attribute("colorInteropID", marker); + auto o = ImageOutput::create(file); + OIIO_CHECK_ASSERT(o && o->open(file, spec)); + if (o) { + OIIO_CHECK_ASSERT(o->write_image(TypeFloat, pix.data())); + OIIO_CHECK_ASSERT(o->close()); + } + // Read-back says the author's bytes survived... + auto in = ImageInput::open(file); + OIIO_CHECK_ASSERT(in.get()); + if (in) { + OIIO_CHECK_EQUAL(in->spec().get_string_attribute("colorInteropID"), + marker); + in->close(); + } + // ...and so do the actual bytes in the file, not just what OIIO + // reports back to us. + std::ifstream raw(file, std::ios::binary); + const std::string bytes((std::istreambuf_iterator(raw)), + std::istreambuf_iterator()); + OIIO_CHECK_ASSERT(bytes.find("colorInteropID") != std::string::npos); + OIIO_CHECK_ASSERT(bytes.find(marker) != std::string::npos); + } + Filesystem::remove(file); +} + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_interop_id_write_and_omit(); + test_never_suppresses(); + test_incapable_omits(); + test_explicit_chroma_and_gamma(); + test_mislabeled_config_derivation(); + test_layer_attribution(); + test_global_policy_tier(); + test_policy_hint_plumbing(); + test_writer_level_suppress(); + test_provenance_rule(); + test_exr_consumption(); + test_exr_multipart_first_part_only(); + test_exr_chromaticities_dropped_and_aces_kept(); + test_exr_invalid_id_omitted(); + test_unknown_marker_write_boundary(); + + const std::string cfgpath = write_test_config(); + ColorConfig config(cfgpath); + if (config.has_error()) { + Strutil::print("Could not load test config: {}\n", config.geterror()); + return 1; + } + test_derivation(config); + Filesystem::remove(cfgpath); + + const std::string profpath = write_profile_config(); + ColorConfig profconfig(profpath); + if (profconfig.has_error()) { + Strutil::print("Could not load profile config: {}\n", + profconfig.geterror()); + return 1; + } + test_profile_selection(profconfig); + Filesystem::remove(profpath); + + return unit_test_failures != 0; +} diff --git a/src/libOpenImageIO/color_metadata_resolver.cpp b/src/libOpenImageIO/color_metadata_resolver.cpp new file mode 100644 index 0000000000..170bcf09af --- /dev/null +++ b/src/libOpenImageIO/color_metadata_resolver.cpp @@ -0,0 +1,1077 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// One audited read-side color-metadata precedence cascade, replacing the +// per-plugin "figure out the color space from whatever attributes we found" +// code that every reader used to hand-roll (and that disagreed between +// formats). A reader now only deposits raw attributes; this file decides, +// in one tested order, which of them names the color space. +// +// The precedence order, the utility-token semantics, the miss/default +// ladder and the diagnostic trace are a straight port of a proven +// prototype. The engine is pure -- a ColorConfig plus plain value types -- +// so the same code path serves resolve() and its explain() trace (explain +// is just resolve with the reason strings kept), and a unit test can drive +// it directly through color_pvt.h. +// +// Carve-out: the CICP matrix and range bytes do not participate in +// identification (primaries + transfer only); they are writer-side/format +// concerns. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "color_pvt.h" +#include "imageio_pvt.h" + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +namespace { + + // The scene/display twin of an interop id: swap a trailing _scene<->_display. + // Empty if the id carries neither suffix. + std::string state_twin(const std::string& id) + { + if (Strutil::ends_with(id, "_scene")) + return id.substr(0, id.size() - 6) + "_display"; + if (Strutil::ends_with(id, "_display")) + return id.substr(0, id.size() - 8) + "_scene"; + return {}; + } + + bool ends_with_scene(const std::string& id) + { + return Strutil::ends_with(id, "_scene"); + } + + // The canonical local name a config resolves `cand` to, or "" if the config + // has no color space reachable by that name/alias/role. A null config never + // resolves anything locally. + std::string local_name(const ColorConfig* config, const std::string& cand) + { + if (!config) + return {}; + int idx = config->getColorSpaceIndex(cand); + if (idx < 0) + return {}; + const char* name = config->getColorSpaceNameByIndex(idx); + return name ? std::string(name) : std::string {}; + } + + // Is `id` a known color interop identity in OIIO's built-in registry? + bool known_registry_id(const std::string& id) + { + return !id.empty() && interop_identities_config_resolves(id); + } + + // Order a single candidate for the current state preference: when a + // preference is set (and not exact-state), the preferred-state twin is + // tried before the candidate itself. + std::vector state_preference_order(const std::string& candidate, + const ColorReadPolicy& p) + { + if (p.state_pref == ColorStatePreference::Auto + || p.scope == ColorResolutionScope::ExactState) + return { candidate }; + const bool want_scene = p.state_pref == ColorStatePreference::Scene; + const std::string twin = state_twin(candidate); + std::vector ordered; + if (!twin.empty() && want_scene != ends_with_scene(candidate)) + ordered.push_back(twin); + ordered.push_back(candidate); + return ordered; + } + + // The shared color-interop-id assignment channel (one call, three entry + // points: the asset-facts id, the explicit assignment, and the failover). + // Utility tokens are excluded here; the caller handles them. Returns the + // resolved local (or bridged registry) name, or "". + std::string resolve_ciid(const ColorConfig* config, + const std::string& value, const ColorReadPolicy& p, + std::string* selected = nullptr) + { + if (is_utility_interop_id(value) || value.empty()) + return {}; + const auto candidates = state_preference_order(value, p); + for (const auto& cand : candidates) { + if (is_utility_interop_id(cand)) + continue; + const std::string local = local_name(config, cand); + if (!local.empty()) { + if (selected) + *selected = cand; + return local; + } + } + if (p.scope != ColorResolutionScope::ConfigOnly) { + for (const auto& cand : candidates) { + if (!is_utility_interop_id(cand) && known_registry_id(cand)) { + if (selected) + *selected = cand; + return cand; + } + } + } + return {}; + } + + // Local-name-then-CIID resolution of an explicit / failover assignment. + std::string resolve_explicit(const ColorConfig* config, + const std::string& value, + const ColorReadPolicy& p) + { + const std::string local = local_name(config, value); + if (!local.empty()) + return local; + return resolve_ciid(config, value, p); + } + + // data / bypass: the best local isData space by rank, else the literal + // token. Rank 0 = exact token match, 1 = any isData, 2 = the other token, + // 3 = an "unknown"-labeled data space. + std::string resolve_data_space(const ColorConfig* config, + const std::string& token) + { + if (!config) + return token; + const std::string other = token == "bypass" ? "data" : "bypass"; + auto role_target = [&](const char* role) -> std::string { + const char* n = config->getColorSpaceNameByRole(role); + return n && n[0] ? std::string(n) : std::string {}; + }; + const std::string token_role = role_target(token.c_str()); + const std::string other_role = role_target(other.c_str()); + const std::string unknown_role = role_target("unknown"); + auto identifies_as = [&](const std::string& name, + const std::string& label, + const std::string& role_name) { + if (Strutil::lower(name) == label) + return true; + if (Strutil::lower(std::string(config->get_color_interop_id(name))) + == label) + return true; + for (const std::string& alias : config->getAliases(name)) + if (Strutil::lower(alias) == label) + return true; + return !role_name.empty() && role_name == name; + }; + + std::string best; + int best_rank = 99; + const std::vector names = config->getColorSpaceNames(); + for (const std::string& name : names) { + if (!config->isData(name)) + continue; + // OCIO's CreateRaw injects a framework-owned "raw" space; it is not + // an authored utility target in an otherwise empty config. + if (names.size() == 1 && Strutil::lower(name) == "raw" + && token_role.empty()) + continue; + int rank = 1; + if (identifies_as(name, token, token_role)) + rank = 0; + else if (identifies_as(name, "unknown", unknown_role)) + rank = 3; + else if (identifies_as(name, other, other_role)) + rank = 2; + if (rank < best_rank) { + best_rank = rank; + best = name; + } + if (best_rank == 0) + break; + } + return best; + } + + // A synthetic session id for usable-but-unmatched colorimetry, per the id + // grammar (no live endpoint is constructed in this layer). scene-referred + // for EXR sources, display-referred otherwise -- carried in the state + // choice the grammar encodes rather than a separate field. + std::string synthesize_custom_space(const float* chroma, bool has_gamma, + float gamma) + { + std::string id = "custom:"; + if (has_gamma) + id += Strutil::fmt::format("g{:.5f}_", gamma); + id += Strutil::fmt::format( + "{:.5f}_{:.5f}_{:.5f}_{:.5f}_{:.5f}_{:.5f}_{:.5f}_{:.5f}", + chroma[0], chroma[1], chroma[2], chroma[3], chroma[4], chroma[5], + chroma[6], chroma[7]); + return id; + } + + // A deterministic, idempotent synthetic id for a decodable-but-unmatched + // ICC profile. (The prototype keys this on the profile's MD5; OIIO has no + // MD5 in-tree, so a stable content hash stands in -- the id grammar and its + // idempotence are what downstream depends on, not the digest algorithm.) + std::string synthesize_icc_space(const std::vector& profile) + { + // strhash64, not strhash: the id must be a full 64-bit digest on + // every platform (strhash is size_t -- 32 bits on 32-bit builds, + // where collision odds stop being negligible and ids diverge + // across builds). + uint64_t h = Strutil::strhash64( + string_view(reinterpret_cast(profile.data()), + profile.size())); + return Strutil::fmt::format("icc:{:016x}", h); + } + + // ---- individual rules ------------------------------------------------- + + struct RuleResult { + ColorRuleOutcome outcome = ColorRuleOutcome::Inapplicable; + std::string candidate; + std::string resolved; + std::string reason; + std::string registered_synthetic; + }; + + RuleResult rule_aces(const ColorMetadataFacts& f) + { + if (!f.aces_image_container) + return {}; + return { + ColorRuleOutcome::Matched, "lin_ap0_scene", "lin_ap0_scene", {}, {} + }; + } + + RuleResult rule_file_rules(const ColorConfig* config, + const ColorCallContext& ctx, + ColorFileRules position, + const ColorReadPolicy& p, bool diag) + { + if (p.file_rules != position || ctx.filename.empty() || !config) + return {}; + string_view name = config->getColorSpaceFromFilepath(ctx.filename, ""); + if (!name.empty()) + return { ColorRuleOutcome::Matched, + diag ? ctx.filename : std::string {}, + std::string(name), + {}, + {} }; + return { ColorRuleOutcome::Missed, + diag ? ctx.filename : std::string {}, + {}, + diag ? "No FileRules entry matched the filename" + : std::string {}, + {} }; + } + + RuleResult rule_color_interop_id(const ColorConfig* config, + const ColorMetadataFacts& f, + const ColorReadPolicy& p, bool diag) + { + if (f.color_interop_id.empty()) + return {}; + const std::string& id = f.color_interop_id; + if (id == "data" || id == "bypass") { + const std::string local = resolve_data_space(config, id); + return { ColorRuleOutcome::Matched, + diag ? id : std::string {}, + local.empty() ? id : local, + {}, + {} }; + } + const std::string resolved = resolve_explicit(config, id, p); + if (!resolved.empty()) + return { ColorRuleOutcome::Matched, + diag ? id : std::string {}, + resolved, + {}, + {} }; + return { ColorRuleOutcome::Missed, + diag ? id : std::string {}, + {}, + diag ? "Color interop id did not resolve" : std::string {}, + {} }; + } + + // ICC (spec black box): a profile is decodable if it carries the 'acsp' + // signature at offset 36 of a >=128-byte header. A decodable-but-unmatched + // profile registers and returns an icc: synthetic under lenient scope; + // undecodable is invalid. (Matching a profile to a named local space is the + // heavy identification port and is not wired here yet.) + RuleResult rule_icc(const ColorMetadataFacts& f, const ColorReadPolicy& p, + bool diag) + { + if (f.icc_profile.empty()) + return {}; + const bool decodable = f.icc_profile.size() >= 128 + && f.icc_profile[36] == 'a' + && f.icc_profile[37] == 'c' + && f.icc_profile[38] == 's' + && f.icc_profile[39] == 'p'; + if (!decodable) + return { ColorRuleOutcome::Invalid, + diag ? "icc" : std::string {}, + {}, + diag ? "ICC profile is not a decodable profile" + : std::string {}, + {} }; + if (p.scope != ColorResolutionScope::Lenient) + return { + ColorRuleOutcome::Missed, + diag ? "icc" : std::string {}, + {}, + diag + ? "ICC profile decoded but the resolution scope rejected it" + : std::string {}, + {} + }; + const std::string id = synthesize_icc_space(f.icc_profile); + return { + ColorRuleOutcome::Matched, diag ? "icc" : std::string {}, id, {}, id + }; + } + + RuleResult rule_cicp(const ColorConfig* config, const ColorMetadataFacts& f, + const ColorReadPolicy& p, bool diag) + { + if (!f.has_cicp) + return {}; + // Spec-impossibility watch: log-only, never an error, never a + // correction -- identification below is unaffected and still keys on + // primaries + transfer alone. primaries==0 / transfer==0 are ITU-T + // H.273 reserved values. A non-zero matrix from a format whose stored + // essence is RGB-only (e.g. PNG) is likewise impossible, but format + // identity is not available in this layer (source-provenance + // attributes are deposited after read), so only the reserved-value + // cases are reported. + if (f.cicp[0] == 0) + OIIO::debugfmt( + "color reconcile: CICP tuple {},{},{},{} carries reserved " + "primaries value 0 (ITU-T H.273)\n", + f.cicp[0], f.cicp[1], f.cicp[2], f.cicp[3]); + if (f.cicp[1] == 0) + OIIO::debugfmt( + "color reconcile: CICP tuple {},{},{},{} carries reserved " + "transfer value 0 (ITU-T H.273)\n", + f.cicp[0], f.cicp[1], f.cicp[2], f.cicp[3]); + // CICP -> interop-id mapping routes through the same central resolve the + // config exposes (primaries + transfer only; matrix/range are unused). + // The mapping is a built-in table lookup; a config is only constructed + // when the caller supplied none (rare -- readers pass their config). + std::unique_ptr registry; + if (!config) + registry.reset(new ColorConfig); + const ColorConfig* map_cfg = config ? config : registry.get(); + string_view raw = map_cfg->get_color_interop_id(f.cicp); + if (raw.empty()) + return { + ColorRuleOutcome::Missed, + diag ? Strutil::fmt::format("{}/{}", f.cicp[0], f.cicp[1]) + : std::string {}, + {}, + diag + ? "CICP primaries and transfer did not map to a known identity" + : std::string {}, + {} + }; + // CICP state is governed by its own one-shot axis (cicp_state). Auto + // falls back to the general state_pref so an unset cicp_state + // reproduces main exactly. + ColorReadPolicy pc = p; + if (p.cicp_state != ColorStatePreference::Auto) + pc.state_pref = p.cicp_state; + std::string candidate(raw); + for (const auto& c : state_preference_order(candidate, pc)) { + std::string selected; + const std::string resolved = resolve_ciid(config, c, pc, &selected); + if (!resolved.empty()) + return { ColorRuleOutcome::Matched, + diag ? (selected.empty() ? c : selected) + : std::string {}, + resolved, + {}, + {} }; + } + // Nothing local; under non-config-only scope the candidate id itself is + // the (bridged) answer. + if (p.scope != ColorResolutionScope::ConfigOnly) + return { ColorRuleOutcome::Matched, + diag ? candidate : std::string {}, + candidate, + {}, + {} }; + return { + ColorRuleOutcome::Missed, + diag ? candidate : std::string {}, + {}, + diag + ? "CICP identity is unavailable in the config under config-only scope" + : std::string {}, + {} + }; + } + + RuleResult rule_png_srgb(const ColorConfig* config, + const ColorMetadataFacts& f, + const ColorReadPolicy& p, bool diag) + { + if (!f.png_srgb) + return {}; + const std::string id = "srgb_rec709_display"; + std::string selected; + const std::string resolved = resolve_ciid(config, id, p, &selected); + if (!resolved.empty()) + return { ColorRuleOutcome::Matched, + diag ? (selected.empty() ? id : selected) : std::string {}, + resolved, + {}, + {} }; + if (p.scope != ColorResolutionScope::ConfigOnly) + return { ColorRuleOutcome::Matched, + diag ? id : std::string {}, + id, + {}, + {} }; + return { + ColorRuleOutcome::Missed, + diag ? id : std::string {}, + {}, + diag ? "PNG sRGB identity is unavailable under config-only scope" + : std::string {}, + {} + }; + } + + // Colorimetry (chromaticities / gamma): the config-matching tiers + // (encoded-gamut-first, round-then-exact chromaticity equality, the + // transfer-function slope catalog) are the heavy port and are not wired + // here yet. What ships is the shape: the nonlinear-gamma split, and the + // lenient custom: synthesis with the state choice the id grammar carries. + RuleResult rule_colorimetry(const ColorMetadataFacts& f, + const ColorCallContext& ctx, + const ColorReadPolicy& p, bool with_gamma, + bool diag) + { + if (!f.has_chromaticities) + return {}; + const bool has_nonlinear = f.has_gamma && f.gamma > 1.001f; + if (with_gamma != has_nonlinear) + return {}; + if (p.scope == ColorResolutionScope::Lenient) { + const std::string id = synthesize_custom_space(f.chromaticities, + has_nonlinear, + f.gamma); + (void)ctx; + return { ColorRuleOutcome::Matched, + diag ? id : std::string {}, + id, + {}, + id }; + } + return { ColorRuleOutcome::Missed, + diag ? "chromaticities" : std::string {}, + {}, + diag ? "No color space matched the supplied chromaticities" + : std::string {}, + {} }; + } + + RuleResult rule_gamma(const ColorMetadataFacts& f, + const ColorCallContext& ctx, const ColorReadPolicy& p, + bool diag) + { + if (!f.has_gamma || f.has_chromaticities) + return {}; + const bool has_nonlinear = f.gamma > 1.001f; + if (p.scope == ColorResolutionScope::Lenient) { + static const float kRec709[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.3290f }; + const std::string id + = synthesize_custom_space(kRec709, has_nonlinear, f.gamma); + (void)ctx; + return { ColorRuleOutcome::Matched, + diag ? id : std::string {}, + id, + {}, + id }; + } + return { ColorRuleOutcome::Missed, + diag ? Strutil::fmt::format("{}", f.gamma) : std::string {}, + {}, + diag ? "No color space matched gamma under the active scope" + : std::string {}, + {} }; + } + + // The config's Default Assignment: FileRules final entry, else the default + // role. Empty if neither resolves. + std::string default_assignment(const ColorConfig* config, + std::string* reason) + { + if (!config) + return {}; + // The single-arg form returns the config's default-rule color space when + // nothing else matches -- i.e. the FileRules Default Assignment. + string_view fr = config->getColorSpaceFromFilepath( + "oiio_color_metadata_default_probe"); + if (!fr.empty()) { + if (reason) + *reason = "Resolved from the FileRules Default Assignment"; + return std::string(fr); + } + const char* role = config->getColorSpaceNameByRole("default"); + if (role && role[0]) { + if (reason) + *reason + = "FileRules Default Assignment was invalid; resolved from the " + "default role"; + return std::string(role); + } + return {}; + } + + std::string finish_miss(const ColorConfig* config, + const ColorCallContext& ctx, + const ColorReadPolicy& p, + ColorResolutionExplanation* expl) + { + // Failover: a caller-supplied assignment tried after metadata misses. + if (!ctx.failover.empty()) { + const std::string resolved = resolve_explicit(config, ctx.failover, + p); + if (!resolved.empty()) { + if (expl) { + expl->steps.push_back({ ColorRule::Failover, + ColorRuleOutcome::Matched, + ctx.failover, + resolved, + {} }); + expl->used_failover = true; + } + return resolved; + } + if (expl) + expl->steps.push_back( + { ColorRule::Failover, + ColorRuleOutcome::Invalid, + ctx.failover, + {}, + "Failover config-local and CIID resolution attempts both missed" }); + } + + // Non-lenient scope preserves an unresolved assignment as the literal + // "unknown" rather than substituting a default. `scope` never overrides + // strict parsing; both surface here as the terminal step. + if (p.scope != ColorResolutionScope::Lenient) { + if (expl) + expl->steps.push_back( + { ColorRule::StrictParsing, ColorRuleOutcome::Matched, + "resolution-scope", "unknown", + "Resolution scope preserves an unresolved assignment" }); + return "unknown"; + } + + // Lenient all-miss. Main assigns nothing here (a reader that determined + // nothing leaves the color space untouched), so the config default is + // gated off unless a policy opts in. + if (p.apply_config_default) { + std::string reason; + const std::string resolved = default_assignment(config, &reason); + if (!resolved.empty()) { + if (expl) { + expl->steps.push_back({ ColorRule::ConfigDefault, + ColorRuleOutcome::Matched, + {}, + resolved, + reason }); + expl->used_default = true; + } + return resolved; + } + } + return {}; + } + +} // namespace + +bool +ColorResolutionExplanation::has_genuine_metadata_match() const +{ + if (resolved.empty()) + return false; + for (auto it = steps.rbegin(); it != steps.rend(); ++it) { + if (it->outcome != ColorRuleOutcome::Matched) + continue; + return it->rule != ColorRule::Failover + && it->rule != ColorRule::ConfigDefault + && it->rule != ColorRule::StrictParsing; + } + return false; +} + +ColorResolutionExplanation +resolve_color_metadata(const ColorConfig* config, + const std::string& explicit_assignment, + const ColorMetadataFacts& facts, + const ColorCallContext& ctx, + const ColorReadPolicy& policy) +{ + ColorResolutionExplanation expl; + const bool diag = true; // the engine always keeps its trace + + // Rule 1: an explicit assignment suppresses every metadata rule. + if (!explicit_assignment.empty()) { + const std::string resolved + = resolve_explicit(config, explicit_assignment, policy); + expl.steps.push_back({ ColorRule::ExplicitAssignment, + resolved.empty() ? ColorRuleOutcome::Missed + : ColorRuleOutcome::Matched, + explicit_assignment, + resolved, + {} }); + if (!resolved.empty()) { + expl.resolved = resolved; + return expl; + } + expl.resolved = finish_miss(config, ctx, policy, &expl); + return expl; + } + + auto record = [&](ColorRule rule, const RuleResult& r) { + expl.steps.push_back( + { rule, r.outcome, r.candidate, r.resolved, r.reason }); + if (!r.registered_synthetic.empty()) + expl.registered_synthetic = r.registered_synthetic; + return r.outcome == ColorRuleOutcome::Matched; + }; + auto apply = [&](ColorRule rule, RuleResult r) { + if (record(rule, r)) { + expl.resolved = std::move(r.resolved); + return true; + } + return false; + }; + + // Utility-token short-circuit: data/bypass outrank everything except an + // ACES flag and an applicable FileRules-First rung. + const bool file_rules_first_applicable + = policy.file_rules == ColorFileRules::First && !ctx.filename.empty(); + if (!facts.aces_image_container && !file_rules_first_applicable + && (facts.color_interop_id == "data" + || facts.color_interop_id == "bypass")) { + if (apply(ColorRule::ColorInteropID, + rule_color_interop_id(config, facts, policy, diag))) + return expl; + } + + if (apply(ColorRule::AcesContainer, rule_aces(facts))) + return expl; + if (apply(ColorRule::FileRulesFirst, + rule_file_rules(config, ctx, ColorFileRules::First, policy, diag))) + return expl; + if (apply(ColorRule::ColorInteropID, + rule_color_interop_id(config, facts, policy, diag))) + return expl; + // CICP before ICC, matching the PNG spec's own chunk precedence + // (cICP > iCCP). + if (apply(ColorRule::Cicp, rule_cicp(config, facts, policy, diag))) + return expl; + if (apply(ColorRule::IccProfile, rule_icc(facts, policy, diag))) + return expl; + if (apply(ColorRule::PngSrgb, rule_png_srgb(config, facts, policy, diag))) + return expl; + if (apply(ColorRule::ChromaticitiesAndGamma, + rule_colorimetry(facts, ctx, policy, /*with_gamma=*/true, diag))) + return expl; + if (apply(ColorRule::Chromaticities, + rule_colorimetry(facts, ctx, policy, /*with_gamma=*/false, diag))) + return expl; + if (apply(ColorRule::Gamma, rule_gamma(facts, ctx, policy, diag))) + return expl; + if (apply(ColorRule::FileRulesFallback, + rule_file_rules(config, ctx, ColorFileRules::FallbackOnly, policy, + diag))) + return expl; + + expl.resolved = finish_miss(config, ctx, policy, &expl); + return expl; +} + +ColorReadCaps +color_read_caps_for_format(string_view format_name) +{ + // Every wired reader's consulted set is currently the same trio: the + // signals the readers historically consulted (EXR: the ACES-container + // flag and colorInteropID; PNG: CICP), applied format-invariantly + // exactly as the reconciler's former inline extraction did -- including + // for an unknown/empty format name, which preserves the historical + // behavior for any caller that has no format to declare. + // ponytail: identical rows today by design (behavior-preserving); + // per-format divergence is a future data edit here, not inline code. + (void)format_name; + ColorReadCaps caps; + caps.aces_container = true; + caps.interop_id = true; + caps.cicp = true; + return caps; +} + +ColorMetadataFacts +color_facts_from_spec(const ImageSpec& spec, const ColorReadCaps& caps) +{ + ColorMetadataFacts f; + if (caps.aces_container) + f.aces_image_container + = spec.get_int_attribute("acesImageContainerFlag") == 1; + if (caps.interop_id) + if (auto c = spec.find_attribute("colorInteropID", TypeString)) + f.color_interop_id = c->get_ustring().string(); + if (caps.icc) + if (auto icc = spec.find_attribute("ICCProfile")) { + const auto* d = static_cast(icc->data()); + f.icc_profile.assign(d, d + icc->datasize()); + } + if (caps.cicp + && spec.getattribute("CICP", TypeDesc(TypeDesc::INT, 4), f.cicp)) + f.has_cicp = true; + if (caps.chromaticities + && spec.getattribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), + f.chromaticities)) + f.has_chromaticities = true; + if (caps.gamma) { + float g = spec.get_float_attribute("oiio:Gamma", 0.0f); + if (g > 0.0f) { + f.has_gamma = true; + f.gamma = g; + } + } + // png_srgb has no ImageSpec carrier: the PNG reader folds its sRGB chunk + // straight into oiio:ColorSpace. It becomes extractable when a reader + // deposits it as an asset-fact attribute (a per-format change, later PR). + return f; +} + +ColorMetadataFacts +color_facts_from_spec(const ImageSpec& spec) +{ + return color_facts_from_spec(spec, ColorReadCaps::all()); +} + +ColorResolutionExplanation +resolve_color_metadata(const ColorConfig* config, const ImageSpec& spec, + const ColorCallContext& ctx, + const ColorReadPolicy& policy) +{ + return resolve_color_metadata(config, "", color_facts_from_spec(spec), ctx, + policy); +} + +std::string +infer_color_space_from_spec(const ColorConfig* config, const ImageSpec& spec, + const ColorCallContext& ctx, + const ColorReadPolicy& policy) +{ + const ColorMetadataFacts facts = color_facts_from_spec(spec); + // A usable answer is a config-local name or a registry-known id the + // cross-config machinery can construct. A session-synthetic (custom:/ + // icc:) answer names no constructible space in this round -- live + // synthetic endpoints are a later round's story -- so when one wins, + // retry under config-only scope, where an unusable signal misses and + // falls through to the next rung instead of synthesizing. + auto usable = [](const ColorResolutionExplanation& e) { + return e.has_genuine_metadata_match() + && !Strutil::starts_with(e.resolved, "custom:") + && !Strutil::starts_with(e.resolved, "icc:"); + }; + ColorResolutionExplanation e = resolve_color_metadata(config, "", facts, + ctx, policy); + if (usable(e)) + return e.resolved; + if (e.has_genuine_metadata_match() + && policy.scope != ColorResolutionScope::ConfigOnly) { + ColorReadPolicy retry = policy; + retry.scope = ColorResolutionScope::ConfigOnly; + e = resolve_color_metadata(config, "", facts, ctx, retry); + if (usable(e)) + return e.resolved; + } + return {}; +} + +void +scrub_color_metadata(ImageSpec& spec) +{ + // Two-bucket rule: the file-provenance facts describe the SOURCE the + // pixels came from; after an identity-known color change (which the + // caller asserts) they are categorically stale -- never persist stale + // information. No per-signal re-resolution: the bucket is a static + // property of each attribute, not a per-input verdict. + const std::string id = spec.get_string_attribute("colorInteropID"); + if (!id.empty() && !is_unknown_marker(id)) + // A definite (or bare-"unknown") claim named the pre-operation + // space; the deliberate unknown-marker family is honored -- those + // markers carry treatment/error state, not provenance. + spec.erase_attribute("colorInteropID"); + spec.erase_attribute("acesImageContainerFlag"); + spec.erase_attribute("ICCProfile"); + spec.erase_attribute("CICP"); + spec.erase_attribute("oiio:cicp:pending"); + spec.erase_attribute("chromaticities"); + spec.erase_attribute("oiio:Gamma"); +} + +void +reconcile_color_metadata(ImageSpec& spec, const ColorReadPolicy& policy, + string_view format_name) +{ + // Each reader deposits the raw color attributes it read; this central + // entry point reproduces the precedence that reader used to hand-roll. + // Only the signals the format's read caps declare consulted enter + // resolution -- extraction goes through the one shared spec->facts + // reader, narrowed by the per-format table, so pulling a reader's + // remaining signals into resolution is a caps data edit (a per-format + // behavior change, its own later PR), not new inline code. + const ColorMetadataFacts facts + = color_facts_from_spec(spec, color_read_caps_for_format(format_name)); + + // The ACES-container flag and colorInteropID: the signals the EXR reader + // consulted. When either is present, reproduce its former inline + // special-casing (set_colorspace, which also clears now-contradictory + // CICP). + if (facts.aces_image_container || !facts.color_interop_id.empty()) { + ColorCallContext ctx; + const auto expl = resolve_color_metadata(nullptr, "", facts, ctx, + policy); + if (expl.has_genuine_metadata_match()) + spec.set_colorspace(expl.resolved); + else if (!facts.color_interop_id.empty()) + // A colorInteropID that resolves to nothing is still honored + // verbatim (the historical passthrough); a later policy may + // tighten this. + spec.set_colorspace(facts.color_interop_id); + return; + } + + // A CICP tuple: the signal the PNG reader consulted, using the interop id + // it maps to (via the built-in registry) to override the color space it + // had already set from the sRGB/gamma chunks. Route that one signal + // through the same cascade. The override is applied with a plain attribute + // set -- keeping the CICP source attribute in place -- exactly as the PNG + // reader used to do inline. + if (facts.has_cicp) { + // Deferred option (spec 09): a CICP tuple is state-ambiguous, so under + // a defer_cicp policy the reader deposits the tuple as *pending* -- + // marker only, no color-space commit -- giving the caller a window to + // set cicp_state before resolve_pending_cicp fires. Default policy + // stays eager (main's behavior). + if (policy.defer_cicp) { + spec.attribute("oiio:cicp:pending", 1); + return; + } + ColorMetadataFacts cf; + cf.has_cicp = true; + for (int i = 0; i < 4; ++i) + cf.cicp[i] = facts.cicp[i]; + ColorCallContext ctx; + const auto expl = resolve_color_metadata(nullptr, "", cf, ctx, policy); + if (expl.has_genuine_metadata_match()) + spec.attribute("oiio:ColorSpace", expl.resolved); + } +} + +bool +resolve_pending_cicp(ImageSpec& spec, const ColorReadPolicy& policy, + const ColorConfig* config) +{ + // No-op unless a deferred read left a pending CICP tuple. + if (spec.get_int_attribute("oiio:cicp:pending") != 1) + return false; + + const ColorMetadataFacts facts = color_facts_from_spec(spec); + bool committed = false; + if (facts.has_cicp) { + ColorMetadataFacts cf; + cf.has_cicp = true; + for (int i = 0; i < 4; ++i) + cf.cicp[i] = facts.cicp[i]; + ColorCallContext ctx; + const auto expl = resolve_color_metadata(config, "", cf, ctx, policy); + if (expl.has_genuine_metadata_match()) { + spec.attribute("oiio:ColorSpace", expl.resolved); + committed = true; + } + } + + // Consume-once: the pending tuple is now resolved, so the marker and the + // one-shot global cicp_state key no longer apply -- clearing the global + // stops it silently re-applying to the next file. A per-call/config-hint + // or config-profile cicp_state is intentionally NOT consumed (it is + // scoped or declared policy, not a lingering global override); this + // preserves the per-call > profile > global-default precedence. + spec.erase_attribute("oiio:cicp:pending"); + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); + return committed; +} + +const ColorConfig* +ambient_color_config() +{ + // Spec 09: the ambient config drives I/O color-metadata policy. When OCIO + // support is unavailable there is no config to consult -- return null so + // readers/writers behave exactly as the historical null-config snapshot. + // ponytail: default_colorconfig() is a cached singleton; the only per-read + // cost is one FileRules scan in config_declared_policy_keys (a handful of + // rules). Cache the extracted keys on the config if that ever shows up hot. + if (!ColorConfig::supportsOpenColorIO()) + return nullptr; + return &ColorConfig::default_colorconfig(); +} + + +ColorReadPolicy +ColorReadPolicy::snapshot(const ImageSpec* config_hints, + const ColorConfig* config, string_view filepath) +{ + // One locked read of the whole policy state, via the shared snapshot + // primitive (the same mechanism the write-side policy uses). Full spec-09 + // ladder, strongest first: per-call hints (layer 6) > the config file-rule + // matching `filepath` (layer 5) > global attribute table (layer 4) > the + // config author's declared `oiio:default`/profile policy (layer 2/3) > the + // built-in defaults (layer 1), calibrated to reproduce main. + ColorReadPolicy p; + ColorPolicySnapshot snap(config_hints, config, filepath); + auto get_string = [&](const char* name) { return snap.get_string(name); }; + auto get_int = [&](const char* name, int dflt) { + return snap.get_int(name, dflt); + }; + + const std::string scope = get_string("oiio:colorpolicy:read:scope"); + if (scope == "config_only") + p.scope = ColorResolutionScope::ConfigOnly; + else if (scope == "exact_state") + p.scope = ColorResolutionScope::ExactState; + + const std::string state = get_string( + "oiio:colorpolicy:read:state_preference"); + if (state == "scene") + p.state_pref = ColorStatePreference::Scene; + else if (state == "display") + p.state_pref = ColorStatePreference::Display; + + const std::string fr = get_string("oiio:colorpolicy:read:file_rules"); + if (fr == "first") + p.file_rules = ColorFileRules::First; + else if (fr == "fallback_only") + p.file_rules = ColorFileRules::FallbackOnly; + + p.ignore_cicp_for_png + = get_int("oiio:colorpolicy:read:ignore_cicp_for_png", 0) != 0; + p.ignore_sidecar = get_int("oiio:colorpolicy:read:ignore_sidecar", 0) != 0; + + // CICP-specific one-shot state axis; empty/unset -> Auto (reproduces main). + const std::string cicp_state = get_string( + "oiio:colorpolicy:read:cicp_state"); + if (cicp_state == "scene") + p.cicp_state = ColorStatePreference::Scene; + else if (cicp_state == "display") + p.cicp_state = ColorStatePreference::Display; + p.defer_cicp = get_int("oiio:colorpolicy:read:defer_cicp", 0) != 0; + return p; +} + +static const char* +rule_name(ColorRule r) +{ + switch (r) { + case ColorRule::ExplicitAssignment: return "ExplicitAssignment"; + case ColorRule::AcesContainer: return "AcesContainer"; + case ColorRule::FileRulesFirst: return "FileRulesFirst"; + case ColorRule::ColorInteropID: return "ColorInteropID"; + case ColorRule::Cicp: return "CICP"; + case ColorRule::IccProfile: return "IccProfile"; + case ColorRule::PngSrgb: return "PngSrgb"; + case ColorRule::ChromaticitiesAndGamma: return "ChromaticitiesAndGamma"; + case ColorRule::Chromaticities: return "Chromaticities"; + case ColorRule::Gamma: return "Gamma"; + case ColorRule::FileRulesFallback: return "FileRulesFallback"; + case ColorRule::Failover: return "Failover"; + case ColorRule::ConfigDefault: return "ConfigDefault"; + case ColorRule::StrictParsing: return "StrictParsing"; + } + return "?"; +} + +static const char* +outcome_name(ColorRuleOutcome o) +{ + switch (o) { + case ColorRuleOutcome::Matched: return "matched"; + case ColorRuleOutcome::Missed: return "missed"; + case ColorRuleOutcome::Invalid: return "invalid"; + default: return "inapplicable"; + } +} + +std::string +render_color_read_plan(const ImageSpec& spec, const ColorConfig* config) +{ + const ColorConfig& cfg = config ? *config + : ColorConfig::default_colorconfig(); + // The provenance attributes (deposited on read) give FileRules-gated rules + // real filename/format to work with; empty when the spec carries neither. + ColorCallContext ctx; + ctx.filename = spec.get_string_attribute("oiio:SourcePath"); + ctx.format = spec.get_string_attribute("oiio:SourceFormat"); + // Preview the full spec-09 ladder: consult the config's declared policy + // (layer 2/3) and the file-rule matching this source path (layer 5), the + // same as a real read of this file would. + const ColorReadPolicy policy = ColorReadPolicy::snapshot(nullptr, &cfg, + ctx.filename); + const ColorMetadataFacts facts = color_facts_from_spec(spec); + const ColorResolutionExplanation expl = resolve_color_metadata(&cfg, spec, + ctx, policy); + + std::string out + = Strutil::fmt::format("Color read plan (source: {}, format: {}):\n", + ctx.filename.empty() ? "-" : ctx.filename, + ctx.format.empty() ? "-" : ctx.format); + for (const auto& s : expl.steps) { + std::string detail; + if (!s.resolved.empty()) + detail = Strutil::fmt::format("'{}'", s.resolved); + else if (!s.candidate.empty()) + detail = Strutil::fmt::format("'{}'", s.candidate); + if (s.rule == ColorRule::Cicp && facts.has_cicp) + detail += Strutil::fmt::format(" (tuple {},{},{},{})", + facts.cicp[0], facts.cicp[1], + facts.cicp[2], facts.cicp[3]); + if (!s.reason.empty()) + detail += Strutil::fmt::format(" ({})", s.reason); + out += Strutil::fmt::format(" rule {:<2} {:<24} {:<13} {}\n", + int(s.rule) + 1, rule_name(s.rule), + outcome_name(s.outcome), detail); + } + if (expl.resolved.empty()) { + out += "Resolved color space: (unresolved -- no rule matched)\n"; + } else { + const char* via = "?"; + for (auto it = expl.steps.rbegin(); it != expl.steps.rend(); ++it) + if (it->outcome == ColorRuleOutcome::Matched) { + via = rule_name(it->rule); + break; + } + out += Strutil::fmt::format("Resolved color space: '{}' (via {})\n", + expl.resolved, via); + } + return out; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_metadata_resolver_test.cpp b/src/libOpenImageIO/color_metadata_resolver_test.cpp new file mode 100644 index 0000000000..552809d64b --- /dev/null +++ b/src/libOpenImageIO/color_metadata_resolver_test.cpp @@ -0,0 +1,920 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for the read-side color-metadata reconciler (pvt), driven +// directly through color_pvt.h. The vectors port a proven prototype's +// precedence, utility-token, strict-parsing, CICP-over-ICC, and +// filename-invariance cases. + +#include +#include +#include +#include + +#include "color_pvt.h" +#include +#include +#include + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; +using namespace OIIO::pvt; + + +// A small, valid OCIO config carrying the identities the config-dependent +// vectors resolve against. Written to a temp file and loaded as a +// ColorConfig (OCIO is enabled in this build). +static std::string +write_test_config() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_cmr_test.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! + name: raw_data + isdata: true + aliases: [data] + - ! + name: lin_ap0_scene + - ! + name: lin_ap1_scene + - ! + name: srgb_rec709_scene + - ! + name: srgb_rec709_display + - ! + name: g24_rec709_display +)"; + f.close(); + return path; +} + + +static ColorMetadataFacts +ciid_facts(const std::string& id) +{ + ColorMetadataFacts f; + f.color_interop_id = id; + return f; +} + +static ColorMetadataFacts +cicp_facts(int p, int t, int m, int r) +{ + ColorMetadataFacts f; + f.has_cicp = true; + f.cicp[0] = p; + f.cicp[1] = t; + f.cicp[2] = m; + f.cicp[3] = r; + return f; +} + + +// ACES container flag wins outright (rule 2), no config needed. +static void +test_aces_container() +{ + ColorMetadataFacts f; + f.aces_image_container = true; + auto e = resolve_color_metadata(nullptr, "", f, {}, {}); + OIIO_CHECK_EQUAL(e.resolved, "lin_ap0_scene"); + OIIO_CHECK_ASSERT(e.has_genuine_metadata_match()); +} + + +// A known registry colorInteropID with no config bridges to itself verbatim. +static void +test_ciid_registry_bridge() +{ + auto e = resolve_color_metadata(nullptr, "", + ciid_facts("lin_adobergb_scene"), {}, {}); + OIIO_CHECK_EQUAL(e.resolved, "lin_adobergb_scene"); + OIIO_CHECK_ASSERT(e.has_genuine_metadata_match()); +} + + +// Vector 2: ciid="unknown" is an unusable payload -- it falls through, and +// the CICP identity resolves instead. (OIIO's CICP table maps (1,13) to the +// display-referred identity -- CICP describes display encodings, so +// srgb_rec709_display is listed ahead of the scene twin and wins on read; +// OIIO's central mapping is authoritative here.) +static void +test_unknown_ciid_falls_through_to_cicp(const ColorConfig& config) +{ + ColorMetadataFacts f = cicp_facts(1, 13, 0, 1); + f.color_interop_id = "unknown"; + auto e = resolve_color_metadata(&config, "", f, {}, {}); + OIIO_CHECK_EQUAL(e.resolved, "srgb_rec709_display"); + // The interop-id rule was visited and missed; CICP matched. + bool saw_ciid_missed = false, saw_cicp_matched = false; + for (auto& s : e.steps) { + if (s.rule == ColorRule::ColorInteropID + && s.outcome == ColorRuleOutcome::Missed) + saw_ciid_missed = true; + if (s.rule == ColorRule::Cicp && s.outcome == ColorRuleOutcome::Matched) + saw_cicp_matched = true; + } + OIIO_CHECK_ASSERT(saw_ciid_missed); + OIIO_CHECK_ASSERT(saw_cicp_matched); +} + + +// Vector 12: data / bypass. With a local isData space it resolves to that +// space; with no config at all the literal token is the answer. +static void +test_utility_tokens(const ColorConfig& config) +{ + auto e = resolve_color_metadata(&config, "", ciid_facts("data"), {}, {}); + OIIO_CHECK_EQUAL(e.resolved, "raw_data"); + + auto e2 = resolve_color_metadata(nullptr, "", ciid_facts("data"), {}, {}); + OIIO_CHECK_EQUAL(e2.resolved, "data"); + // Exactly one step, the interop-id rule, matched. + OIIO_CHECK_EQUAL(e2.steps.size(), size_t(1)); + OIIO_CHECK_EQUAL(int(e2.steps[0].rule), int(ColorRule::ColorInteropID)); +} + + +// Utility-token matching lowercases config names/aliases through OIIO's +// locale-independent Strutil::lower, so a mixed-case data space is still +// recognized regardless of the process locale (the Turkish-I hazard: a +// locale-dependent tolower maps 'I' to dotless-i and "BYPASS" stops +// matching "bypass"). +static void +test_mixed_case_utility_token() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_cmr_mixedcase.ocio"; + { + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: ByPass + scene_linear: lin +colorspaces: + - ! + name: ByPass + aliases: [Data] + isdata: true + - ! + name: lin +)"; + } + ColorConfig cfg(path); + OIIO_CHECK_ASSERT(!cfg.has_error()); + // The mixed-case NAME must rank 0 for the "bypass" token (lowered name + // == token), and the mixed-case ALIAS must rank 0 for the "data" token + // (lowered alias == token). + auto e = resolve_color_metadata(&cfg, "", ciid_facts("bypass"), {}, {}); + OIIO_CHECK_EQUAL(e.resolved, "ByPass"); + auto e2 = resolve_color_metadata(&cfg, "", ciid_facts("data"), {}, {}); + OIIO_CHECK_EQUAL(e2.resolved, "ByPass"); + Filesystem::remove(path); +} + + +// Vector 9: an explicit assignment that misses, under a non-lenient scope, +// yields the literal "unknown" in exactly two steps and never consults the +// metadata behind it. +static void +test_strict_terminal(const ColorConfig& config) +{ + ColorReadPolicy p; + p.scope = ColorResolutionScope::ConfigOnly; + ColorMetadataFacts f = ciid_facts("lin_ap1_scene"); // must be ignored + auto e = resolve_color_metadata(&config, "not-a-space", f, {}, p); + OIIO_CHECK_EQUAL(e.resolved, "unknown"); + OIIO_CHECK_EQUAL(e.steps.size(), size_t(2)); + OIIO_CHECK_EQUAL(int(e.steps[0].rule), int(ColorRule::ExplicitAssignment)); + OIIO_CHECK_EQUAL(int(e.steps[0].outcome), int(ColorRuleOutcome::Missed)); + OIIO_CHECK_EQUAL(int(e.steps[1].rule), int(ColorRule::StrictParsing)); + OIIO_CHECK_ASSERT(!e.has_genuine_metadata_match()); +} + + +// Vector 16: garbage ICC bytes are invalid and fall through. (CICP now sits +// above ICC, so the fall-through is observed on a lone garbage profile: the +// ICC step records Invalid and no genuine metadata match results.) +static void +test_garbage_icc_falls_through(const ColorConfig& config) +{ + ColorMetadataFacts f; + f.icc_profile = std::vector(200, 0x42); + auto e = resolve_color_metadata(&config, "", f, {}, {}); + OIIO_CHECK_ASSERT(!e.has_genuine_metadata_match()); + bool icc_invalid = false; + for (auto& s : e.steps) + if (s.rule == ColorRule::IccProfile + && s.outcome == ColorRuleOutcome::Invalid) + icc_invalid = true; + OIIO_CHECK_ASSERT(icc_invalid); +} + + +// Build a minimally-decodable ICC profile: a >=128-byte header carrying the +// 'acsp' signature at offset 36. +static std::vector +fake_icc_profile() +{ + std::vector p(200, 0x00); + p[36] = 'a'; + p[37] = 'c'; + p[38] = 's'; + p[39] = 'p'; + return p; +} + + +// Vector 18: a decodable-but-unmatched ICC profile resolves to its own +// registered synthetic id. +static void +test_icc_synthetic(const ColorConfig& config) +{ + ColorMetadataFacts f; + f.icc_profile = fake_icc_profile(); + auto e = resolve_color_metadata(&config, "", f, {}, {}); + OIIO_CHECK_ASSERT(Strutil::starts_with(e.resolved, "icc:")); + OIIO_CHECK_EQUAL(e.resolved, e.registered_synthetic); + // The digest is the full 64-bit strhash64 of the profile bytes on every + // platform (never the size_t strhash, which truncates on 32-bit builds). + const auto& p = f.icc_profile; + OIIO_CHECK_EQUAL(e.resolved, + Strutil::fmt::format( + "icc:{:016x}", + Strutil::strhash64( + string_view(reinterpret_cast(p.data()), + p.size())))); +} + + +// CICP sits ABOVE ICC, per the PNG spec's own chunk precedence +// (cICP > iCCP). A CICP tuple plus a decodable ICC profile resolves via +// CICP, not ICC; the ICC rule is never even visited. +static void +test_cicp_over_icc(const ColorConfig& config) +{ + ColorMetadataFacts f = cicp_facts(1, 13, 0, 1); + f.icc_profile = fake_icc_profile(); + auto e = resolve_color_metadata(&config, "", f, {}, {}); + OIIO_CHECK_EQUAL(e.resolved, "srgb_rec709_display"); + OIIO_CHECK_ASSERT(!Strutil::starts_with(e.resolved, "icc:")); + for (auto& s : e.steps) + OIIO_CHECK_ASSERT(s.rule != ColorRule::IccProfile); +} + + +// Spec-impossible CICP values are log-only (debugfmt): resolution behavior +// is exactly as before. A reserved primaries/transfer of 0 misses the +// identity mapping and falls through; a non-zero matrix byte never affects +// identification (primaries + transfer only). The debug line itself is not +// asserted here -- debugfmt is env-gated and capturing stderr isn't worth +// the plumbing for a log-only path. +static void +test_spec_impossible_cicp(const ColorConfig& config) +{ + // Reserved (0,0): no identity mapping, no genuine match, CICP missed. + auto e = resolve_color_metadata(&config, "", cicp_facts(0, 0, 0, 0), {}, + {}); + OIIO_CHECK_ASSERT(!e.has_genuine_metadata_match()); + bool cicp_missed = false; + for (auto& s : e.steps) + if (s.rule == ColorRule::Cicp && s.outcome == ColorRuleOutcome::Missed) + cicp_missed = true; + OIIO_CHECK_ASSERT(cicp_missed); + + // Non-zero matrix byte: identical resolution to the (1,13,0,*) vectors. + auto e2 = resolve_color_metadata(&config, "", cicp_facts(1, 13, 5, 1), {}, + {}); + OIIO_CHECK_EQUAL(e2.resolved, "srgb_rec709_display"); + OIIO_CHECK_ASSERT(e2.has_genuine_metadata_match()); +} + + +// Vector 7: under metadata-only file-rules placement, the same metadata with +// and without a FileRules-matching filename produces equal resolved values +// AND byte-identical step traces, with both FileRules rungs inapplicable. +static void +test_filename_invariance(const ColorConfig& config) +{ + ColorMetadataFacts f = cicp_facts(1, 13, 0, 1); + ColorCallContext no_name; + ColorCallContext with_name; + with_name.filename = "/tmp/frame.g24_rec709_display.exr"; + auto a = resolve_color_metadata(&config, "", f, no_name, {}); + auto b = resolve_color_metadata(&config, "", f, with_name, {}); + OIIO_CHECK_EQUAL(a.resolved, b.resolved); + OIIO_CHECK_EQUAL(a.steps.size(), b.steps.size()); + for (size_t i = 0; i < a.steps.size() && i < b.steps.size(); ++i) { + OIIO_CHECK_EQUAL(int(a.steps[i].rule), int(b.steps[i].rule)); + OIIO_CHECK_EQUAL(int(a.steps[i].outcome), int(b.steps[i].outcome)); + OIIO_CHECK_EQUAL(a.steps[i].candidate, b.steps[i].candidate); + OIIO_CHECK_EQUAL(a.steps[i].resolved, b.steps[i].resolved); + OIIO_CHECK_EQUAL(a.steps[i].reason, b.steps[i].reason); + } + for (auto& s : a.steps) { + if (s.rule == ColorRule::FileRulesFirst + || s.rule == ColorRule::FileRulesFallback) + OIIO_CHECK_EQUAL(int(s.outcome), + int(ColorRuleOutcome::Inapplicable)); + } +} + + +// The central entry point stamps the color space exactly as the reader's +// former inline code did: ACES flag -> lin_ap0_scene; colorInteropID -> +// verbatim; neither -> nothing. +static void +test_reconcile_entry_point() +{ + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("acesImageContainerFlag", 1); + reconcile_color_metadata(spec, ColorReadPolicy()); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_ap0_scene"); + } + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("colorInteropID", "lin_adobergb_scene"); + reconcile_color_metadata(spec, ColorReadPolicy()); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_adobergb_scene"); + } + { + ImageSpec spec(4, 4, 3, TypeFloat); + reconcile_color_metadata(spec, ColorReadPolicy()); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), ""); + } + { + // A CICP tuple overrides the color space with the interop id it maps + // to (Rec.709 primaries + sRGB transfer -> srgb_rec709_display; CICP + // describes display encodings, so the display twin wins on read), and + // the CICP source attribute is kept in place. + ImageSpec spec(4, 4, 3, TypeFloat); + const int cicp[4] = { 1, 13, 0, 1 }; + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + reconcile_color_metadata(spec, ColorReadPolicy()); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_display"); + OIIO_CHECK_EQUAL(spec.find_attribute("CICP") != nullptr, true); + } +} + + +// ColorReadCaps: the per-format consulted-signals table (the read-direction +// mirror of ColorWriteCaps) and the caps-narrowed spec->facts extraction. +// Zero-diff proof: every wired format's row is today's format-invariant +// consulted trio, so reconciliation under any format name -- or none -- is +// identical; the caps-narrowed extraction drops exactly the disabled +// signals. +static void +test_read_caps() +{ + // The table: wired readers and the unknown-format default share the + // consulted trio; signals resolution does not consult stay off. + for (string_view fmt : { "openexr", "png", "" }) { + const ColorReadCaps caps = color_read_caps_for_format(fmt); + OIIO_CHECK_ASSERT(caps.aces_container && caps.interop_id && caps.cicp); + OIIO_CHECK_ASSERT(!caps.icc && !caps.chromaticities && !caps.gamma); + } + + // Caps-narrowed extraction: enabled signals extract exactly as the + // unrestricted overload does; disabled ones stay absent. + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("colorInteropID", "srgb_rec709_scene"); + const int cicp[4] = { 1, 13, 0, 1 }; + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + const float chroma[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.329f }; + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), chroma); + spec.attribute("oiio:Gamma", 2.2f); + + const ColorMetadataFacts full = color_facts_from_spec(spec); + OIIO_CHECK_ASSERT(full.has_cicp && full.has_chromaticities && full.has_gamma + && full.color_interop_id == "srgb_rec709_scene"); + const ColorMetadataFacts narrowed + = color_facts_from_spec(spec, color_read_caps_for_format("png")); + OIIO_CHECK_ASSERT(narrowed.has_cicp + && narrowed.color_interop_id == "srgb_rec709_scene"); + OIIO_CHECK_EQUAL(narrowed.has_chromaticities, false); + OIIO_CHECK_EQUAL(narrowed.has_gamma, false); + + // Zero-diff: reconciliation stamps the same result under every wired + // format name and with no format at all (the rows coincide by design). + for (string_view fmt : { "openexr", "png", "" }) { + ImageSpec a(4, 4, 3, TypeFloat); + a.attribute("colorInteropID", "lin_adobergb_scene"); + reconcile_color_metadata(a, ColorReadPolicy(), fmt); + OIIO_CHECK_EQUAL(a.get_string_attribute("oiio:ColorSpace"), + "lin_adobergb_scene"); + ImageSpec b(4, 4, 3, TypeFloat); + b.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + reconcile_color_metadata(b, ColorReadPolicy(), fmt); + OIIO_CHECK_EQUAL(b.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_display"); + OIIO_CHECK_EQUAL(b.find_attribute("CICP") != nullptr, true); + } +} + + +// render_color_read_plan: the read-side dry-run preview. A spec carrying a +// CICP tuple resolves through the cascade; the rendered plan names the CICP +// rule, its matched outcome, the tuple, and the final assignment. +static void +test_render_read_plan(const ColorConfig& config) +{ + ImageSpec spec(16, 16, 3, TypeDesc::FLOAT); + const int cicp[4] = { 1, 13, 0, 1 }; + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + const std::string plan = render_color_read_plan(spec, &config); + OIIO_CHECK_ASSERT(Strutil::contains(plan, "rule 5 CICP")); + OIIO_CHECK_ASSERT(Strutil::contains(plan, "matched")); + OIIO_CHECK_ASSERT(Strutil::contains(plan, "(tuple 1,13,0,1)")); + OIIO_CHECK_ASSERT(Strutil::contains( + plan, "Resolved color space: 'srgb_rec709_display' (via CICP)")); +} + + +// Deferred + consume-once CICP resolution (spec 09, "Deferred resolution and +// consume-once policy"). A CICP tuple is state-ambiguous, so a defer_cicp read +// deposits it as pending without committing a color space; the caller then +// sets cicp_state and resolves. Resolution consumes the pending tuple and the +// one-shot global cicp_state key. +static void +test_deferred_cicp(const ColorConfig& config) +{ + // Rec.709 primaries + sRGB transfer: maps to srgb_rec709_display, whose + // scene twin (srgb_rec709_scene) also exists in the config. + const int cicp[4] = { 1, 13, 0, 1 }; + + // Deferred, then policy set AFTER read decides the twin: SCENE. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + ColorReadPolicy defer; + defer.defer_cicp = true; + reconcile_color_metadata(spec, defer, "png"); + // Pending: no color space committed, marker present, CICP kept. + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), ""); + OIIO_CHECK_EQUAL(spec.get_int_attribute("oiio:cicp:pending"), 1); + OIIO_CHECK_EQUAL(spec.find_attribute("CICP") != nullptr, true); + + ColorReadPolicy scene; + scene.cicp_state = ColorStatePreference::Scene; + const bool did = resolve_pending_cicp(spec, scene, &config); + OIIO_CHECK_ASSERT(did); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_scene"); + // Consumed: the pending marker is gone. + OIIO_CHECK_EQUAL(spec.find_attribute("oiio:cicp:pending"), nullptr); + // Second resolve is a no-op (nothing pending). + OIIO_CHECK_EQUAL(resolve_pending_cicp(spec, scene, &config), false); + } + + // Same tuple, DISPLAY policy set after read -> display twin. Proves the + // post-read policy, not the tuple, decides the result. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + ColorReadPolicy defer; + defer.defer_cicp = true; + reconcile_color_metadata(spec, defer, "png"); + ColorReadPolicy display; + display.cicp_state = ColorStatePreference::Display; + resolve_pending_cicp(spec, display, &config); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_display"); + } + + // No-op: setting cicp_state when nothing is pending changes nothing. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_ap1_scene"); + ColorReadPolicy scene; + scene.cicp_state = ColorStatePreference::Scene; + OIIO_CHECK_EQUAL(resolve_pending_cicp(spec, scene, &config), false); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_ap1_scene"); + } + + // Consume-once via the GLOBAL cicp_state key: after a pending resolve it is + // cleared, so it does NOT silently re-apply to the next file. + { + OIIO::attribute("oiio:colorpolicy:read:defer_cicp", 1); + OIIO::attribute("oiio:colorpolicy:read:cicp_state", "scene"); + + // File 1: deferred read + resolve under the global scene policy. + ImageSpec s1(4, 4, 3, TypeFloat); + s1.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + reconcile_color_metadata(s1, ColorReadPolicy::snapshot(), "png"); + OIIO_CHECK_EQUAL(s1.get_int_attribute("oiio:cicp:pending"), 1); + resolve_pending_cicp(s1, ColorReadPolicy::snapshot(), &config); + OIIO_CHECK_EQUAL(s1.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_scene"); + + // The global cicp_state was consumed by that resolve. + std::string leftover; + OIIO::getattribute("oiio:colorpolicy:read:cicp_state", leftover); + OIIO_CHECK_EQUAL(leftover, ""); + + // File 2: same deferred read, but the prior scene policy is gone, so + // the default (display) twin wins -- consume-once proven. + ImageSpec s2(4, 4, 3, TypeFloat); + s2.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + reconcile_color_metadata(s2, ColorReadPolicy::snapshot(), "png"); + resolve_pending_cicp(s2, ColorReadPolicy::snapshot(), &config); + OIIO_CHECK_EQUAL(s2.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_display"); + + OIIO::attribute("oiio:colorpolicy:read:defer_cicp", 0); + } + + // Eager path unchanged: without defer_cicp the default policy commits the + // display twin at read time and leaves no pending marker. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + reconcile_color_metadata(spec, ColorReadPolicy(), "png"); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_display"); + OIIO_CHECK_EQUAL(spec.find_attribute("oiio:cicp:pending"), nullptr); + } +} + + +// --------------------------------------------------------------------------- +// Spec 09 (RFC POC) -- config-declared metadata policy via OCIO FileRule +// custom keys. A config author attaches `oiio:colorpolicy:*` custom keys to +// the reserved `oiio:default` FileRule (regex `$^`, so it never matches a file +// -- it exists only to carry policy). OCIO round-trips those keys byte-stably +// and other apps ignore them; here we prove OIIO READS them and applies them +// as policy, one layer below an explicit OIIO::attribute. +// --------------------------------------------------------------------------- + +// Write a config carrying exactly one declared policy key on `oiio:default`. +static std::string +write_policy_config(const std::string& full_key, const std::string& value) +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_cmr_policy.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {")" + << full_key << R"(": ")" << value << R"("}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! + name: raw_data + isdata: true + aliases: [data] + - ! + name: lin_ap1_scene + - ! + name: srgb_rec709_scene + - ! + name: srgb_rec709_display +)"; + f.close(); + return path; +} + + +// READ proof: a config declaring `oiio:colorpolicy:read:cicp_state: scene` +// must flip a CICP (1,13,0,1) tuple from the display twin to the scene twin, +// with NO OIIO attribute set -- the policy came from the CONFIG. Contrast: the +// baseline config (no such key) still resolves to the display twin. +static void +test_config_declared_read_policy(const ColorConfig& plaincfg) +{ + // Guard: no global attribute anywhere -- prove the config is the source. + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); + + const std::string declpath + = write_policy_config("oiio:colorpolicy:read:cicp_state", "scene"); + ColorConfig declcfg(declpath); + OIIO_CHECK_ASSERT(!declcfg.has_error()); + + // The FileRules custom-key reader returns the declared key verbatim, and + // the baseline config declares nothing. + auto keys = config_declared_policy_keys(declcfg, "oiio:default"); + OIIO_CHECK_EQUAL(keys["oiio:colorpolicy:read:cicp_state"], "scene"); + OIIO_CHECK_EQUAL( + config_declared_policy_keys(plaincfg, "oiio:default").size(), + size_t(0)); + + const ColorMetadataFacts f = cicp_facts(1, 13, 0, 1); + + // Baseline: no declared key -> Auto -> the display twin (builtin default). + { + const ColorReadPolicy p = ColorReadPolicy::snapshot(nullptr, &plaincfg); + OIIO_CHECK_ASSERT(p.cicp_state == ColorStatePreference::Auto); + const auto e = resolve_color_metadata(&plaincfg, "", f, {}, p); + OIIO_CHECK_EQUAL(e.resolved, "srgb_rec709_display"); + } + // Config-declared: the config's key alone flips resolution to the scene + // twin -- no OIIO attribute set. + { + const ColorReadPolicy p = ColorReadPolicy::snapshot(nullptr, &declcfg); + OIIO_CHECK_ASSERT(p.cicp_state == ColorStatePreference::Scene); + const auto e = resolve_color_metadata(&declcfg, "", f, {}, p); + OIIO_CHECK_EQUAL(e.resolved, "srgb_rec709_scene"); + } + // Precedence: an explicit global attribute (layer 4) still overrides the + // config's declared key (layer 2). + { + OIIO::attribute("oiio:colorpolicy:read:cicp_state", "display"); + const ColorReadPolicy p = ColorReadPolicy::snapshot(nullptr, &declcfg); + OIIO_CHECK_ASSERT(p.cicp_state == ColorStatePreference::Display); + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); + } + + Filesystem::remove(declpath); +} + + +// WRITE proof: the SAME FileRules mechanism drives write policy. A config +// declaring `oiio:colorpolicy:write:cicp: never` must suppress the CICP tuple +// a PNG write would otherwise emit for srgb_rec709_display -- with NO OIIO +// attribute set. Contrast: the baseline config emits the author's tuple. +static void +test_config_declared_write_policy(const ColorConfig& plaincfg) +{ + OIIO::attribute("oiio:colorpolicy:write:cicp", ""); + + const std::string declpath + = write_policy_config("oiio:colorpolicy:write:cicp", "never"); + ColorConfig declcfg(declpath); + OIIO_CHECK_ASSERT(!declcfg.has_error()); + + // Snapshot picks the declared write key up as the ConfigDeclared tier. + const ColorWritePolicy wp_plain = ColorWritePolicy::snapshot(nullptr, + &plaincfg); + const ColorWritePolicy wp_decl = ColorWritePolicy::snapshot(nullptr, + &declcfg); + OIIO_CHECK_ASSERT(wp_plain.cicp == ColorSignalPolicy::Auto); + OIIO_CHECK_ASSERT(wp_decl.cicp == ColorSignalPolicy::Never); + OIIO_CHECK_ASSERT(wp_decl.cicp_layer == ColorPlanDecider::ConfigDeclared); + + // The emitted-metadata plan flips: a PNG write emits the author's CICP + // tuple by default, but the config's declared `never` suppresses it. + ImageSpec spec(4, 4, 3, TypeFloat); + spec.set_colorspace("srgb_rec709_display"); + const int cicp[4] = { 1, 13, 0, 1 }; + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + const ColorWriteCaps caps = color_write_caps_for_format("png"); + const ColorMetadataPlan plan_plain = plan_color_metadata(&plaincfg, spec, + caps, wp_plain); + const ColorMetadataPlan plan_decl = plan_color_metadata(&declcfg, spec, + caps, wp_decl); + OIIO_CHECK_ASSERT(plan_plain.cicp.emit()); // default: emit the tuple + OIIO_CHECK_ASSERT(!plan_decl.cicp.emit()); // config-declared: suppressed + OIIO_CHECK_ASSERT(plan_decl.cicp.decider + == ColorPlanDecider::ConfigDeclared); + + Filesystem::remove(declpath); +} + + +// Full spec-09 precedence ladder (layers 2-6). A config declares a baseline +// `oiio:default` opinion (layer 2) AND a per-file opinion on a rule that +// matches *.png (layer 5). The matched-rule opinion outranks both the config +// default (layer 2) and an explicit global attribute (layer 4) -- the +// documented CSS-specificity rung -- while a per-call hint (layer 6) still +// wins over everything. A non-matching path (*.exr) never picks up the png +// rule, so it falls back to the config default. +static void +test_config_declared_layer_precedence() +{ + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); // clean slate + + std::string path = Filesystem::temp_directory_path() + + "/oiio_cmr_layers.ocio"; + { + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:read:cicp_state: display}} + - ! {name: pngscene, colorspace: raw_data, pattern: "*", extension: "png", custom: {oiio:colorpolicy:read:cicp_state: scene}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} +)"; + } + ColorConfig cfg(path); + OIIO_CHECK_ASSERT(!cfg.has_error()); + + // The matched-rule reader returns the png rule's key for a .png path and + // nothing for a .exr path (the png rule does not match it). + OIIO_CHECK_EQUAL(config_matched_rule_policy_keys( + cfg, "a.png")["oiio:colorpolicy:read:cicp_state"], + "scene"); + OIIO_CHECK_EQUAL(config_matched_rule_policy_keys(cfg, "a.exr") + .count("oiio:colorpolicy:read:cicp_state"), + size_t(0)); + + // Layer 5 > layer 2: the .png rule (scene) beats the config default + // (display). + OIIO_CHECK_ASSERT( + ColorReadPolicy::snapshot(nullptr, &cfg, "a.png").cicp_state + == ColorStatePreference::Scene); + // Non-matching path: only the config default applies -> display. + OIIO_CHECK_ASSERT( + ColorReadPolicy::snapshot(nullptr, &cfg, "a.exr").cicp_state + == ColorStatePreference::Display); + + // Layer 5 > layer 4: an explicit global attribute (display) does NOT + // override the matched .png rule (scene) -- the CSS-specificity footgun. + OIIO::attribute("oiio:colorpolicy:read:cicp_state", "display"); + OIIO_CHECK_ASSERT( + ColorReadPolicy::snapshot(nullptr, &cfg, "a.png").cicp_state + == ColorStatePreference::Scene); + // ...but for a non-matching path, the global attribute (layer 4) rules. + OIIO_CHECK_ASSERT( + ColorReadPolicy::snapshot(nullptr, &cfg, "a.exr").cicp_state + == ColorStatePreference::Display); + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); + + // Layer 6 > layer 5: a per-call hint (display) overrides even the matched + // .png rule (scene). + ImageSpec hints; + hints.attribute("oiio:colorpolicy:read:cicp_state", "display"); + OIIO_CHECK_ASSERT( + ColorReadPolicy::snapshot(&hints, &cfg, "a.png").cicp_state + == ColorStatePreference::Display); + + Filesystem::remove(path); +} + + +// Robustness (spec 09 / spec 08 rule 6): config-declared policy is best-effort +// and must NEVER break resolution. A malformed config, an unknown key, or a +// garbage value for a known key are all ignored -- resolution falls through to +// the built-in default exactly as if nothing were declared, and nothing throws. +static void +test_config_declared_robustness() +{ + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); // clean slate + const ColorMetadataFacts f = cicp_facts(1, 13, 0, 1); + + // (a) Malformed config: a nonexistent path yields an errored ColorConfig + // whose impl has no OCIO config. The key readers must return empty (guarded, + // never throwing) and the snapshot must fall through to the default. + { + ColorConfig bad("/no/such/oiio_cmr_missing.ocio"); + OIIO_CHECK_ASSERT(bad.has_error()); + OIIO_CHECK_EQUAL(config_declared_policy_keys(bad, "oiio:default").size(), + size_t(0)); + OIIO_CHECK_EQUAL(config_matched_rule_policy_keys(bad, "a.png").size(), + size_t(0)); + const ColorReadPolicy p = ColorReadPolicy::snapshot(nullptr, &bad); + OIIO_CHECK_ASSERT(p.cicp_state == ColorStatePreference::Auto); + } + + // (b) Unknown key: the config declares an `oiio:`-prefixed key OIIO does not + // recognize. It is read verbatim but never consulted by the snapshot, so + // resolution is unchanged (warn-once-ignore, spec 08 rule 6). + { + const std::string path + = write_policy_config("oiio:colorpolicy:read:totally_bogus", "1"); + ColorConfig cfg(path); + OIIO_CHECK_ASSERT(!cfg.has_error()); + auto keys = config_declared_policy_keys(cfg, "oiio:default"); + OIIO_CHECK_EQUAL(keys["oiio:colorpolicy:read:totally_bogus"], "1"); + const ColorReadPolicy p = ColorReadPolicy::snapshot(nullptr, &cfg); + OIIO_CHECK_ASSERT(p.cicp_state == ColorStatePreference::Auto); + const auto e = resolve_color_metadata(&cfg, "", f, {}, p); + OIIO_CHECK_EQUAL(e.resolved, + "srgb_rec709_display"); // default, unmoved + Filesystem::remove(path); + } + + // (c) Garbage value for a KNOWN key: `cicp_state` accepts only scene/display; + // any other value is ignored (stays Auto), never misparsed into a state. + { + const std::string path + = write_policy_config("oiio:colorpolicy:read:cicp_state", "banana"); + ColorConfig cfg(path); + OIIO_CHECK_ASSERT(!cfg.has_error()); + const ColorReadPolicy p = ColorReadPolicy::snapshot(nullptr, &cfg); + OIIO_CHECK_ASSERT(p.cicp_state == ColorStatePreference::Auto); + const auto e = resolve_color_metadata(&cfg, "", f, {}, p); + OIIO_CHECK_EQUAL(e.resolved, + "srgb_rec709_display"); // default, unmoved + Filesystem::remove(path); + } +} + + +// No-color-management opt-out (spec 09): with NO active config there is nothing +// to consult, so the policy snapshot is exactly the built-in default -- and a +// config that declares no keys is indistinguishable from it. This is the +// property `pvt::ambient_color_config()` relies on when it returns nullptr. +static void +test_policy_optout(const ColorConfig& plaincfg) +{ + OIIO::attribute("oiio:colorpolicy:read:cicp_state", ""); + OIIO::attribute("oiio:colorpolicy:write:cicp", ""); + + // Null config == a real config with zero declared keys == all defaults. + const ColorReadPolicy r_null = ColorReadPolicy::snapshot(nullptr, nullptr); + const ColorReadPolicy r_plain = ColorReadPolicy::snapshot(nullptr, + &plaincfg); + OIIO_CHECK_ASSERT(r_null.cicp_state == ColorStatePreference::Auto); + OIIO_CHECK_ASSERT(r_plain.cicp_state == ColorStatePreference::Auto); + OIIO_CHECK_ASSERT(r_null.state_pref == r_plain.state_pref); + OIIO_CHECK_ASSERT(r_null.scope == r_plain.scope); + OIIO_CHECK_ASSERT(r_null.defer_cicp == r_plain.defer_cicp); + + const ColorWritePolicy w_null = ColorWritePolicy::snapshot(nullptr, + nullptr); + const ColorWritePolicy w_plain = ColorWritePolicy::snapshot(nullptr, + &plaincfg); + OIIO_CHECK_ASSERT(w_null.cicp == ColorSignalPolicy::Auto); + OIIO_CHECK_ASSERT(w_plain.cicp == ColorSignalPolicy::Auto); + + // An ambiguous CICP resolves to the display twin under both -- the opt-out + // changes nothing about the historical null-config behavior. + const ColorMetadataFacts f = cicp_facts(1, 13, 0, 1); + OIIO_CHECK_EQUAL(resolve_color_metadata(nullptr, "", f, {}, r_null).resolved, + "srgb_rec709_display"); +} + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_aces_container(); + test_ciid_registry_bridge(); + test_reconcile_entry_point(); + test_read_caps(); + + const std::string cfgpath = write_test_config(); + ColorConfig config(cfgpath); + if (config.has_error()) { + Strutil::print("Could not load test config: {}\n", config.geterror()); + return 1; + } + + test_unknown_ciid_falls_through_to_cicp(config); + test_utility_tokens(config); + test_mixed_case_utility_token(); + test_strict_terminal(config); + test_garbage_icc_falls_through(config); + test_icc_synthetic(config); + test_cicp_over_icc(config); + test_spec_impossible_cicp(config); + test_filename_invariance(config); + test_render_read_plan(config); + test_deferred_cicp(config); + test_config_declared_read_policy(config); + test_config_declared_write_policy(config); + test_config_declared_layer_precedence(); + test_config_declared_robustness(); + test_policy_optout(config); + + Filesystem::remove(cfgpath); + return unit_test_failures != 0; +} diff --git a/src/libOpenImageIO/color_ocio.cpp b/src/libOpenImageIO/color_ocio.cpp index a0935f06b4..dc9b2048c6 100644 --- a/src/libOpenImageIO/color_ocio.cpp +++ b/src/libOpenImageIO/color_ocio.cpp @@ -3,9 +3,18 @@ // https://github.com/AcademySoftwareFoundation/OpenImageIO #include +#include +#include #include +#include #include +#include +#include +#include +#include #include +#include +#include #include #include @@ -18,15 +27,11 @@ #include #include #include +#include #include "imageio_pvt.h" -#define MAKE_OCIO_VERSION_HEX(maj, min, patch) \ - (((maj) << 24) | ((min) << 16) | (patch)) - -#include - -namespace OCIO = OCIO_NAMESPACE; +#include "color_ocio_pvt.h" OIIO_NAMESPACE_3_1_BEGIN @@ -38,20 +43,70 @@ static const Imath::C3f test_colors[n_test_colors] = { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 }, { 1, 1, 1 }, { 0.5, 0.5, 0.5 } }; } // namespace +// Make an editable copy of `config`, working around an OCIO bug (fixed in +// 2.3.1) where createEditableCopy() drops the default view transform name. +OCIO::ConfigRcPtr +copy_config(const OCIO::ConstConfigRcPtr& config) +{ + auto copy = config->createEditableCopy(); +#if OCIO_VERSION_HEX < 0x02030100 + // OCIO before 2.3.1 loses the default view transform name when copying a + // config; restore it. + std::string default_vt = config->getDefaultViewTransformName(); + if (!default_vt.empty() + && default_vt != copy->getDefaultViewTransformName()) + copy->setDefaultViewTransformName(default_vt.c_str()); +#endif + return copy; +} + + +// Test probe backing pvt::copy_config_preserves_default_view_transform() +// (declared in the current namespace, forwarded here). Builds a config with +// TWO view transforms whose explicit default is the NON-first one, runs the +// real copy_config(), and reports whether the copy kept that explicit default. +// The two-VT shape matters: OCIO reports the first view transform as the +// implicit default when none is set, so a single-VT probe would pass even if +// createEditableCopy() silently dropped the explicit default. Returns true on +// preservation -- native on OCIO >= 2.3.1, workaround-restored below that -- +// and vacuously true when OCIO support or the view-transform API is +// unavailable. +bool +copy_config_default_vt_probe() +{ + if (!ColorConfig::supportsOpenColorIO()) + return true; + try { + OCIO::ConfigRcPtr cfg = OCIO::Config::CreateRaw()->createEditableCopy(); + for (const char* name : { "probe_vt_a", "probe_vt_b" }) { + auto vt = OCIO::ViewTransform::Create(OCIO::REFERENCE_SPACE_SCENE); + vt->setName(name); + vt->setTransform(OCIO::MatrixTransform::Create(), + OCIO::VIEWTRANSFORM_DIR_FROM_REFERENCE); + cfg->addViewTransform(vt); + } + // Explicit default is the SECOND view transform, not OCIO's implicit + // first-VT default -- so a dropped default is observable. + cfg->setDefaultViewTransformName("probe_vt_b"); + + OCIO::ConstConfigRcPtr src = cfg; + OCIO::ConfigRcPtr copy = copy_config(src); + const char* copied_default = copy->getDefaultViewTransformName(); + return copied_default && std::string(copied_default) == "probe_vt_b"; + } catch (const OCIO::Exception&) { + return true; // view-transform API unavailable -> vacuous pass + } +} + #if 1 || !defined(NDEBUG) /* allow color configuration debugging */ -static bool colordebug = Strutil::stoi(Sysutil::getenv("OIIO_DEBUG_COLOR")) - || Strutil::stoi(Sysutil::getenv("OIIO_DEBUG_ALL")); -# define DBG(...) \ - if (colordebug) \ - Strutil::print(__VA_ARGS__) -#else -# define DBG(...) +bool colordebug = Strutil::stoi(Sysutil::getenv("OIIO_DEBUG_COLOR")) + || Strutil::stoi(Sysutil::getenv("OIIO_DEBUG_ALL")); #endif -static int disable_ocio = Strutil::stoi(Sysutil::getenv("OIIO_DISABLE_OCIO")); -static int disable_builtin_configs = Strutil::stoi( +int disable_ocio = Strutil::stoi(Sysutil::getenv("OIIO_DISABLE_OCIO")); +int disable_builtin_configs = Strutil::stoi( Sysutil::getenv("OIIO_DISABLE_BUILTIN_OCIO_CONFIGS")); static OCIO::ConstConfigRcPtr ocio_current_config; @@ -66,79 +121,6 @@ ColorConfig::default_colorconfig() -// Class used as the key to index color processors in the cache. -class ColorProcCacheKey { -public: - ColorProcCacheKey(ustring in, ustring out, ustring key = ustring(), - ustring val = ustring(), ustring looks = ustring(), - ustring display = ustring(), ustring view = ustring(), - ustring file = ustring(), - ustring namedtransform = ustring(), bool inverse = false) - : inputColorSpace(in) - , outputColorSpace(out) - , context_key(key) - , context_value(val) - , looks(looks) - , file(file) - , namedtransform(namedtransform) - , inverse(inverse) - { - hash = inputColorSpace.hash() + 14033ul * outputColorSpace.hash() - + 823ul * context_key.hash() + 28411ul * context_value.hash() - + 1741ul - * (looks.hash() + display.hash() + view.hash() - + file.hash() + namedtransform.hash()) - + (inverse ? 6421 : 0); - // N.B. no separate multipliers for looks, display, view, file, - // namedtransform, because they're never used for the same lookup. - } - - friend bool operator<(const ColorProcCacheKey& a, - const ColorProcCacheKey& b) - { - return std::tie(a.hash, a.inputColorSpace, a.outputColorSpace, - a.context_key, a.context_value, a.looks, a.display, - a.view, a.file, a.namedtransform, a.inverse) - < std::tie(b.hash, b.inputColorSpace, b.outputColorSpace, - b.context_key, b.context_value, b.looks, b.display, - b.view, b.file, b.namedtransform, b.inverse); - } - - friend bool operator==(const ColorProcCacheKey& a, - const ColorProcCacheKey& b) - { - return std::tie(a.hash, a.inputColorSpace, a.outputColorSpace, - a.context_key, a.context_value, a.looks, a.display, - a.view, a.file, a.namedtransform, a.inverse) - == std::tie(b.hash, b.inputColorSpace, b.outputColorSpace, - b.context_key, b.context_value, b.looks, b.display, - b.view, b.file, b.namedtransform, b.inverse); - } - ustring inputColorSpace; - ustring outputColorSpace; - ustring context_key; - ustring context_value; - ustring looks; - ustring display; - ustring view; - ustring file; - ustring namedtransform; - bool inverse; - size_t hash; -}; - - -struct ColorProcCacheKeyHasher { - size_t operator()(const ColorProcCacheKey& c) const { return c.hash; } -}; - - -typedef tsl::robin_map - ColorProcessorMap; - - - bool ColorConfig::supportsOpenColorIO() { @@ -154,276 +136,6 @@ ColorConfig::OpenColorIO_version_hex() } -struct CSInfo { - std::string name; // Name of this color space - int index; // More than one can have the same index -- aliases - enum Flags { - none = 0, - is_linear_response = 1, // any cs with linear transfer function - is_scene_linear = 2, // equivalent to scene_linear - is_srgb = 4, // sRGB (primaries, and transfer function) - is_lin_srgb = 8, // sRGB/Rec709 primaries, linear response - is_ACEScg = 16, // ACEScg - is_Rec709 = 32, // Rec709 primaries and transfer function - is_data = 64, // Non-color-managed data - is_known = is_srgb | is_lin_srgb | is_ACEScg | is_Rec709 - }; - int m_flags = 0; - bool examined = false; - std::string canonical; // Canonical name for this color space - OCIO::ConstColorSpaceRcPtr ocio_cs; - - CSInfo(string_view name_, int index_, int flags_ = none, - string_view canonical_ = "") - : name(name_) - , index(index_) - , m_flags(flags_) - , canonical(canonical_) - { - } - - void setflag(int flagval) { m_flags |= flagval; } - - // Set flag to include any bits in flagval, and also if alias is not yet - // set, set it to name. - void setflag(int flagval, std::string& alias) - { - m_flags |= flagval; - if (alias.empty()) - alias = name; - } - - int flags() const { return m_flags; } -}; - - - -// Hidden implementation of ColorConfig -class ColorConfig::Impl { -public: - OCIO::ConfigRcPtr config_; - OCIO::ConfigRcPtr builtinconfig_; - -private: - std::vector colorspaces; - std::string scene_linear_alias; // Alias for a scene-linear color space - std::string lin_srgb_alias; - std::string srgb_alias; - std::string ACEScg_alias; - std::string Rec709_alias; - mutable spin_rw_mutex m_mutex; - mutable std::string m_error; - ColorProcessorMap colorprocmap; // cache of ColorProcessors - atomic_int colorprocs_requested; - atomic_int colorprocs_created; - std::string m_configname; - ColorConfig* m_self = nullptr; - bool m_config_is_built_in = false; - -public: - Impl(ColorConfig* self) - : m_self(self) - { - } - - ~Impl() - { -#if 0 - // Debugging the cache -- make sure we're creating a small number - // compared to repeated requests. - if (colorprocs_requested) - DBG("ColorConfig::Impl : color procs requested: {}, created: {}\n", - colorprocs_requested, colorprocs_created); -#endif - } - - bool init(string_view filename); - - void add(const std::string& name, int index, int flags = 0) - { - spin_rw_write_lock lock(m_mutex); - colorspaces.emplace_back(name, index, flags); - // classify(colorspaces.back()); - } - - // Find the CSInfo record for the named color space, or nullptr if it's - // not a color space we know. - const CSInfo* find(string_view name) const - { - for (auto&& cs : colorspaces) - if (cs.name == name) - return &cs; - return nullptr; - } - CSInfo* find(string_view name) - { - for (auto&& cs : colorspaces) - if (cs.name == name) - return &cs; - return nullptr; - } - - // Search for a matching ColorProcessor, return it if found (otherwise - // return an empty handle). - ColorProcessorHandle findproc(const ColorProcCacheKey& key) - { - ++colorprocs_requested; - spin_rw_read_lock lock(m_mutex); - auto found = colorprocmap.find(key); - return (found == colorprocmap.end()) ? ColorProcessorHandle() - : found->second; - } - - // Add the given color processor. Be careful -- if a matching one is - // already in the table, just return the existing one. If they pass - // in an empty handle, just return it. - ColorProcessorHandle addproc(const ColorProcCacheKey& key, - ColorProcessorHandle handle) - { - if (!handle) - return handle; - spin_rw_write_lock lock(m_mutex); - auto found = colorprocmap.find(key); - if (found == colorprocmap.end()) { - // No equivalent item in the map. Add this one. - colorprocmap[key] = handle; - ++colorprocs_created; - } else { - // There's already an equivalent one. Oops. Discard this one and - // return the one already in the map. - handle = found->second; - } - return handle; - } - - int getNumColorSpaces() const { return (int)colorspaces.size(); } - - const char* getColorSpaceNameByIndex(int index) const - { - return colorspaces[index].name.c_str(); - } - - string_view resolve(string_view name) const; - - // Note: Uses std::format syntax - template - void error(const char* fmt, const Args&... args) const - { - spin_rw_write_lock lock(m_mutex); - m_error = Strutil::fmt::format(fmt, args...); - } - std::string geterror(bool clear = true) const - { - std::string err; - spin_rw_write_lock lock(m_mutex); - if (clear) { - std::swap(err, m_error); - } else { - err = m_error; - } - return err; - } - bool haserror() const - { - spin_rw_read_lock lock(m_mutex); - return !m_error.empty(); - } - void clear_error() - { - spin_rw_write_lock lock(m_mutex); - m_error.clear(); - } - - const std::string& configname() const { return m_configname; } - void configname(string_view name) { m_configname = name; } - - OCIO::ConstCPUProcessorRcPtr - get_to_builtin_cpu_proc(const char* my_from, const char* builtin_to) const; - - bool isColorSpaceLinear(string_view name) const; - - bool isData(string_view name) const; - -private: - // Return the CSInfo flags for the given color space name - int flags(string_view name) - { - CSInfo* cs = find(name); - if (!cs) - return 0; - examine(cs); - spin_rw_read_lock lock(m_mutex); - return cs->flags(); - } - - // Set cs.flag to include any bits in flagval. - void setflag(CSInfo& cs, int flagval) - { - spin_rw_write_lock lock(m_mutex); - cs.setflag(flagval); - } - - // Set cs.flag to include any bits in flagval, and also if alias is not - // yet set, set it to cs.name. - void setflag(CSInfo& cs, int flagval, std::string& alias) - { - spin_rw_write_lock lock(m_mutex); - cs.setflag(flagval, alias); - } - - void inventory(); - - // Set the flags for the given color space and canonical name, if we can - // make a guess based on the name. This is very inexpensive. This should - // only be called from within a lock of the mutex. - void classify_by_name(CSInfo& cs); - - // Set the flags for the given color space and canonical name, trying some - // tricks to deduce the color space from the primaries, white point, and - // transfer function. This is more expensive, and might only work for OCIO - // 2.2 and above. This should only be called from within a lock of the - // mutex. - void classify_by_conversions(CSInfo& cs); - - // Apply more heuristics to try to deduce more color space information. - void reclassify_heuristics(CSInfo& cs); - - // If the CSInfo hasn't yet been "examined" (fully classified by all - // heuristics), do so. This should NOT be called from within a lock of the - // mutex. - void examine(CSInfo* cs) - { - if (!cs->examined) { - spin_rw_write_lock lock(m_mutex); - if (!cs->examined) { - classify_by_name(*cs); - classify_by_conversions(*cs); - reclassify_heuristics(*cs); - cs->examined = true; - } - } - } - - void debug_print_aliases() - { - DBG("Aliases: scene_linear={} lin_srgb={} srgb={} ACEScg={} Rec709={}\n", - scene_linear_alias, lin_srgb_alias, srgb_alias, ACEScg_alias, - Rec709_alias); - } - - // For OCIO 2.3+, we can ask for the equivalent of some built-in - // color spaces. - void identify_builtin_equivalents(); - - bool check_same_as_builtin_transform(const char* my_from, - const char* builtin_to) const; - bool test_conversion_yields(const char* from, const char* to, - cspan test_colors, - cspan result_colors) const; - const char* IdentifyBuiltinColorSpace(const char* name) const; -}; - - // ColorConfig utility to take inventory of the color spaces available. // It sets up knowledge of "linear", "srgb_rec709_scene", "Rec709", etc, @@ -811,6 +523,45 @@ ColorConfig::ColorConfig(string_view filename) { (void)reset(filename); } +ColorConfig::ColorConfig(UninitTag) + : m_impl(new Impl(this)) +{ +} + + + +ColorConfig::ColorConfig(ColorConfig&& other) noexcept + : m_impl(std::move(other.m_impl)) +{ + if (m_impl) + m_impl->set_self(this); +} + + + +ColorConfig& +ColorConfig::operator=(ColorConfig&& other) noexcept +{ + if (this != &other) { + m_impl = std::move(other.m_impl); + if (m_impl) + m_impl->set_self(this); + } + return *this; +} + + + +ColorConfig +ColorConfig::from_text(string_view config_text, string_view working_dir) +{ + ColorConfig cc { UninitTag() }; + (void)cc.m_impl->init_from_text(config_text, working_dir); + return cc; +} + + + ColorConfig::~ColorConfig() {} @@ -862,23 +613,32 @@ fix_config_file_rules(OCIO::ConfigRcPtr& config) +void +ColorConfig::Impl::init_builtin() +{ + try { + auto cfg = OCIO::Config::CreateFromFile("ocio://default"); + OIIO_CONTRACT_ASSERT(cfg); + // Fix up a mutable copy, then freeze it into the const member. + OCIO::ConfigRcPtr builtin = copy_config(cfg); + fix_config_file_rules(builtin); + builtinconfig_ = builtin; + } catch (OCIO::Exception& e) { + error("Error making OCIO built-in config: {}", e.what()); + } +} + + + bool ColorConfig::Impl::init(string_view filename) { OIIO_MAYBE_UNUSED Timer timer; - bool ok = true; auto oldlog = OCIO::GetLoggingLevel(); OCIO::SetLoggingLevel(OCIO::LOGGING_LEVEL_NONE); - try { - auto cfg = OCIO::Config::CreateFromFile("ocio://default"); - OIIO_CONTRACT_ASSERT(cfg); - builtinconfig_ = cfg->createEditableCopy(); - fix_config_file_rules(builtinconfig_); - } catch (OCIO::Exception& e) { - error("Error making OCIO built-in config: {}", e.what()); - } + init_builtin(); // If no filename was specified, use env $OCIO if (filename.empty()) @@ -894,10 +654,14 @@ ColorConfig::Impl::init(string_view filename) configname(filename); auto cfg = OCIO::Config::CreateFromFile( std::string(filename).c_str()); - if (cfg) - config_ = cfg->createEditableCopy(); - if (config_ && Strutil::istarts_with(filename, "ocio://")) - fix_config_file_rules(config_); + if (cfg) { + // Fix up a mutable copy, then freeze it into the const + // member. + OCIO::ConfigRcPtr copy = copy_config(cfg); + if (copy && Strutil::istarts_with(filename, "ocio://")) + fix_config_file_rules(copy); + config_ = copy; + } } catch (OCIO::Exception& e) { error("Error reading OCIO config \"{}\": {}", filename, e.what()); } catch (...) { @@ -906,10 +670,22 @@ ColorConfig::Impl::init(string_view filename) } OCIO::SetLoggingLevel(oldlog); - ok = config_.get() != nullptr; - DBG("OCIO config {} loaded in {:0.2f} seconds\n", filename, timer.lap()); + return finish_init(); +} + + + +bool +ColorConfig::Impl::finish_init() +{ + OIIO_MAYBE_UNUSED Timer timer; + bool ok = config_.get() != nullptr; + + if (!original_config_) + original_config_ = config_; + inventory(); // NOTE: inventory already does classify_by_name @@ -938,7 +714,7 @@ ColorConfig::Impl::init(string_view filename) } #endif debug_print_aliases(); - DBG("OCIO config {} classified in {:0.2f} seconds\n", filename, + DBG("OCIO config {} classified in {:0.2f} seconds\n", configname(), timer.lap()); return ok; @@ -946,6 +722,59 @@ ColorConfig::Impl::init(string_view filename) +bool +ColorConfig::Impl::init_from_config(OCIO::ConstConfigRcPtr config, + string_view name, + OCIO::ConstConfigRcPtr original) +{ + auto oldlog = OCIO::GetLoggingLevel(); + OCIO::SetLoggingLevel(OCIO::LOGGING_LEVEL_NONE); + init_builtin(); + OCIO::SetLoggingLevel(oldlog); + + configname(name); + config_ = std::move(config); + original_config_ = original ? std::move(original) : config_; + return finish_init(); +} + + + +bool +ColorConfig::Impl::init_from_text(string_view config_text, + string_view working_dir) +{ + auto oldlog = OCIO::GetLoggingLevel(); + OCIO::SetLoggingLevel(OCIO::LOGGING_LEVEL_NONE); + + OCIO::ConstConfigRcPtr frozen; + std::string name = "text:(invalid)"; + try { + std::istringstream iss { std::string(config_text) }; + auto cfg = OCIO::Config::CreateFromStream(iss); + if (cfg) { + // Fix up a mutable copy, then freeze it into the const member. + OCIO::ConfigRcPtr copy = copy_config(cfg); + if (!working_dir.empty()) + copy->setWorkingDir(std::string(working_dir).c_str()); + const char* cfgname = copy->getName(); + name = (cfgname && *cfgname) + ? Strutil::fmt::format("text:{}", cfgname) + : std::string("text:(anonymous)"); + frozen = copy; + } + } catch (OCIO::Exception& e) { + error("Error reading OCIO config from text: {}", e.what()); + } catch (...) { + error("Error reading OCIO config from text"); + } + OCIO::SetLoggingLevel(oldlog); + + return init_from_config(std::move(frozen), name); +} + + + bool ColorConfig::reset(string_view filename) { @@ -1407,9 +1236,12 @@ ColorConfig::getDisplayViewColorSpaceName(const std::string& display, try { string_view name = getImpl()->config_->getDisplayViewColorSpaceName( c_str(display), c_str(view)); - // Handle certain Shared View cases + // Handle certain Shared View cases. Return interned storage, not + // c_str(display): the argument may be a temporary (string_view + // callers convert implicitly), and a pointer into it dangles as + // soon as the caller's full-expression ends. if (strcmp(c_str(name), "") == 0) - name = display; + return ustring(display).c_str(); return c_str(name); } catch (OCIO::Exception& e) { DBG("OCIO exception in getDisplayViewColorSpaceName: {}", e.what()); @@ -1502,35 +1334,510 @@ ColorConfig::configname() const -string_view -ColorConfig::resolve(string_view name) const +std::string +ColorConfig::serialize(const SerializeOptions& options) const { - return getImpl()->resolve(name); + OCIO::ConstConfigRcPtr config; + if (getImpl()->config_ && !disable_ocio) + config = options.interopified ? getImpl()->interopifiedConfig() + : getImpl()->config_; + if (!config) { + getImpl()->error( + "ColorConfig::serialize: no {}config is available to serialize", + options.interopified ? "interoperability-repaired " : ""); + return {}; + } + try { + std::ostringstream os; + config->serialize(os); + return os.str(); + } catch (OCIO::Exception& e) { + getImpl()->error("ColorConfig::serialize: {}", e.what()); + } catch (...) { + getImpl()->error( + "ColorConfig::serialize: unknown error in OpenColorIO serialize"); + } + return {}; } -string_view -ColorConfig::Impl::resolve(string_view name) const +bool +ColorConfig::archive(string_view filename, const ArchiveOptions& options) const { - OCIO::ConstConfigRcPtr config = config_; - if (config && !disable_ocio) { + OCIO::ConstConfigRcPtr config; + if (getImpl()->config_ && !disable_ocio) + config = options.interopified ? getImpl()->interopifiedConfig() + : getImpl()->config_; + if (!config) { + getImpl()->error( + "ColorConfig::archive: no {}config is available to archive", + options.interopified ? "interoperability-repaired " : ""); + return false; + } + try { + if (options.working_dir.size()) { + // Archive as if the working directory were the override, without + // touching the frozen member config. + OCIO::ConfigRcPtr copy = copy_config(config); + copy->setWorkingDir(options.working_dir.c_str()); + config = copy; + } + if (!config->isArchivable()) { + getImpl()->error( + "ColorConfig::archive: config \"{}\" is not archivable " + "(it needs a working directory, and every search path and " + "FileTransform source must stay within it){}", + configname(), + options.working_dir.empty() + ? " -- consider passing ArchiveOptions::working_dir" + : ""); + return false; + } + OIIO::ofstream out; + Filesystem::open(out, filename, + std::ios_base::out | std::ios_base::binary + | std::ios_base::trunc); + if (!out) { + getImpl()->error("ColorConfig::archive: could not open \"{}\"", + filename); + return false; + } + config->archive(out); + out.close(); + if (!out) { + getImpl()->error("ColorConfig::archive: error writing \"{}\"", + filename); + return false; + } + return true; + } catch (OCIO::Exception& e) { + getImpl()->error("ColorConfig::archive: {}", e.what()); + } catch (...) { + getImpl()->error( + "ColorConfig::archive: unknown error in OpenColorIO archive"); + } + return false; +} + + + +ColorConfig +ColorConfig::evolve(const EvolveOptions& options) const +{ + ColorConfig cc { UninitTag() }; + + OCIO::ConstConfigRcPtr base; + if (getImpl()->config_ && !disable_ocio) + base = options.reset ? getImpl()->original_config_ : getImpl()->config_; + // The evolved instance's configname marks its provenance (once -- an + // evolve chain doesn't stack suffixes). + std::string name = getImpl()->configname(); + if (!Strutil::ends_with(name, "#evolved")) + name += "#evolved"; + + OCIO::ConstConfigRcPtr modified; + if (!base) { + cc.m_impl->error( + "ColorConfig::evolve: no config is available to evolve"); + } else { try { - OCIO::ConstColorSpaceRcPtr cs = config->getColorSpace(c_str(name)); - if (cs) - return cs->getName(); + OCIO::ConfigRcPtr copy = copy_config(base); + if (options.working_dir.size()) + copy->setWorkingDir(options.working_dir.c_str()); + for (const auto& kv : options.context) + copy->addEnvironmentVar(kv.first.c_str(), kv.second.c_str()); + modified = copy; } catch (OCIO::Exception& e) { - DBG("OCIO exception in resolve: {}", e.what()); + cc.m_impl->error("ColorConfig::evolve: {}", e.what()); + } catch (...) { + cc.m_impl->error( + "ColorConfig::evolve: unknown error in OpenColorIO"); } } - // OCIO did not know this name as a color space, role, or alias. + // Adopt the modified copy (or, on failure, initialize the usual + // failed-config fallback state with the error preserved). The evolved + // instance inherits this config's ORIGINAL as its reset root. + (void)cc.m_impl->init_from_config(std::move(modified), name, + getImpl()->original_config_); + return cc; +} - // Maybe it's an informal alias of common names? - spin_rw_write_lock lock(m_mutex); - if ((Strutil::iequals(name, "sRGB") - || Strutil::iequals(name, "srgb_rec709_scene")) - && !srgb_alias.empty()) - return srgb_alias; + + +ColorConfigDebugInfo +ColorConfig::get_debug_info(const DebugInfoOptions& /*options*/) const +{ + const Impl* impl = getImpl(); + ColorConfigDebugInfo info; + info.oiio_version = OIIO_VERSION_STRING; + info.ocio_version = OCIO::GetVersion(); + info.config_name = configname(); + info.registry_data_version = interop_registry_data_version(); + if (impl->config_ && !disable_ocio) { + info.structural_cache_id = get_config_cache_id(impl->config_); + try { + if (const char* id = impl->config_->getCacheID()) + info.cache_id = id; + } catch (...) { + } + } + // Interchange discovery: report the existing state, never trigger the + // lazy bootstrap. + if (!impl->interopComputed()) + info.interchange_state = ColorInterchangeState::Pending; + else if (impl->interopIsInteroperable()) { + info.interchange_state = ColorInterchangeState::Interoperable; + info.interchange_name = impl->interopInterchangeName(); + } else + info.interchange_state = ColorInterchangeState::NotFound; + info.cache_entries["color processors"] = impl->processorCacheSize(); + info.cache_entries["color processors requested"] = std::size_t( + std::max(0, impl->processorsRequested())); + info.cache_entries["color processors created"] = std::size_t( + std::max(0, impl->processorsCreated())); + info.cache_entries["fingerprints"] + = OIIO::pvt::color_space_fingerprint_cache_size(); + info.cache_entries["characterizations"] + = OIIO::pvt::characterization_cache_size(); + return info; +} + + + +std::string +ColorConfigDebugInfo::to_string() const +{ + using Strutil::fmt::format; + std::string out; + out += format("OpenImageIO {} / OpenColorIO {}\n", oiio_version, + ocio_version); + out += format("config: \"{}\"\n", config_name); + out += format(" structural cache id: {}\n", + structural_cache_id.size() ? structural_cache_id : "(none)"); + out += format(" cache id (context folded in): {}\n", + cache_id.size() ? cache_id : "(none)"); + switch (interchange_state) { + case ColorInterchangeState::Pending: + out += "interchange discovery: pending (not yet queried)\n"; + break; + case ColorInterchangeState::Interoperable: + out += format( + "interchange discovery: interoperable (scene interchange \"{}\")\n", + interchange_name); + break; + case ColorInterchangeState::NotFound: + out += "interchange discovery: no scene interchange identified\n"; + break; + } + out += format("interop registry data: {}\n", registry_data_version); + out += "caches:\n"; + for (const auto& kv : cache_entries) + out += format(" {}: {} entries\n", kv.first, kv.second); + return out; +} + + + +void +ColorConfig::clear_caches(const ClearCachesOptions& /*options*/) const +{ + // This instance's processor cache and per-query hints. + getImpl()->clearInstanceCaches(); + // The process-global memo entries scoped to this config's structural + // identity. Shared process data not scoped to it (the built-in interop + // registry and its fingerprint index) is untouched. + if (getImpl()->config_ && !disable_ocio) { + const std::string cfgId = get_config_cache_id(getImpl()->config_); + if (cfgId.size()) { + fingerprint_cache_erase_config(cfgId); + characterization_cache_erase_config(cfgId); + } + } +} + + + +string_view +ColorConfig::resolve(string_view name) const +{ + return getImpl()->resolve(name); +} + + + +string_view +ColorConfig::resolve(string_view name, string_view failover) const +{ + return getImpl()->resolve(name, failover); +} + + + +namespace { + +// Helpers for the interop-ID resolution tiers layered onto resolve(). They +// only read the OCIO config and the pure grammar/sanitization functions from +// color_pvt.h; none of them touch the interoperability bootstrap or the +// fingerprint engine. Every string_view they return is backed by an OCIO-owned +// color space name (stable for the life of the config), never a temporary. + +// The UNIQUE owner of a sanitized base token among the config's color space +// names and aliases (across all reference types and visibilities): the owning +// space's name when exactly one space claims `token`, empty when none or more +// than one do. The sanitizer is many-to-one ("Foo Bar" and "foo_bar" both map +// to "foo_bar"), so this is the shared ambiguity guard for BOTH sides of the +// config-local id form: generation refuses to serialize an id that resolution +// could not uniquely reverse, and resolution refuses to guess among colliding +// spaces (the never-guess rule). Linear scan per query; cache an inverted map +// if either side ever shows up hot. +string_view +unique_space_for_sanitized_token(const OCIO::ConstConfigRcPtr& config, + const std::string& token) +{ + int n = 0; + try { + n = config->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL); + } catch (...) { + return {}; + } + string_view found; + for (int i = 0; i < n; ++i) { + const char* nm + = config->getColorSpaceNameByIndex(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL, i); + if (!nm || !*nm) + continue; + OCIO::ConstColorSpaceRcPtr cs; + try { + cs = config->getColorSpace(nm); + } catch (...) { + continue; + } + if (!cs) + continue; + bool claims = OIIO::pvt::sanitize_id_token(cs->getName()) == token; + for (int a = 0, ae = cs->getNumAliases(); !claims && a < ae; ++a) + claims = OIIO::pvt::sanitize_id_token(cs->getAlias(a)) == token; + if (!claims) + continue; + if (!found.empty()) + return {}; // a second owner: the token is ambiguous + found = cs->getName(); + } + return found; +} + +// Tier 1a'' -- the config-local form ":local:". Resolves only +// when the id parses as outer:local:base and `outer` sanitizes to this config's +// own name; then it matches `base` against every color space's sanitized name +// or alias (across all reference types and visibilities), refusing an +// ambiguous token via the unique-owner guard above. Deliberately never +// consults a color space's interop_id attribute -- that is tier 1c's job. +string_view +resolve_local_namespace(const OCIO::ConstConfigRcPtr& config, string_view name) +{ + OIIO::pvt::InteropIdParts parts = OIIO::pvt::parse_interop_id( + std::string(name)); + if (parts.form != OIIO::pvt::InteropIdForm::OUTER_INNER_BASE + || parts.inner != "local") + return {}; + const char* cfgname = config->getName(); + if (!cfgname || !*cfgname) + return {}; + if (OIIO::pvt::sanitize_id_token(cfgname) != parts.outer) + return {}; + return unique_space_for_sanitized_token(config, parts.base); +} + +// Tier 1c -- match `name` against a color space's explicit interop_id attribute +// (OCIO 2.5+). A match requires the attribute to equal the query exactly, or to +// match with exactly ONE side's leftmost namespace stripped (never both -- so +// "oiio:x" does not false-match a query for "ocio:x" just because both strip to +// "x"). Utility tokens (data/unknown/bypass) are excluded from this lookup +// entirely: declaring interop_id: bypass must not make a space reachable by +// querying "bypass". Attributes in the reserved `local` namespace are +// likewise excluded (see below). +string_view +resolve_explicit_interop_id(const OCIO::ConstConfigRcPtr& config, + string_view name) +{ +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + std::string id(name); + std::string id_stripped = OIIO::pvt::strip_leftmost_namespace(id); + // Linear scan over the catalog; add a cached inverted map if this tier gets hot. + // only fires when the direct/stripped/local tiers all miss (a rare path), + // so a per-query scan is cheaper than maintaining a cache. Build the map if + // this ever shows up hot. + int n = 0; + try { + n = config->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL); + } catch (...) { + return {}; + } + for (int i = 0; i < n; ++i) { + const char* nm + = config->getColorSpaceNameByIndex(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL, i); + if (!nm || !*nm) + continue; + OCIO::ConstColorSpaceRcPtr cs; + try { + cs = config->getColorSpace(nm); + } catch (...) { + continue; + } + if (!cs) + continue; + const char* iid = cs->getInteropID(); + if (!iid || !*iid) + continue; + std::string attr(iid); + if (OIIO::pvt::is_utility_interop_id(attr)) + continue; + // The `local` namespace is reserved end-to-end for the config-local + // ":local:" form. A declared attribute whose leftmost + // segment is `local` must never satisfy this tier: honoring a + // grammar-legal "local:x" here would let one config poach another + // config's private local IDs through the stripped-attribute match + // below ("othercfg:local:x" strips to "local:x"). + if (attr == "local" || Strutil::starts_with(attr, "local:")) + continue; + if (attr == id || attr == id_stripped + || OIIO::pvt::strip_leftmost_namespace(attr) == id) + return cs->getName(); + } +#else + (void)config; + (void)name; +#endif + return {}; +} + +// The color space name that `token` (a name, alias, or role) resolves to in +// `config`, or empty if it resolves to nothing. +std::string +resolve_token_colorspace_name(const OCIO::ConstConfigRcPtr& config, + const char* token) +{ + try { + OCIO::ConstColorSpaceRcPtr c = config->getColorSpace(token); + return c ? std::string(c->getName()) : std::string(); + } catch (...) { + return std::string(); + } +} + +// Whether a data color space `cs` (name `nm`) identifies as `token` -- either +// via its explicit interop_id attribute (OCIO 2.5+), or because `token`'s +// name/alias/role resolution (precomputed as `token_target`) lands on it. +bool +data_space_identifies_as(const OCIO::ConstColorSpaceRcPtr& cs, const char* nm, + string_view token, const std::string& token_target) +{ +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + const char* iid = cs->getInteropID(); + if (iid && *iid && token == string_view(iid)) + return true; +#endif + return !token_target.empty() && token_target == nm; +} + +// Utility-token preference for the literal queries "data" and "bypass": rank +// every data color space and return the lowest-ranked one. rank 0 = identifies +// as the requested token, rank 1 = plain data space (no identity), rank 2 = +// identifies as the other token (data<->bypass), rank 3 = identifies as +// "unknown"; lowest rank wins and a rank-0 hit short-circuits. ("unknown" is +// intentionally not routed here -- it only ever resolves as a literal +// name/alias, handled by tier 1a.) +string_view +resolve_data_utility(const OCIO::ConstConfigRcPtr& config, + string_view requested) +{ + const char* req = requested == "data" ? "data" : "bypass"; + const char* other = requested == "data" ? "bypass" : "data"; + std::string req_target = resolve_token_colorspace_name(config, req); + std::string other_target = resolve_token_colorspace_name(config, other); + std::string unk_target = resolve_token_colorspace_name(config, "unknown"); + + const char* best = nullptr; + int best_rank = 4; // worse than any real rank (0..3) + int n = 0; + try { + n = config->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL); + } catch (...) { + return {}; + } + for (int i = 0; i < n; ++i) { + const char* nm + = config->getColorSpaceNameByIndex(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL, i); + if (!nm || !*nm) + continue; + OCIO::ConstColorSpaceRcPtr cs; + try { + cs = config->getColorSpace(nm); + } catch (...) { + continue; + } + if (!cs || !cs->isData()) + continue; + // A one-space OCIO::Config::CreateRaw() config is nothing but a + // synthetic data space named "raw". Skip that space so an empty/raw + // config does not spuriously resolve "data"/"bypass" to it -- but key + // the skip on the config's single-colorspace shape (n == 1), not the + // name alone: a real config may legitimately hold a data space named + // "Raw" alongside others, and that one is a valid target. A role + // explicitly naming the requested token still wins. + if (n == 1 && Strutil::iequals(nm, "raw") && req_target != nm) + continue; + int rank; + if (data_space_identifies_as(cs, nm, req, req_target)) + rank = 0; + else if (data_space_identifies_as(cs, nm, other, other_target)) + rank = 2; + else if (data_space_identifies_as(cs, nm, "unknown", unk_target)) + rank = 3; + else + rank = 1; // plain data space, no identity + if (rank < best_rank) { + best_rank = rank; + best = cs->getName(); + if (rank == 0) + break; + } + } + return best ? string_view(best) : string_view(); +} + +} // namespace + + + +string_view +ColorConfig::Impl::resolve_name_tier1a(string_view name) const +{ + OCIO::ConstConfigRcPtr config = config_; + if (config && !disable_ocio) { + try { + OCIO::ConstColorSpaceRcPtr cs = config->getColorSpace(c_str(name)); + if (cs) + return cs->getName(); + } catch (OCIO::Exception& e) { + DBG("OCIO exception in resolve: {}", e.what()); + } + } + // OCIO did not know this name as a color space, role, or alias. + + // Maybe it's an informal alias of common names? + spin_rw_write_lock lock(m_mutex); + if ((Strutil::iequals(name, "sRGB") + || Strutil::iequals(name, "srgb_rec709_scene")) + && !srgb_alias.empty()) + return srgb_alias; if ((Strutil::iequals(name, "lin_srgb") || Strutil::iequals(name, "lin_rec709") || Strutil::iequals(name, "lin_rec709_scene") @@ -1547,7 +1854,122 @@ ColorConfig::Impl::resolve(string_view name) const if (Strutil::iequals(name, "Rec709") && Rec709_alias.size()) return Rec709_alias; - return name; + return {}; +} + + + +string_view +ColorConfig::Impl::resolve_syntactic(string_view name) const +{ + // Tier 1a: direct OCIO color space / role / alias, then informal aliases. + if (string_view r = resolve_name_tier1a(name); !r.empty()) + return r; + + // Tier 1a': stripped-namespace retry. Only meaningful when the name carries + // a namespace to strip. strip_leftmost_namespace() consumes exactly one + // leading ":"; note "my-studio::srgb" strips to ":srgb" (the blank inner + // colon survives), which deliberately will NOT match "srgb". + if (name.find(':') != string_view::npos) { + std::string stripped = OIIO::pvt::strip_leftmost_namespace( + std::string(name)); + if (string_view r = resolve_name_tier1a(stripped); !r.empty()) + return r; + } + + // The remaining tiers all consult the OCIO config directly. + if (config_ && !disable_ocio) { + // Tier 1a'': config-local form ":local:". + if (string_view r = resolve_local_namespace(config_, name); !r.empty()) + return r; + + // Tier 1c: a color space's explicit interop_id attribute (OCIO 2.5+). + if (string_view r = resolve_explicit_interop_id(config_, name); + !r.empty()) + return r; + + // Utility tokens: "data"/"bypass" resolve to a ranked data color space. + // "unknown" is intentionally not ranked -- it only ever resolves via the + // literal name/alias match already attempted in tier 1a. + if (name == "data" || name == "bypass") + if (string_view r = resolve_data_utility(config_, name); !r.empty()) + return r; + } + + return {}; +} + + + +string_view +ColorConfig::Impl::resolve(string_view name, string_view failover) const +{ + // Every syntactic (fingerprint-free) tier first. + if (string_view r = resolve_syntactic(name); !r.empty()) + return r; + + if (config_ && !disable_ocio) { + // Tier 2: registry equivalence (fingerprint match). Canonicalize the id + // through the built-in interop identities registry and return this + // config's OWN equivalent simple color space -- the user's own space + // wins over building any cross-config processor (that is a later, + // separate feature; this tier returns only names). Utility tokens have + // no registry fingerprint and miss automatically. This is the first tier + // that fingerprints, so it lazily builds the process-global registry + // index and this config's probe/bootstrap state on first use; every + // earlier tier -- and ColorConfig construction -- stays fingerprint-free. + // The const_cast reaches the memoizing (logically-const) fingerprint + // caches, mirroring getImpl()'s non-const handle onto a const config. + if (string_view r = const_cast(this) + ->resolve_registry_equivalence(name); + !r.empty()) + return r; + } + + // Total miss: the caller decides. The 1-arg public resolve() passes the + // input name, preserving OIIO's historical passthrough so callers that + // assumed identity resolution keep working. + return failover; +} + + + +bool +ColorConfig::Impl::equivalent_syntactic(string_view color_space1, + string_view color_space2) const +{ + // Mirrors ColorConfig::equivalent() exactly, except resolution is + // resolve_syntactic() -- so a match can come from names, roles, aliases + // (formal or informal), a declared interop_id, or the cheap + // classification flags, but NEVER from the fingerprint tier. + if (color_space1.empty() || color_space2.empty()) + return false; + if (Strutil::iequals(color_space1, color_space2)) + return true; + + string_view r1 = resolve_syntactic(color_space1); + if (!r1.empty()) + color_space1 = r1; + string_view r2 = resolve_syntactic(color_space2); + if (!r2.empty()) + color_space2 = r2; + if (Strutil::iequals(color_space1, color_space2)) + return true; + + const int mask = CSInfo::is_srgb | CSInfo::is_lin_srgb | CSInfo::is_ACEScg + | CSInfo::is_Rec709; + const CSInfo* csi1 = find(color_space1); + const CSInfo* csi2 = find(color_space2); + if (csi1 && csi2) { + int flags1 = csi1->flags() & mask; + int flags2 = csi2->flags() & mask; + if ((flags1 | flags2) && csi1->flags() == csi2->flags()) + return true; + if ((csi1->canonical.size() && csi2->canonical.size()) + && Strutil::iequals(csi1->canonical, csi2->canonical)) + return true; + } + return false; } @@ -1621,104 +2043,6 @@ ocio_bitdepth(TypeDesc type) -// Custom ColorProcessor that wraps an OpenColorIO Processor. -class ColorProcessor_OCIO final : public ColorProcessor { -public: - ColorProcessor_OCIO(OCIO::ConstProcessorRcPtr p) - : m_p(p) - , m_cpuproc(p->getDefaultCPUProcessor()) - { - } - ~ColorProcessor_OCIO() override {} - - bool isNoOp() const override { return m_p->isNoOp(); } - bool hasChannelCrosstalk() const override - { - return m_p->hasChannelCrosstalk(); - } - void apply(float* data, int width, int height, int channels, - stride_t chanstride, stride_t xstride, - stride_t ystride) const override - { - try { - OCIO::PackedImageDesc pid(data, width, height, channels, - OCIO::BIT_DEPTH_F32, // For now, only float - chanstride, xstride, ystride); - m_cpuproc->apply(pid); - } catch (OCIO::Exception& e) { - OIIO::errorfmt("OCIO error in apply: {}\n", e.what()); - // FIXME -- some day, we should make ColorProcessor::apply return - // a status, and we should indicate here that it failed. - } - } - -private: - OCIO::ConstProcessorRcPtr m_p; - OCIO::ConstCPUProcessorRcPtr m_cpuproc; -}; - - - -// ColorProcessor that implements a matrix multiply color transformation. -class ColorProcessor_Matrix final : public ColorProcessor { -public: - ColorProcessor_Matrix(const Imath::M44f& Matrix, bool inverse) - : ColorProcessor() - , m_M(Matrix) - { - if (inverse) - m_M = m_M.inverse(); - } - ~ColorProcessor_Matrix() override {} - - void apply(float* data, int width, int height, int channels, - stride_t chanstride, stride_t xstride, - stride_t ystride) const override - { - using namespace simd; - if (channels == 3 && chanstride == sizeof(float)) { - for (int y = 0; y < height; ++y) { - char* d = (char*)data + y * ystride; - for (int x = 0; x < width; ++x, d += xstride) { - vfloat4 color; - color.load((float*)d, 3); - vfloat4 xcolor = color * m_M; - xcolor.store((float*)d, 3); - } - } - } else if (channels >= 4 && chanstride == sizeof(float)) { - for (int y = 0; y < height; ++y) { - char* d = (char*)data + y * ystride; - for (int x = 0; x < width; ++x, d += xstride) { - vfloat4 color; - color.load((float*)d); - vfloat4 xcolor = color * m_M; - xcolor.store((float*)d); - } - } - } else { - channels = std::min(channels, 4); - for (int y = 0; y < height; ++y) { - char* d = (char*)data + y * ystride; - for (int x = 0; x < width; ++x, d += xstride) { - vfloat4 color; - char* dc = d; - for (int c = 0; c < channels; ++c, dc += chanstride) - color[c] = *(float*)dc; - vfloat4 xcolor = color * m_M; - for (int c = 0; c < channels; ++c, dc += chanstride) - *(float*)dc = xcolor[c]; - } - } - } - } - -private: - simd::matrix44 m_M; -}; - - - ColorProcessorHandle ColorConfig::createColorProcessor(string_view inputColorSpace, string_view outputColorSpace, @@ -1738,14 +2062,24 @@ ColorConfig::createColorProcessor(ustring inputColorSpace, ustring context_value) const { std::string pending_error; + std::string lenient_fallback_msg; // First, look up the requested processor in the cache. If it already // exists, just return it. ColorProcCacheKey prockey(inputColorSpace, outputColorSpace, context_key, context_value); ColorProcessorHandle handle = getImpl()->findproc(prockey); - if (handle) + if (handle) { + // A cached lenient cross-config fallback must behave exactly like + // its first computation: re-signal its continue-message so this + // call's outcome (pixels will NOT be converted) is observable per + // call, not lost to whoever consumed the shared error string first. + std::string fallback = getImpl()->lenient_fallback_message( + handle.get()); + if (fallback.size()) + getImpl()->error("{}", fallback); return handle; + } // DBG("createColorProcessor {} -> {}\n", inputColorSpace, // outputColorSpace); @@ -1801,6 +2135,30 @@ ColorConfig::createColorProcessor(ustring inputColorSpace, } // DBG("OCIO processor '{}' -> '{}' is NOT NoOp, handle = {}\n", // inputColorSpace, outputColorSpace, (bool)handle); + } else if (!p) { + // Local resolution failed (OCIO threw for a name this config does + // not define). If a requested name is a registry-known interop + // identity the config lacks, reconcile the conversion across configs + // through the interop bridge. This is deliberately on by default + // (observable via debug log), with OCIO strict_parsing as the + // opt-out. This may return a bridged processor, a lenient + // pass-through fallback (non-strict parsing), or nothing -- + // leaving today's error -- when the feature does not apply or + // strict parsing is on. + std::string reconciled; + ColorProcessorHandle bridged = getImpl()->reconcile_cross_config( + inputColorSpace, outputColorSpace, reconciled); + if (bridged) { + handle = bridged; + if (reconciled.empty()) + getImpl()->clear_error(); // clean bridge success + pending_error = reconciled; // empty on success; a + // continue-message on fallback + lenient_fallback_msg = reconciled; // ditto (registered below) + } else if (reconciled.size()) { + pending_error = reconciled; // strict hard error: why+how-to-fix + } + // else: reconciliation declined -- keep today's OCIO error. } } @@ -1817,7 +2175,10 @@ ColorConfig::createColorProcessor(ustring inputColorSpace, if (pending_error.size()) getImpl()->error("{}", pending_error); - return getImpl()->addproc(prockey, handle); + return getImpl()->addproc(prockey, handle, + lenient_fallback_msg.size() + ? &lenient_fallback_msg + : nullptr); } @@ -1933,13 +2294,23 @@ ColorConfig::createDisplayTransform(ustring display, ustring view, ustring() /*file*/, ustring() /*namedtransform*/, inverse); ColorProcessorHandle handle = getImpl()->findproc(prockey); - if (handle) + if (handle) { + // A cached lenient cross-config fallback must behave exactly like + // its first computation: re-signal its continue-message (see + // createColorProcessor for the identical pattern). + std::string fallback = getImpl()->lenient_fallback_message( + handle.get()); + if (fallback.size()) + getImpl()->error("{}", fallback); return handle; + } + std::string lenient_fallback_msg; // Ask OCIO to make a Processor that can handle the requested // transformation. if (getImpl()->config_ && !disable_ocio) { OCIO::ConstConfigRcPtr config = getImpl()->config_; + std::string pending_error; try { auto transform = OCIO::DisplayViewTransform::Create(); auto legacy_viewing_pipeline = OCIO::LegacyViewingPipeline::Create(); @@ -1971,14 +2342,45 @@ ColorConfig::createDisplayTransform(ustring display, ustring view, getImpl()->clear_error(); handle = ColorProcessorHandle(new ColorProcessor_OCIO(p)); } catch (OCIO::Exception& e) { - getImpl()->error(e.what()); + // Local resolution failed (OCIO threw for an input name this config + // does not define). If the input is a registry-known interop + // identity the config lacks, reconcile the display transform across + // configs through the interop bridge -- on by default, observable + // via debug log, with OCIO strict_parsing as the opt-out -- mirroring + // the color-space route. Crucially, on failure this takes the + // strict-aware fallback -- it does NOT silently continue with + // setSrc(ROLE_SCENE_LINEAR) as an earlier iteration of this display + // path did. + pending_error = e.what(); + std::string reconciled; + ColorProcessorHandle bridged + = getImpl()->reconcile_cross_config_display(inputColorSpace, + display, view, + inverse, + reconciled); + if (bridged) { + handle = bridged; + if (reconciled.empty()) + getImpl()->clear_error(); // clean bridge success + pending_error = reconciled; // empty on success; a + // continue-message on fallback + lenient_fallback_msg = reconciled; // ditto (registered below) + } else if (reconciled.size()) { + pending_error = reconciled; // strict hard error: why+how-to-fix + } + // else: reconciliation declined -- keep today's OCIO error. } catch (...) { getImpl()->error( "An unknown error occurred in OpenColorIO, getProcessor"); } + if (pending_error.size()) + getImpl()->error("{}", pending_error); } - return getImpl()->addproc(prockey, handle); + return getImpl()->addproc(prockey, handle, + lenient_fallback_msg.size() + ? &lenient_fallback_msg + : nullptr); } @@ -2144,65 +2546,628 @@ ColorConfig::getColorSpaceFromFilepath(string_view str, string_view default_cs, DBG("OCIO exception in getColorSpaceFromFilepath: {}", e.what()); } } - if (cs_name_match) { - string_view parsed = parseColorSpaceFromString(str); - if (parsed.size()) - return parsed; - } - return default_cs; -} + if (cs_name_match) { + string_view parsed = parseColorSpaceFromString(str); + if (parsed.size()) + return parsed; + } + return default_cs; +} + +bool +ColorConfig::filepathOnlyMatchesDefaultRule(string_view str) const +{ + try { + return getImpl()->config_->filepathOnlyMatchesDefaultRule(c_str(str)); + } catch (OCIO::Exception& e) { + DBG("OCIO exception in filepathOnlyMatchesDefaultRule: {}", e.what()); + } + return false; +} + +string_view +ColorConfig::parseColorSpaceFromString(string_view str) const +{ + // Reproduce the logic in OCIO v1 parseColorSpaceFromString + + if (str.empty()) + return ""; + + // Get the colorspace names, sorted shortest-to-longest + auto names = getColorSpaceNames(); + std::sort(names.begin(), names.end(), + [](const std::string& a, const std::string& b) { + return a.length() < b.length(); + }); + + // See if it matches a LUT name. + // This is the position of the RIGHT end of the colorspace substring, + // not the left + size_t rightMostColorPos = std::string::npos; + std::string rightMostColorspace; + + // Find the right-most occurrence within the string for each colorspace. + for (auto&& csname : names) { + // find right-most extension matched in filename + size_t pos = Strutil::irfind(str, csname); + if (pos == std::string::npos) + continue; + + // If we have found a match, move the pointer over to the right end + // of the substring. This will allow us to find the longest name + // that matches the rightmost colorspace + pos += csname.size(); + + if (rightMostColorPos == std::string::npos + || pos >= rightMostColorPos) { + rightMostColorPos = pos; + rightMostColorspace = csname; + } + } + return string_view(ustring(rightMostColorspace)); +} + + +////////////////////////////////////////////////////////////////////////// +// +// Color-space classification: the "simple" transform allowlist and the lazy +// per-space analysis pass that sets the CSInfo classification bits. + +// Shared classification of atomic (non-structural) transform types. Returns +// true when the transform is simple enough for interop matching. GROUP, +// FILE, and COLORSPACE are structural -- they need recursive or deferred +// handling that differs by caller, so callers handle those themselves. This +// is the single policy switch shared by the recursive authored-transform +// scan (containsBlockableTransform) and, in the future, op-by-op inspection +// of a realized GroupTransform. +bool +isSimpleAtomicTransform(const OCIO::ConstTransformRcPtr& transform) +{ + using namespace OCIO; + if (!transform) + return true; + switch (transform->getTransformType()) { + case TRANSFORM_TYPE_LUT3D: + case TRANSFORM_TYPE_CDL: + case TRANSFORM_TYPE_LOOK: + case TRANSFORM_TYPE_DISPLAY_VIEW: return false; + + case TRANSFORM_TYPE_BUILTIN: { + auto builtin = DynamicPtrCast(transform); + if (builtin) { + string_view style(builtin->getStyle() ? builtin->getStyle() : ""); + // ACES rendering pipelines (output + LMT) carry rendering, tone + // mapping, and look logic that defeats the primaries+TF + // fingerprint. "DISPLAY - CIE-XYZ-D65_to_*" builtins, by + // contrast, are pure display encodings (matrix + transfer + // function) and fingerprint cleanly -- keep them simple. + if (Strutil::starts_with(style, "ACES-OUTPUT") + || Strutil::starts_with(style, "ACES-LMT")) + return false; + } + return true; + } + + case TRANSFORM_TYPE_FIXED_FUNCTION: { +#if OCIO_VERSION_HEX <= MAKE_OCIO_VERSION_HEX(2, 4, 0) + return false; +#else + auto ff = DynamicPtrCast(transform); + if (ff) { + const auto style = ff->getStyle(); + if (style == FIXED_FUNCTION_LIN_TO_DOUBLE_LOG + || style == FIXED_FUNCTION_LIN_TO_GAMMA_LOG) + return true; + } + return false; +#endif + } + + case TRANSFORM_TYPE_MATRIX: + case TRANSFORM_TYPE_RANGE: + case TRANSFORM_TYPE_EXPONENT: + case TRANSFORM_TYPE_EXPONENT_WITH_LINEAR: + case TRANSFORM_TYPE_LOG: + case TRANSFORM_TYPE_LOG_AFFINE: + case TRANSFORM_TYPE_LOG_CAMERA: + case TRANSFORM_TYPE_ALLOCATION: + case TRANSFORM_TYPE_LUT1D: +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 3, 0) + case TRANSFORM_TYPE_GRADING_RGB_CURVE: +#endif + return true; + + default: + // Unknown/unclassified transform types are not simple. + return false; + } +} + + +namespace { +using namespace OCIO; + + +// Does any string authored into this transform reference a context var, or +// could a FileTransform in it resolve through a context-dependent search +// path? Direct (non-recursive) scan of the authored transform only, except +// recursing through GROUP. +// For now a '$'-substring scan stands in for per-search-path-entry analysis; +// upgrade to per-entry var analysis if configs with mixed var/non-var search +// paths need finer verdicts. +bool +transformUsesContextVars(const ConstTransformRcPtr& transform, + bool search_path_has_vars) +{ + if (!transform) + return false; + auto has_var = [](const char* s) { return s && Strutil::contains(s, "$"); }; + switch (transform->getTransformType()) { + case TRANSFORM_TYPE_FILE: { + auto ft = DynamicPtrCast(transform); + if (!ft) + return false; + return has_var(ft->getSrc()) || has_var(ft->getCCCId()) + || search_path_has_vars; + } + case TRANSFORM_TYPE_GROUP: { + auto gt = DynamicPtrCast(transform); + for (int i = 0, e = gt ? gt->getNumTransforms() : 0; i < e; ++i) + if (transformUsesContextVars(gt->getTransform(i), + search_path_has_vars)) + return true; + return false; + } + case TRANSFORM_TYPE_COLORSPACE: { + auto cst = DynamicPtrCast(transform); + if (!cst) + return false; + return has_var(cst->getSrc()) || has_var(cst->getDst()); + } + default: return false; + } +} + +bool +fileTransformIsBlockable(const ConstFileTransformRcPtr& fileTransform) +{ + if (!fileTransform) { + return true; + } + const char* src = fileTransform->getSrc(); + if (!src || !*src) { + return true; + } + if (Strutil::iends_with(src, ".spi1d") + || Strutil::iends_with(src, ".spimtx")) { + return false; + } + return true; +} + +bool +containsBlockableTransform(const ConstConfigRcPtr& config, + const ConstContextRcPtr& context, + const ConstTransformRcPtr& transform, + std::unordered_set& keep, + std::unordered_set& omit); +bool +containsBlockableTransform(const ConstConfigRcPtr& config, + const ConstContextRcPtr& context, const char* name, + std::unordered_set& keep, + std::unordered_set& omit); +bool +containsBlockableTransform(const ConstConfigRcPtr& config, + const ConstTransformRcPtr& transform, + std::unordered_set& keep, + std::unordered_set& omit); + +// Walk every color space, keeping the ones with only simple transforms. +// keep/omit memoize spaces already scanned so referenced sub-transforms +// aren't rescanned. +std::unordered_set +scan_simple_color_space_names(const ConstConfigRcPtr& config) +{ + std::unordered_set keep; + if (!config) { + return keep; + } + + std::unordered_set omit; + ConstContextRcPtr ctx = config->getCurrentContext(); + + const int n = config->getNumColorSpaces(SEARCH_REFERENCE_SPACE_ALL, + COLORSPACE_ALL); + for (int i = 0; i < n; ++i) { + const char* name + = config->getColorSpaceNameByIndex(SEARCH_REFERENCE_SPACE_ALL, + COLORSPACE_ALL, i); + if (!name || !*name) { + continue; + } + if (keep.count(name) || omit.count(name)) { + continue; + } + + if (containsBlockableTransform(config, ctx, name, keep, omit)) { + omit.insert(name); + } else { + keep.insert(name); + } + } + + return keep; +} + +bool +colorSpaceHasBlockableTransform(const ConstConfigRcPtr& config, + const ConstColorSpaceRcPtr& cs, + std::unordered_set& keep, + std::unordered_set& omit) +{ + if (!cs) { + return true; + } + const char* csName = cs->getName(); + if (cs->isData()) { + if (csName && *csName) + omit.insert(csName); + return true; + } + if (csName && keep.count(csName)) { + return false; + } + + ConstTransformRcPtr toRef = cs->getTransform(COLORSPACE_DIR_TO_REFERENCE); + if (toRef && containsBlockableTransform(config, toRef, keep, omit)) { + if (csName && *csName) + omit.insert(csName); + return true; + } + + ConstTransformRcPtr fromRef = cs->getTransform( + COLORSPACE_DIR_FROM_REFERENCE); + if (fromRef && containsBlockableTransform(config, fromRef, keep, omit)) { + if (csName && *csName) + omit.insert(csName); + return true; + } + + if (csName && *csName) + keep.insert(csName); + return false; +} + +bool +namedTransformHasBlockableTransform(const ConstConfigRcPtr& config, + const ConstNamedTransformRcPtr& nt, + std::unordered_set& keep, + std::unordered_set& omit) +{ + if (!nt) { + return true; + } + ConstTransformRcPtr fwd = nt->getTransform(TRANSFORM_DIR_FORWARD); + if (fwd && containsBlockableTransform(config, fwd, keep, omit)) { + return true; + } + ConstTransformRcPtr rev = nt->getTransform(TRANSFORM_DIR_INVERSE); + if (rev && containsBlockableTransform(config, rev, keep, omit)) { + return true; + } + return false; +} + +bool +containsBlockableTransform(const ConstConfigRcPtr& config, + const ConstContextRcPtr& context, const char* name, + std::unordered_set& keep, + std::unordered_set& omit) +{ + if (!name || !*name) { + return true; + } + ConstContextRcPtr ctx = context ? context : config->getCurrentContext(); + auto name_cs = ctx->resolveStringVar(c_str(name)); + + + ConstColorSpaceRcPtr cs = config->getColorSpace(c_str(name_cs)); + if (cs) { + if (omit.count(c_str(cs->getName()))) { + return true; + } + if (keep.count(c_str(cs->getName()))) { + return false; + } + return colorSpaceHasBlockableTransform(config, cs, keep, omit); + } + + ConstNamedTransformRcPtr nt = config->getNamedTransform(c_str(name_cs)); + if (!nt) { + return true; + } + return namedTransformHasBlockableTransform(config, nt, keep, omit); +} + +bool +containsBlockableTransform(const ConstConfigRcPtr& config, + const ConstTransformRcPtr& transform, + std::unordered_set& keep, + std::unordered_set& omit) +{ + return containsBlockableTransform(config, config->getCurrentContext(), + transform, keep, omit); +} + +bool +containsBlockableTransform(const ConstConfigRcPtr& config, + const ConstContextRcPtr& context, + const ConstTransformRcPtr& transform, + std::unordered_set& keep, + std::unordered_set& omit) +{ + if (!transform) { + return false; + } + + ConstContextRcPtr ctx = context ? context : config->getCurrentContext(); + + switch (transform->getTransformType()) { + case TRANSFORM_TYPE_FILE: { + ConstFileTransformRcPtr ft = DynamicPtrCast( + transform); + return fileTransformIsBlockable(ft); + } + case TRANSFORM_TYPE_GROUP: { + ConstGroupTransformRcPtr gt = DynamicPtrCast( + transform); + if (!gt) + return false; + for (int i = 0, e = gt->getNumTransforms(); i < e; ++i) { + if (containsBlockableTransform(config, ctx, gt->getTransform(i), + keep, omit)) { + return true; + } + } + return false; + } + case TRANSFORM_TYPE_COLORSPACE: { + ConstColorSpaceTransformRcPtr cst + = DynamicPtrCast(transform); + if (!cst) { + return true; + } + + const char* src = cst->getSrc(); + const char* dst = cst->getDst(); + + auto src_cs_name = ctx->resolveStringVar(c_str(src)); + auto dst_cs_name = ctx->resolveStringVar(c_str(dst)); + ConstColorSpaceRcPtr src_cs = config->getColorSpace(c_str(src_cs_name)); + ConstColorSpaceRcPtr dst_cs = config->getColorSpace(c_str(dst_cs_name)); + + if (!src_cs && dst_cs) { + bool blocked = containsBlockableTransform(config, ctx, + c_str(dst_cs->getName()), + keep, omit); + return blocked; + } + if (!dst_cs && src_cs) { + bool blocked = containsBlockableTransform(config, ctx, + c_str(src_cs->getName()), + keep, omit); + return blocked; + } + + if (src_cs && dst_cs) { + if (omit.count(src_cs->getName()) + || omit.count(dst_cs->getName())) { + return true; + } + if (keep.count(c_str(src_cs->getName())) + && keep.count(c_str(dst_cs->getName()))) + return false; + bool blocked = containsBlockableTransform(config, ctx, + c_str(src_cs->getName()), + keep, omit); + if (blocked) + return true; + blocked = containsBlockableTransform(config, ctx, + c_str(dst_cs->getName()), keep, + omit); + return blocked; + } + return true; + } + default: + // Atomic (non-structural) types: defer to the shared allowlist. + return !isSimpleAtomicTransform(transform); + } +} + + +} // namespace + +// The unsorted set of "simple" color space names for a config. "Simple" +// means likely stable for interop matching: not data, not "is-unique", and +// not blocked by an unsupported/complex transform construct (the policy +// lives in containsBlockableTransform()). +std::vector +get_simple_color_spaces(const OCIO::ConstConfigRcPtr& config) +{ + std::vector simpleSpaces; + auto keep = scan_simple_color_space_names(config); + simpleSpaces.reserve(keep.size()); + for (const auto& name : keep) { + simpleSpaces.emplace_back(name); + } + return simpleSpaces; +} + + + +const std::vector& +ColorConfig::Impl::getSimpleColorSpaces() const +{ + { + spin_rw_read_lock lock(m_mutex); + if (m_simple_color_spaces_cached) + return m_simple_color_spaces_cache; + } + + auto simple_spaces = get_simple_color_spaces(config_); + std::sort(simple_spaces.begin(), simple_spaces.end()); + + { + spin_rw_write_lock lock(m_mutex); + if (!m_simple_color_spaces_cached) { + m_simple_color_spaces_cache = std::move(simple_spaces); + m_simple_color_spaces_cached = true; + } + return m_simple_color_spaces_cache; + } +} + + + +void +ColorConfig::Impl::analyze(CSInfo* cs) +{ + // Unlocked fast-path check: acquire pairs with the release publish at + // the bottom, making the flags/active written before it visible. + if (cs->analyzed.load(std::memory_order_acquire)) + return; + + // Gather everything that takes its own locks *before* locking m_mutex + // (the lock-ordering hazard examine() also avoids). + const std::vector& simple = getSimpleColorSpaces(); + + int flagval = 0; + bool active = true; + if (config_ && !disable_ocio) { + OCIO::ConstColorSpaceRcPtr ocs = config_->getColorSpace( + cs->name.c_str()); + if (ocs) { + if (ocs->isData()) + flagval |= CSInfo::is_data; + if (ocs->hasCategory("is-unique")) + flagval |= CSInfo::is_unique; + + const char* sp = config_->getSearchPath(); + bool sp_has_vars = sp && Strutil::contains(sp, "$"); + if (!transformUsesContextVars( + ocs->getTransform(OCIO::COLORSPACE_DIR_TO_REFERENCE), + sp_has_vars) + && !transformUsesContextVars( + ocs->getTransform(OCIO::COLORSPACE_DIR_FROM_REFERENCE), + sp_has_vars)) + flagval |= CSInfo::is_context_invariant; + + // Membership in the active colorspace enumeration. + // For now O(n) scan per analyzed space; build a name set once if + // analysis of whole large configs becomes hot. + active = false; + const int n + = config_->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ACTIVE); + for (int i = 0; i < n; ++i) { + const char* aname = config_->getColorSpaceNameByIndex( + OCIO::SEARCH_REFERENCE_SPACE_ALL, OCIO::COLORSPACE_ACTIVE, + i); + if (aname && cs->name == aname) { + active = true; + break; + } + } + } + } + if (std::binary_search(simple.begin(), simple.end(), cs->name)) + flagval |= CSInfo::is_simple; + else if (!(flagval & CSInfo::is_data)) + flagval |= CSInfo::has_complex_transform; + if ((flagval & (CSInfo::is_data | CSInfo::is_unique)) + || isLearnedComplex(currentContextID(), cs->name)) + flagval |= CSInfo::should_skip_matching; -bool -ColorConfig::filepathOnlyMatchesDefaultRule(string_view str) const -{ - try { - return getImpl()->config_->filepathOnlyMatchesDefaultRule(c_str(str)); - } catch (OCIO::Exception& e) { - DBG("OCIO exception in filepathOnlyMatchesDefaultRule: {}", e.what()); + spin_rw_write_lock lock(m_mutex); + if (!cs->analyzed.load(std::memory_order_relaxed)) { + cs->setflag(flagval); + cs->active = active; + cs->analyzed.store(true, std::memory_order_release); } - return false; } -string_view -ColorConfig::parseColorSpaceFromString(string_view str) const -{ - // Reproduce the logic in OCIO v1 parseColorSpaceFromString - if (str.empty()) - return ""; - // Get the colorspace names, sorted shortest-to-longest - auto names = getColorSpaceNames(); - std::sort(names.begin(), names.end(), - [](const std::string& a, const std::string& b) { - return a.length() < b.length(); - }); +int +ColorConfig::Impl::analysisFlags(string_view name, bool* active) +{ + CSInfo* cs = find(name); + if (!cs) { + if (active) + *active = false; + return 0; + } + analyze(cs); + spin_rw_read_lock lock(m_mutex); + if (active) + *active = cs->active; + return cs->flags(); +} - // See if it matches a LUT name. - // This is the position of the RIGHT end of the colorspace substring, - // not the left - size_t rightMostColorPos = std::string::npos; - std::string rightMostColorspace; - // Find the right-most occurrence within the string for each colorspace. - for (auto&& csname : names) { - // find right-most extension matched in filename - size_t pos = Strutil::irfind(str, csname); - if (pos == std::string::npos) - continue; - // If we have found a match, move the pointer over to the right end - // of the substring. This will allow us to find the longest name - // that matches the rightmost colorspace - pos += csname.size(); +int +ColorConfig::Impl::compute_analysis_flags(const std::string& name, + bool& active) const +{ + // Mirrors analyze() but for any name (including spaces outside the active + // CSInfo inventory). Gather lock-taking work before any caller lock. + const std::vector& simple = getSimpleColorSpaces(); - if (rightMostColorPos == std::string::npos - || pos >= rightMostColorPos) { - rightMostColorPos = pos; - rightMostColorspace = csname; + int flagval = 0; + active = true; + if (config_ && !disable_ocio) { + OCIO::ConstColorSpaceRcPtr ocs = config_->getColorSpace(name.c_str()); + if (ocs) { + if (ocs->isData()) + flagval |= CSInfo::is_data; + if (ocs->hasCategory("is-unique")) + flagval |= CSInfo::is_unique; + + const char* sp = config_->getSearchPath(); + bool sp_has_vars = sp && Strutil::contains(sp, "$"); + if (!transformUsesContextVars( + ocs->getTransform(OCIO::COLORSPACE_DIR_TO_REFERENCE), + sp_has_vars) + && !transformUsesContextVars( + ocs->getTransform(OCIO::COLORSPACE_DIR_FROM_REFERENCE), + sp_has_vars)) + flagval |= CSInfo::is_context_invariant; + + active = false; + const int n + = config_->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ACTIVE); + for (int i = 0; i < n; ++i) { + const char* aname = config_->getColorSpaceNameByIndex( + OCIO::SEARCH_REFERENCE_SPACE_ALL, OCIO::COLORSPACE_ACTIVE, + i); + if (aname && name == aname) { + active = true; + break; + } + } } } - return string_view(ustring(rightMostColorspace)); + if (std::binary_search(simple.begin(), simple.end(), name)) + flagval |= CSInfo::is_simple; + else if (!(flagval & CSInfo::is_data)) + flagval |= CSInfo::has_complex_transform; + if ((flagval & (CSInfo::is_data | CSInfo::is_unique)) + || isLearnedComplex(currentContextID(), name)) + flagval |= CSInfo::should_skip_matching; + return flagval; } @@ -2242,14 +3207,14 @@ enum class CICPRange : int { }; struct ColorInteropID { - constexpr ColorInteropID(const char* interop_id) + constexpr ColorInteropID(string_view interop_id) : interop_id(interop_id) , cicp({ 0, 0, 0, 0 }) , has_cicp(false) { } - constexpr ColorInteropID(const char* interop_id, CICPPrimaries primaries, + constexpr ColorInteropID(string_view interop_id, CICPPrimaries primaries, CICPTransfer transfer, CICPMatrix matrix) : interop_id(interop_id) , cicp({ int(primaries), int(transfer), int(matrix), @@ -2258,13 +3223,22 @@ struct ColorInteropID { { } - const char* interop_id; + string_view interop_id; std::array cicp; bool has_cicp; }; // Mapping between color interop ID and CICP, based on Color Interop Forum // recommendations. +// +// The `interop_id` string of every entry below (other than the "unknown" +// utility token, which the registry deliberately does not declare -- see +// pvt::is_utility_interop_id) spells a canonical id declared by the +// embedded interop identities registry (interop-identities-config.ocio). +// The entries are plain literals because they are data rows; the registry +// remains the source of truth for id spelling, and color_test.cpp's +// test_legacy_table_registry_sync() guards against any entry (a typo, a +// registry rename) quietly drifting out of the registry set. constexpr ColorInteropID color_interop_ids[] = { // Scene referred interop IDs first so they are the default in automatic // conversion from CICP to interop ID. Some are not display color spaces @@ -2280,6 +3254,13 @@ constexpr ColorInteropID color_interop_ids[] = { { "lin_adobergb_scene" }, { "lin_ciexyzd65_scene", CICPPrimaries::XYZD65, CICPTransfer::Linear, CICPMatrix::Unspecified }, + // Rec.709 primaries + transfer 13 (IEC 61966-2-1, the sRGB OETF) is + // display-referred sRGB, not scene-referred: CICP describes the + // encoding of the actual (already display-referred) pixel values, per + // ITU-T H.273. Listed here, ahead of srgb_rec709_scene, so it wins the + // first-match lookup in get_color_interop_id(const int cicp[4]). + { "srgb_rec709_display", CICPPrimaries::Rec709, CICPTransfer::sRGB, + CICPMatrix::BT709 }, { "srgb_rec709_scene", CICPPrimaries::Rec709, CICPTransfer::sRGB, CICPMatrix::BT709 }, { "g22_rec709_scene", CICPPrimaries::Rec709, CICPTransfer::Gamma22, @@ -2291,11 +3272,10 @@ constexpr ColorInteropID color_interop_ids[] = { CICPMatrix::BT709 }, { "g22_adobergb_scene" }, { "data" }, - { "unknown" }, + { "unknown" }, // utility token; deliberately not a registry entry. - // Display referred interop IDs. - { "srgb_rec709_display", CICPPrimaries::Rec709, CICPTransfer::sRGB, - CICPMatrix::BT709 }, + // Display referred interop IDs. (srgb_rec709_display is listed above, + // ahead of srgb_rec709_scene, so it resolves first on read.) { "g24_rec709_display", CICPPrimaries::Rec709, CICPTransfer::BT709, CICPMatrix::BT709 }, { "srgb_p3d65_display", CICPPrimaries::P3D65, CICPTransfer::sRGB, @@ -2319,37 +3299,233 @@ constexpr ColorInteropID color_interop_ids[] = { CICPMatrix::BT709 }, { "g26_xyzd65_display", CICPPrimaries::XYZD65, CICPTransfer::Gamma26, CICPMatrix::Unspecified }, + // The P3-primaries DCDM form (g26_p3d65 colorimetry + the DCI white + // headroom). CICP carries no headroom concept, so this shares the P3D65 / + // gamma-2.6 tuple with g26_p3d65_display; the reverse cicp->id lookup is + // first-match and keeps returning g26_p3d65_display (the no-headroom form), + // the conservative decode. ponytail: distinct id, same tuple by design. + { "dcdm_p3d65_display", CICPPrimaries::P3D65, CICPTransfer::Gamma26, + CICPMatrix::BT709 }, { "pq_xyzd65_display", CICPPrimaries::XYZD65, CICPTransfer::PQ, CICPMatrix::Unspecified }, }; + +// Read-only interop identities: valid to RESOLVE on read (a file may carry the +// tag and it must interpret), but never emitted as a write/derive target. +// dcdm_p3d65_display -- the P3-primaries DCDM form -- is a read-only +// interpretation identity; the canonical WRITE form of that colorimetry is +// g26_xyzd65_display (the XYZ DCDM). Derivation must never hand back a read-only +// id, and the CICP reverse lookup must skip it. [decision: Zach 2026-07-23] +static bool +is_readonly_interop_id(string_view id) +{ + return id == "dcdm_p3d65_display"; +} } // namespace string_view ColorConfig::get_color_interop_id(string_view colorspace) const { + // Cheap lookup ONLY (see the doc comment in color.h): a declared + // interop_id attribute, the data-space utility token, or a static-table + // name/alias/classification match -- all through the syntactic + // (fingerprint-free) resolution subset. The expensive derivation cascade + // (fingerprint matching against the built-in registry, config-local id + // manufacture) lives in pvt::derive_color_interop_id() and runs behind + // the characterization engine's derive tier (where write planning + // consumes it), never inside this getter. if (colorspace.empty()) return ""; -#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + if (getImpl()->config_ && !disable_ocio) { - const char* interop_id = nullptr; + std::string resolved(getImpl()->resolve_syntactic(colorspace)); + if (resolved.empty()) + resolved = colorspace; + OCIO::ConstColorSpaceRcPtr c; try { - OCIO::ConstColorSpaceRcPtr c = getImpl()->config_->getColorSpace( - std::string(resolve(colorspace)).c_str()); - if (c) - interop_id = c->getInteropID(); + c = getImpl()->config_->getColorSpace(resolved.c_str()); } catch (...) { - interop_id = nullptr; + c = nullptr; } - if (interop_id) { - return interop_id; + if (c) { + // An author-declared interop_id on the space is unconditionally + // authoritative (OCIO 2.5+). A non-empty value is what "declared" + // means; an unset attribute (empty string) falls through rather + // than short-circuiting to empty. +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + if (const char* iid = c->getInteropID(); + iid && *iid && !is_readonly_interop_id(iid)) + return iid; +#endif + // Utility sub-case: a data space with no explicit token is + // "data", never "unknown" or empty. (isData() is available on + // all OCIO 2.x.) + if (c->isData()) + return "data"; } } + + // The static CICP / interop-id table matched by syntactic (name / role / + // alias / cheap-classification) equivalence -- also the table get_cicp() + // consults to map an id back to a CICP tuple. Its literals live in + // static storage, so the returned view is stable. + for (const ColorInteropID& interop : color_interop_ids) { + if (is_readonly_interop_id(interop.interop_id)) + continue; // read-only id: never a write/derive target + if (getImpl()->equivalent_syntactic(colorspace, interop.interop_id)) + return interop.interop_id; + } + + // Not identified: return empty, never a guessed default. + return ""; +} + + +// The full write-side derivation cascade behind pvt::derive_color_interop_id +// (the pvt shim at the end of this file forwards here). This is the +// EXPENSIVE path -- fingerprint probing can build the registry index and +// OCIO processors -- so it is a distinct entry point consumed by the +// characterization engine's derive tier (through which the write planner +// and the public derive verbs receive it), never hidden behind the cheap +// public getter. +string_view +derive_color_interop_id_impl(const ColorConfig& config, string_view colorspace) +{ + if (colorspace.empty()) + return ""; + + // Marker-vs-marker precedence (ADR-0020): an incoming unknown-marker is + // already a terminal statement about identity, not a color space to derive + // FROM. Return it unchanged, canonically spelled, before the cascade runs. + // + // Without this guard the cascade silently rewrites one marker into + // another: resolve() legally strips the leftmost namespace (a CIF + // fall-back), so "error:unknown" and "oiio:unknown" both become bare + // "unknown" -- and in a config that happens to contain a space NAMED + // "unknown", step 1's config-declared branch below then answers + // "ocio:unknown". That converts a strict-resolution FAILURE, or OIIO's own + // synthetic isData/NoOp treatment marker, into a claim that the CONFIG + // declared unknownness. The evidence of the error path is destroyed at the + // point of derivation, and derive_color_interop_id is public-facing, so + // any caller distinguishing "the config told us" from "we failed to + // resolve" gets the wrong answer. + // + // The wire is unaffected either way -- the writer maps every marker in + // this family to bare "unknown" on disk -- so this is purely about keeping + // the internal signal diagnosable. + switch (::OIIO::pvt::classify_interop_marker(colorspace)) { + case ::OIIO::pvt::InteropMarker::OcioUnknown: return "ocio:unknown"; + case ::OIIO::pvt::InteropMarker::OiioUnknown: return "oiio:unknown"; + case ::OIIO::pvt::InteropMarker::ErrorUnknown: return "error:unknown"; + default: break; + } + + // Four-step Color Interop Forum write-side derivation. The first step to + // produce an id wins; the fingerprint engine only wakes on the first + // query that reaches it. + + auto* impl = pvt::ColorConfigClassificationPeek::impl(config); + if (!impl) + return ""; + + std::string resolved; + bool resolves_to_real_space = false; + if (impl->config_ && !disable_ocio) { + resolved = std::string(config.resolve(colorspace)); + OCIO::ConstColorSpaceRcPtr c; + try { + c = impl->config_->getColorSpace(resolved.c_str()); + } catch (...) { + c = nullptr; + } + if (c) { + resolves_to_real_space = true; + // Step 1: an author-declared interop_id on the space is + // unconditionally authoritative -- it wins over fingerprinting and + // over any strict/unknown handling (OCIO 2.5+). A non-empty value is + // what "declared" means; an unset attribute (empty string) falls + // through to the tiers below rather than short-circuiting to empty. + // A declared value of literally "unknown" is the config-side + // declaration of unknownness and derives the "ocio:unknown" + // marker (the config declared it, OIIO reports where). +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + if (const char* iid = c->getInteropID(); + iid && *iid && !is_readonly_interop_id(iid)) + return Strutil::iequals(iid, "unknown") ? "ocio:unknown" : iid; #endif + // Step 1, config-declared unknown: a space NAMED (not merely + // aliased) "unknown" with no contradicting declared interop_id + // is the config's own statement that this data's color space is + // unknown -- derive the "ocio:unknown" marker, before the + // isData sub-case and any fingerprint tier. A user's explicit + // colorInteropID attribute of "unknown" never reaches this + // derivation (the planner writes explicit values verbatim), so + // bare "unknown" on disk always means the author's own bytes. + if (Strutil::iequals(c->getName(), "unknown")) + return "ocio:unknown"; + // Step 1, utility sub-case: a data space with no explicit token + // resolves to "data" HERE -- before any fingerprint tier -- never + // "unknown" or empty. (isData() is available on all OCIO 2.x.) + if (c->isData()) + return "data"; + } + } + + // Step 2: definitional equivalence to a built-in registry identity, by + // fingerprint. Returns THAT registry identity's id (a process-global-stable + // string), not the query's own name. Only meaningful once the query resolves + // to a real space to fingerprint. + if (resolves_to_real_space) { + if (string_view r = impl->deriveRegistryInteropId(resolved); + !r.empty() && !is_readonly_interop_id(r)) + return r; + } + + // Step 2.5 (legacy syntactic fallback -- DECISION a): the static CICP / + // interop-id table matched by name/alias/flag equivalence. Kept AFTER the + // real fingerprint match (so a genuine fingerprint match is always + // preferred); retiring it would change get_cicp(), which consults this same + // table to map an id back to a CICP tuple. It is never the final-resort + // match -- steps 3 and 4 always follow -- so it can never act as a guessed + // default. Its literals live in static storage, so the returned view is + // stable. The "unknown" utility entry is deliberately SKIPPED here: the + // derivation never emits bare "unknown" (a query that is only the + // literal token, with no backing config space, is a cannot-determine and + // omits; a space genuinely named "unknown" already derived the + // "ocio:unknown" marker in step 1). for (const ColorInteropID& interop : color_interop_ids) { - if (equivalent(colorspace, interop.interop_id)) { + if (interop.interop_id == "unknown" + || is_readonly_interop_id(interop.interop_id)) + continue; + if (config.equivalent(colorspace, interop.interop_id)) return interop.interop_id; + } + + // Step 3 (DECISION b): a named config plus a query that resolves to a real + // space yields a config-local id ":local:", both segments + // sanitized independently per the CIF grammar. This ALWAYS attempts -- it is + // not gated behind an opt-in knob -- because its two natural preconditions, + // a non-empty config name AND a resolvable query, already keep it from + // firing on a genuine miss. The generated std::string is interned via + // ustring so the returned view outlives this call (the local literals and + // OCIO-owned strings the earlier steps return are already stable). + if (resolves_to_real_space) { + const char* cfgname = impl->config_->getName(); + if (cfgname && *cfgname) { + // Never serialize an ambiguous id: the sanitizer is many-to-one, + // so if a SECOND space's sanitized name/alias collides on this + // token, resolution could not uniquely reverse the id. Fall + // through to step 4's never-guess empty rather than emit an id + // that silently names two spaces. + const std::string base = OIIO::pvt::sanitize_id_token(resolved); + if (!unique_space_for_sanitized_token(impl->config_, base).empty()) + return ustring(OIIO::pvt::sanitize_id_token(cfgname) + + ":local:" + base); } } + + // Step 4: nothing identified the space -- return empty, never a guessed + // default (a wrong id costs trust in the whole system). return ""; } @@ -2357,6 +3533,11 @@ string_view ColorConfig::get_color_interop_id(const int cicp[4]) const { for (const ColorInteropID& interop : color_interop_ids) { + // Skip read-only ids: the reverse tuple lookup feeds write derivation, + // and a read-only id (e.g. dcdm_p3d65_display, which shares the + // P3D65/Gamma26 tuple with g26_p3d65_display) must never be a target. + if (is_readonly_interop_id(interop.interop_id)) + continue; if (interop.has_cicp && interop.cicp[0] == cicp[0] && interop.cicp[1] == cicp[1]) { return interop.interop_id; @@ -2380,6 +3561,39 @@ ColorConfig::get_cicp(string_view colorspace) const } +std::vector +ColorConfig::find_color_spaces(cspan chromaticities, + cspan transfer_function, + cspan encoding, + cspan image_state, + const ColorSpaceSearchOptions& search) const +{ + // Thin public adapter: fill the internal option set (the active-space + // toggle has no public counterpart -- the public API always searches + // active spaces) and forward to the pvt search core. The internal core + // throws std::invalid_argument on a malformed/unresolvable hint; the + // public surface converts that to the class's has_error()/geterror() + // convention and never throws. + OIIO::pvt::FindColorSpacesOptions options; + options.chromaticities.assign(chromaticities.begin(), chromaticities.end()); + options.transfer_functions.assign(transfer_function.begin(), + transfer_function.end()); + options.encodings.assign(encoding.begin(), encoding.end()); + options.image_states.assign(image_state.begin(), image_state.end()); + options.include_inactive = search.include_inactive; + options.include_context_sensitive = search.include_context_sensitive; + options.exhaustive = search.include_complex; + options.strict = search.authored_encoding_only; + options.context = search.context; + try { + return OIIO::pvt::find_color_spaces(*this, options); + } catch (const std::exception& e) { + getImpl()->error("find_color_spaces: {}", e.what()); + return {}; + } +} + + ////////////////////////////////////////////////////////////////////////// // // Image Processing Implementations @@ -2393,19 +3607,22 @@ ImageBufAlgo::colorconvert(ImageBuf& dst, const ImageBuf& src, string_view from, int nthreads) { OIIO::pvt::LoggedTimer logtime("IBA::colorconvert"); - if (from.empty() || from == "current") { - from = src.spec().get_string_attribute("oiio:Colorspace", - "scene_linear"); - } - if (from.empty() || from == "unknown" || to.empty() || to == "unknown") { + if (!colorconfig) + colorconfig = &ColorConfig::default_colorconfig(); + // Automatic metadata hygiene around the operation: prepare() resolves + // the source (explicit -> tagged -> inferred from the spec's color + // hints -> lenient default, with the unresolvable-source failure + // split); finish() below maintains the output spec. + OIIO::pvt::ColorOperationHygiene hygiene; + if (!hygiene.prepare(src, dst, *colorconfig, from)) + return false; + from = hygiene.source(); + if (to.empty() || to == "unknown") { dst.errorfmt("Unknown color space name (from=\"{}\", to=\"{}\")", from, to); return false; } - if (!colorconfig) - colorconfig = &ColorConfig::default_colorconfig(); - ColorProcessorHandle processor = colorconfig->createColorProcessor(colorconfig->resolve(from), colorconfig->resolve(to), @@ -2420,15 +3637,36 @@ ImageBufAlgo::colorconvert(ImageBuf& dst, const ImageBuf& src, string_view from, return false; } + // A lenient cross-config fallback is a pass-through no-op standing in + // for a conversion that could not be reconciled (non-strict parsing: the + // requested spaces couldn't be bridged, but the pipeline proceeds + // anyway). The outcome travels WITH the processor -- never the shared + // error string, which cache hits and unrelated calls may not reflect -- + // so ask the config whether THIS processor is such a fallback. No pixels + // are actually converted in that case, so the output must keep + // documenting its true (source) space rather than claiming the requested + // destination -- an honest no-op instead of metadata that asserts a + // conversion that never happened. + bool lenient_passthrough = pvt::ColorConfigClassificationPeek::impl( + *colorconfig) + ->lenient_fallback_message(processor.get()) + .size() + > 0; + logtime.stop(-1); // transition to other colorconvert bool ok = colorconvert(dst, src, processor.get(), unpremult, roi, nthreads); - if (ok) { - // Coming from a non-color space preserves the original space - // DBG("done, setting output colorspace to {}\n", to); - if (colorconfig->isData(from)) - to = from; - dst.specmod().set_colorspace(to); + // Coming from a non-color space, or a lenient pass-through no-op, the + // pixels never changed space: the operation is space-preserving and + // the output keeps documenting its true (source) space with its + // still-true hints. Otherwise the identity is Known: verdict stamped, + // stale provenance facts scrubbed (uniformly -- explicit and inferred + // sources alike), cheap current-state descriptors maintained. + auto identity = OIIO::pvt::ColorOperationIdentity::Known; + if (colorconfig->isData(from) || lenient_passthrough) { + to = from; + identity = OIIO::pvt::ColorOperationIdentity::Preserved; } + hygiene.finish(identity, to, ok); return ok; } @@ -2704,22 +3942,22 @@ ImageBufAlgo::ociolook(ImageBuf& dst, const ImageBuf& src, string_view looks, const ColorConfig* colorconfig, ROI roi, int nthreads) { OIIO::pvt::LoggedTimer logtime("IBA::ociolook"); - if (from.empty() || from == "current") { - auto linearspace = colorconfig->resolve("scene_linear"); - from = src.spec().get_string_attribute("oiio:Colorspace", linearspace); - } - if (to.empty() || to == "current") { - auto linearspace = colorconfig->resolve("scene_linear"); - to = src.spec().get_string_attribute("oiio:Colorspace", linearspace); - } - if (from.empty() || to.empty()) { + if (!colorconfig) + colorconfig = &ColorConfig::default_colorconfig(); + // Hygiene resolves the operation's source; an unspecified `to` means + // the look leaves the image in that same (resolved) space. + OIIO::pvt::ColorOperationHygiene hygiene; + if (!hygiene.prepare(src, dst, *colorconfig, from)) + return false; + from = hygiene.source(); + if (to.empty() || to == "current") + to = hygiene.source(); + if (to.empty()) { dst.errorfmt("Unknown color space name"); return false; } ColorProcessorHandle processor; { - if (!colorconfig) - colorconfig = &ColorConfig::default_colorconfig(); processor = colorconfig->createLookTransform(looks, colorconfig->resolve(from), colorconfig->resolve(to), @@ -2736,8 +3974,8 @@ ImageBufAlgo::ociolook(ImageBuf& dst, const ImageBuf& src, string_view looks, logtime.stop(); // transition to colorconvert bool ok = colorconvert(dst, src, processor.get(), unpremult, roi, nthreads); - if (ok) - dst.specmod().set_colorspace(to); + // The look declares its output space: identity-known, full hygiene. + hygiene.finish(OIIO::pvt::ColorOperationIdentity::Known, to, ok); return ok; } @@ -2767,19 +4005,14 @@ ImageBufAlgo::ociodisplay(ImageBuf& dst, const ImageBuf& src, const ColorConfig* colorconfig, ROI roi, int nthreads) { OIIO::pvt::LoggedTimer logtime("IBA::ociodisplay"); + if (!colorconfig) + colorconfig = &ColorConfig::default_colorconfig(); + OIIO::pvt::ColorOperationHygiene hygiene; + if (!hygiene.prepare(src, dst, *colorconfig, from)) + return false; + from = hygiene.source(); ColorProcessorHandle processor; { - if (!colorconfig) - colorconfig = &ColorConfig::default_colorconfig(); - if (from.empty() || from == "current") { - auto linearspace = colorconfig->resolve("scene_linear"); - from = src.spec().get_string_attribute("oiio:ColorSpace", - linearspace); - } - if (from.empty()) { - dst.errorfmt("Unknown color space name"); - return false; - } processor = colorconfig->createDisplayTransform(display, view, colorconfig->resolve(from), @@ -2794,21 +4027,47 @@ ImageBufAlgo::ociodisplay(ImageBuf& dst, const ImageBuf& src, } } + // Same lenient cross-config pass-through signal as + // ImageBufAlgo::colorconvert(): the fallback outcome travels WITH the + // processor (reconcile_cross_config_display() fell back to a no-op under + // non-strict parsing), never through the shared error string. No pixels + // moved, so the output must keep documenting the space the pixels are + // actually in -- never the space the failed conversion was reaching for. + // Which space that is depends on direction (handled per-branch below). + bool lenient_passthrough = pvt::ColorConfigClassificationPeek::impl( + *colorconfig) + ->lenient_fallback_message(processor.get()) + .size() + > 0; + logtime.stop(); // transition to colorconvert bool ok = colorconvert(dst, src, processor.get(), unpremult, roi, nthreads); if (ok) { - if (inverse) - dst.specmod().set_colorspace(colorconfig->resolve(from)); - else { - if (display.empty() || display == "default") - display = colorconfig->getDefaultDisplayName(); - if (view.empty() || view == "default") - view = colorconfig->getDefaultViewName(display, - colorconfig->resolve( - from)); - dst.specmod().set_colorspace( - colorconfig->getDisplayViewColorSpaceName(display, view)); + if (display.empty() || display == "default") + display = colorconfig->getDefaultDisplayName(); + if (view.empty() || view == "default") + view = colorconfig->getDefaultViewName(display, + colorconfig->resolve(from)); + // The pixels land in the (display, view) space forward, or the + // scene `from` space inverse -- unless the conversion fell back to + // a lenient pass-through no-op, in which case they never left the + // space the input arrived in: tag that source space, not the space + // the failed conversion was reaching for (the honest no-op rule), + // and treat the operation as space-preserving (its still-true + // hints pass through). + const bool disp_view_target = inverse == lenient_passthrough; + std::string target; + if (disp_view_target) { + const char* c = colorconfig->getDisplayViewColorSpaceName(display, + view); + target = c ? c : ""; + } else { + target = colorconfig->resolve(from); } + hygiene.finish(lenient_passthrough + ? OIIO::pvt::ColorOperationIdentity::Preserved + : OIIO::pvt::ColorOperationIdentity::Known, + target, true); } return ok; } @@ -2843,10 +4102,12 @@ ImageBufAlgo::ociofiletransform(ImageBuf& dst, const ImageBuf& src, dst.errorfmt("Unknown filetransform name"); return false; } + if (!colorconfig) + colorconfig = &ColorConfig::default_colorconfig(); + OIIO::pvt::ColorOperationHygiene hygiene; + hygiene.prepare(src, dst, *colorconfig); ColorProcessorHandle processor; { - if (!colorconfig) - colorconfig = &ColorConfig::default_colorconfig(); processor = colorconfig->createFileTransform(name, inverse); if (!processor) { if (colorconfig->has_error()) @@ -2860,15 +4121,19 @@ ImageBufAlgo::ociofiletransform(ImageBuf& dst, const ImageBuf& src, logtime.stop(); // transition to colorconvert bool ok = colorconvert(dst, src, processor.get(), unpremult, roi, nthreads); - if (ok) - // If we can parse a color space from the file name, and we're not inverting - // the transform, then we'll use the color space name from the file. - // Otherwise, we'll leave `oiio:ColorSpace` alone. - // TODO: Use OCIO to extract InputDescription and OutputDescription CLF - // metadata attributes, if present. - if (!colorconfig->filepathOnlyMatchesDefaultRule(name)) - dst.specmod().set_colorspace( - colorconfig->getColorSpaceFromFilepath(name)); + // An arbitrary file/LUT transform's resulting space cannot generally + // be known, and users must not expect it: identity-unknowable, so + // hygiene erases the verdict, the stale provenance facts, and the + // current-state descriptors (absence = could-not-determine, never a + // guess). The one exception is when the file name itself names the + // result via the config's file rules -- then the identity is declared + // and full Known hygiene applies, preserving the longstanding + // color-space-from-filepath behavior. + if (!colorconfig->filepathOnlyMatchesDefaultRule(name)) + hygiene.finish(OIIO::pvt::ColorOperationIdentity::Known, + colorconfig->getColorSpaceFromFilepath(name), ok); + else + hygiene.finish(OIIO::pvt::ColorOperationIdentity::Unknowable, {}, ok); return ok; } @@ -2993,22 +4258,56 @@ ImageBufAlgo::colorconvert(span color, const ColorProcessor* processor, void ColorConfig::set_colorspace(ImageSpec& spec, string_view colorspace) const { - // If we're not changing color space, don't mess with anything + // Re-asserting the color space the spec already carries is a no-op: it + // adds no information, and refreshing state on a pure re-assertion + // would perturb read paths that re-assert redundantly (e.g. Exif + // decoding re-tags sRGB on virtually every camera file after the + // reader already did). Descriptor refreshes ride actual color + // operations (ColorOperationHygiene) or an actual change of claim. string_view oldspace = spec.get_string_attribute("oiio:ColorSpace"); if (oldspace.size() && colorspace.size() && oldspace == colorspace) return; - // Set or clear the main "oiio:ColorSpace" attribute if (colorspace.empty()) { + // Absence semantics: assume NOTHING about the color space. The + // verdict, every file-provenance fact, and every current-state + // descriptor are erased (could-not-determine is expressed by + // absence, never by a guess). spec.erase_attribute("oiio:ColorSpace"); + OIIO::pvt::scrub_color_metadata(spec); + OIIO::pvt::erase_color_state_descriptors(spec); } else { spec.attribute("oiio:ColorSpace", colorspace); + if (oldspace.size()) { + // Asserting a DIFFERENT space over an existing claim: the + // identity-known two-bucket hygiene. File-provenance facts + // (colorInteropID, CICP, chromaticities, gamma, ICC, the ACES + // container flag) described the old claim and are now stale: + // scrub them all. Current-state descriptors are the other + // bucket: MAINTAINED, update-or-erase from the cheap + // characterization -- but only for a spec that already + // carries some; introducing characterization a buffer never + // had is the pixel-operation hygiene's job + // (ColorOperationHygiene), not the tag's, and tag-only paths + // keep their observable output unchanged. + OIIO::pvt::scrub_color_metadata(spec); + if (spec.find_attribute("oiio:ColorSpace:state") + || spec.find_attribute("oiio:ColorSpace:encoding") + || spec.find_attribute("oiio:ColorSpace:range") + || spec.find_attribute("oiio:ColorSpace:equality_id")) + OIIO::pvt::maintain_color_state_descriptors(spec, *this, + colorspace); + } + // First tagging (no previous claim) deliberately does NOT scrub + // the provenance facts: at read time the verdict is routinely + // DERIVED from those very facts (the metadata reconciler, the + // format readers), which are evidence for the claim, not + // contradictions of it. } - // Clear a bunch of other metadata that might contradict the colorspace, - // including some format-specific things that we don't want to propagate - // from input to output if we know that color space transformations have - // occurred. + // Format-specific color hints outside the provenance bucket that could + // contradict the new claim, plus oiio:Gamma for the first-tagging path + // (the scrub covers it on the paths above). Longstanding behavior. if (!equivalent(colorspace, "srgb_rec709_scene")) spec.erase_attribute("Exif:ColorSpace"); spec.erase_attribute("tiff:ColorSpace"); @@ -3057,5 +4356,338 @@ set_colorspace_rec709_gamma(ImageSpec& spec, float gamma) ColorConfig::default_colorconfig().set_colorspace_rec709_gamma(spec, gamma); } +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the helpers above live in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + + +bool +copy_config_preserves_default_view_transform() +{ + return v3_1::copy_config_default_vt_probe(); +} + + +std::vector +legacy_interop_id_table_names() +{ + std::vector names; + for (const auto& interop : v3_1::color_interop_ids) + names.emplace_back(std::string(interop.interop_id)); + return names; +} + + +string_view +derive_color_interop_id(const ColorConfig& config, string_view colorspace) +{ + return v3_1::derive_color_interop_id_impl(config, colorspace); +} + + +ColorProcessorHandle +interop_registry_processor(string_view from_id, string_view to_id) +{ + // Build a processor between two spaces of OIIO's embedded interop + // identities registry (NOT the user config): the write-canonical mapping + // targets registry identities (e.g. the DCDM g26_p3d65 -> g26_xyzd65 + // P3->XYZ + DCI-headroom conversion) whose real transforms live only here, + // even when a user config declares the same NAMES as bare, transform-less + // color spaces (which would yield a no-op in that config). + OCIO::ConstConfigRcPtr cfg = v3_1::build_interop_identities_config(); + if (!cfg || from_id.empty() || to_id.empty()) + return {}; + try { + OCIO::ConstProcessorRcPtr p + = cfg->getProcessor(std::string(from_id).c_str(), + std::string(to_id).c_str()); + if (!p || p->isNoOp()) + return {}; + return ColorProcessorHandle(new v3_1::ColorProcessor_OCIO(p)); + } catch (OCIO::Exception&) { + return {}; + } +} + + +int +color_space_analysis_flags(const ColorConfig& config, string_view name, + bool* active) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl) { + if (active) + *active = false; + return 0; + } + return impl->analysisFlags(name, active); +} + +bool +color_space_analyzed(const ColorConfig& config, string_view name) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->analysisComputed(name) : false; +} + + +// Config-declared metadata policy (spec 09, RFC POC). An OCIO config author +// can attach `oiio:colorpolicy:*` custom keys to a FileRule that exists purely +// to carry policy (rule name `oiio:`, regex `$^` so it never matches +// a file). OCIO round-trips those custom keys byte-stably and other apps +// ignore them, so this is a zero-new-API channel for the config to DECLARE +// policy. This reads back the keys OIIO's policy snapshot then honors. +std::map +config_declared_policy_keys(const ColorConfig& config, string_view rule_name) +{ + std::map keys; + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || !impl->config_) + return keys; + OCIO::ConstFileRulesRcPtr rules; + try { + rules = impl->config_->getFileRules(); + } catch (...) { + return keys; // a malformed config never breaks policy resolution + } + if (!rules) + return keys; + for (size_t i = 0, e = rules->getNumEntries(); i < e; ++i) { + const char* rname = rules->getName(i); + if (!rname || rule_name != rname) + continue; + for (size_t k = 0, nk = rules->getNumCustomKeys(i); k < nk; ++k) { + const char* kn = rules->getCustomKeyName(i, k); + const char* kv = rules->getCustomKeyValue(i, k); + if (kn && kv) + keys[kn] = kv; + } + } + return keys; +} + + +// Config-declared metadata policy carried by the file rule that actually +// MATCHES `filepath` (spec 09 layer 5, "matched-rule per-file opinions"). OCIO +// evaluates its own file-rule patterns against the path and reports which rule +// won; this reads that rule's `oiio:colorpolicy:*` custom keys. Profile rules +// (regex `$^`) never match a real path, so they are never picked up here -- +// only genuine pattern/extension/Default rules are. Empty when OCIO support is +// off, `filepath` is empty, or the matched rule carries no custom keys. +std::map +config_matched_rule_policy_keys(const ColorConfig& config, string_view filepath) +{ + std::map keys; + if (filepath.empty()) + return keys; + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + if (!impl || !impl->config_) + return keys; + try { + OCIO::ConstFileRulesRcPtr rules = impl->config_->getFileRules(); + if (!rules) + return keys; + // Ask OCIO which rule this path matches (index is an out-param; the + // returned color space is unused -- we only want the rule). + size_t ruleIdx = 0; + impl->config_->getColorSpaceFromFilepath(std::string(filepath).c_str(), + ruleIdx); + for (size_t k = 0, nk = rules->getNumCustomKeys(ruleIdx); k < nk; ++k) { + const char* kn = rules->getCustomKeyName(ruleIdx, k); + const char* kv = rules->getCustomKeyValue(ruleIdx, k); + if (kn && kv) + keys[kn] = kv; + } + } catch (...) { + return keys; // a malformed config never breaks policy resolution + } + return keys; +} + + +// Automatic metadata hygiene around the color-aware IBA operations. +// (Contract and per-identity-class semantics: see color_pvt.h.) + + +void +ColorOperationHygiene::prepare(const ImageBuf& src, ImageBuf& dst, + const ColorConfig& config) +{ + m_config = &config; + m_dst = &dst; + (void)src; + // One locked snapshot of the read-policy state per operation. + m_policy = ColorReadPolicy::snapshot(); +} + + + +bool +ColorOperationHygiene::prepare(const ImageBuf& src, ImageBuf& dst, + const ColorConfig& config, string_view from) +{ + prepare(src, dst, config); + if (!from.empty() && from != "current") { + // Explicit source: verbatim (the operation resolves it). + m_source = from; + if (m_source != "unknown") + return true; + } else { + m_source = src.spec().get_string_attribute("oiio:ColorSpace"); + if (m_source.empty()) { + // A fully untagged source: infer one from the color hints the + // spec carries (colorInteropID, CICP, ICC, chromaticities/ + // gamma) before standing on any default. + ColorCallContext ctx; + ctx.filename = std::string(src.name()); + ctx.format = std::string(src.file_format_name()); + std::string hinted = infer_color_space_from_spec(&config, + src.spec(), ctx, + m_policy); + if (!hinted.empty()) { + Strutil::debug("color operation inferred source color space " + "\"{}\" from the input's color metadata\n", + hinted); + m_source = hinted; + } + } + if (!m_source.empty() && m_source != "unknown") + return true; + } + + // Unresolvable source (nothing determined it, or it is the literal + // "unknown"). Failure split, by consequence: + // - Lenient scope, hintless: today's scene_linear default stands (a + // tracking gap is not an error -- convenience, not contract). + // - A literal "unknown" source, or any unresolved source under a + // strict scope, is an ERROR for pixel math -- never a config-default + // guess into a processor -- except that a config-declared + // "error:unknown" catch space is honored under effective-strict + // (strict scope AND the config's own strictparsing). + const bool lenient = m_policy.scope == ColorResolutionScope::Lenient; + if (lenient && m_source.empty()) { + m_source = "scene_linear"; + return true; + } + if (!lenient) { + bool config_strict = false; + try { + auto impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + config_strict = impl->config_ + && impl->config_->isStrictParsingEnabled(); + } catch (...) { + } + if (config_strict && config.getColorSpaceIndex("error:unknown") >= 0) { + m_source = "error:unknown"; + return true; + } + } + dst.errorfmt("Could not determine the source color space (from=\"{}\")", + m_source); + m_source.clear(); + return false; +} + + + +void +ColorOperationHygiene::finish(ColorOperationIdentity identity, + string_view target_color_space, + bool pixels_succeeded) +{ + if (!m_dst || !pixels_succeeded) + return; // failed pixel math: leave the spec exactly as it was + ImageSpec& spec = m_dst->specmod(); + + switch (identity) { + case ColorOperationIdentity::Preserved: + // Space-preserving (or an honest no-op): facts and descriptors + // pass through, and the verdict is stamped only when the + // operation names one the spec doesn't already carry (avoiding + // set_colorspace's collateral invalidation of still-true hints). + if (!target_color_space.empty() + && target_color_space + != spec.get_string_attribute("oiio:ColorSpace")) + spec.set_colorspace(target_color_space); + break; + + case ColorOperationIdentity::Unknowable: + // The resulting space cannot be known and users must not expect + // it: absence everywhere (could-not-determine, never-guess) -- + // not "oiio:unknown", which marks treatment, not ignorance. + // set_colorspace("") carries the full absence semantics: verdict, + // provenance facts, and descriptors are all erased. + spec.set_colorspace(""); + break; + + case ColorOperationIdentity::Known: + // Through the operation's own config (not the process default the + // ImageSpec convenience method uses). + m_config->set_colorspace(spec, target_color_space); + // Two-bucket rule, applied uniformly (explicit and inferred + // sources alike): file-provenance facts are stale, scrub them; + // current-state descriptors are retained and UPDATED below. + // (set_colorspace itself only applies this hygiene when changing + // an existing claim, and only maintains descriptors a spec + // already carries; the pixel operation asserts the change + // unconditionally and INTRODUCES the descriptors.) + scrub_color_metadata(spec); + // Cheap descriptor maintenance only: never a processor, a probe, + // or a fingerprint, and this path never asks for chromaticities + // or transfer information. Update-or-erase, never guess. + // Range is current-state, operation-aware: a Known conversion sets + // the target's intrinsic range when one is explicitly known and + // otherwise does not invent one (the erase removes a stale value); + // a Preserved operation retains the buffer's range untouched. + maintain_color_state_descriptors(spec, *m_config, target_color_space); + break; + } +} + + + +void +maintain_color_state_descriptors(ImageSpec& spec, const ColorConfig& config, + string_view color_space) +{ + // Cheap get only: get_color_space_info() does direct or previously + // cached work -- it never builds a processor, probes a transform, or + // computes a fingerprint. Direct/cached values update the + // sub-attribute; an unavailable value erases it -- update-or-erase, + // never guess. (An uncomputed equality id in particular is removed, + // never derived here: retaining the previous id would be observably + // wrong, forcing a fingerprint would violate the cheap-only rule.) + ColorSpaceInfo info = config.get_color_space_info(color_space); + auto set_or_erase = [&](const char* name, string_view value) { + if (value.size()) + spec.attribute(name, value); + else + spec.erase_attribute(name); + }; + set_or_erase("oiio:ColorSpace:state", info.image_state()); + set_or_erase("oiio:ColorSpace:encoding", info.encoding()); + set_or_erase("oiio:ColorSpace:range", info.range()); + set_or_erase("oiio:ColorSpace:equality_id", info.equality_id()); +} + + + +void +erase_color_state_descriptors(ImageSpec& spec) +{ + spec.erase_attribute("oiio:ColorSpace:state"); + spec.erase_attribute("oiio:ColorSpace:encoding"); + spec.erase_attribute("oiio:ColorSpace:range"); + spec.erase_attribute("oiio:ColorSpace:equality_id"); +} + +} // namespace pvt OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_ocio_pvt.h b/src/libOpenImageIO/color_ocio_pvt.h new file mode 100644 index 0000000000..d497c14e11 --- /dev/null +++ b/src/libOpenImageIO/color_ocio_pvt.h @@ -0,0 +1,1118 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +/// \file +/// Shared internal declarations for the color_*.cpp translation units that +/// together implement ColorConfig and the color-interop machinery +/// (color_ocio.cpp, color_registry.cpp, color_fingerprint.cpp, +/// color_crossconfig.cpp, color_icc_probe.cpp, color_search.cpp). +/// Everything here requires OpenColorIO types and is private to +/// libOpenImageIO -- NOT installed, and not includable by format plugins or +/// unit tests (which are built without OCIO include paths). + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +#include "color_pvt.h" +#include "imageio_pvt.h" + +#define MAKE_OCIO_VERSION_HEX(maj, min, patch) \ + (((maj) << 24) | ((min) << 16) | (patch)) + +#include + +namespace OCIO = OCIO_NAMESPACE; + + +OIIO_NAMESPACE_3_1_BEGIN + +#if 1 || !defined(NDEBUG) /* allow color configuration debugging */ +extern bool colordebug; // defined in color_ocio.cpp +# define DBG(...) \ + if (colordebug) \ + Strutil::print(__VA_ARGS__) +#else +# define DBG(...) +#endif + +// Runtime kill switches (defined in color_ocio.cpp). +extern int disable_ocio; +extern int disable_builtin_configs; + + +// Class used as the key to index color processors in the cache. +class ColorProcCacheKey { +public: + ColorProcCacheKey(ustring in, ustring out, ustring key = ustring(), + ustring val = ustring(), ustring looks = ustring(), + ustring display = ustring(), ustring view = ustring(), + ustring file = ustring(), + ustring namedtransform = ustring(), bool inverse = false) + : inputColorSpace(in) + , outputColorSpace(out) + , context_key(key) + , context_value(val) + , looks(looks) + , file(file) + , namedtransform(namedtransform) + , inverse(inverse) + { + hash = inputColorSpace.hash() + 14033ul * outputColorSpace.hash() + + 823ul * context_key.hash() + 28411ul * context_value.hash() + + 1741ul + * (looks.hash() + display.hash() + view.hash() + + file.hash() + namedtransform.hash()) + + (inverse ? 6421 : 0); + // N.B. no separate multipliers for looks, display, view, file, + // namedtransform, because they're never used for the same lookup. + } + + friend bool operator<(const ColorProcCacheKey& a, + const ColorProcCacheKey& b) + { + return std::tie(a.hash, a.inputColorSpace, a.outputColorSpace, + a.context_key, a.context_value, a.looks, a.display, + a.view, a.file, a.namedtransform, a.inverse) + < std::tie(b.hash, b.inputColorSpace, b.outputColorSpace, + b.context_key, b.context_value, b.looks, b.display, + b.view, b.file, b.namedtransform, b.inverse); + } + + friend bool operator==(const ColorProcCacheKey& a, + const ColorProcCacheKey& b) + { + return std::tie(a.hash, a.inputColorSpace, a.outputColorSpace, + a.context_key, a.context_value, a.looks, a.display, + a.view, a.file, a.namedtransform, a.inverse) + == std::tie(b.hash, b.inputColorSpace, b.outputColorSpace, + b.context_key, b.context_value, b.looks, b.display, + b.view, b.file, b.namedtransform, b.inverse); + } + ustring inputColorSpace; + ustring outputColorSpace; + ustring context_key; + ustring context_value; + ustring looks; + ustring display; + ustring view; + ustring file; + ustring namedtransform; + bool inverse; + size_t hash; +}; + + +struct ColorProcCacheKeyHasher { + size_t operator()(const ColorProcCacheKey& c) const { return c.hash; } +}; + + +typedef tsl::robin_map + ColorProcessorMap; + + +struct CSInfo { + std::string name; // Name of this color space + int index; // More than one can have the same index -- aliases + enum Flags { + none = 0, + is_linear_response = 1, // any cs with linear transfer function + is_scene_linear = 2, // equivalent to scene_linear + is_srgb = 4, // sRGB (primaries, and transfer function) + is_lin_srgb = 8, // sRGB/Rec709 primaries, linear response + is_ACEScg = 16, // ACEScg + is_Rec709 = 32, // Rec709 primaries and transfer function + is_data = 64, // Non-color-managed data + is_known = is_srgb | is_lin_srgb | is_ACEScg | is_Rec709, + // Color-space classification bits, computed lazily by Impl::analyze() + // (a second pass, separate from the classify_* heuristics that set + // the bits above). Used to decide which spaces are stable candidates + // for interop matching. + is_unique = 128, // has OCIO category "is-unique" + should_skip_matching = 256, // never a matching candidate + has_complex_transform = 512, // rejected by the simple allowlist + is_simple = 1024, // member of the simple set + is_context_invariant = 2048, // no context vars affect this space + }; + // The lazily-computed classification state is written under the Impl's + // m_mutex but deliberately READ without it on hot paths (the examine()/ + // analyze() double-checked fast path, equivalent()'s flag compare), so + // the flag words are atomics: `examined`/`analyzed` publish with release + // stores paired with acquire loads on the unlocked fast-path checks, and + // m_flags is or-accumulated. Plain ints/bools here were a C++ data race + // (UB) under concurrent first-use classification. `active` stays a plain + // bool: it is only accessed under m_mutex. + std::atomic m_flags { 0 }; + std::atomic examined { false }; + std::atomic analyzed { false }; // analyze() ran (see Impl::analyze) + bool active = true; // member of the active colorspace enumeration + std::string canonical; // Canonical name for this color space + OCIO::ConstColorSpaceRcPtr ocio_cs; + + CSInfo(string_view name_, int index_, int flags_ = none, + string_view canonical_ = "") + : name(name_) + , index(index_) + , m_flags(flags_) + , canonical(canonical_) + { + } + + // Atomics are not copyable; the copy constructor exists only for + // std::vector growth during single-threaded inventory(). + CSInfo(const CSInfo& other) + : name(other.name) + , index(other.index) + , m_flags(other.m_flags.load(std::memory_order_relaxed)) + , examined(other.examined.load(std::memory_order_relaxed)) + , analyzed(other.analyzed.load(std::memory_order_relaxed)) + , active(other.active) + , canonical(other.canonical) + , ocio_cs(other.ocio_cs) + { + } + + void setflag(int flagval) + { + m_flags.fetch_or(flagval, std::memory_order_relaxed); + } + + // Set flag to include any bits in flagval, and also if alias is not yet + // set, set it to name. + void setflag(int flagval, std::string& alias) + { + m_flags.fetch_or(flagval, std::memory_order_relaxed); + if (alias.empty()) + alias = name; + } + + int flags() const { return m_flags.load(std::memory_order_relaxed); } +}; + + +// The classification bits observed by tests through pvt::ColorSpaceAnalysis +// (color_pvt.h) are the raw CSInfo classification bits; keep the two in +// sync so a shim result is interpreted correctly. +static_assert(int(OIIO::pvt::ColorSpaceIsData) == CSInfo::is_data + && int(OIIO::pvt::ColorSpaceIsUnique) == CSInfo::is_unique + && int(OIIO::pvt::ColorSpaceShouldSkipMatching) + == CSInfo::should_skip_matching + && int(OIIO::pvt::ColorSpaceHasComplexTransform) + == CSInfo::has_complex_transform + && int(OIIO::pvt::ColorSpaceIsSimple) == CSInfo::is_simple + && int(OIIO::pvt::ColorSpaceIsContextInvariant) + == CSInfo::is_context_invariant, + "pvt::ColorSpaceAnalysis must mirror CSInfo classification bits"); + + +// Color space fingerprint probe layout. The identity probe is the first +// kFingerprintBasePixels RGBA pixels (primaries, black, dark neutral, white); +// a trailing linearity quartet -- four (dark, bright) pairs -- rides in the +// same vector (total kFingerprintProbePixels pixels) but is excluded from +// equality matching (see fingerprints_match). +static constexpr int kFingerprintBasePixels = 6; +static constexpr int kFingerprintProbePixels = 14; // 6 identity + 8 linearity +// Absolute (not relative) tolerance: scene probes are bounded [0,1] ACES and +// display probes bounded [0,~1.1] XYZ, so one epsilon works everywhere. Chosen +// empirically to separate distinct spaces while tolerating cross-optimization +// float noise. Ceiling: less discriminating for HDR values well above 1.0. +static constexpr float kFingerprintAbsTolerance = 5e-3f; + +// The reference-space probe sets after normalization into a config's reference +// space; the raw calibrated values live in initialize_probe_values(). +struct ProbeValues { + std::vector scene; + std::vector display; +}; + + +// The cache id of an OCIO context ("" when unavailable). Used to scope +// context-dependent failure state (the learned-complex set) by the exact +// context it was observed under, so a failure under one context can never +// poison queries under another. +inline std::string +context_cache_id(const OCIO::ConstContextRcPtr& ctx) +{ + try { + if (ctx) + if (const char* id = ctx->getCacheID()) + return id; + } catch (...) { + } + return {}; +} + + +// A context carrying a query's per-call variable overrides, layered on the +// config's current context. Overrides are scoped to the one query. Shared by +// the characterization search and the characterization engine. +inline OCIO::ConstContextRcPtr +make_context_with_overrides(const OCIO::ConstConfigRcPtr& config, + const std::map& vars) +{ + if (!config) + return nullptr; + OCIO::ConstContextRcPtr context = config->getCurrentContext(); + if (!vars.empty()) { + OCIO::ContextRcPtr ctx = context->createEditableCopy(); + for (const auto& kv : vars) + ctx->setStringVar(kv.first.c_str(), kv.second.c_str()); + context = ctx; + } + return context; +} + + +// Hidden implementation of ColorConfig +class ColorConfig::Impl { +public: + // Frozen after construction: all construction-time fixups (e.g. + // fix_config_file_rules) happen on a mutable local inside init(), then + // the result is stored const. Any later modification must go through an + // explicit createEditableCopy() producing a NEW config (and thus a new + // cache identity) -- the const type makes the compiler enforce the + // copy-on-modify contract that the phase-1 cache depends on. + OCIO::ConstConfigRcPtr config_; + OCIO::ConstConfigRcPtr builtinconfig_; + // The config as FIRST constructed (before any evolve() modifications), + // carried through evolve chains so `EvolveOptions::reset` can always + // return to the root. Equals config_ for a non-evolved config. + OCIO::ConstConfigRcPtr original_config_; + +private: + std::vector colorspaces; + std::string scene_linear_alias; // Alias for a scene-linear color space + std::string lin_srgb_alias; + std::string srgb_alias; + std::string ACEScg_alias; + std::string Rec709_alias; + mutable spin_rw_mutex m_mutex; + mutable std::string m_error; + ColorProcessorMap colorprocmap; // cache of ColorProcessors + // Lenient cross-config fallbacks: the pass-through no-op processors + // reconcile_cross_config{,_display} hand back under non-strict parsing, + // keyed by processor identity, mapped to the composed continue-message. + // Guarded by m_mutex; entries live as long as colorprocmap holds the + // processor (the life of this Impl). + std::unordered_map m_lenient_fallbacks; + atomic_int colorprocs_requested; + atomic_int colorprocs_created; + std::string m_configname; + ColorConfig* m_self = nullptr; + bool m_config_is_built_in = false; + + // Cache of the "simple" color space names (those that survive the + // transform allowlist), sorted, computed once on first request under the + // same double-checked pattern as examine(). + mutable std::vector m_simple_color_spaces_cache; + mutable bool m_simple_color_spaces_cached = false; + + // Color spaces learned to be complex only at query time (e.g. a transform + // that failed to realize). This is a per-query hint, not a permanent + // verdict; it is cleared with the Impl (i.e. the config) lifetime, and + // entries are keyed "|" so failure observed + // under one context never blacklists the space for another. + mutable std::mutex m_learned_complex_mutex; + mutable std::unordered_set m_learned_complex; + + // The probe config used for fingerprinting: a processor-cache-disabled + // editable copy of config_, built lazily on the first fingerprint query + // (constructing a ColorConfig touches none of it). Building probe + // processors is one-shot -- each probe runs once -- so OCIO's processor + // cache would only add lock contention and pin every probe processor for + // the life of the config; disabling it is the documented fast path. The + // normalized scene/display probe values are derived from this copy. + mutable OCIO::ConstConfigRcPtr m_probe_config; + mutable OCIO::ConstContextRcPtr m_probe_context; + mutable ProbeValues m_probe_values; + mutable bool m_probe_ready = false; + + // Interoperability assertion + in-memory bootstrap state, computed lazily + // on the first interop query (see ensure_interop()); constructing a + // ColorConfig runs none of it. `interopified` is a PROCESSOR_CACHE_OFF + // editable copy of config_, repaired to resolve a scene (and, where + // possible, display) interchange -- config_ itself is never mutated. + struct InteropState { + bool is_interoperable = false; // config_ carries a scene interchange + std::string interchange_colorspace; // discovered interchange space name + OCIO::ConstConfigRcPtr interopified; // repaired probe copy of config_ + bool warned = false; // this config emitted the warning + std::string warning_message; // the composed once-per-config warning + // (recorded here, NOT on the ColorConfig + // error string -- see ensure_interop()). + }; + mutable InteropState m_interop; + mutable bool m_interop_ready = false; + +public: + Impl(ColorConfig* self) + : m_self(self) + { + } + + ~Impl() + { +#if 0 + // Debugging the cache -- make sure we're creating a small number + // compared to repeated requests. + if (colorprocs_requested) + DBG("ColorConfig::Impl : color procs requested: {}, created: {}\n", + colorprocs_requested, colorprocs_created); +#endif + } + + bool init(string_view filename); + + // Initialize from OCIO config YAML text held in memory (see + // ColorConfig::from_text). `working_dir`, if non-empty, becomes the + // config's working directory. + bool init_from_text(string_view config_text, string_view working_dir); + + // Initialize from an already-built (frozen) OCIO config -- the shared + // adoption path behind the from-memory factories. `name` becomes the + // configname() identifier. `original`, if non-null, records the root + // config an evolve chain resets to (defaults to `config` itself). + bool init_from_config(OCIO::ConstConfigRcPtr config, string_view name, + OCIO::ConstConfigRcPtr original = nullptr); + + // Re-point the back-reference after a ColorConfig move. + void set_self(ColorConfig* self) { m_self = self; } + + void add(const std::string& name, int index, int flags = 0) + { + spin_rw_write_lock lock(m_mutex); + colorspaces.emplace_back(name, index, flags); + // classify(colorspaces.back()); + } + + // Find the CSInfo record for the named color space, or nullptr if it's + // not a color space we know. + const CSInfo* find(string_view name) const + { + for (auto&& cs : colorspaces) + if (cs.name == name) + return &cs; + return nullptr; + } + CSInfo* find(string_view name) + { + for (auto&& cs : colorspaces) + if (cs.name == name) + return &cs; + return nullptr; + } + + // Search for a matching ColorProcessor, return it if found (otherwise + // return an empty handle). + ColorProcessorHandle findproc(const ColorProcCacheKey& key) + { + ++colorprocs_requested; + spin_rw_read_lock lock(m_mutex); + auto found = colorprocmap.find(key); + return (found == colorprocmap.end()) ? ColorProcessorHandle() + : found->second; + } + + // Add the given color processor. Be careful -- if a matching one is + // already in the table, just return the existing one. If they pass + // in an empty handle, just return it. If `lenient_fallback_msg` is + // non-null, the handle is a lenient cross-config pass-through fallback: + // record its continue-message against the cached processor (under the + // same lock as the insertion), so the per-call outcome travels WITH the + // processor and a later cache hit can re-signal it. + ColorProcessorHandle + addproc(const ColorProcCacheKey& key, ColorProcessorHandle handle, + const std::string* lenient_fallback_msg = nullptr) + { + if (!handle) + return handle; + spin_rw_write_lock lock(m_mutex); + auto found = colorprocmap.find(key); + if (found == colorprocmap.end()) { + // No equivalent item in the map. Add this one. + colorprocmap[key] = handle; + ++colorprocs_created; + } else { + // There's already an equivalent one. Oops. Discard this one and + // return the one already in the map. + handle = found->second; + } + if (lenient_fallback_msg) + m_lenient_fallbacks[handle.get()] = *lenient_fallback_msg; + return handle; + } + + // If `proc` is a registered lenient cross-config pass-through fallback + // (see addproc), return its continue-message; otherwise return empty. + // This -- never the shared error string, which cache hits and unrelated + // calls may have cleared or overwritten -- is how callers must decide + // whether a non-null processor actually converts pixels. + std::string lenient_fallback_message(const ColorProcessor* proc) const + { + spin_rw_read_lock lock(m_mutex); + auto found = m_lenient_fallbacks.find(proc); + return found == m_lenient_fallbacks.end() ? std::string() + : found->second; + } + + // Diagnostic counters for ColorConfig::get_debug_info(). + size_t processorCacheSize() const + { + spin_rw_read_lock lock(m_mutex); + return colorprocmap.size(); + } + int processorsRequested() const { return colorprocs_requested; } + int processorsCreated() const { return colorprocs_created; } + + // Drop this instance's cached derived processors and per-query hints + // (see ColorConfig::clear_caches). The lazy classification, probe, and + // interop state is identity-derived and deliberately untouched: its + // publication protocol (acquire/release flags) assumes monotonic + // computation, and re-deriving it could never produce different + // results for the same frozen config. + void clearInstanceCaches() + { + { + spin_rw_write_lock lock(m_mutex); + colorprocmap.clear(); + m_lenient_fallbacks.clear(); + } + std::lock_guard lock(m_learned_complex_mutex); + m_learned_complex.clear(); + } + + int getNumColorSpaces() const { return (int)colorspaces.size(); } + + const char* getColorSpaceNameByIndex(int index) const + { + return colorspaces[index].name.c_str(); + } + + // Full resolution cascade. `failover` is what a total miss returns: the + // public 1-arg ColorConfig::resolve() passes `name` (historical + // passthrough), the 2-arg overload passes the caller's failover. + string_view resolve(string_view name, string_view failover) const; + string_view resolve(string_view name) const { return resolve(name, name); } + + // The syntactic (fingerprint-free) subset of resolve(): direct OCIO + // name/role/alias, informal aliases, stripped-namespace retry, + // config-local form, declared interop_id, and the data/bypass utility + // ranking -- everything except the registry-equivalence fingerprint + // tier. Returns empty on a miss (no passthrough), and never wakes the + // fingerprint engine or builds the registry index. + string_view resolve_syntactic(string_view name) const; + + // equivalent() restricted to syntactic resolution + the cheap + // classification flags -- the equivalence the cheap public + // get_color_interop_id() table tier uses. Never fingerprints. + bool equivalent_syntactic(string_view color_space1, + string_view color_space2) const; + + // Note: Uses std::format syntax + template + void error(const char* fmt, const Args&... args) const + { + spin_rw_write_lock lock(m_mutex); + m_error = Strutil::fmt::format(fmt, args...); + } + std::string geterror(bool clear = true) const + { + std::string err; + spin_rw_write_lock lock(m_mutex); + if (clear) { + std::swap(err, m_error); + } else { + err = m_error; + } + return err; + } + bool haserror() const + { + spin_rw_read_lock lock(m_mutex); + return !m_error.empty(); + } + void clear_error() + { + spin_rw_write_lock lock(m_mutex); + m_error.clear(); + } + + const std::string& configname() const { return m_configname; } + void configname(string_view name) { m_configname = name; } + + OCIO::ConstCPUProcessorRcPtr + get_to_builtin_cpu_proc(const char* my_from, const char* builtin_to) const; + + bool isColorSpaceLinear(string_view name) const; + + bool isData(string_view name) const; + + // The sorted set of "simple" color space names (those that survive the + // transform allowlist), computed once and cached. + const std::vector& getSimpleColorSpaces() const; + + // Return the CSInfo classification flags for the named color space, + // computing them lazily on first request (see analyze()). Returns 0 for + // unknown names. `active`, if non-null, receives whether the space is in + // the config's active colorspace enumeration. + int analysisFlags(string_view name, bool* active = nullptr); + + // Classification flags for `name` computed directly (no CSInfo cache), + // for color spaces outside the active inventory (e.g. inactive spaces the + // characterization search examines). `active` receives active-enumeration + // membership. Mirrors analyze()'s body. + int compute_analysis_flags(const std::string& name, bool& active) const; + + // The internal characterization search (see pvt::find_color_spaces). Lives + // on Impl because it needs the config, the classification flags, the + // interop registry, and the probe producers below. + std::vector + find_color_spaces(const OIIO::pvt::FindColorSpacesOptions& options); + + // Probe producers for the search axes -- each drives an OCIO processor to + // derive a candidate's characteristic, then hands the raw samples to the + // pure pvt:: primitives. The effective (declared, else registry-inferred) + // OCIO encoding; the chromaticities (reserved table, else AP0-probe + // derivation); and the behavioral transfer signature (neutral-axis probe + // run in the encode direction). `context`, when non-null, is the exact + // context every probe processor is built under (find_color_spaces' + // per-call override); null means the config's own current context. + std::string effectiveEncoding(string_view name) const; + std::optional + deriveChromaticities(string_view name, + const OCIO::ConstContextRcPtr& context = {}) const; + std::optional + deriveTransferSignature(string_view name, + const OCIO::ConstContextRcPtr& context = {}) const; + + // Compute the color space fingerprint for `name` (transform the fixed + // probe from the reference role to the space). Builds the probe config + // lazily on first use. Returns nullopt if the space is unknown or can't be + // probed. Defined below, after the probe helpers it relies on. + std::optional + computeFingerprint(string_view name) const; + + // Fingerprint every "simple" color space, iterating the classification's + // sorted simple-space cache so the result order is deterministic. + std::vector> + fingerprintSimpleColorSpaces() const; + + // Look up (or compute and publish) the fingerprint for `name` in the + // process-global flyweight fingerprint cache. A hit is a cheap read; a miss + // computes the fingerprint OUTSIDE any cache lock and publishes it + // first-writer-wins. Returns nullopt if the space can't be fingerprinted. + std::optional + fingerprintCached(string_view name); + + // Fingerprint every "simple" color space and publish each into the + // process-global flyweight cache. Returns how many were fingerprinted (the + // deterministic bulk "warm" pass; see fingerprintSimpleColorSpaces()). + std::size_t fingerprintWarm(); + + // Step 2 of pvt::derive_color_interop_id(): the write-side analog of + // resolve_registry_equivalence(). Given a color space name that already + // resolves to a real space in this config, fingerprint it and return the + // built-in registry identity it is definitionally equal to -- i.e. the + // REGISTRY space's own interop id, not the query's name. Empty on no match. + // Gated to skip data / config-unique / skip-matching spaces (a data space is + // already answered by step 1). Non-const: it populates the logically-const + // lazy classification and fingerprint caches, and lazily builds the + // process-global registry fingerprint index on first use. Called by + // ColorConfig via getImpl(), so it lives in the public section. + string_view deriveRegistryInteropId(string_view resolved_name); + + // Interoperability assertion/bootstrap queries (each triggers the lazy + // bootstrap, except interopComputed(), which must NOT, so callers can + // verify that constructing a ColorConfig does no interop work). + bool interopIsInteroperable() const; + std::string interopInterchangeName() const; + bool interopComputed() const; // does not trigger the bootstrap + bool interopWarned() const; + bool interopifiedResolvesSceneInterchange() const; + bool interopifiedCacheOff() const; + + // The interopified (repaired, in-memory) copy of config_, or null. Triggers + // the lazy interop bootstrap. + OCIO::ConstConfigRcPtr interopifiedConfig() const; + + // Reconcile a color conversion whose local resolution failed, when a + // requested name is a registry-known interop identity this config lacks. + // Cross-config reconciliation is deliberately on by default, observable + // via debug log, with OCIO strict_parsing as the opt-out. Routes the + // foreign endpoint through the built-in interop identities config via the + // cross-config chokepoint. Returned-handle / `errmsg` contract lives at + // the definition. Never throws. + ColorProcessorHandle reconcile_cross_config(string_view src, + string_view dst, + std::string& errmsg) const; + + // Display-view sibling of reconcile_cross_config: reconcile a display + // transform whose local resolution failed because the INPUT color space is + // a registry-known interop identity this config lacks. The display/view are + // inherently local; only the source can be foreign. Routes the foreign + // source through the interop identities config into this config's + // display/view via the display-view chokepoint. Same strict/lenient/ + // narration contract as reconcile_cross_config. Never throws. + ColorProcessorHandle + reconcile_cross_config_display(string_view input, string_view display, + string_view view, bool inverse, + std::string& errmsg) const; + + // Whether analyze() has already run for the named space, WITHOUT + // triggering it (used to verify lazy behavior). False for unknown names. + bool analysisComputed(string_view name) const + { + const CSInfo* cs = find(name); + if (!cs) + return false; + spin_rw_read_lock lock(m_mutex); + return cs->analyzed; + } + + // Record a color space as complex for the life of this config, so later + // queries skip it. This is a hint, not a permanent verdict, and it is + // keyed by the context (cache id) the failure was observed under: a + // realize failure under one context override set must not blacklist the + // space for other contexts. + void markLearnedComplex(string_view ctxscope, string_view name) const + { + std::lock_guard lock(m_learned_complex_mutex); + m_learned_complex.emplace( + Strutil::fmt::format("{}|{}", ctxscope, name)); + } + bool isLearnedComplex(string_view ctxscope, string_view name) const + { + std::lock_guard lock(m_learned_complex_mutex); + return m_learned_complex.count( + Strutil::fmt::format("{}|{}", ctxscope, name)) + != 0; + } + + // The cache id of this config's CURRENT (ambient) context, the scope + // used when a query supplies no explicit context override. + std::string currentContextID() const + { + try { + if (config_) + return context_cache_id(config_->getCurrentContext()); + } catch (...) { + } + return {}; + } + +private: + // Return the CSInfo flags for the given color space name + int flags(string_view name) + { + CSInfo* cs = find(name); + if (!cs) + return 0; + examine(cs); + spin_rw_read_lock lock(m_mutex); + return cs->flags(); + } + + // Set cs.flag to include any bits in flagval. + void setflag(CSInfo& cs, int flagval) + { + spin_rw_write_lock lock(m_mutex); + cs.setflag(flagval); + } + + // Set cs.flag to include any bits in flagval, and also if alias is not + // yet set, set it to cs.name. + void setflag(CSInfo& cs, int flagval, std::string& alias) + { + spin_rw_write_lock lock(m_mutex); + cs.setflag(flagval, alias); + } + + void inventory(); + + // Build builtinconfig_ (the fixed-up ocio://default copy). Shared by + // every init path. + void init_builtin(); + + // The shared tail of every init path: inventory + builtin-equivalent + // identification + debug dumps. Returns whether config_ is usable. + bool finish_init(); + + // Tier 1a of resolve(): a direct OCIO color space / role / alias lookup, + // then OIIO's informal universal-name aliases (sRGB, lin_srgb, ACEScg, + // scene_linear, Rec709). Returns a stable view of the resolved name, or an + // empty string_view if the name matched none of them (resolve() layers the + // interop-ID tiers and the input-name passthrough on top of this). + string_view resolve_name_tier1a(string_view name) const; + + // Tier 2 of resolve(): registry equivalence. Returns this config's OWN + // simple color space that is definitionally the same color as the built-in + // interop identity `name` names -- matched by a cheap explicit-interop-id + // compare, then a tolerance-gated fingerprint match -- or empty on no + // match. It only ever returns a name; it never builds a cross-config + // processor. Fingerprints the query config and builds the process-global + // registry fingerprint index lazily on first use (utility tokens are an + // automatic miss and never reach a fingerprint compare). Non-const: it + // populates the logically-const lazy classification and fingerprint caches. + string_view resolve_registry_equivalence(string_view name); + + // Set the flags for the given color space and canonical name, if we can + // make a guess based on the name. This is very inexpensive. This should + // only be called from within a lock of the mutex. + void classify_by_name(CSInfo& cs); + + // Set the flags for the given color space and canonical name, trying some + // tricks to deduce the color space from the primaries, white point, and + // transfer function. This is more expensive, and might only work for OCIO + // 2.2 and above. This should only be called from within a lock of the + // mutex. + void classify_by_conversions(CSInfo& cs); + + // Apply more heuristics to try to deduce more color space information. + void reclassify_heuristics(CSInfo& cs); + + // If the CSInfo hasn't yet been "examined" (fully classified by all + // heuristics), do so. This should NOT be called from within a lock of the + // mutex. + void examine(CSInfo* cs) + { + // Unlocked fast-path check: acquire pairs with the release publish + // below, making the classification written before it visible. + if (!cs->examined.load(std::memory_order_acquire)) { + spin_rw_write_lock lock(m_mutex); + if (!cs->examined.load(std::memory_order_relaxed)) { + classify_by_name(*cs); + classify_by_conversions(*cs); + reclassify_heuristics(*cs); + cs->examined.store(true, std::memory_order_release); + } + } + } + + // If the CSInfo's classification bits haven't been computed yet, do so. + // Same double-checked lazy pattern as examine(), but a separate pass: + // it needs the simple-space scan, not the classify_* heuristics, and is + // a wholly new entry point not wired through add()/inventory(). Should + // NOT be called from within a lock of the mutex. Defined below, after the + // transform-policy helpers it relies on. + void analyze(CSInfo* cs); + + // Build the processor-cache-disabled probe config and its normalized probe + // values, lazily and once, under the same double-checked pattern as + // examine(). Should NOT be called from within a lock of the mutex. + void ensureProbeConfig() const; + + // Discover the scene interchange space of config_ (the verbatim discovery + // order), filling `state.is_interoperable`/`interchange_colorspace`. + // Reads config_; mutates only the passed-in state. + void interop_bootstrap(InteropState& state) const; + + // Run the interoperability assertion + in-memory bootstrap once, lazily, + // under the same double-checked pattern as examine(): discover whether + // config_ is interoperable, build the interopified probe copy, and warn + // once per structural config if the assertion fails. Should NOT be called + // from within a lock of the mutex. ColorConfig construction never runs it. + void ensure_interop() const; + + void debug_print_aliases() + { + DBG("Aliases: scene_linear={} lin_srgb={} srgb={} ACEScg={} Rec709={}\n", + scene_linear_alias, lin_srgb_alias, srgb_alias, ACEScg_alias, + Rec709_alias); + } + + // For OCIO 2.3+, we can ask for the equivalent of some built-in + // color spaces. + void identify_builtin_equivalents(); + + bool check_same_as_builtin_transform(const char* my_from, + const char* builtin_to) const; + bool test_conversion_yields(const char* from, const char* to, + cspan test_colors, + cspan result_colors) const; + const char* IdentifyBuiltinColorSpace(const char* name) const; +}; + + +namespace pvt { +// Grants the color-space classification test shims (declared in +// color_pvt.h, defined in the current namespace below) access to the +// private ColorConfig::Impl (shared by the color_*.cpp translation units). +struct ColorConfigClassificationPeek { + static ColorConfig::Impl* impl(const ColorConfig& config) + { + return config.getImpl(); + } +}; +} // namespace pvt + + +// Custom ColorProcessor that wraps an OpenColorIO Processor. +class ColorProcessor_OCIO final : public ColorProcessor { +public: + ColorProcessor_OCIO(OCIO::ConstProcessorRcPtr p) + : m_p(p) + , m_cpuproc(p->getDefaultCPUProcessor()) + { + } + ~ColorProcessor_OCIO() override {} + + bool isNoOp() const override { return m_p->isNoOp(); } + bool hasChannelCrosstalk() const override + { + return m_p->hasChannelCrosstalk(); + } + void apply(float* data, int width, int height, int channels, + stride_t chanstride, stride_t xstride, + stride_t ystride) const override + { + try { + OCIO::PackedImageDesc pid(data, width, height, channels, + OCIO::BIT_DEPTH_F32, // For now, only float + chanstride, xstride, ystride); + m_cpuproc->apply(pid); + } catch (OCIO::Exception& e) { + OIIO::errorfmt("OCIO error in apply: {}\n", e.what()); + // FIXME -- some day, we should make ColorProcessor::apply return + // a status, and we should indicate here that it failed. + } + } + +private: + OCIO::ConstProcessorRcPtr m_p; + OCIO::ConstCPUProcessorRcPtr m_cpuproc; +}; + + + +// ColorProcessor that implements a matrix multiply color transformation. +class ColorProcessor_Matrix final : public ColorProcessor { +public: + ColorProcessor_Matrix(const Imath::M44f& Matrix, bool inverse) + : ColorProcessor() + , m_M(Matrix) + { + if (inverse) + m_M = m_M.inverse(); + } + ~ColorProcessor_Matrix() override {} + + void apply(float* data, int width, int height, int channels, + stride_t chanstride, stride_t xstride, + stride_t ystride) const override + { + using namespace simd; + if (channels == 3 && chanstride == sizeof(float)) { + for (int y = 0; y < height; ++y) { + char* d = (char*)data + y * ystride; + for (int x = 0; x < width; ++x, d += xstride) { + vfloat4 color; + color.load((float*)d, 3); + vfloat4 xcolor = color * m_M; + xcolor.store((float*)d, 3); + } + } + } else if (channels >= 4 && chanstride == sizeof(float)) { + for (int y = 0; y < height; ++y) { + char* d = (char*)data + y * ystride; + for (int x = 0; x < width; ++x, d += xstride) { + vfloat4 color; + color.load((float*)d); + vfloat4 xcolor = color * m_M; + xcolor.store((float*)d); + } + } + } else { + channels = std::min(channels, 4); + for (int y = 0; y < height; ++y) { + char* d = (char*)data + y * ystride; + for (int x = 0; x < width; ++x, d += xstride) { + vfloat4 color; + char* dc = d; + for (int c = 0; c < channels; ++c, dc += chanstride) + color[c] = *(float*)dc; + vfloat4 xcolor = color * m_M; + for (int c = 0; c < channels; ++c, dc += chanstride) + *(float*)dc = xcolor[c]; + } + } + } + } + +private: + simd::matrix44 m_M; +}; + + +// Test probe backing pvt::copy_config_preserves_default_view_transform(). +// Defined in color_ocio.cpp. +bool +copy_config_default_vt_probe(); + +// Make an editable copy of `config`, working around an OCIO bug (fixed in +// 2.3.1) where createEditableCopy() drops the default view transform name. +// Defined in color_ocio.cpp. +OCIO::ConfigRcPtr +copy_config(const OCIO::ConstConfigRcPtr& config); + +// The unsorted set of "simple" color space names for a config (the transform +// allowlist policy). Defined in color_ocio.cpp. +std::vector +get_simple_color_spaces(const OCIO::ConstConfigRcPtr& config); + +// Shared classification of atomic (non-structural) transform types: true when +// the transform is simple enough for interop matching. Defined in +// color_ocio.cpp. +bool +isSimpleAtomicTransform(const OCIO::ConstTransformRcPtr& transform); + +// The full write-side derivation cascade behind pvt::derive_color_interop_id. +// Defined in color_ocio.cpp. +string_view +derive_color_interop_id_impl(const ColorConfig& config, string_view colorspace); + +// Core of pvt::characterize_color_space() -- the field-selective +// characterization engine (see color_pvt.h for the contract) -- and its +// process-global cache's test hooks. Defined in color_characterization.cpp. +OIIO::pvt::CharacterizationRecord +characterize_color_space_impl(const ColorConfig& config, + string_view color_space, + uint32_t requested_fields, + const std::map& context); +size_t +characterization_cache_size_impl(); +void +characterization_cache_reset_impl(); + +// The calibrated fingerprint probe values, normalized into `config`'s +// reference spaces. Defined in color_fingerprint.cpp. +ProbeValues +initialize_probe_values(const OCIO::ConstConfigRcPtr& config, + const OCIO::ConstContextRcPtr& context); + +// Transform the (reference-space) probe by the color space's from-reference +// transform; the resulting floats are its fingerprint. Defined in +// color_fingerprint.cpp. +std::optional +compute_fingerprint(const OCIO::ConstConfigRcPtr& config, + const OCIO::ConstColorSpaceRcPtr& cs, + const OCIO::ConstContextRcPtr& context, + const ProbeValues& probes); + +// Exact, tolerance-gated fingerprint identity match. Defined in +// color_fingerprint.cpp. +bool +fingerprints_match(const OIIO::pvt::ColorSpaceFingerprint& left, + const OIIO::pvt::ColorSpaceFingerprint& right); + +// OIIO's built-in interop identities config (process-global, built once). +// Defined in color_registry.cpp. +OCIO::ConstConfigRcPtr +build_interop_identities_config(); + +// The `name:` header of the EMBEDDED interop identities config YAML (e.g. +// "interop-identities-config-v3.2.0.3") -- the registry DATA version, +// independent of the linked OCIO version and read without building the +// registry config. Defined in color_registry.cpp. +std::string +interop_registry_data_version(); + +// Erase every process-global fingerprint-cache entry scoped to structural +// config id `cfgId` (see ColorConfig::clear_caches). Defined in +// color_fingerprint.cpp. +void +fingerprint_cache_erase_config(string_view cfgId); + +// Erase every process-global characterization-cache entry scoped to +// structural config id `cfgId`. Defined in color_characterization.cpp. +void +characterization_cache_erase_config(string_view cfgId); + + +// Each entry pairs a registry color space name (which, for the identities OIIO +// recognizes, equals its interop id) with that space's fingerprint, computed +// through the SAME interopified / PROCESSOR_CACHE_OFF probe path the query side +// uses, so registry and query fingerprints are directly comparable. Sorted by +// name for binary-search lookup. +struct RegistryFingerprintIndex { + OCIO::ConstConfigRcPtr config; // interopified registry (the probe source) + std::vector> entries; +}; + +// Build (once, lazily) and return the process-global registry fingerprint +// index. Defined in color_registry.cpp. +const RegistryFingerprintIndex& +registry_fingerprint_index(); + +// Map a query interop id to its registry entry's fingerprint. Defined in +// color_registry.cpp. +const OIIO::pvt::ColorSpaceFingerprint* +registry_fingerprint_for_id(const RegistryFingerprintIndex& index, + string_view id, std::string& canonical_id_out); + +// Reverse of registry_fingerprint_for_id: registry identity for a query +// fingerprint. Defined in color_registry.cpp. +string_view +registry_id_for_fingerprint(const RegistryFingerprintIndex& index, + const OIIO::pvt::ColorSpaceFingerprint& query_fp); + +// The STRUCTURAL cache id of a config (context-independent). Defined in +// color_crossconfig.cpp. +std::string +get_config_cache_id(const OCIO::ConstConfigRcPtr& config); + +// The "interopified" (repaired, PROCESSOR_CACHE_OFF, memoized) copy of +// `config`. Defined in color_crossconfig.cpp. +OCIO::ConstConfigRcPtr +interopify_config(const OCIO::ConstConfigRcPtr& config); + +// The cross-config chokepoint: the single wrapper over OCIO's two-config +// GetProcessorFromConfigs. Defined in color_crossconfig.cpp. +OCIO::ConstProcessorRcPtr +processor_from_configs( + const OCIO::ConstConfigRcPtr& src_config, string_view src_name, + const OCIO::ConstConfigRcPtr& dst_config, string_view dst_name, + std::string& errmsg, const OCIO::ConstContextRcPtr& src_context = nullptr, + const OCIO::ConstContextRcPtr& dst_context = nullptr, + const char* interchange_role = OCIO::ROLE_INTERCHANGE_SCENE); + +// Display-view sibling of the cross-config chokepoint. Defined in +// color_crossconfig.cpp. +OCIO::ConstProcessorRcPtr +display_processor_from_configs( + const OCIO::ConstConfigRcPtr& src_config, string_view src_name, + const OCIO::ConstConfigRcPtr& dst_config, string_view display, + string_view view, OCIO::TransformDirection direction, std::string& errmsg, + const OCIO::ConstContextRcPtr& src_context = nullptr, + const OCIO::ConstContextRcPtr& dst_context = nullptr); + +// Core of pvt::identify_icc_profile(). Defined in color_icc_probe.cpp. +OIIO::pvt::IccIdentifyResult +identify_icc_profile_impl(const ColorConfig& config, cspan iccdata); + +// Core of pvt::derive_mastering_volume(). Defined in color_icc_probe.cpp. +bool +derive_mastering_volume_impl(const ColorConfig& config, string_view display, + string_view view, + OIIO::pvt::MasteringDisplayVolume& volume); + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_registry.cpp b/src/libOpenImageIO/color_registry.cpp new file mode 100644 index 0000000000..8fb5b7295b --- /dev/null +++ b/src/libOpenImageIO/color_registry.cpp @@ -0,0 +1,351 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// The built-in interop identities config (the compiled-in registry of +// CIF-published interop identities) and the process-global registry +// fingerprint index built from it. Split out of color_ocio.cpp; see +// color_ocio_pvt.h for the shared internal declarations. + +#include +#include +#include +#include +#include +#include + +#include + +#include "color_ocio_pvt.h" + +#include "interop_identities_config.h" + + + +// The built-in interop identities config and the interoperability +// assertion/bootstrap machinery below touch ColorConfig::Impl, which lives in +// the ABI-versioned v3_1 namespace -- so they must too. The OIIO_API pvt +// shims that expose them are declared (by color_pvt.h) in the library's +// "current" namespace and are defined further down in a separate +// OIIO_NAMESPACE_BEGIN block; those reach back here with explicit v3_1:: +// qualification. +OIIO_NAMESPACE_3_1_BEGIN + +// Return OIIO's built-in interop identities config: a config that defines +// color spaces for the CIF-published interop identities OIIO knows how to +// reliably recognize and relate in other OCIO configs. Built once per +// process and reused for the life of the process. +// +// With a linked OCIO that predates native interop ID support, this is the +// small config OIIO ships compiled in (see interop_identities_config.h), +// parsed as-is. With OCIO >= 2.5 -- which ships builtin studio configs that +// already carry the CIF interop identities natively (every color space has +// getInteropID() set) -- it is OCIO's latest builtin studio config with only +// the identities that config doesn't already provide layered on top of a +// mutable copy. +OCIO::ConstConfigRcPtr +build_interop_identities_config() +{ + // Build once and reuse for all ColorConfig instances -- function-local + // static initialization is thread-safe (C++11 magic statics), so no + // extra mutex is needed here. + static OCIO::ConstConfigRcPtr s_interop_identities_config = + []() -> OCIO::ConstConfigRcPtr { + try { + std::istringstream iss(kInteropIdentitiesConfig); + OCIO::ConstConfigRcPtr embedded = OCIO::Config::CreateFromStream( + iss); +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + // Start from OCIO's own latest builtin studio config and layer in + // only the embedded identities it doesn't already carry. Each + // embedded entry's color space name equals its interop_id by + // construction, so the prefix and lookup below key on the name: + // - skip "ocio:"-namespaced identities: the studio config + // defines the OCIO namespace itself; + // - add "oiio:"-namespaced identities: OIIO-only additions the + // studio config never carries; + // - add bare CIF identities only when the studio config doesn't + // already resolve the name, so its own (superior) definition + // always wins where present. + OCIO::ConfigRcPtr config = OCIO::Config::CreateFromBuiltinConfig( + "ocio://studio-config-latest") + ->createEditableCopy(); + for (int i = 0, n = embedded->getNumColorSpaces(); i < n; ++i) { + const char* name = embedded->getColorSpaceNameByIndex(i); + if (Strutil::starts_with(name, "ocio:")) + continue; + if (config->getColorSpace(name)) + continue; + config->addColorSpace(embedded->getColorSpace(name)); + } + // For now the overlay adds the few bare CIF identities this build's + // OCIO >= 2.5 studio config turns out not to carry (mostly display + // identities); most bare identities are already present as + // aliases. Reconfirm which the studio config carries before OCIO + // >= 2.5 becomes OIIO's minimum, when the embedded config can + // shrink to just the "oiio:" delta. + // + // The embedded registry's cinema encoding tags override the + // studio config's, even where the studio definition supplies the + // (superior) transforms: sdr-cinema (gamma-2.6 theatrical family, + // 48 cd/m² calibration white) and hdr-cinema (PQ cinema masters) + // are OIIO-side classifications that OCIO's builtin config tags + // plain sdr-video / hdr-video. + for (int i = 0, n = embedded->getNumColorSpaces(); i < n; ++i) { + const char* name = embedded->getColorSpaceNameByIndex(i); + auto ecs = embedded->getColorSpace(name); + const char* enc = ecs ? ecs->getEncoding() : nullptr; + if (!enc + || (!Strutil::iequals(enc, "sdr-cinema") + && !Strutil::iequals(enc, "hdr-cinema"))) + continue; + auto existing = config->getColorSpace(name); + if (!existing + || Strutil::iequals(existing->getEncoding() + ? existing->getEncoding() + : "", + enc)) + continue; + auto retagged = existing->createEditableCopy(); + retagged->setEncoding(enc); + config->addColorSpace(retagged); // replaces by name + } + return config; +#else + return embedded; +#endif + } catch (OCIO::Exception&) { + return {}; + } + }(); + return s_interop_identities_config; +} + + + +std::string +interop_registry_data_version() +{ + // Line-scan the embedded registry YAML for its config-level `name:` + // header (the first `name:` line in the file), which carries the + // registry DATA version -- no config build, independent of the linked + // OCIO version (whose >= 2.5 composite reports a different name). + string_view yaml(kInteropIdentitiesConfig); + for (string_view line : Strutil::splitsv(yaml, "\n")) { + line = Strutil::strip(line); + if (Strutil::parse_prefix(line, "name:")) + return std::string(Strutil::strip(line)); + } + return "(unknown)"; +} + + +////////////////////////////////////////////////////////////////////////// +// +// Registry fingerprint index: the one genuinely new primitive the read-side +// registry-equivalence tier (and the write-side derivation that shares this +// file) needs. It fingerprints the built-in interop identities config's own +// simple color spaces so a query config's spaces can be matched against them by +// value. Everything else it uses -- the registry config, the interopified probe +// copy, the probe protocol, the fingerprint compute and match -- is reused from +// the foundation above. + +// Build (once, lazily) and return the process-global registry fingerprint +// index. Nothing here runs until the first resolve() query reaches the +// registry-equivalence tier; ColorConfig construction never touches it. The +// index is immutable for the life of the process -- the registry config is a +// process-global constant, so entries are content-addressed and never +// invalidated or evicted. The C++11 magic-static guard makes the one-time build +// thread-safe (same idiom as build_interop_identities_config()). The write-side +// derivation fingerprints the same registry the same way and reuses this exact +// builder. +const RegistryFingerprintIndex& +registry_fingerprint_index() +{ + static const RegistryFingerprintIndex s_index = + []() -> RegistryFingerprintIndex { + RegistryFingerprintIndex idx; + OCIO::ConstConfigRcPtr registry = build_interop_identities_config(); + if (!registry) + return idx; + idx.config = interopify_config(registry); + if (!idx.config) + return idx; + try { + OCIO::ConstContextRcPtr context = idx.config->getCurrentContext(); + ProbeValues probes = initialize_probe_values(idx.config, context); + for (const auto& name : get_simple_color_spaces(idx.config)) { + auto cs = idx.config->getColorSpace(name.c_str()); + auto fp = compute_fingerprint(idx.config, cs, context, probes); + if (fp) + idx.entries.emplace_back(name, std::move(*fp)); + } + } catch (...) { + idx.entries.clear(); + } + std::sort(idx.entries.begin(), idx.entries.end(), + [](const auto& a, const auto& b) { + return a.first < b.first; + }); + return idx; + }(); + return s_index; +} + +// Map a query interop id to its registry entry's fingerprint. Resolves the id +// against the registry by name or alias (utility tokens name no registry space +// and so miss here) to the canonical registry space, then finds that space's +// fingerprint in the sorted index. On a hit, `canonical_id_out` receives the +// registry space's own interop id (its interop_id attribute on OCIO >= 2.5, +// else its name) for the cheap direct-id compare on the query side. Returns +// null when the id resolves to no registry space, or to one with no fingerprint +// (e.g. a non-simple registry space). +const OIIO::pvt::ColorSpaceFingerprint* +registry_fingerprint_for_id(const RegistryFingerprintIndex& index, + string_view id, std::string& canonical_id_out) +{ + canonical_id_out.clear(); + if (!index.config || id.empty()) + return nullptr; + OCIO::ConstColorSpaceRcPtr cs; + try { + cs = index.config->getColorSpace(std::string(id).c_str()); + } catch (...) { + return nullptr; + } + if (!cs) + return nullptr; + const std::string name = cs->getName(); +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + if (const char* iid = cs->getInteropID(); iid && *iid) + canonical_id_out = iid; +#endif + if (canonical_id_out.empty()) + canonical_id_out = name; + auto it = std::lower_bound(index.entries.begin(), index.entries.end(), name, + [](const auto& e, const std::string& key) { + return e.first < key; + }); + if (it != index.entries.end() && it->first == name) + return &it->second; + return nullptr; +} + +// Reverse of registry_fingerprint_for_id (the write-side direction): given a +// query space's fingerprint, return the built-in registry identity whose +// fingerprint matches it within tolerance, walking the index's sorted +// deterministic order so first-match is stable. The returned view is the +// registry identity's own interop id -- its interop_id attribute (OCIO >= 2.5, +// where the registry is the studio config whose space names, e.g. "ACEScg", +// differ from the CIF ids, e.g. "lin_ap1_scene"), else its name (the embedded +// config, where name == interop_id). This mirrors registry_fingerprint_for_id's +// canonicalization. Both strings are owned by the process-global registry +// config/index, so the view is stable for the life of the process. Empty when +// nothing matches. +string_view +registry_id_for_fingerprint(const RegistryFingerprintIndex& index, + const OIIO::pvt::ColorSpaceFingerprint& query_fp) +{ + for (const auto& entry : index.entries) { + if (!fingerprints_match(query_fp, entry.second)) + continue; +#if OCIO_VERSION_HEX >= MAKE_OCIO_VERSION_HEX(2, 5, 0) + if (index.config) { + try { + if (auto cs = index.config->getColorSpace(entry.first.c_str())) + if (const char* iid = cs->getInteropID(); iid && *iid) + return iid; + } catch (...) { + } + } +#endif + return entry.first; + } + return {}; +} + + + +cspan +ColorConfig::get_builtin_interop_ids() +{ + // Built once from the embedded registry scan; process lifetime. The + // scan shim lives in the library's "current" namespace (color_pvt.h), + // not in this ABI-versioned one, hence the explicit qualification. + static const std::vector ids + = OIIO::pvt::embedded_interop_identities_ids(); + static const std::vector views(ids.begin(), ids.end()); + return cspan(views.data(), views.size()); +} + +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the helpers above live in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + + +int +interop_identities_config_size() +{ + auto config = v3_1::build_interop_identities_config(); + return config ? config->getNumColorSpaces() : 0; +} + +bool +interop_identities_config_resolves(string_view interop_id) +{ + auto config = v3_1::build_interop_identities_config(); + if (!config || interop_id.empty()) + return false; + try { + // getColorSpace resolves by color space name or alias, which is how + // every CIF identity in this config is reachable (see the config's + // per-entry name/alias scheme). + return bool(config->getColorSpace(std::string(interop_id).c_str())); + } catch (OCIO::Exception&) { + return false; + } +} + +std::vector +interop_identities_config_names() +{ + std::vector names; + auto config = v3_1::build_interop_identities_config(); + if (!config) + return names; + int n = config->getNumColorSpaces(); + names.reserve(n); + for (int i = 0; i < n; ++i) + names.emplace_back(config->getColorSpaceNameByIndex(i)); + return names; +} + + +std::vector +embedded_interop_identities_ids() +{ + // Line-scan the embedded registry YAML for `interop_id: `: the + // canonical CIID set, independent of the linked OCIO version (the + // parsed composite config's declared names diverge from the canonical + // id set with OCIO >= 2.5's studio-config overlay). This scan backs + // the internal ColorConfig::get_builtin_interop_ids() facade below. + std::set ids; + string_view yaml(kInteropIdentitiesConfig); // array is NUL-terminated + for (string_view line : Strutil::splitsv(yaml, "\n")) { + line = Strutil::strip(line); + if (Strutil::parse_prefix(line, "interop_id:")) + ids.emplace(Strutil::strip(line)); + } + return { ids.begin(), ids.end() }; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_search.cpp b/src/libOpenImageIO/color_search.cpp new file mode 100644 index 0000000000..cad71080da --- /dev/null +++ b/src/libOpenImageIO/color_search.cpp @@ -0,0 +1,1023 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Color-space search by characterization (the config-driving walk behind +// pvt::find_color_spaces) and the per-candidate characteristic probes it +// uses. The pure term grammar and axis combination live in +// characterization_search.cpp. Split out of color_ocio.cpp; see +// color_ocio_pvt.h for the shared internal declarations. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "color_ocio_pvt.h" + + + +// The built-in interop identities config and the interoperability +// assertion/bootstrap machinery below touch ColorConfig::Impl, which lives in +// the ABI-versioned v3_1 namespace -- so they must too. The OIIO_API pvt +// shims that expose them are declared (by color_pvt.h) in the library's +// "current" namespace and are defined further down in a separate +// OIIO_NAMESPACE_BEGIN block; those reach back here with explicit v3_1:: +// qualification. +OIIO_NAMESPACE_3_1_BEGIN + +////////////////////////////////////////////////////////////////////////// +// +// Color-space search by characterization (pvt::find_color_spaces): resolve a +// set of partial-characterization hints, then walk the config in index order +// keeping every color space whose derivable gamut / transfer / encoding / +// image-state satisfies each hinted axis under the three-valued filter +// (pvt::three_valued_axis). The term grammar and axis combination are pure +// (characterization_search.cpp); everything here needs the live config. + +// The pure search primitives and shared types are declared in the library's +// non-versioned pvt namespace (color_pvt.h); alias it so this versioned +// block reaches them without colliding with the local v3_x::pvt (which holds +// the classification peek). +namespace spvt = OIIO::pvt; + +namespace { +using namespace OCIO; + +// Drop a leading "namespace:" from an interop id ("ocio:lin_ap1_scene" -> +// "lin_ap1_scene"). Unchanged when there is no colon. +std::string +search_strip_namespace(string_view id) +{ + const size_t colon = id.find(':'); + if (colon != string_view::npos) + id.remove_prefix(colon + 1); + return std::string(id); +} + +// The complete gamut component of an interop id: strip the namespace and a +// trailing _scene/_display, then take everything after the last '_' +// ("lin_awg3_scene" -> "awg3"). Empty when no '_' remains. Only a *complete* +// component matches a chromaticity hint; an arbitrary fragment ("p3") does not. +std::string +gamut_component(string_view interop_id) +{ + std::string base = search_strip_namespace(interop_id); + for (string_view suffix : { "_scene", "_display" }) { + if (base.size() > suffix.size() && Strutil::ends_with(base, suffix)) { + base.resize(base.size() - suffix.size()); + break; + } + } + const size_t sep = base.rfind('_'); + return sep == std::string::npos ? std::string() : base.substr(sep + 1); +} + +// The curve-only transfer family of an interop id or crv_ named-transform name +// ("srgb_rec709_scene" -> "srgb", "crv_srgb_tx" -> "srgb"): the family token +// (spvt::family_token strips a crv_ prefix and one state suffix) reduced to the +// leading component. This is what lets a crv_ hint and an interop-identified +// candidate agree on the same transfer vocabulary. Assumes the transfer token +// never contains '_', which holds for all current registry ids. +std::string +tf_curve_family(string_view id) +{ + std::string family = spvt::family_token(search_strip_namespace(id)); + const size_t sep = family.find('_'); + if (sep != std::string::npos) + family.resize(sep); + return family; +} + +// Run the fixed neutral-axis probe set through a realized CPU processor (encode +// direction) and reduce it to a behavioral transfer signature. This is the one +// config-driving step the pure tf_signature_from_probes() left to its caller. +std::optional +probe_signature_over(const ConstCPUProcessorRcPtr& cpu) +{ + if (!cpu) + return {}; + const cspan axis = spvt::tf_probe_axis(); + std::vector outputs; + outputs.reserve(axis.size()); + try { + for (double v : axis) { + float rgb[3] = { float(v), float(v), float(v) }; + cpu->applyRGB(rgb); + outputs.push_back((double(rgb[0]) + double(rgb[1]) + double(rgb[2])) + / 3.0); + } + } catch (...) { + return {}; + } + return spvt::tf_signature_from_probes(outputs); +} + +// Lowercased keys under which a named transform can be found as a transfer +// hint: its name, the name minus a " - curve" suffix, and its crv_-shaped +// name/aliases plus their curve-family forms. +std::vector +named_transform_keys(const ConstNamedTransformRcPtr& nt) +{ + std::vector keys; + if (!nt) + return keys; + const auto add = [&](std::string key) { + key = Strutil::lower(key); + if (!key.empty() + && std::find(keys.begin(), keys.end(), key) == keys.end()) + keys.push_back(std::move(key)); + }; + + const std::string name = nt->getName() ? nt->getName() : ""; + const std::string lowered = Strutil::lower(name); + add(name); + static constexpr string_view curve_suffix = " - curve"; + if (Strutil::ends_with(lowered, curve_suffix)) + add(lowered.substr(0, lowered.size() - curve_suffix.size())); + if (Strutil::starts_with(lowered, "crv_")) + add(spvt::family_token(lowered)); + + for (size_t i = 0, e = nt->getNumAliases(); i < e; ++i) { + const std::string alias = nt->getAlias(i) ? nt->getAlias(i) : ""; + const std::string lowered_alias = Strutil::lower(alias); + if (Strutil::starts_with(lowered_alias, "crv_")) { + add(alias); + add(spvt::family_token(lowered_alias)); + } else if (lowered_alias.size() > 4 + && Strutil::ends_with(lowered_alias, "_crv")) { + add(alias); + add(lowered_alias.substr(0, lowered_alias.size() - 4)); + } + } + return keys; +} + +ConstNamedTransformRcPtr +find_named_transform(const ConstConfigRcPtr& config, const std::string& query) +{ + if (!config) + return {}; + const std::string wanted = Strutil::lower(query); + for (int i = 0, e = config->getNumNamedTransforms(); i < e; ++i) { + const char* name = config->getNamedTransformNameByIndex(i); + auto nt = name ? config->getNamedTransform(name) + : ConstNamedTransformRcPtr(); + const auto keys = named_transform_keys(nt); + if (std::find(keys.begin(), keys.end(), wanted) != keys.end()) + return nt; + } + return {}; +} + +// Authored-transform inspection for the exhaustive path: every atomic +// transform must pass the shared simple-atomic allowlist, every FileTransform +// must reference one of the exhaustive-eligible container formats, and +// ColorSpaceTransform references recurse (cycle-guarded by `visited`). Also +// reports whether any FileTransform was seen at all -- the exhaustive walk +// only revisits spaces that actually reference files. +struct AuthoredInspection { + bool allowed = true; + bool has_file_transform = false; +}; + +AuthoredInspection +inspect_authored_transform(const ConstConfigRcPtr& config, + const ConstContextRcPtr& context, + const ConstTransformRcPtr& transform, + std::unordered_set& visited) +{ + if (!transform) + return {}; + switch (transform->getTransformType()) { + case TRANSFORM_TYPE_GROUP: { + auto group = DynamicPtrCast(transform); + if (!group) + return { false, false }; + AuthoredInspection result; + for (int i = 0, e = group->getNumTransforms(); i < e; ++i) { + const auto child + = inspect_authored_transform(config, context, + group->getTransform(i), visited); + result.allowed &= child.allowed; + result.has_file_transform |= child.has_file_transform; + } + return result; + } + case TRANSFORM_TYPE_FILE: { + auto file = DynamicPtrCast(transform); + if (!file || !file->getSrc()) + return { false, true }; + const std::string source = context ? context->resolveStringVar( + file->getSrc()) + : std::string(file->getSrc()); + const bool allowed = Strutil::iends_with(source, ".spi1d") + || Strutil::iends_with(source, ".spimtx") + || Strutil::iends_with(source, ".ctf") + || Strutil::iends_with(source, ".clf"); + return { allowed, true }; + } + case TRANSFORM_TYPE_COLORSPACE: { + auto cst = DynamicPtrCast(transform); + if (!cst) + return { false, false }; + AuthoredInspection result; + for (const char* raw : { cst->getSrc(), cst->getDst() }) { + std::string name = raw ? raw : ""; + if (context) + name = context->resolveStringVar(name.c_str()); + auto referenced = config->getColorSpace(name.c_str()); + if (!referenced || !visited.insert(name).second) + continue; + ConstTransformRcPtr selected = referenced->getTransform( + COLORSPACE_DIR_FROM_REFERENCE); + if (!selected) + selected = referenced->getTransform( + COLORSPACE_DIR_TO_REFERENCE); + const auto child = inspect_authored_transform(config, context, + selected, visited); + result.allowed &= child.allowed; + result.has_file_transform |= child.has_file_transform; + } + return result; + } + default: return { isSimpleAtomicTransform(transform), false }; + } +} + +// Realized-op inspection: after OCIO realizes the transform into a processor, +// every op in the group must pass the shared simple-atomic allowlist (file +// transforms have been realized into LUT ops by then). +bool +inspect_realized_transform(const ConstTransformRcPtr& transform) +{ + if (!transform) + return true; + if (transform->getTransformType() == TRANSFORM_TYPE_GROUP) { + auto group = DynamicPtrCast(transform); + if (!group) + return false; + for (int i = 0, e = group->getNumTransforms(); i < e; ++i) + if (!inspect_realized_transform(group->getTransform(i))) + return false; + return true; + } + return isSimpleAtomicTransform(transform); +} + +// A resolved hint term on a value axis (encoding / image-state / chromaticity) +// and on the transfer axis. Each pairs a term mode with the resolved value(s) +// the hint denotes. +struct ResolvedStringTerm { + spvt::SearchTermMode mode = spvt::SearchTermMode::include; + std::vector values; +}; +struct ResolvedChromaticityTerm { + spvt::SearchTermMode mode = spvt::SearchTermMode::include; + std::vector values; +}; +struct ResolvedTransferTerm { + spvt::SearchTermMode mode = spvt::SearchTermMode::include; + spvt::TransferHint hint; +}; + +// Route every per-axis verdict through the one pure three-valued combinator: +// build the parallel (modes, matches) spans, then combine. +template +bool +evaluate_axis(const std::vector& terms, const Property& property, + Matches matches, Known known) +{ + std::vector modes; + std::vector hits; + modes.reserve(terms.size()); + hits.reserve(terms.size()); + for (const Term& term : terms) { + modes.push_back(term.mode); + hits.push_back(matches(term, property) ? 1 : 0); + } + return spvt::three_valued_axis(modes, hits, known(property)); +} + +bool +string_axis_accepts(const std::vector& terms, + const std::optional& property) +{ + return evaluate_axis( + terms, property, + [](const ResolvedStringTerm& term, + const std::optional& value) { + return value + && std::find(term.values.begin(), term.values.end(), *value) + != term.values.end(); + }, + [](const std::optional& value) { + return value.has_value(); + }); +} + +// Set-valued variant for axes where a candidate legitimately carries more +// than one value (the encoding axis: authored attribute + interop-identity +// twin). A term matches when any value matches; the property is known when +// the set is non-empty. +bool +string_set_axis_accepts(const std::vector& terms, + const std::vector& values) +{ + return evaluate_axis( + terms, values, + [](const ResolvedStringTerm& term, + const std::vector& vals) { + for (const std::string& v : vals) + if (std::find(term.values.begin(), term.values.end(), v) + != term.values.end()) + return true; + return false; + }, + [](const std::vector& vals) { return !vals.empty(); }); +} + +bool +chromaticity_axis_accepts(const std::vector& terms, + const std::optional& property) +{ + return evaluate_axis( + terms, property, + [](const ResolvedChromaticityTerm& term, + const std::optional& value) { + // Exact ==; all fuzz was absorbed at derivation by + // round_chromaticity_coord. + return value + && std::find(term.values.begin(), term.values.end(), *value) + != term.values.end(); + }, + [](const std::optional& value) { + return value.has_value(); + }); +} + +bool +transfer_axis_accepts(const std::vector& terms, + const spvt::TransferProperty& property) +{ + return evaluate_axis( + terms, property, + [](const ResolvedTransferTerm& term, + const spvt::TransferProperty& value) { + return spvt::transfer_hint_matches(term.hint, value); + }, + [](const spvt::TransferProperty& value) { return value.known(); }); +} + +} // namespace + + + +std::string +ColorConfig::Impl::effectiveEncoding(string_view name) const +{ + if (!config_ || disable_ocio) + return {}; + std::string resolved(resolve(name)); + auto cs = config_->getColorSpace(resolved.c_str()); + if (cs && cs->getEncoding() && cs->getEncoding()[0]) + return cs->getEncoding(); + // Fall back to the encoding declared by the interop-identity equivalent + // (full derivation: the twin may only be discoverable by fingerprint). + std::string id(derive_color_interop_id_impl(*m_self, resolved)); + if (!id.empty()) { + if (auto registry = build_interop_identities_config()) + if (auto ics = registry->getColorSpace(id.c_str())) + if (ics->getEncoding() && ics->getEncoding()[0]) + return ics->getEncoding(); + } + return {}; +} + + + +std::optional +ColorConfig::Impl::deriveChromaticities( + string_view name, const OCIO::ConstContextRcPtr& context) const +{ + if (!config_ || disable_ocio || name.empty()) + return {}; + std::string resolved(resolve(name)); + // Reserved/registry table first: a space that resolves to a known interop + // id uses that id's reserved primaries (single hypothesis, exact ==). + if (auto reserved = spvt::reserved_chromaticities_for_id( + std::string(derive_color_interop_id_impl(*m_self, resolved)))) + return reserved; + + // Probe fallback: push pure R/G/B/W through colorspace -> the scene + // interchange (the config's AP0 anchor) and solve for xy. This is the + // config-driving step the pure chromaticities_from_ap0_probes() left to + // its caller. Single hypothesis (D65 whitepoint + Bradford CAT) via the + // CPU processor; a multi-hypothesis whitepoint/CAT sweep is a documented + // follow-on for non-D65 / log-curve spaces. + if (!interopIsInteroperable()) + return {}; + const std::string interchange = interopInterchangeName(); + if (interchange.empty()) + return {}; + auto cs = config_->getColorSpace(resolved.c_str()); + if (!cs || cs->isData()) + return {}; + float rgb[12] = { 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1 }; + try { + // Probe under the explicit per-call context when one was supplied + // (context overrides are scoped to the whole query, probes included). + auto ctx = context ? context : config_->getCurrentContext(); + auto proc = config_->getProcessor(ctx, resolved.c_str(), + interchange.c_str()); + if (!proc) + return {}; + auto cpu = proc->getDefaultCPUProcessor(); + if (!cpu) + return {}; + OCIO::PackedImageDesc desc(rgb, 4, 1, 3); + cpu->apply(desc); + } catch (...) { + return {}; + } + return spvt::chromaticities_from_ap0_probes(cspan(rgb, 12)); +} + + + +std::optional +ColorConfig::Impl::deriveTransferSignature( + string_view name, const OCIO::ConstContextRcPtr& context) const +{ + if (!config_ || disable_ocio || name.empty()) + return {}; + std::string resolved(resolve(name)); + auto cs = config_->getColorSpace(resolved.c_str()); + if (!cs || cs->isData()) + return {}; + + // Linear probe source: the scene interchange anchor for scene-referred + // spaces, the display interchange (CIE-XYZ-D65 by contract) for + // display-referred ones. The signature is the encode direction + // (linear source -> colorspace). + std::string source; + if (cs->getReferenceSpaceType() == OCIO::REFERENCE_SPACE_SCENE) { + if (interopIsInteroperable()) + source = interopInterchangeName(); + } else if (const char* disp = config_->getCanonicalName( + OCIO::ROLE_INTERCHANGE_DISPLAY)) { + source = disp; + } + if (source.empty()) + return {}; + + std::optional sig; + try { + // Probe under the explicit per-call context when one was supplied + // (context overrides are scoped to the whole query, probes included). + auto ctx = context ? context : config_->getCurrentContext(); + auto proc = config_->getProcessor(ctx, source.c_str(), + resolved.c_str()); + sig = probe_signature_over(proc ? proc->getDefaultCPUProcessor() + : OCIO::ConstCPUProcessorRcPtr()); + } catch (...) { + return {}; + } + if (!sig) + return {}; + sig->encoding = effectiveEncoding(resolved); + sig->family = tf_curve_family( + derive_color_interop_id_impl(*m_self, resolved)); + return sig; +} + + + +std::vector +ColorConfig::Impl::find_color_spaces(const spvt::FindColorSpacesOptions& options) +{ + if (!options.include_active && !options.include_inactive) + return {}; + if (!config_ || disable_ocio) + return {}; + + // Context overrides are scoped to this one query -- resolve and probe + // everything below against a context copy carrying the overrides. + const OCIO::ConstContextRcPtr ctx + = make_context_with_overrides(config_, options.context); + // Learned-complex state is scoped to the exact context this query probes + // under (see markLearnedComplex). + const std::string ctx_scope = context_cache_id(ctx); + const OCIO::ConstConfigRcPtr registry = build_interop_identities_config(); + + // ---------------- Hint resolution (fail-fast, pre-walk) ---------------- + // Per axis: exact local name -> known interop id -> axis-specific + // fallback. Every unresolvable hint throws std::invalid_argument here, + // before a single candidate is examined. + + auto local_space = [&](const std::string& raw) { + return config_->getColorSpace(raw.c_str()); + }; + auto registry_space = + [&](const std::string& raw) -> OCIO::ConstColorSpaceRcPtr { + return registry ? registry->getColorSpace(raw.c_str()) + : OCIO::ConstColorSpaceRcPtr(); + }; + auto reference_state = [](const OCIO::ConstColorSpaceRcPtr& cs) { + return std::string(cs->getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY + ? "display" + : "scene"); + }; + + // The interop-identity twin's encoding for a derived interop id (empty + // when there is no id or the id has no registry entry). The id itself + // comes from the shared characterization engine below, so repeat + // searches and prior public derives make this cache-only. + auto twin_encoding_for_id = [&](const std::string& id) -> std::string { + if (id.empty() || !registry) + return {}; + auto ics = registry->getColorSpace(id.c_str()); + if (ics && ics->getEncoding() && ics->getEncoding()[0]) + return Strutil::lower(ics->getEncoding()); + return {}; + }; + + // Per-candidate characterization routes through the one shared + // field-selective engine (the same records the internal get/derive facade + // read and publish): each axis requests only the field bits it needs, + // partial cached records are merged in, and a prior complete derive + // makes the whole walk cache-only. Derivation failures surface as + // unavailable fields, which the three-valued axes treat as "unknown". + using CField = spvt::CharacterizationField; + auto characterization = [&](const std::string& name, CField fields) { + return characterize_color_space_impl(*m_self, name, uint32_t(fields), + options.context); + }; + + auto resolve_encoding = + [&](const std::string& raw) -> std::vector { + if (auto local = local_space(raw)) { + // Hint-by-example reads the space's own effective encoding + // (authored, else twin-adopted); strict reads authored only. + std::string encoding + = options.strict + ? (local->getEncoding() + ? Strutil::lower(local->getEncoding()) + : std::string()) + : Strutil::lower(effectiveEncoding(local->getName())); + if (encoding.empty()) + throw std::invalid_argument( + "encoding hint has no derivable encoding: " + raw); + return { std::move(encoding) }; + } + if (auto rcs = registry_space(raw)) { + std::string encoding = rcs->getEncoding() + ? Strutil::lower(rcs->getEncoding()) + : std::string(); + if (encoding.empty()) + throw std::invalid_argument( + "encoding hint has no derivable encoding: " + raw); + return { std::move(encoding) }; + } + // Literal fallback: must be a known encoding (authored in this config + // or the identities registry). + const std::string literal = Strutil::lower(raw); + std::unordered_set known; + auto gather = [&](const OCIO::ConstConfigRcPtr& cfg) { + if (!cfg) + return; + const int nn + = cfg->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL); + for (int i = 0; i < nn; ++i) { + const char* nm = cfg->getColorSpaceNameByIndex( + OCIO::SEARCH_REFERENCE_SPACE_ALL, OCIO::COLORSPACE_ALL, i); + auto ccs = nm ? cfg->getColorSpace(nm) + : OCIO::ConstColorSpaceRcPtr(); + if (ccs && ccs->getEncoding() && ccs->getEncoding()[0]) + known.insert(Strutil::lower(ccs->getEncoding())); + } + }; + gather(config_); + gather(registry); + if (!known.count(literal)) + throw std::invalid_argument("unresolved encoding hint: " + raw); + return { literal }; + }; + + auto resolve_state = + [&](const std::string& raw) -> std::vector { + if (auto local = local_space(raw)) + return { reference_state(local) }; + if (auto rcs = registry_space(raw)) + return { reference_state(rcs) }; + const std::string literal = Strutil::lower(raw); + if (literal == "scene" || literal == "display") + return { literal }; + if (literal == "all") + return { "scene", "display" }; + throw std::invalid_argument("unresolved image-state hint: " + raw); + }; + + auto resolve_chromaticities = + [&](const std::string& raw) -> std::vector { + if (auto local = local_space(raw)) { + if (auto value = deriveChromaticities(local->getName(), ctx)) + return { *value }; + throw std::invalid_argument( + "chromaticities hint has no derivable value: " + raw); + } + // Registry chromaticities come from the reserved-primaries table + // (table-only; gamuts absent from the table, e.g. ciexyzd65, are + // documented as not-yet-resolvable, sharing the probe-port follow-on). + if (auto rcs = registry_space(raw)) { + if (auto value = spvt::reserved_chromaticities_for_id( + rcs->getName())) + return { *value }; + throw std::invalid_argument( + "chromaticities hint has no derivable value: " + raw); + } + // Gamut-component fragment: the complete component of some registry id. + const std::string component = Strutil::lower(raw); + std::vector values; + if (registry) { + const int nn + = registry->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL); + for (int i = 0; i < nn; ++i) { + const char* nm = registry->getColorSpaceNameByIndex( + OCIO::SEARCH_REFERENCE_SPACE_ALL, OCIO::COLORSPACE_ALL, i); + if (!nm || Strutil::lower(gamut_component(nm)) != component) + continue; + auto value = spvt::reserved_chromaticities_for_id(nm); + if (value + && std::find(values.begin(), values.end(), *value) + == values.end()) + values.push_back(*value); + } + } + if (values.empty()) + throw std::invalid_argument("unresolved chromaticities hint: " + + raw); + return values; + }; + + auto resolve_string_terms = [&](const std::vector& inputs, + const auto& resolver) { + std::vector terms; + for (const std::string& input : inputs) { + auto [mode, value] = spvt::parse_search_term(input); + if (value.empty()) + continue; + terms.push_back({ mode, resolver(value) }); + } + return terms; + }; + + auto resolve_transfer_terms = [&]() { + std::vector terms; + for (const std::string& input : options.transfer_functions) { + auto [mode, value] = spvt::parse_search_term(input); + if (value.empty()) + continue; + + ResolvedTransferTerm term; + term.mode = mode; + spvt::TransferHint& hint = term.hint; + if (auto local = local_space(value)) { + const std::string name = local->getName(); + try { + // NOTE: OCIO's isColorSpaceLinear() takes no context, so + // for a context-sensitive space it evaluates under the + // config's ambient context. The context-threaded + // signature probe below is the authoritative transfer + // evidence for such spaces. + hint.identity = isColorSpaceLinear(name); + hint.family = tf_curve_family( + derive_color_interop_id_impl(*m_self, name)); + } catch (...) { + } + if (!hint.identity) + if (auto sig = deriveTransferSignature(name, ctx)) + hint.signatures.push_back(std::move(*sig)); + } else if (auto rcs = registry_space(value)) { + // Known interop id: the registry space is an identity, so its + // curve behavior is exactly what the id names. + const std::string id = rcs->getName(); + const std::string encoding + = rcs->getEncoding() ? Strutil::lower(rcs->getEncoding()) + : std::string(); + hint.identity = encoding == "scene-linear" + || encoding == "display-linear"; + hint.family = tf_curve_family(id); + if (!hint.identity) { + try { + const char* role + = rcs->getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY + ? OCIO::ROLE_INTERCHANGE_DISPLAY + : OCIO::ROLE_INTERCHANGE_SCENE; + auto proc = registry->getProcessor(role, id.c_str()); + if (auto sig = probe_signature_over( + proc ? proc->getDefaultCPUProcessor() + : OCIO::ConstCPUProcessorRcPtr())) { + sig->encoding = encoding; + hint.signatures.push_back(std::move(*sig)); + } + } catch (...) { + } + } + } else { + // Named transforms: local config first, then the registry. + // The identities registry ships no crv_ named transforms, so + // the local-crv-must-match-registry-twin family rule finds no + // twin and assigns no family; such hints match by signature + // only until the registry grows crv_ entries. + auto nt = find_named_transform(config_, value); + bool from_registry = false; + OCIO::ConstConfigRcPtr source_config = config_; + OCIO::ConstContextRcPtr source_ctx = ctx; + if (!nt && registry) { + nt = find_named_transform(registry, value); + from_registry = static_cast(nt); + source_config = registry; + source_ctx = registry->getCurrentContext(); + } + if (nt) { + const std::string encoding + = nt->getEncoding() ? Strutil::lower(nt->getEncoding()) + : std::string(); + hint.identity = encoding == "scene-linear" + || encoding == "display-linear"; + const std::string transform_name = Strutil::lower( + nt->getName() ? nt->getName() : ""); + std::optional sig; + if (!hint.identity) { + try { + auto proc = source_config->getProcessor( + source_ctx, nt, OCIO::TRANSFORM_DIR_INVERSE); + sig = probe_signature_over( + proc ? proc->getDefaultCPUProcessor() + : OCIO::ConstCPUProcessorRcPtr()); + if (sig) + sig->encoding = encoding; + } catch (...) { + } + if (sig) + hint.signatures.push_back(*sig); + } + if (!hint.identity && sig + && Strutil::starts_with(transform_name, "crv_")) { + bool registry_behavior = from_registry; + if (!registry_behavior && registry) { + if (auto registered + = find_named_transform(registry, + transform_name)) { + try { + auto rproc = registry->getProcessor( + registry->getCurrentContext(), + registered, + OCIO::TRANSFORM_DIR_INVERSE); + if (auto rsig = probe_signature_over( + rproc + ? rproc->getDefaultCPUProcessor() + : OCIO::ConstCPUProcessorRcPtr())) + registry_behavior + = spvt::transfer_signatures_match( + *sig, *rsig); + } catch (...) { + } + } + } + if (registry_behavior) + hint.family = tf_curve_family(transform_name); + } + } + } + if (!hint.identity && hint.signatures.empty()) + throw std::invalid_argument( + "unresolved or unprobeable transfer-function hint: " + + value); + terms.push_back(std::move(term)); + } + return terms; + }; + + std::vector chromaticity_terms; + for (const std::string& input : options.chromaticities) { + auto [mode, value] = spvt::parse_search_term(input); + if (value.empty()) + continue; + chromaticity_terms.push_back({ mode, resolve_chromaticities(value) }); + } + const auto transfer_terms = resolve_transfer_terms(); + const auto encoding_terms = resolve_string_terms(options.encodings, + resolve_encoding); + const auto state_terms = resolve_string_terms(options.image_states, + resolve_state); + + // ---------------- Candidate eligibility ---------------- + // Default universe: simple, matchable, not data/unique/learned-complex. + // With exhaustive=true a non-simple, file-backed space MAY be revisited if + // its authored graph is exhaustive-eligible and realizes cleanly. + auto candidate_eligible = [&](const std::string& name, int flags) { + if ((flags & (CSInfo::is_data | CSInfo::is_unique)) + || isLearnedComplex(ctx_scope, name)) + return false; + if ((flags & CSInfo::is_simple) + && !(flags & CSInfo::should_skip_matching)) + return true; + if (!options.exhaustive) + return false; + + auto ocs = config_->getColorSpace(name.c_str()); + if (!ocs) + return false; + OCIO::ConstTransformRcPtr transform = ocs->getTransform( + OCIO::COLORSPACE_DIR_FROM_REFERENCE); + OCIO::TransformDirection direction = OCIO::TRANSFORM_DIR_FORWARD; + if (!transform) { + transform = ocs->getTransform(OCIO::COLORSPACE_DIR_TO_REFERENCE); + direction = OCIO::TRANSFORM_DIR_INVERSE; + } + try { + std::unordered_set visited { name }; + const auto authored + = inspect_authored_transform(config_, ctx, transform, visited); + if (!authored.allowed || !authored.has_file_transform) + return false; + + // The exhaustive "construction succeeds" gate is a realize-clean + + // allowlist check -- realize the processor and require every + // realized op to pass the same simple-atomic allowlist. It + // deliberately does NOT consult the fingerprint subsystem (which + // carries no tolerance gate and scans nondeterministically); the + // allowlist realize-check is sufficient and keeps the exhaustive + // gate deterministic and tolerance-clean. + auto proc = config_->getProcessor(ctx, transform, direction); + if (!proc + || !inspect_realized_transform(proc->createGroupTransform())) { + markLearnedComplex(ctx_scope, name); + return false; + } + } catch (...) { + return false; + } + return true; + }; + + // ---------------- Bounded-exhaustive walk ---------------- + struct RankedName { + int invariant, active, simple; + std::string name; + }; + std::vector result; + + const int n = config_->getNumColorSpaces(OCIO::SEARCH_REFERENCE_SPACE_ALL, + OCIO::COLORSPACE_ALL); + for (int i = 0; i < n; ++i) { + const char* cname = config_->getColorSpaceNameByIndex( + OCIO::SEARCH_REFERENCE_SPACE_ALL, OCIO::COLORSPACE_ALL, i); + if (!cname || !*cname) + continue; + const std::string name(cname); + + bool active = true; + int flags = 0; + if (find(name)) + flags = analysisFlags(name, &active); + else + flags = compute_analysis_flags(name, active); // inactive spaces + + if ((active && !options.include_active) + || (!active && !options.include_inactive)) + continue; + if (!(flags & CSInfo::is_context_invariant) + && !options.include_context_sensitive) + continue; + if (!candidate_eligible(name, flags)) + continue; + + auto cs = config_->getColorSpace(name.c_str()); + if (!cs) + continue; + + // Per-candidate characterization comes from the shared engine, one + // axis at a time in the established evaluation order, so a + // candidate rejected by a cheap axis is never probed for an + // expensive one. Engine derivation failures yield an "unknown" + // property (three-valued), never an abort. + // + // Encoding characterizes as up to two values: the authored attribute + // plus — non-strict — the interop-identity twin's encoding (which is + // also the adopted value when no attribute is authored). A LUT space + // tagged g26_p3d65_display with encoding sdr-video matches both + // "sdr-video" and "sdr-cinema". + std::vector encoding_values; + if (!encoding_terms.empty()) { + std::string literal = cs->getEncoding() + ? Strutil::lower(cs->getEncoding()) + : std::string(); + if (!literal.empty()) + encoding_values.push_back(literal); + if (!options.strict) { + const auto rec = characterization(name, CField::ColorInteropID); + std::string twin = twin_encoding_for_id( + rec.available(CField::ColorInteropID) ? rec.color_interop_id + : std::string()); + if (!twin.empty() && twin != literal) + encoding_values.push_back(std::move(twin)); + } + } + if (!string_set_axis_accepts(encoding_terms, encoding_values)) + continue; + + const std::optional state { reference_state(cs) }; + if (!string_axis_accepts(state_terms, state)) + continue; + + std::optional chromaticities; + if (!chromaticity_terms.empty()) + chromaticities = characterization(name, CField::Chromaticities) + .chromaticities_xy; + if (!chromaticity_axis_accepts(chromaticity_terms, chromaticities)) + continue; + + spvt::TransferProperty transfer; + if (!transfer_terms.empty()) { + // NOTE: the engine's conservative identity verdict comes from + // OCIO's context-free isColorSpaceLinear(), which it consults + // only under the ambient context; the context-threaded signature + // it probes is authoritative for context-sensitive spaces. The + // family key still derives from the interop id even when the + // signature probe fails. + const auto rec = characterization(name, + CField::TransferFunction + | CField::ColorInteropID); + transfer.identity = rec.transfer_identity; + if (rec.available(CField::ColorInteropID)) + transfer.family = tf_curve_family(rec.color_interop_id); + if (rec.transfer_signature) + transfer.signature = rec.transfer_signature; + } + if (!transfer_axis_accepts(transfer_terms, transfer)) + continue; + + result.push_back({ (flags & CSInfo::is_context_invariant) ? 0 : 1, + active ? 0 : 1, (flags & CSInfo::is_simple) ? 0 : 1, + name }); + } + + // ---------------- Deterministic order ---------------- + // Determinism comes from this final sort, never the scan order: + // (context-invariant, active, simple, name). + std::sort(result.begin(), result.end(), + [](const RankedName& l, const RankedName& r) { + if (l.invariant != r.invariant) + return l.invariant < r.invariant; + if (l.active != r.active) + return l.active < r.active; + if (l.simple != r.simple) + return l.simple < r.simple; + return l.name < r.name; + }); + std::vector names; + names.reserve(result.size()); + for (RankedName& entry : result) + names.push_back(std::move(entry.name)); + return names; +} + +OIIO_NAMESPACE_END + + + +// The pvt shims below are declared (OIIO_API) in the library's "current" +// namespace by color_pvt.h, so they must be defined there too, not inside +// the ABI-versioned v3_1 namespace the helpers above live in. +OIIO_NAMESPACE_BEGIN + +namespace pvt { + + +std::vector +find_color_spaces(const ColorConfig& config, + const FindColorSpacesOptions& options) +{ + auto* impl = v3_1::pvt::ColorConfigClassificationPeek::impl(config); + return impl ? impl->find_color_spaces(options) + : std::vector {}; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_spec_resolve_test.cpp b/src/libOpenImageIO/color_spec_resolve_test.cpp new file mode 100644 index 0000000000..6dd4b39e47 --- /dev/null +++ b/src/libOpenImageIO/color_spec_resolve_test.cpp @@ -0,0 +1,936 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for the spec-aware color-metadata resolution surface (pvt): +// fact extraction from an ImageSpec and the spec resolve() overload, driven +// directly through color_pvt.h. Also locks the reader-path / spec-path +// same-hints-same-answer regression. + +#include +#include +#include +#include + +#include "color_pvt.h" +#include +#include +#include +#include +#include + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; +using namespace OIIO::pvt; + + +// A small, valid OCIO config whose srgb_rec709_display carries a real +// (non-identity) transform, so pixel values distinguish which source space +// a conversion actually used. CICP (1,13,*,*) maps to srgb_rec709_display +// via the built-in table (CICP describes display encodings, so the display +// twin is listed first), which this config resolves locally. +static std::string +write_test_config() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_csr_test.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + data: raw_data + scene_linear: lin_test_scene +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_scene} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! + name: raw_data + isdata: true + - ! + name: lin_test_scene + - ! + name: lin_ap1_scene + - ! + name: srgb_rec709_scene + - ! + name: srgb_rec709_display + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1.0]} +file_rules: + - ! {name: lin_rule, pattern: "*lin_test_scene*", extension: "*", colorspace: lin_test_scene} + - ! {name: Default, colorspace: default} +)"; + f.close(); + return path; +} + + +// A strict-parsing config that declares the "error:unknown" catch space +// the effective-strict failure split honors (with a real transform, so +// pixels prove which source space a conversion used). +static std::string +write_strict_config() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_csr_strict.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +strictparsing: true +roles: + default: raw_data + scene_linear: lin_strict +displays: + disp: + - ! {name: view, colorspace: lin_strict} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! + name: raw_data + isdata: true + - ! + name: lin_strict + - ! + name: "error:unknown" + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1.0]} +)"; + f.close(); + return path; +} + + +// A minimal identity 1D LUT, for exercising ociofiletransform. +static std::string +write_cube_lut(const std::string& basename) +{ + std::string path = Filesystem::temp_directory_path() + "/" + basename; + std::ofstream f(path); + f << "LUT_1D_SIZE 2\n0.0 0.0 0.0\n0.9 0.9 0.9\n"; + f.close(); + return path; +} + + +// A config that resolves NO interop identity locally, for the +// config-or-registry failover vectors. +static std::string +write_sparse_config() +{ + std::string path = Filesystem::temp_directory_path() + + "/oiio_csr_sparse.ocio"; + std::ofstream f(path); + f << R"(ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_only +displays: + disp: + - ! {name: view, colorspace: lin_only} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! + name: raw_data + isdata: true + - ! + name: lin_only +)"; + f.close(); + return path; +} + + +// A >=128-byte blob carrying the 'acsp' signature at offset 36 (the +// resolver's decodability test). +static std::vector +fake_icc_profile() +{ + std::vector p(200, 0x00); + p[36] = 'a'; + p[37] = 'c'; + p[38] = 's'; + p[39] = 'p'; + return p; +} + + +static const int kCicpSrgb[4] = { 1, 13, 0, 1 }; + + +// Every ColorMetadataFacts field the spec carries is extracted; absent +// attributes leave their fields absent. +static void +test_facts_from_spec() +{ + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("acesImageContainerFlag", 1); + spec.attribute("colorInteropID", "lin_ap1_scene"); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + const float chroma[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.3290f }; + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), chroma); + spec.attribute("oiio:Gamma", 2.4f); + auto icc = fake_icc_profile(); + spec.attribute("ICCProfile", TypeDesc(TypeDesc::UINT8, int(icc.size())), + icc.data()); + + ColorMetadataFacts f = color_facts_from_spec(spec); + OIIO_CHECK_ASSERT(f.aces_image_container); + OIIO_CHECK_EQUAL(f.color_interop_id, "lin_ap1_scene"); + OIIO_CHECK_EQUAL(f.icc_profile.size(), icc.size()); + OIIO_CHECK_ASSERT(f.has_cicp); + OIIO_CHECK_EQUAL(f.cicp[1], 13); + OIIO_CHECK_ASSERT(f.has_chromaticities); + OIIO_CHECK_EQUAL(f.chromaticities[6], 0.3127f); + OIIO_CHECK_ASSERT(f.has_gamma); + OIIO_CHECK_EQUAL(f.gamma, 2.4f); + + ColorMetadataFacts empty = color_facts_from_spec( + ImageSpec(4, 4, 3, TypeFloat)); + OIIO_CHECK_ASSERT(!empty.aces_image_container); + OIIO_CHECK_ASSERT(empty.color_interop_id.empty()); + OIIO_CHECK_ASSERT(empty.icc_profile.empty()); + OIIO_CHECK_ASSERT(!empty.has_cicp); + OIIO_CHECK_ASSERT(!empty.has_chromaticities); + OIIO_CHECK_ASSERT(!empty.has_gamma); +} + + +// Regression lock: the same hints resolve identically whether entered as a +// facts struct (the reader path) or through the spec overload (the IBA +// path) -- equal resolved values AND step-by-step equal traces. +static void +test_same_hints_same_answer(const ColorConfig& config) +{ + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("colorInteropID", "unknown"); // unusable, falls through + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + + ColorMetadataFacts facts; + facts.color_interop_id = "unknown"; + facts.has_cicp = true; + for (int i = 0; i < 4; ++i) + facts.cicp[i] = kCicpSrgb[i]; + + auto a = resolve_color_metadata(&config, "", facts, {}, {}); + auto b = resolve_color_metadata(&config, spec, {}, {}); + OIIO_CHECK_EQUAL(a.resolved, b.resolved); + OIIO_CHECK_EQUAL(a.resolved, "srgb_rec709_display"); + OIIO_CHECK_EQUAL(a.steps.size(), b.steps.size()); + for (size_t i = 0; i < a.steps.size() && i < b.steps.size(); ++i) { + OIIO_CHECK_EQUAL(int(a.steps[i].rule), int(b.steps[i].rule)); + OIIO_CHECK_EQUAL(int(a.steps[i].outcome), int(b.steps[i].outcome)); + OIIO_CHECK_EQUAL(a.steps[i].candidate, b.steps[i].candidate); + OIIO_CHECK_EQUAL(a.steps[i].resolved, b.steps[i].resolved); + OIIO_CHECK_EQUAL(a.steps[i].reason, b.steps[i].reason); + } +} + + +// Config-or-registry failover: a config that cannot name the CICP identity +// bridges to the registry id under the (default) lenient scope; config-only +// scope refuses instead of bridging. +static void +test_config_or_registry_failover(const ColorConfig& sparse) +{ + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + + auto lenient = resolve_color_metadata(&sparse, spec, {}, {}); + OIIO_CHECK_EQUAL(lenient.resolved, "srgb_rec709_display"); + OIIO_CHECK_ASSERT(lenient.has_genuine_metadata_match()); + + ColorReadPolicy config_only; + config_only.scope = ColorResolutionScope::ConfigOnly; + auto strict = resolve_color_metadata(&sparse, spec, {}, config_only); + OIIO_CHECK_ASSERT(!strict.has_genuine_metadata_match()); +} + + +// The inference helper: a usable hint answers; a synthetic-only answer +// (here: chromaticities with no config match) is not usable as an IBA +// source; when CICP and a decodable ICC profile are both present, CICP +// outranks ICC (PNG chunk precedence: cICP > iCCP) and answers directly. +static void +test_infer_helper(const ColorConfig& config) +{ + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + OIIO_CHECK_EQUAL(infer_color_space_from_spec(&config, spec, {}, {}), + "srgb_rec709_display"); + } + { + // Wide-gamut chromaticities resolve only to a custom: synthetic -- + // no constructible space, so no inference. + ImageSpec spec(4, 4, 3, TypeFloat); + const float chroma[8] = { 0.708f, 0.292f, 0.170f, 0.797f, + 0.131f, 0.046f, 0.3127f, 0.3290f }; + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), chroma); + OIIO_CHECK_EQUAL(infer_color_space_from_spec(&config, spec, {}, {}), + ""); + } + { + // CICP outranks the decodable ICC profile (cICP > iCCP) and + // answers on the first pass; no config-only retry is needed. + ImageSpec spec(4, 4, 3, TypeFloat); + auto icc = fake_icc_profile(); + spec.attribute("ICCProfile", TypeDesc(TypeDesc::UINT8, int(icc.size())), + icc.data()); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + OIIO_CHECK_EQUAL(infer_color_space_from_spec(&config, spec, {}, {}), + "srgb_rec709_display"); + } + { + ImageSpec spec(4, 4, 3, TypeFloat); // no hints at all + OIIO_CHECK_EQUAL(infer_color_space_from_spec(&config, spec, {}, {}), + ""); + } +} + + +// IBA wiring: an untagged, CICP-carrying source converts exactly as if the +// caller had passed the mapped space explicitly; an explicit source always +// wins over contradictory hints; a hintless untagged source keeps today's +// scene_linear default. +static void +test_iba_inference(const ColorConfig& config) +{ + ImageSpec spec(8, 8, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + ImageBuf src(spec); + ImageBufAlgo::fill(src, { 0.5f, 0.25f, 0.75f }); + + // Inferred source == explicit source, pixel for pixel. + ImageBuf inferred = ImageBufAlgo::colorconvert(src, "", "lin_test_scene", + true, "", "", &config); + OIIO_CHECK_ASSERT(!inferred.has_error()); + ImageBuf explicit_src + = ImageBufAlgo::colorconvert(src, "srgb_rec709_display", + "lin_test_scene", true, "", "", &config); + OIIO_CHECK_ASSERT(!explicit_src.has_error()); + auto cmp = ImageBufAlgo::compare(inferred, explicit_src, 0.0f, 0.0f); + OIIO_CHECK_EQUAL(cmp.nfail, 0); + OIIO_CHECK_EQUAL(inferred.spec().get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + + // The conversion was real: it differs from a no-op source. + ImageBuf noop = ImageBufAlgo::colorconvert(src, "lin_test_scene", + "lin_test_scene", true, "", "", + &config); + OIIO_CHECK_ASSERT(!noop.has_error()); + auto cmp2 = ImageBufAlgo::compare(inferred, noop, 0.0f, 0.0f); + OIIO_CHECK_ASSERT(cmp2.nfail > 0); + + // Explicit source wins over the contradictory CICP hint. + ImageBuf explicit_wins = ImageBufAlgo::colorconvert(src, "lin_ap1_scene", + "lin_test_scene", true, + "", "", &config); + OIIO_CHECK_ASSERT(!explicit_wins.has_error()); + auto cmp3 = ImageBufAlgo::compare(explicit_wins, explicit_src, 0.0f, 0.0f); + OIIO_CHECK_ASSERT(cmp3.nfail > 0); + + // No hints: today's scene_linear default stands (scene_linear -> + // lin_test_scene is a no-op in this config). + ImageBuf plain_src(ImageSpec(8, 8, 3, TypeFloat)); + ImageBufAlgo::fill(plain_src, { 0.5f, 0.25f, 0.75f }); + ImageBuf dflt = ImageBufAlgo::colorconvert(plain_src, "", "lin_test_scene", + true, "", "", &config); + OIIO_CHECK_ASSERT(!dflt.has_error()); + ImageBuf dflt_explicit + = ImageBufAlgo::colorconvert(plain_src, "scene_linear", + "lin_test_scene", true, "", "", &config); + auto cmp4 = ImageBufAlgo::compare(dflt, dflt_explicit, 0.0f, 0.0f); + OIIO_CHECK_EQUAL(cmp4.nfail, 0); +} + + +// The scrubber applies the two-bucket rule categorically: after an +// identity-known color change (which the caller asserts), every +// file-provenance fact is stale and erased -- no per-signal re-resolution +// -- while the deliberate unknown-marker family is honored. +static void +test_scrubber(const ColorConfig& config) +{ + (void)config; // categorical scrubbing needs no config + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + spec.attribute("colorInteropID", "lin_ap1_scene"); + const float chroma[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.3290f }; + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), chroma); + spec.attribute("oiio:Gamma", 2.4f); + auto icc = fake_icc_profile(); + spec.attribute("ICCProfile", TypeDesc(TypeDesc::UINT8, int(icc.size())), + icc.data()); + scrub_color_metadata(spec); + // Every provenance fact is gone. + OIIO_CHECK_ASSERT(!spec.find_attribute("CICP")); + OIIO_CHECK_ASSERT(!spec.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(!spec.find_attribute("chromaticities")); + OIIO_CHECK_ASSERT(!spec.find_attribute("oiio:Gamma")); + OIIO_CHECK_ASSERT(!spec.find_attribute("ICCProfile")); + // The color space itself is never scrubbed. + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + } + { + // Categorical: even claims no resolver could decide (a vendor id, + // a garbage ICC blob) are provenance and go -- never persist stale + // information. + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + spec.attribute("colorInteropID", "vendorx_mystery"); + std::vector garbage(200, 0x42); + spec.attribute("ICCProfile", + TypeDesc(TypeDesc::UINT8, int(garbage.size())), + garbage.data()); + scrub_color_metadata(spec); + OIIO_CHECK_ASSERT(!spec.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(!spec.find_attribute("ICCProfile")); + } + { + // The deliberate unknown-marker family (ocio:unknown / + // oiio:unknown / error:unknown) is honored, never scrubbed + // (treatment/error state, not provenance); a bare "unknown" claim + // named the pre-operation state and goes. + for (const char* marker : + { "ocio:unknown", "oiio:unknown", "error:unknown" }) { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + spec.attribute("colorInteropID", marker); + scrub_color_metadata(spec); + OIIO_CHECK_EQUAL(spec.get_string_attribute("colorInteropID"), + marker); + } + + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + spec.attribute("colorInteropID", "unknown"); + scrub_color_metadata(spec); + OIIO_CHECK_ASSERT(!spec.find_attribute("colorInteropID")); + } + { + // Current-state descriptors are the other bucket: never touched by + // the provenance scrub. + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + spec.attribute("oiio:ColorSpace:state", "scene"); + spec.attribute("oiio:ColorSpace:range", "narrow"); + scrub_color_metadata(spec); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace:state"), + "scene"); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace:range"), + "narrow"); + } +} + + +// Check a current-state descriptor against the update-or-erase rule: a +// usable value updates the attribute, an unavailable one erases it. +static void +check_descriptor(const ImageSpec& spec, const char* name, string_view value) +{ + if (value.size()) + OIIO_CHECK_EQUAL(spec.get_string_attribute(name), std::string(value)); + else + OIIO_CHECK_ASSERT(!spec.find_attribute(name)); +} + + +// ColorConfig::set_colorspace routes through the shared identity-known +// hygiene: changing an existing claim scrubs the provenance-facts bucket +// and maintains (update-or-erase) any current-state descriptors present; +// first tagging preserves the facts (they are the evidence read paths +// derive the claim from); the empty name erases everything; re-asserting +// the current claim is a no-op. All of it cheap: no fingerprint is ever +// derived. +static void +test_set_colorspace_hygiene(const ColorConfig& config) +{ + const float chroma[8] = { 0.64f, 0.33f, 0.30f, 0.60f, + 0.15f, 0.06f, 0.3127f, 0.3290f }; + auto icc = fake_icc_profile(); + auto add_facts = [&](ImageSpec& spec) { + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + spec.attribute("colorInteropID", "lin_ap1_scene"); + spec.attribute("chromaticities", TypeDesc(TypeDesc::FLOAT, 8), chroma); + spec.attribute("oiio:Gamma", 2.4f); + spec.attribute("ICCProfile", TypeDesc(TypeDesc::UINT8, int(icc.size())), + icc.data()); + spec.attribute("Exif:ColorSpace", 1); + spec.attribute("tiff:ColorSpace", 1); + }; + + // Changing an existing claim: verdict updated, the full provenance + // bucket (and the format-specific hints) scrubbed -- and no + // descriptors are introduced on a spec that carried none. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "srgb_rec709_scene"); + add_facts(spec); + config.set_colorspace(spec, "lin_test_scene"); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + for (const char* attr : + { "CICP", "colorInteropID", "chromaticities", "oiio:Gamma", + "ICCProfile", "Exif:ColorSpace", "tiff:ColorSpace" }) + OIIO_CHECK_ASSERT(!spec.find_attribute(attr)); + for (const char* attr : + { "oiio:ColorSpace:state", "oiio:ColorSpace:encoding", + "oiio:ColorSpace:range", "oiio:ColorSpace:equality_id" }) + OIIO_CHECK_ASSERT(!spec.find_attribute(attr)); + } + + // Descriptor maintenance on a change is update-or-erase against the + // cheap characterization of the new space -- and never computes a + // fingerprint. + { + const size_t fp_before = color_space_fingerprint_cache_size(); + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "srgb_rec709_display"); + spec.attribute("oiio:ColorSpace:state", "display"); + spec.attribute("oiio:ColorSpace:range", "narrow"); + spec.attribute("oiio:ColorSpace:equality_id", "stale_equality_id"); + config.set_colorspace(spec, "lin_test_scene"); + ColorSpaceInfo info = config.get_color_space_info("lin_test_scene"); + OIIO_CHECK_ASSERT(info.valid()); + OIIO_CHECK_EQUAL(info.image_state(), "scene"); + check_descriptor(spec, "oiio:ColorSpace:state", info.image_state()); + check_descriptor(spec, "oiio:ColorSpace:encoding", info.encoding()); + // Stale values that the cheap get cannot vouch for are erased, + // never retained and never derived. + check_descriptor(spec, "oiio:ColorSpace:range", info.range()); + check_descriptor(spec, "oiio:ColorSpace:equality_id", + info.equality_id()); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), fp_before); + } + + // First tagging (no previous claim) preserves the provenance facts: + // read paths derive the claim from them. Only the longstanding + // hand-invalidated hints (gamma, Exif/tiff) are removed. + { + ImageSpec spec(4, 4, 3, TypeFloat); + add_facts(spec); + config.set_colorspace(spec, "lin_test_scene"); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + for (const char* attr : + { "CICP", "colorInteropID", "chromaticities", "ICCProfile" }) + OIIO_CHECK_ASSERT(spec.find_attribute(attr)); + OIIO_CHECK_ASSERT(!spec.find_attribute("oiio:Gamma")); + OIIO_CHECK_ASSERT(!spec.find_attribute("Exif:ColorSpace")); + OIIO_CHECK_ASSERT(!spec.find_attribute("tiff:ColorSpace")); + OIIO_CHECK_ASSERT(!spec.find_attribute("oiio:ColorSpace:state")); + } + + // Empty name: absence semantics -- verdict, facts, and descriptors + // are all erased. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "srgb_rec709_scene"); + add_facts(spec); + spec.attribute("oiio:ColorSpace:state", "scene"); + spec.attribute("oiio:ColorSpace:range", "narrow"); + config.set_colorspace(spec, ""); + OIIO_CHECK_ASSERT(!spec.find_attribute("oiio:ColorSpace")); + for (const char* attr : + { "CICP", "colorInteropID", "chromaticities", "oiio:Gamma", + "ICCProfile", "oiio:ColorSpace:state", "oiio:ColorSpace:range" }) + OIIO_CHECK_ASSERT(!spec.find_attribute(attr)); + } + + // Re-asserting the current claim is a no-op: facts and descriptors + // (even stale ones) are untouched. Refreshes ride an actual change of + // claim or the pixel-operation hygiene, keeping redundant re-tagging + // read paths (e.g. Exif decode) byte-identical. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + add_facts(spec); + spec.attribute("oiio:ColorSpace:state", "display"); + config.set_colorspace(spec, "lin_test_scene"); + OIIO_CHECK_ASSERT(spec.find_attribute("CICP")); + OIIO_CHECK_ASSERT(spec.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(spec.find_attribute("oiio:Gamma")); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace:state"), + "display"); + } + + // set_colorspace_rec709_gamma inherits the routing and still records + // the gamma afterward. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "lin_test_scene"); + config.set_colorspace_rec709_gamma(spec, 2.2f); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "g22_rec709_scene"); + OIIO_CHECK_EQUAL(spec.get_float_attribute("oiio:Gamma"), 2.2f); + } + + // ImageSpec::set_colorspace routes through ColorConfig::set_colorspace + // (default config) and additionally always invalidates CICP -- but the + // first-tagging path still preserves the other evidence facts. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + spec.attribute("colorInteropID", "lin_ap1_scene"); + spec.set_colorspace("lin_ap1_scene"); + OIIO_CHECK_EQUAL(spec.get_string_attribute("oiio:ColorSpace"), + "lin_ap1_scene"); + OIIO_CHECK_ASSERT(!spec.find_attribute("CICP")); + OIIO_CHECK_ASSERT(spec.find_attribute("colorInteropID")); + } +} + + +// IBA wiring of the scrubber: identity-known operations scrub the outgoing +// spec's provenance facts uniformly -- inferred AND explicit sources alike +// (the facts describe the pre-operation source either way). +static void +test_iba_scrub_wiring(const ColorConfig& config) +{ + ImageSpec spec(8, 8, 3, TypeFloat); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + auto icc = fake_icc_profile(); + spec.attribute("ICCProfile", TypeDesc(TypeDesc::UINT8, int(icc.size())), + icc.data()); + ImageBuf src(spec); + ImageBufAlgo::fill(src, { 0.5f, 0.25f, 0.75f }); + + ImageBuf inferred = ImageBufAlgo::colorconvert(src, "", "lin_test_scene", + true, "", "", &config); + OIIO_CHECK_ASSERT(!inferred.has_error()); + OIIO_CHECK_EQUAL(inferred.spec().get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + OIIO_CHECK_ASSERT(!inferred.spec().find_attribute("ICCProfile")); + OIIO_CHECK_ASSERT(!inferred.spec().find_attribute("CICP")); + + ImageBuf explicit_src = ImageBufAlgo::colorconvert(src, "srgb_rec709_scene", + "lin_test_scene", true, + "", "", &config); + OIIO_CHECK_ASSERT(!explicit_src.has_error()); + // Uniform two-bucket scrub: the explicit-source path scrubs too. + OIIO_CHECK_ASSERT(!explicit_src.spec().find_attribute("ICCProfile")); + OIIO_CHECK_ASSERT(!explicit_src.spec().find_attribute("CICP")); +} + + +// A hint-laden source buffer: tagged (or not), every provenance fact +// present, plus pre-existing (stale) current-state descriptors. +static ImageBuf +make_hinted_src(const char* space) +{ + ImageSpec spec(4, 4, 3, TypeFloat); + if (space && space[0]) + spec.attribute("oiio:ColorSpace", space); + spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), kCicpSrgb); + spec.attribute("colorInteropID", "lin_ap1_scene"); + auto icc = fake_icc_profile(); + spec.attribute("ICCProfile", TypeDesc(TypeDesc::UINT8, int(icc.size())), + icc.data()); + spec.attribute("oiio:ColorSpace:range", "narrow"); + spec.attribute("oiio:ColorSpace:equality_id", "stale_equality_id"); + ImageBuf buf(spec); + ImageBufAlgo::fill(buf, { 0.5f, 0.25f, 0.75f }); + return buf; +} + + +// Per-class hygiene outcomes. Identity-known: verdict updated, provenance +// facts scrubbed (explicit AND inferred source), cheap descriptors +// maintained update-or-erase. Identity-unknowable: verdict, facts, and +// descriptors all erased (absence, never a guess). Space-preserving: +// everything passes through. +static void +test_hygiene_per_class(const ColorConfig& config) +{ + // Known, explicit source. + { + ImageBuf src = make_hinted_src("srgb_rec709_scene"); + ImageBuf out = ImageBufAlgo::colorconvert(src, "srgb_rec709_scene", + "lin_test_scene", true, "", + "", &config); + OIIO_CHECK_ASSERT(!out.has_error()); + const ImageSpec& s = out.spec(); + OIIO_CHECK_EQUAL(s.get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + OIIO_CHECK_ASSERT(!s.find_attribute("CICP")); + OIIO_CHECK_ASSERT(!s.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(!s.find_attribute("ICCProfile")); + ColorSpaceInfo info = config.get_color_space_info("lin_test_scene"); + OIIO_CHECK_ASSERT(info.valid()); + check_descriptor(s, "oiio:ColorSpace:state", info.image_state()); + check_descriptor(s, "oiio:ColorSpace:encoding", info.encoding()); + // Range operation-awareness: an ordinary conversion does not + // invent a range, and the stale pre-operation value is gone. + check_descriptor(s, "oiio:ColorSpace:range", info.range()); + OIIO_CHECK_ASSERT(info.range().empty()); + } + // Known, inferred source: identical hygiene (uniform rule). + { + ImageBuf src = make_hinted_src(""); + ImageBuf out = ImageBufAlgo::colorconvert(src, "", "lin_test_scene", + true, "", "", &config); + OIIO_CHECK_ASSERT(!out.has_error()); + const ImageSpec& s = out.spec(); + OIIO_CHECK_EQUAL(s.get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + OIIO_CHECK_ASSERT(!s.find_attribute("CICP")); + OIIO_CHECK_ASSERT(!s.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(!s.find_attribute("ICCProfile")); + } + // Known via ociodisplay, source inferred from a colorInteropID hint: + // pixels match the explicit-source call, verdict is the view's space, + // facts are scrubbed. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("colorInteropID", "lin_ap1_scene"); + ImageBuf src(spec); + ImageBufAlgo::fill(src, { 0.5f, 0.25f, 0.75f }); + ImageBuf inferred = ImageBufAlgo::ociodisplay(src, "disp", "view", "", + "", true, false, "", "", + &config); + OIIO_CHECK_ASSERT(!inferred.has_error()); + ImageBuf explicit_src + = ImageBufAlgo::ociodisplay(src, "disp", "view", "lin_ap1_scene", + "", true, false, "", "", &config); + OIIO_CHECK_ASSERT(!explicit_src.has_error()); + auto cmp = ImageBufAlgo::compare(inferred, explicit_src, 0.0f, 0.0f); + OIIO_CHECK_EQUAL(cmp.nfail, 0); + OIIO_CHECK_EQUAL(inferred.spec().get_string_attribute("oiio:ColorSpace"), + "srgb_rec709_scene"); + OIIO_CHECK_ASSERT(!inferred.spec().find_attribute("colorInteropID")); + } + // Unknowable: an arbitrary LUT whose path names no color space. The + // verdict, every provenance fact, and every descriptor are erased -- + // absence, never "oiio:unknown". + { + std::string lut = write_cube_lut("oiio_csr_plain.cube"); + ImageBuf src = make_hinted_src("srgb_rec709_scene"); + ImageBuf out = ImageBufAlgo::ociofiletransform(src, lut, false, false, + &config); + OIIO_CHECK_ASSERT(!out.has_error()); + const ImageSpec& s = out.spec(); + OIIO_CHECK_ASSERT(!s.find_attribute("oiio:ColorSpace")); + OIIO_CHECK_ASSERT(!s.find_attribute("CICP")); + OIIO_CHECK_ASSERT(!s.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(!s.find_attribute("ICCProfile")); + OIIO_CHECK_ASSERT(!s.find_attribute("oiio:ColorSpace:range")); + OIIO_CHECK_ASSERT(!s.find_attribute("oiio:ColorSpace:equality_id")); + Filesystem::remove(lut); + } + // ... but a LUT path that names a color space via the config's file + // rules declares the identity: full Known hygiene, longstanding + // color-space-from-filepath behavior preserved. + { + std::string lut = write_cube_lut("oiio_csr_to_lin_test_scene.cube"); + ImageBuf src = make_hinted_src("srgb_rec709_scene"); + ImageBuf out = ImageBufAlgo::ociofiletransform(src, lut, false, false, + &config); + OIIO_CHECK_ASSERT(!out.has_error()); + OIIO_CHECK_EQUAL(out.spec().get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + OIIO_CHECK_ASSERT(!out.spec().find_attribute("colorInteropID")); + Filesystem::remove(lut); + } + // Preserved: a data-space no-op passes verdict, facts, and + // descriptors through untouched (its hints are still true). + { + ImageBuf src = make_hinted_src("raw_data"); + ImageBuf out = ImageBufAlgo::colorconvert(src, "raw_data", + "lin_test_scene", true, "", + "", &config); + OIIO_CHECK_ASSERT(!out.has_error()); + const ImageSpec& s = out.spec(); + OIIO_CHECK_EQUAL(s.get_string_attribute("oiio:ColorSpace"), "raw_data"); + OIIO_CHECK_ASSERT(s.find_attribute("CICP")); + OIIO_CHECK_ASSERT(s.find_attribute("colorInteropID")); + OIIO_CHECK_ASSERT(s.find_attribute("ICCProfile")); + // Range operation-awareness: a space-preserving operation retains + // the buffer's range. + OIIO_CHECK_EQUAL(s.get_string_attribute("oiio:ColorSpace:range"), + "narrow"); + } +} + + +// The disparity rule (treatment and identity are separate axes): the +// synthetic "oiio:unknown" treatment marker may legally coexist with a +// definite verdict, and hygiene must not "fix" the disparity -- the +// marker survives identity-known hygiene untouched. +static void +test_hygiene_disparity_pin(const ColorConfig& config) +{ + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "srgb_rec709_scene"); + spec.attribute("colorInteropID", "oiio:unknown"); + ImageBuf src(spec); + ImageBufAlgo::fill(src, { 0.5f, 0.25f, 0.75f }); + ImageBuf out = ImageBufAlgo::colorconvert(src, "srgb_rec709_scene", + "lin_test_scene", true, "", "", + &config); + OIIO_CHECK_ASSERT(!out.has_error()); + OIIO_CHECK_EQUAL(out.spec().get_string_attribute("oiio:ColorSpace"), + "lin_test_scene"); + OIIO_CHECK_EQUAL(out.spec().get_string_attribute("colorInteropID"), + "oiio:unknown"); +} + + +// equality_id maintenance is update-or-erase, never derive: an uncached +// equality id is erased (the stale pre-operation value must not survive) +// and the IBA path never computes a fingerprint; once an explicit derive +// has cached the record, a fresh operation sees the cached value. +static void +test_hygiene_equality_id(const ColorConfig& config) +{ + const size_t fp_before = color_space_fingerprint_cache_size(); + ImageBuf src = make_hinted_src("srgb_rec709_scene"); + ImageBuf out = ImageBufAlgo::colorconvert(src, "srgb_rec709_scene", + "lin_test_scene", true, "", "", + &config); + OIIO_CHECK_ASSERT(!out.has_error()); + // Uncached: erased, never derived -- and no fingerprint work happened. + OIIO_CHECK_ASSERT( + !out.spec().find_attribute("oiio:ColorSpace:equality_id")); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), fp_before); + + // An explicit derive caches the full record; a fresh operation now + // sees the cached value (or a settled negative, which stays erased). + ColorSpaceInfo derived = config.derive_color_space_info("lin_test_scene"); + OIIO_CHECK_ASSERT(derived.valid()); + ImageBuf src2 = make_hinted_src("srgb_rec709_scene"); + ImageBuf out2 = ImageBufAlgo::colorconvert(src2, "srgb_rec709_scene", + "lin_test_scene", true, "", "", + &config); + OIIO_CHECK_ASSERT(!out2.has_error()); + check_descriptor(out2.spec(), "oiio:ColorSpace:equality_id", + derived.equality_id()); +} + + +// The failure split, by consequence: pixel math with an unresolvable +// source errors via has_error (never a config-default guess into a +// processor); the config-declared "error:unknown" catch space is honored +// under effective-strict (strict scope AND config strictparsing); the +// (default) lenient scope keeps today's behavior exactly. +static void +test_hygiene_failure_split(const ColorConfig& config, + const ColorConfig& strict_config) +{ + ImageBuf untagged(ImageSpec(4, 4, 3, TypeFloat)); + ImageBufAlgo::fill(untagged, { 0.5f, 0.25f, 0.75f }); + + // A source tagged literally "unknown" errors under any scope. + { + ImageSpec spec(4, 4, 3, TypeFloat); + spec.attribute("oiio:ColorSpace", "unknown"); + ImageBuf src(spec); + ImageBufAlgo::fill(src, { 0.5f, 0.25f, 0.75f }); + ImageBuf out = ImageBufAlgo::colorconvert(src, "", "lin_test_scene", + true, "", "", &config); + OIIO_CHECK_ASSERT(out.has_error()); + (void)out.geterror(); + } + + OIIO::attribute("oiio:colorpolicy:read:scope", "config_only"); + // Strict scope, no strictparsing on the config: hard error, the + // catch space is not consulted (effective-strict requires both). + { + ImageBuf out = ImageBufAlgo::colorconvert(untagged, "", + "lin_test_scene", true, "", + "", &config); + OIIO_CHECK_ASSERT(out.has_error()); + (void)out.geterror(); + } + // Effective-strict with the catch space declared: resolution failure + // lands in "error:unknown" and the operation proceeds -- pixel-equal + // to naming the catch space explicitly. + { + ImageBuf out = ImageBufAlgo::colorconvert(untagged, "", "lin_strict", + true, "", "", &strict_config); + OIIO_CHECK_ASSERT(!out.has_error()); + ImageBuf explicit_catch + = ImageBufAlgo::colorconvert(untagged, "error:unknown", + "lin_strict", true, "", "", + &strict_config); + OIIO_CHECK_ASSERT(!explicit_catch.has_error()); + auto cmp = ImageBufAlgo::compare(out, explicit_catch, 0.0f, 0.0f); + OIIO_CHECK_EQUAL(cmp.nfail, 0); + // The conversion was real (the catch space carries a transform). + ImageBuf noop = ImageBufAlgo::colorconvert(untagged, "lin_strict", + "lin_strict", true, "", "", + &strict_config); + auto cmp2 = ImageBufAlgo::compare(out, noop, 0.0f, 0.0f); + OIIO_CHECK_ASSERT(cmp2.nfail > 0); + } + OIIO::attribute("oiio:colorpolicy:read:scope", "lenient"); + + // Lenient scope: a hintless untagged source keeps today's + // scene_linear default (a tracking gap is not an error). + { + ImageBuf out = ImageBufAlgo::colorconvert(untagged, "", + "srgb_rec709_display", true, + "", "", &config); + OIIO_CHECK_ASSERT(!out.has_error()); + } +} + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_facts_from_spec(); + + const std::string cfgpath = write_test_config(); + ColorConfig config(cfgpath); + if (config.has_error()) { + Strutil::print("Could not load test config: {}\n", config.geterror()); + return 1; + } + const std::string sparsepath = write_sparse_config(); + ColorConfig sparse(sparsepath); + if (sparse.has_error()) { + Strutil::print("Could not load sparse config: {}\n", sparse.geterror()); + return 1; + } + + const std::string strictpath = write_strict_config(); + ColorConfig strict_config(strictpath); + if (strict_config.has_error()) { + Strutil::print("Could not load strict test config: {}\n", + strict_config.geterror()); + return 1; + } + + test_same_hints_same_answer(config); + test_config_or_registry_failover(sparse); + test_infer_helper(config); + test_iba_inference(config); + test_scrubber(config); + test_set_colorspace_hygiene(config); + test_iba_scrub_wiring(config); + test_hygiene_per_class(config); + test_hygiene_disparity_pin(config); + test_hygiene_equality_id(config); + test_hygiene_failure_split(config, strict_config); + + Filesystem::remove(cfgpath); + Filesystem::remove(sparsepath); + Filesystem::remove(strictpath); + return unit_test_failures != 0; +} diff --git a/src/libOpenImageIO/color_test.cpp b/src/libOpenImageIO/color_test.cpp index aff34a232e..63ff653871 100644 --- a/src/libOpenImageIO/color_test.cpp +++ b/src/libOpenImageIO/color_test.cpp @@ -4,19 +4,32 @@ #include #include +#include #include +#include +#include #include +#include +#include #include +#include "color_pvt.h" #include #include #include +#include +#include +#include #include #include +#include #include #include + #include +#include "imageio_pvt.h" + using namespace OIIO; using namespace simd; @@ -25,9 +38,11 @@ using namespace simd; // Aid for things that are too short to benchmark accurately #define REP10(x) x, x, x, x, x, x, x, x, x, x -static int iterations = 1000000; -static int ntrials = 5; -static bool verbose = false; +static int iterations = 1000000; +static int ntrials = 5; +static bool verbose = false; +static bool bench_mode = false; +static bool bench_child = false; @@ -45,6 +60,12 @@ getargs(int argc, char* argv[]) .help(Strutil::fmt::format("Number of iterations (default: {})", iterations)); ap.arg("--trials %d", &ntrials) .help("Number of trials"); + ap.arg("--bench", &bench_mode) + .help("Run the interop engine's cold/warm benchmark phases (timings + " + "cardinality counts; not a pass/fail gate)"); + ap.arg("--bench-child-construct", &bench_child) + .help("Internal: used by --bench to measure ColorConfig construction " + "in a fresh subprocess; do not use directly"); // clang-format on ap.parse(argc, (const char**)argv); @@ -120,21 +141,4770 @@ test_Rec709_conversion() -int -main(int argc, char* argv[]) +static void +test_interop_identities_config() { -#if !defined(NDEBUG) || defined(OIIO_CI) || defined(OIIO_CODE_COVERAGE) - // For the sake of test time, reduce the default iterations for DEBUG, - // CI, and code coverage builds. Explicit use of --iters or --trials - // will override this, since it comes before the getargs() call. - iterations /= 10; - ntrials = 1; + int nspaces = OIIO::pvt::interop_identities_config_size(); + OIIO_CHECK_GT(nspaces, 0); + + // With OCIO >= 2.5 (0x02050000) the config is built on OCIO's own builtin + // studio config, so it must still resolve a studio-native identity -- that + // is, the registry is the studio config's superset, hence its space count + // is at least the studio baseline -- and it must also resolve OIIO's own + // additions layered on top. + if (ColorConfig::OpenColorIO_version_hex() >= 0x02050000) { + OIIO_CHECK_ASSERT( + OIIO::pvt::interop_identities_config_resolves("ACES2065-1")); + OIIO_CHECK_ASSERT(OIIO::pvt::interop_identities_config_resolves( + "oiio:lin_p3d60_display")); + } +} + + + +static void +test_interop_id_grammar() +{ + using OIIO::pvt::InteropIdForm; + using OIIO::pvt::is_utility_interop_id; + using OIIO::pvt::is_valid_interop_id; + using OIIO::pvt::parse_interop_id; + using OIIO::pvt::sanitize_id_token; + using OIIO::pvt::strip_leftmost_namespace; + + // Validity + form, per the CIF Annex B grammar (4 legal forms; 3+ + // colons is always invalid). + OIIO_CHECK_ASSERT(is_valid_interop_id("lin_ap0_scene")); + OIIO_CHECK_EQUAL((int)parse_interop_id("lin_ap0_scene").form, + (int)InteropIdForm::BASE); + + // "local:srgb" is an ordinary INNER_BASE id at the grammar layer -- + // the grammar has zero knowledge of "local" as special; that's a + // question one layer up (resolution code checking + // form == OUTER_INNER_BASE && inner == "local"). + { + auto parts = parse_interop_id("local:srgb"); + OIIO_CHECK_ASSERT(is_valid_interop_id("local:srgb")); + OIIO_CHECK_EQUAL((int)parts.form, (int)InteropIdForm::INNER_BASE); + OIIO_CHECK_EQUAL(parts.inner, "local"); + OIIO_CHECK_EQUAL(parts.base, "srgb"); + } + + { + auto parts = parse_interop_id("show1-config:local:srgb"); + OIIO_CHECK_ASSERT(is_valid_interop_id("show1-config:local:srgb")); + OIIO_CHECK_EQUAL((int)parts.form, (int)InteropIdForm::OUTER_INNER_BASE); + OIIO_CHECK_EQUAL(parts.outer, "show1-config"); + OIIO_CHECK_EQUAL(parts.inner, "local"); + OIIO_CHECK_EQUAL(parts.base, "srgb"); + } + + { + auto parts = parse_interop_id("my-studio::srgb"); + OIIO_CHECK_ASSERT(is_valid_interop_id("my-studio::srgb")); + OIIO_CHECK_EQUAL((int)parts.form, (int)InteropIdForm::OUTER_BLANK_BASE); + OIIO_CHECK_EQUAL(parts.outer, "my-studio"); + OIIO_CHECK_ASSERT(parts.inner.empty()); + OIIO_CHECK_EQUAL(parts.base, "srgb"); + } + + OIIO_CHECK_FALSE(is_valid_interop_id("")); + OIIO_CHECK_FALSE(is_valid_interop_id(":base")); + OIIO_CHECK_FALSE(is_valid_interop_id(":inner:base")); + OIIO_CHECK_FALSE(is_valid_interop_id("a:b:c:d")); + // Validation never folds case or sanitizes. + OIIO_CHECK_FALSE(is_valid_interop_id("Lin_AP0_Scene")); + OIIO_CHECK_FALSE(is_valid_interop_id("caf\xc3\xa9")); // "café" + OIIO_CHECK_FALSE(is_valid_interop_id("\xe4\xb8\xad")); // "中" + OIIO_CHECK_FALSE(is_valid_interop_id("outer::")); + OIIO_CHECK_FALSE(is_valid_interop_id("outer:")); + OIIO_CHECK_FALSE(is_valid_interop_id("lin_ap0_scene:")); + + // Sanitization (Annex C, 5-step precedence). + OIIO_CHECK_EQUAL(sanitize_id_token("lin_ap0_scene"), "lin_ap0_scene"); + OIIO_CHECK_EQUAL(sanitize_id_token("ACEScg"), "acescg"); + OIIO_CHECK_EQUAL(sanitize_id_token("sRGB - Texture"), "srgb_-_texture"); + OIIO_CHECK_EQUAL(sanitize_id_token("a{b}c"), "a(b)c"); + OIIO_CHECK_EQUAL(sanitize_id_token("ac"), "a(b)c"); + OIIO_CHECK_EQUAL(sanitize_id_token("a,b"), "a.b"); + OIIO_CHECK_EQUAL(sanitize_id_token("a;b"), "a|b"); + OIIO_CHECK_EQUAL(sanitize_id_token("a:b"), "a|b"); + OIIO_CHECK_EQUAL(sanitize_id_token("a'b\"c"), "a#b#c"); + OIIO_CHECK_EQUAL(sanitize_id_token("a\\b"), "a/b"); + OIIO_CHECK_EQUAL(sanitize_id_token("a!b=c@d"), "a*b*c*d"); + // Non-ASCII: one '^' per whole UTF-8 code point, never per byte. + { + std::string cafe = "caf\xc3\xa9"; // "café", 2-byte 'é' + std::string got = sanitize_id_token(cafe); + OIIO_CHECK_EQUAL(got, "caf^"); + OIIO_CHECK_EQUAL(got.size(), size_t(4)); + } + { + std::string zhong = "\xe4\xb8\xad"; // "中", 3-byte code point + std::string got = sanitize_id_token(zhong); + OIIO_CHECK_EQUAL(got, "^"); + OIIO_CHECK_EQUAL(got.size(), size_t(1)); + } + OIIO_CHECK_EQUAL(sanitize_id_token("a\xe4\xb8\xad" + "b"), + "a^b"); + + // Namespace stripping: pure substring op, independent of validity, + // never assumes the result is itself a valid id. + OIIO_CHECK_EQUAL(strip_leftmost_namespace("a:b:c"), "b:c"); + OIIO_CHECK_EQUAL(strip_leftmost_namespace("a::c"), ":c"); + OIIO_CHECK_EQUAL(strip_leftmost_namespace("a"), "a"); + // Load-bearing: the blank-inner leading colon is retained, so the + // result is NOT "srgb". + OIIO_CHECK_EQUAL(strip_leftmost_namespace("my-studio::srgb"), ":srgb"); + OIIO_CHECK_NE(strip_leftmost_namespace("my-studio::srgb"), + std::string("srgb")); + + // Utility tokens: case-sensitive exact membership, no grammar + // involvement. + OIIO_CHECK_ASSERT(is_utility_interop_id("data")); + OIIO_CHECK_ASSERT(is_utility_interop_id("unknown")); + OIIO_CHECK_ASSERT(is_utility_interop_id("bypass")); + OIIO_CHECK_FALSE(is_utility_interop_id("Data")); + + // The one marker classifier: utility tokens case-sensitive, the + // deliberate unknown-marker family case-insensitive, everything else + // (including empty) an ordinary definite claim. + using OIIO::pvt::classify_interop_marker; + using OIIO::pvt::InteropMarker; + using OIIO::pvt::is_unknown_marker; + OIIO_CHECK_EQUAL((int)classify_interop_marker("data"), + (int)InteropMarker::UtilityData); + OIIO_CHECK_EQUAL((int)classify_interop_marker("bypass"), + (int)InteropMarker::UtilityBypass); + OIIO_CHECK_EQUAL((int)classify_interop_marker("unknown"), + (int)InteropMarker::BareUnknown); + OIIO_CHECK_EQUAL((int)classify_interop_marker("ocio:unknown"), + (int)InteropMarker::OcioUnknown); + OIIO_CHECK_EQUAL((int)classify_interop_marker("oiio:unknown"), + (int)InteropMarker::OiioUnknown); + OIIO_CHECK_EQUAL((int)classify_interop_marker("error:unknown"), + (int)InteropMarker::ErrorUnknown); + OIIO_CHECK_EQUAL((int)classify_interop_marker("OIIO:Unknown"), + (int)InteropMarker::OiioUnknown); + OIIO_CHECK_EQUAL((int)classify_interop_marker("Data"), + (int)InteropMarker::Definite); + OIIO_CHECK_EQUAL((int)classify_interop_marker("Unknown"), + (int)InteropMarker::Definite); + OIIO_CHECK_EQUAL((int)classify_interop_marker("lin_ap0_scene"), + (int)InteropMarker::Definite); + OIIO_CHECK_EQUAL((int)classify_interop_marker(""), + (int)InteropMarker::Definite); + OIIO_CHECK_ASSERT(is_unknown_marker("error:unknown")); + OIIO_CHECK_ASSERT(is_unknown_marker("OCIO:UNKNOWN")); + OIIO_CHECK_FALSE(is_unknown_marker("unknown")); + OIIO_CHECK_FALSE(is_unknown_marker("")); +} + + + +static void +test_registry_invariants() +{ + using OIIO::pvt::interop_identities_config_names; + using OIIO::pvt::interop_identities_config_resolves; + using OIIO::pvt::InteropIdForm; + using OIIO::pvt::is_utility_interop_id; + using OIIO::pvt::parse_interop_id; + using OIIO::pvt::strip_leftmost_namespace; + + std::vector names = interop_identities_config_names(); + OIIO_CHECK_GT(names.size(), size_t(0)); + + // Invariant 1: every registry entry's `name:` equals its `interop_id:` + // in the source config (verified at authoring time -- both fields are + // set to the identical value for every entry). At the OCIO API level + // that invariant means every declared name must resolve to itself: + // count mismatches across the whole registry and expect 0. + int name_mismatches = 0; + for (const auto& name : names) + if (!interop_identities_config_resolves(name)) + ++name_mismatches; + OIIO_CHECK_EQUAL(name_mismatches, 0); + + // Invariant 2: every namespaced entry's bare stripped form resolves + // through OCIO's own alias resolution to the same color space -- no + // registry-side code needed. Proof it's an alias and not a coincidental + // separate entry: the stripped form does not itself appear in the + // config's own declared-name list. + std::unordered_set declared_names(names.begin(), names.end()); + int namespaced_checked = 0; + for (const auto& name : names) { + auto parts = parse_interop_id(name); + if (parts.form != InteropIdForm::INNER_BASE) + continue; // not an "inner:base" namespaced entry + std::string bare = strip_leftmost_namespace(name); + OIIO_CHECK_ASSERT(interop_identities_config_resolves(name)); + OIIO_CHECK_ASSERT(interop_identities_config_resolves(bare)); + OIIO_CHECK_ASSERT(declared_names.find(bare) == declared_names.end()); + ++namespaced_checked; + } + OIIO_CHECK_GT(namespaced_checked, 0); + + // Invariant 5: `data` is a config entry (isdata: true); the utility + // tokens `unknown`/`bypass` are pure grammar-layer strings, never + // registry lookups. + OIIO_CHECK_ASSERT(interop_identities_config_resolves("data")); + OIIO_CHECK_FALSE(interop_identities_config_resolves("unknown")); + OIIO_CHECK_FALSE(interop_identities_config_resolves("bypass")); + OIIO_CHECK_ASSERT(is_utility_interop_id("unknown")); + OIIO_CHECK_ASSERT(is_utility_interop_id("bypass")); + + // The DCDM family: the XYZ form (g26_xyzd65_display, alias dcdm_xyzd65) and + // the P3 form (dcdm_p3d65_display, = g26_p3d65 colorimetry + the DCI white + // headroom) both resolve as registry spaces. dcdm_p3d65_display is the P3 + // DCDM identity the write-canonical conversion adds; g26_xyzd65_display is + // the conversion TARGET, which must be a real space for the mapping to run. + OIIO_CHECK_ASSERT(interop_identities_config_resolves("g26_xyzd65_display")); + OIIO_CHECK_ASSERT(interop_identities_config_resolves("dcdm_xyzd65")); + OIIO_CHECK_ASSERT(interop_identities_config_resolves("g26_p3d65_display")); + OIIO_CHECK_ASSERT(interop_identities_config_resolves("dcdm_p3d65_display")); + + // Cross-check against the CIF wiki's published Color Interop IDs: + // https://github.com/AcademySoftwareFoundation/ColorInterop/wiki/Registered-Color-Interop-IDs + // A representative subset of the published IDs; extend to the full + // list if it is ever vendored. + static const char* published_ids[] = { + "lin_ap0_scene", + "lin_rec709_scene", + "lin_p3d65_scene", + "lin_rec2020_scene", + "lin_adobergb_scene", + "srgb_rec709_display", + "g24_rec709_display", + "g22_rec709_display", + "lin_rec709_display", + "lin_p3d65_display", + "lin_p3d60_display", // oiio: alias, bare + "lin_ciexyzd65_display", // ocio: alias, bare + "data", + }; + for (const char* id : published_ids) + OIIO_CHECK_ASSERT(interop_identities_config_resolves(id)); +} + + + +// Namespace-tolerant round-trip over the built-in interop identities registry: +// for every canonical (grammar-valid) interop id the registry declares, if it +// resolves to a real space in the active config, deriving that space's id back +// must return the SAME id -- exactly, or up to removing one leftmost namespace +// from a single side (never both, the rule resolve() itself uses). A config +// author may declare a namespaced form of a published id, and declared ids are +// authoritative; one-sided namespace stripping preserves that identity. Swept +// over ocio://default and, on OCIO >= 2.5, the builtin studio config. +static void +test_registry_round_trip() +{ + using OIIO::pvt::interop_identities_config_names; + using OIIO::pvt::is_valid_interop_id; + using OIIO::pvt::strip_leftmost_namespace; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // ocio:// built-in configs require OCIO >= 2.2. + if (ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + + const std::vector ids = interop_identities_config_names(); + OIIO_CHECK_GT(ids.size(), size_t(0)); + + // One-sided-strip round-trip predicate: got equals id, or exactly one side + // loses one leftmost namespace to reach the other -- never both. + auto round_trips = [&](const std::string& got, const std::string& id) { + return got == id || strip_leftmost_namespace(got) == id + || got == strip_leftmost_namespace(id); + }; + + // equivalent() resolves names first, so a color interop ID and a native + // config name for the same encoding are equivalent. "lin_ap1_scene" (a + // CIID) and "ACEScg" (ocio://default's native name) denote the same space. + { + ColorConfig cc("ocio://default"); + if (!cc.has_error() && cc.getNumColorSpaces() > 0) + OIIO_CHECK_ASSERT(cc.equivalent("lin_ap1_scene", "ACEScg")); + } + + const bool have_studio = ColorConfig::OpenColorIO_version_hex() + >= 0x02050000; + std::vector configs = { "ocio://default" }; + if (have_studio) + configs.emplace_back("ocio://studio-config-latest"); + + for (const std::string& cfgname : configs) { + ColorConfig cc(cfgname); + if (cc.has_error() || cc.getNumColorSpaces() == 0) + continue; // built-in config unavailable in this OCIO build + + for (const std::string& id : ids) { + // Only the grammar-valid entries are canonical interop ids; the + // registry also carries the studio config's human-readable space + // names (e.g. "ACEScg", "sRGB - Display"), which are not ids. + if (!is_valid_interop_id(id)) + continue; + std::string resolved(cc.resolve(id)); + if (cc.getColorSpaceIndex(resolved) < 0) + continue; // id did not land on a real space in this config + std::string got(OIIO::pvt::derive_color_interop_id(cc, resolved)); + if (round_trips(got, id)) + continue; + + // The only non-one-sided landing observed: id and got are two + // DIFFERENT vendor-namespaced forms of the same published id + // (registry's "oiio:applelog_rec2020_scene" vs the studio config's + // declared "ocio:applelog_rec2020_scene"), bridged by resolve()'s + // value-based fingerprint tier rather than by namespace. resolve()'s + // one-sided rule cannot relate two distinct namespaces, so this is + // deliberately NOT a round-trip identity -- but get_color_interop_id + // is correctly returning the config's own declared (authoritative) + // form. Require it really is that case (identical bare tails) so a + // genuinely new exception still fails loudly. + + // Below OCIO 2.5 authored interop_id keys are dropped at parse, + // so only the value-based fingerprint tier can land an id -- and + // it cannot separate value-identical pairs. OCIO 2.4's + // ocio://default declares "sRGB Encoded P3-D65 - Texture", the + // same math as the registry's srgb_p3d65_display (they differ + // only in referredness, which pixel probes cannot see), so the + // sweep lands srgbe_p3d65_display on its sibling. Accept + // exactly that class there -- equivalent() proves the + // value-identity -- and nothing else: a non-equivalent + // mislanding still fails loudly. Declared ids disambiguate at + // 2.5+. + if (!have_studio && cc.equivalent(id, got)) + continue; + OIIO_CHECK_EQUAL(strip_leftmost_namespace(got), + strip_leftmost_namespace(id)); + } + } + + // Explicit live case: on the OCIO >= 2.5 studio config, registry id + // "g24_rec709_scene" resolves to a space the studio config declares as + // "ocio:g24_rec709_scene", so the round trip passes only via the + // stripped-form arm (one leftmost namespace removed from the derived id), + // never as an exact match. + if (have_studio) { + ColorConfig studio("ocio://studio-config-latest"); + if (!studio.has_error() && studio.getNumColorSpaces() > 0) { + std::string resolved(studio.resolve("g24_rec709_scene")); + OIIO_CHECK_GE(studio.getColorSpaceIndex(resolved), 0); + std::string got( + OIIO::pvt::derive_color_interop_id(studio, resolved)); + OIIO_CHECK_NE(got, std::string("g24_rec709_scene")); + OIIO_CHECK_EQUAL(strip_leftmost_namespace(got), + std::string("g24_rec709_scene")); + } + } +} + + + +// The internal ColorConfig::get_builtin_interop_ids() lookup must be an +// exact-set match for the canonical `interop_id:` set declared in the +// embedded interop identities registry source (NOT the composite parsed +// config, whose declared names diverge from the canonical id set under +// OCIO >= 2.5's studio-config overlay), and its storage must be stable for +// the life of the process. +static void +test_builtin_interop_ids_sync() +{ + using OIIO::pvt::embedded_interop_identities_ids; + + std::vector registry = embedded_interop_identities_ids(); + OIIO_CHECK_GT(registry.size(), size_t(0)); + + std::unordered_set registry_set(registry.begin(), + registry.end()); + cspan all = ColorConfig::get_builtin_interop_ids(); + std::unordered_set all_set; + for (string_view id : all) + all_set.emplace(id); + + OIIO_CHECK_EQUAL(all_set.size(), registry_set.size()); + for (const auto& id : registry_set) { + if (all_set.count(id) != 1) + Strutil::print(" registry id missing from builtin ids: {}\n", id); + OIIO_CHECK_ASSERT(all_set.count(id) == 1); + } + for (const auto& id : all_set) { + if (registry_set.count(id) != 1) + Strutil::print(" builtin id not in registry: {}\n", id); + OIIO_CHECK_ASSERT(registry_set.count(id) == 1); + } + + // Process-lifetime storage: repeated calls return the same data. + OIIO_CHECK_ASSERT(ColorConfig::get_builtin_interop_ids().data() + == all.data()); + OIIO_CHECK_EQUAL(ColorConfig::get_builtin_interop_ids().size(), all.size()); +} + + + +// The legacy static CICP/interop-id +// table (color_ocio.cpp's `color_interop_ids[]`) must not drift from the +// registry that is its single source of truth for id spelling. The table +// spells its ids as plain string literals (they are data rows), so this +// runtime check is the whole guarantee: every entry except the "unknown" +// utility token, which the registry deliberately omits, must resolve in +// the registry set -- a typo or a registry rename fails here. +static void +test_legacy_table_registry_sync() +{ + using OIIO::pvt::embedded_interop_identities_ids; + using OIIO::pvt::legacy_interop_id_table_names; + + std::vector registry = embedded_interop_identities_ids(); + OIIO_CHECK_GT(registry.size(), size_t(0)); + std::unordered_set registry_set(registry.begin(), + registry.end()); + + std::vector table = legacy_interop_id_table_names(); + OIIO_CHECK_GT(table.size(), size_t(0)); + int unknown_count = 0; + for (const auto& id : table) { + // "unknown" is the one table entry the registry deliberately does + // not declare (utility token, not an identity). "data" is also a + // utility token but IS a registry entry, so it takes the normal + // registry-membership path below. + if (id == "unknown") { + ++unknown_count; + continue; + } + if (registry_set.count(id) != 1) + Strutil::print(" table id not in registry: {}\n", id); + OIIO_CHECK_ASSERT(registry_set.count(id) == 1); + } + OIIO_CHECK_EQUAL(unknown_count, 1); +} + + + +// Exercise the color-space classification pass: the simple-transform +// allowlist and the lazy per-space analysis that sets the classification +// bits. Uses a minimal generated OCIO config. CDL and ACES-OUTPUT builtins +// stand in for the general "complex transform" class (which also covers +// LUT3D/LOOK); a matrix+TF space and a context-varying-but-resolvable +// ColorSpaceTransform reference cover the simple cases. +static void +test_color_space_classification() +{ + using OIIO::pvt::color_space_analysis_flags; + using OIIO::pvt::color_space_analyzed; + namespace P = OIIO::pvt; + + if (!ColorConfig::supportsOpenColorIO()) + return; + + static const char* config_yaml = R"(ocio_profile_version: 2.1 + +environment: + SHOT_CS: matrix_tf_space +search_path: "" +roles: + scene_linear: ref + default: ref + +displays: + disp: + - ! {name: main, colorspace: ref} + +colorspaces: + - ! + name: ref + + - ! + name: rawdata + isdata: true + + - ! + name: matrix_tf_space + from_scene_reference: ! + children: + - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} + - ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: cdl_space + from_scene_reference: ! {slope: [0.9, 1.1, 1.0]} + + - ! + name: aces_output_space + from_scene_reference: ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0} + + - ! + name: camera_log + to_scene_reference: ! {src: $SHOT_CS, dst: ref} +)"; + + std::string config_path = Filesystem::temp_directory_path() + + "/oiio_color_test_classify.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(config_path, config_yaml)); + + ColorConfig cc(config_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // Fully lazy: constructing the ColorConfig runs no classification. + OIIO_CHECK_FALSE(color_space_analyzed(cc, "matrix_tf_space")); + + // Matrix + transfer function: a simple, context-invariant space. The + // first flags query is what triggers the lazy analysis. + int mflags = color_space_analysis_flags(cc, "matrix_tf_space"); + OIIO_CHECK_ASSERT(mflags & P::ColorSpaceIsSimple); + OIIO_CHECK_ASSERT(mflags & P::ColorSpaceIsContextInvariant); + OIIO_CHECK_FALSE(mflags & P::ColorSpaceHasComplexTransform); + // ...and now it has been analyzed. + OIIO_CHECK_ASSERT(color_space_analyzed(cc, "matrix_tf_space")); + + // Complex transforms (CDL, ACES-OUTPUT) are rejected by the allowlist. + int cdlflags = color_space_analysis_flags(cc, "cdl_space"); + OIIO_CHECK_ASSERT(cdlflags & P::ColorSpaceHasComplexTransform); + OIIO_CHECK_FALSE(cdlflags & P::ColorSpaceIsSimple); + int acesflags = color_space_analysis_flags(cc, "aces_output_space"); + OIIO_CHECK_ASSERT(acesflags & P::ColorSpaceHasComplexTransform); + OIIO_CHECK_FALSE(acesflags & P::ColorSpaceIsSimple); + + // A data space is flagged is_data and is never a matching candidate. + int dflags = color_space_analysis_flags(cc, "rawdata"); + OIIO_CHECK_ASSERT(dflags & P::ColorSpaceIsData); + OIIO_CHECK_ASSERT(dflags & P::ColorSpaceShouldSkipMatching); + OIIO_CHECK_FALSE(dflags & P::ColorSpaceIsSimple); + + // A space whose transform references a context variable is not context + // invariant, but (resolving through the context) is still simple. + int cflags = color_space_analysis_flags(cc, "camera_log"); + OIIO_CHECK_FALSE(cflags & P::ColorSpaceIsContextInvariant); + OIIO_CHECK_ASSERT(cflags & P::ColorSpaceIsSimple); + + // Unknown names classify as nothing. + OIIO_CHECK_EQUAL(color_space_analysis_flags(cc, "no_such_space"), 0); + OIIO_CHECK_FALSE(color_space_analyzed(cc, "no_such_space")); + + Filesystem::remove(config_path); +} + + + +// Exercise color space fingerprinting: the probe protocol, the exact +// tolerance-gated matcher (including the scene-vs-display reference-kind gate), +// byte-reproducibility, and deterministic sorted iteration. Uses OCIO's +// built-in default config, which carries the aces_interchange role this slice +// assumes is resolved. +static void +test_color_space_fingerprint() +{ + using OIIO::pvt::color_space_fingerprint; + using OIIO::pvt::color_space_fingerprint_order; + using OIIO::pvt::color_space_fingerprints_match; + using OIIO::pvt::ColorSpaceFingerprint; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // ocio:// built-in configs require OCIO >= 2.2. + if (ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + + ColorConfig cc("ocio://default"); + if (cc.has_error() || cc.getNumColorSpaces() == 0) + return; // built-in configs unavailable in this OCIO build + + // A space and one of its aliases are the same OCIO color space, so their + // fingerprints are byte-identical -- and match within tolerance. + ColorSpaceFingerprint ap0 = color_space_fingerprint(cc, "ACES2065-1"); + ColorSpaceFingerprint ap0alias = color_space_fingerprint(cc, "lin_ap0"); + OIIO_CHECK_ASSERT(ap0.computed()); + OIIO_CHECK_ASSERT(ap0alias.computed()); + OIIO_CHECK_ASSERT(color_space_fingerprints_match(ap0, ap0alias)); + OIIO_CHECK_ASSERT(ap0.values == ap0alias.values); // byte-identical + + // The same space fingerprinted twice yields byte-identical floats + // (OPTIMIZATION_NONE + reused probe config). + ColorSpaceFingerprint ap0again = color_space_fingerprint(cc, "ACES2065-1"); + OIIO_CHECK_ASSERT(ap0.values == ap0again.values); + + // Two distinct scene spaces (lin_ap0 vs an sRGB-encoded space) do NOT + // match. + ColorSpaceFingerprint srgb = color_space_fingerprint(cc, "sRGB - Texture"); + OIIO_CHECK_ASSERT(srgb.computed()); + OIIO_CHECK_ASSERT(srgb.reference_kind == ap0.reference_kind); + OIIO_CHECK_FALSE(color_space_fingerprints_match(ap0, srgb)); + + // A scene space and a display space never compare equal: the reference-kind + // gate rejects them before any float comparison. + ColorSpaceFingerprint disp = color_space_fingerprint(cc, "sRGB - Display"); + if (disp.computed()) { + OIIO_CHECK_ASSERT(disp.reference_kind != ap0.reference_kind); + OIIO_CHECK_FALSE(color_space_fingerprints_match(ap0, disp)); + } + + // Unknown names produce an empty (uncomputed) fingerprint. + OIIO_CHECK_FALSE(color_space_fingerprint(cc, "no_such_space").computed()); + + // The bulk pass iterates the classification's sorted simple-space cache, so + // the fingerprinted names come back in deterministic sorted order. + std::vector order = color_space_fingerprint_order(cc); + OIIO_CHECK_ASSERT(!order.empty()); + OIIO_CHECK_ASSERT(std::is_sorted(order.begin(), order.end())); + OIIO_CHECK_ASSERT(order == color_space_fingerprint_order(cc)); // stable +} + + + +// The identification tier's hardest documented discrimination case: sRGB's +// PIECEWISE transfer curve vs a PURE gamma-2.2 power curve, over identical +// (Rec.709/D65) primaries and the same display reference. Everything except +// the transfer function is held equal, so the only thing separating the two +// fingerprints is the curve -- and the shipped absolute tolerance (5e-3, see +// kFingerprintAbsTolerance) must separate them in BOTH directions. This is the +// number Doug Walker's "what does your tolerance conflate?" question wants: an +// executable statement of what the tolerance provably does NOT conflate. +// +// The separation is dominated by the probe's dark neutral (linear ~3.4%), +// where the two curves are furthest apart; the diffuse-white and black probe +// pixels agree exactly, which is precisely why the probe cannot be a +// white-point-only check. +static void +test_transfer_curve_discrimination() +{ + using OIIO::pvt::color_space_fingerprint; + using OIIO::pvt::color_space_fingerprints_match; + using OIIO::pvt::ColorSpaceFingerprint; + + // The documented identification gate. Kept as a literal here (rather than + // reaching into color_ocio_pvt.h) so a silent widening of the shipped + // constant fails this test instead of moving with it. + const float kDocumentedTolerance = 5e-3f; + + if (!ColorConfig::supportsOpenColorIO()) + return; + if (ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + + // Two display-referred spaces over the SAME XYZ-D65 -> Rec.709 matrix. + // srgb_disp uses the sRGB piecewise curve (ExponentWithLinear, gamma 2.4 + + // 0.055 offset); g22_disp uses a pure 2.2 power law. Nothing else differs. + static const char* yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ACEScg + scene_linear: ACEScg + aces_interchange: ACES2065-1 +displays: + disp: + - ! {name: srgb, colorspace: srgb_disp} + - ! {name: g22, colorspace: g22_disp} +colorspaces: + - ! + name: ACES2065-1 + encoding: scene-linear + - ! + name: ACEScg + encoding: scene-linear + to_scene_reference: ! {matrix: [0.6954522414, 0.1406786965, 0.1638690622, 0, 0.0447945634, 0.8596711185, 0.0955343182, 0, -0.0055258826, 0.0040252103, 1.0015006723, 0, 0, 0, 0, 1]} +display_colorspaces: + - ! + name: srgb_disp + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.2409699419, -1.5373831776, -0.4986107603, 0, -0.9692436363, 1.8759675015, 0.0415550574, 0, 0.0556300797, -0.2039769589, 1.0569715142, 0, 0, 0, 0, 1]} + - ! {gamma: 2.4, offset: 0.055, direction: inverse} + - ! + name: g22_disp + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.2409699419, -1.5373831776, -0.4986107603, 0, -0.9692436363, 1.8759675015, 0.0415550574, 0, 0.0556300797, -0.2039769589, 1.0569715142, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} +)"; + std::string path = Filesystem::temp_directory_path() + + "/oiio_transfer_discrimination.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path, yaml)); + ColorConfig cc(path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + ColorSpaceFingerprint srgb = color_space_fingerprint(cc, "srgb_disp"); + ColorSpaceFingerprint g22 = color_space_fingerprint(cc, "g22_disp"); + OIIO_CHECK_ASSERT(srgb.computed()); + OIIO_CHECK_ASSERT(g22.computed()); + if (srgb.computed() && g22.computed()) { + // Same reference kind and probe layout: the reference-kind gate cannot + // be what separates them -- only the float comparison can. + OIIO_CHECK_EQUAL(srgb.reference_kind, g22.reference_kind); + OIIO_CHECK_EQUAL(srgb.values.size(), g22.values.size()); + + // NOT confused, in both directions. + OIIO_CHECK_FALSE(color_space_fingerprints_match(srgb, g22)); + OIIO_CHECK_FALSE(color_space_fingerprints_match(g22, srgb)); + + // And the separation is real, not marginal: report the largest + // disagreement over the six identity probe pixels (the 24 floats the + // matcher actually compares) and require it to clear the documented + // gate. The number belongs in the P1-4 PR body. + float worst = 0.0f; + size_t worst_i = 0; + const size_t bound = std::min(24, srgb.values.size()); + for (size_t i = 0; i < bound; ++i) { + const float d = std::abs(srgb.values[i] - g22.values[i]); + if (d > worst) { + worst = d; + worst_i = i; + } + } + Strutil::print( + "transfer-curve discrimination: sRGB vs gamma-2.2, max identity-probe " + "separation {:.6f} at float {} (probe pixel {}, channel {}); " + "documented tolerance {:.6f}; margin {:.2f}x\n", + worst, worst_i, worst_i / 4, worst_i % 4, kDocumentedTolerance, + worst / kDocumentedTolerance); + OIIO_CHECK_ASSERT(worst > kDocumentedTolerance); + } + + // End to end, through the tier that actually consumes the comparison: each + // space must derive its OWN registry identity, never its neighbour's. The + // registry carries both srgb_rec709_display and g22_rec709_display, so a + // tolerance too loose to separate the curves would show up here as two + // spaces claiming one id (whichever the deterministic walk reached first). + const std::string srgb_id( + OIIO::pvt::derive_color_interop_id(cc, "srgb_disp")); + const std::string g22_id( + OIIO::pvt::derive_color_interop_id(cc, "g22_disp")); + Strutil::print(" derived ids: srgb_disp -> '{}', g22_disp -> '{}'\n", + srgb_id, g22_id); + OIIO_CHECK_ASSERT(srgb_id != g22_id); + OIIO_CHECK_FALSE(srgb_id == "g22_rec709_display"); + OIIO_CHECK_FALSE(g22_id == "srgb_rec709_display"); + + Filesystem::remove(path); +} + + + +// Exercise the config interoperability check: a config carrying the +// aces_interchange role is interoperable and does not warn; a config whose +// scene reference is positively identifiable (known alias/name, or OCIO +// builtin identification) is repaired -- the interopified copy binds the +// interchange role; a stripped config whose reference CANNOT be positively +// identified is NOT repaired (fail-don't-guess: never fabricate an AP0 +// equivalence), and warns exactly once per config structure. The whole thing +// is lazy -- constructing a ColorConfig runs none of it. +static void +test_config_interoperability() +{ + using OIIO::pvt::color_config_interchange_name; + using OIIO::pvt::color_config_interop_computed; + using OIIO::pvt::color_config_interop_warned; + using OIIO::pvt::color_config_interopified_cache_off; + using OIIO::pvt::color_config_interopified_resolves_scene_interchange; + using OIIO::pvt::color_config_is_interoperable; + + if (!ColorConfig::supportsOpenColorIO()) + return; + + // A config that declares an aces_interchange role is interoperable. + static const char* interop_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref +)"; + // A config that resolves no scene interchange at all -- but does have a + // scene-referred identity (reference) space to anchor a repair on. + static const char* stripped_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: log_space + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + + std::string interop_path = Filesystem::temp_directory_path() + + "/oiio_color_test_interop.ocio"; + std::string stripped_path = Filesystem::temp_directory_path() + + "/oiio_color_test_stripped.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(interop_path, interop_yaml)); + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(stripped_path, stripped_yaml)); + + // --- Interoperable config --------------------------------------------- + { + ColorConfig cc(interop_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Fully lazy: construction ran no interop bootstrap. + OIIO_CHECK_FALSE(color_config_interop_computed(cc)); + + OIIO_CHECK_ASSERT(color_config_is_interoperable(cc)); + // ...and querying it is what triggered the bootstrap. + OIIO_CHECK_ASSERT(color_config_interop_computed(cc)); + // The aces_interchange role points at "ref". + OIIO_CHECK_EQUAL(color_config_interchange_name(cc), "ref"); + // An interoperable config never warns. + OIIO_CHECK_FALSE(color_config_interop_warned(cc)); + } + + // --- Non-interoperable config ----------------------------------------- + { + ColorConfig cc(stripped_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_FALSE(color_config_interop_computed(cc)); + + // The original config resolves no scene interchange. + OIIO_CHECK_FALSE(color_config_is_interoperable(cc)); + OIIO_CHECK_ASSERT(color_config_interop_computed(cc)); + OIIO_CHECK_ASSERT(color_config_interchange_name(cc).empty()); + // The bootstrap warning is debug-gated + recorded in-memory only -- + // it must NOT pollute the ColorConfig error string (R4): a + // non-interoperable config that nobody has tried to cross-config- + // convert with is otherwise perfectly healthy. + OIIO_CHECK_FALSE(cc.has_error()); + + // The in-memory interopified copy is NOT repaired: "ref" is a bare + // transformless space that nothing positively identifies as AP0 + // (no role, no known alias, no builtin identification), and the + // bridge must never fabricate that equivalence. The copy still + // exists (processor cache off), but resolves no scene interchange. + OIIO_CHECK_FALSE( + color_config_interopified_resolves_scene_interchange(cc)); + OIIO_CHECK_ASSERT(color_config_interopified_cache_off(cc)); + + // It warned exactly once: this instance emitted the warning, and a + // second query is silent (the lazy gate ran the bootstrap only once). + OIIO_CHECK_ASSERT(color_config_interop_warned(cc)); + OIIO_CHECK_FALSE(color_config_is_interoperable(cc)); + OIIO_CHECK_ASSERT(color_config_interop_warned(cc)); + // Still no error string, even after two failed bootstrap queries. + OIIO_CHECK_FALSE(cc.has_error()); + + // A second ColorConfig over the same (structurally identical) config + // independently discovers it is non-interoperable during its OWN + // ensure_interop() and reports its OWN `warned` observable + // accordingly -- that is decoupled from the process-global guard + // that throttles the printed debug line to once per structural + // config id (only one of the two Impls "wins" that dedup claim, but + // both must observably report having been warned). + ColorConfig cc2(stripped_path); + OIIO_CHECK_FALSE(color_config_is_interoperable(cc2)); + OIIO_CHECK_ASSERT(color_config_interop_warned(cc2)); + // ...and its own copy likewise resolves no scene interchange. + OIIO_CHECK_FALSE( + color_config_interopified_resolves_scene_interchange(cc2)); + } + + // --- Positively identifiable reference: repair IS performed ------------- + // The reference space is transformless but NAMED as a known scene + // interchange alias ("ACES2065-1"), so the identification is positive and + // the interopified copy may bind the interchange role to it. + { + static const char* identifiable_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ACES2065-1 + scene_linear: ACES2065-1 +displays: + disp: + - ! {name: main, colorspace: ACES2065-1} +colorspaces: + - ! + name: ACES2065-1 +)"; + std::string identifiable_path = Filesystem::temp_directory_path() + + "/oiio_color_test_identifiable.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(identifiable_path, identifiable_yaml)); + ColorConfig cc(identifiable_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Alias discovery finds "ACES2065-1" even without the role... + OIIO_CHECK_ASSERT(color_config_is_interoperable(cc)); + OIIO_CHECK_EQUAL(color_config_interchange_name(cc), "ACES2065-1"); + // ...and the interopified copy binds the role to it. + OIIO_CHECK_ASSERT( + color_config_interopified_resolves_scene_interchange(cc)); + OIIO_CHECK_FALSE(color_config_interop_warned(cc)); + Filesystem::remove(identifiable_path); + } + + Filesystem::remove(interop_path); + Filesystem::remove(stripped_path); +} + + + +// Exercise the cross-config processor chokepoint (pvt::cross_config_probe, a +// wrapper over OCIO's two-config GetProcessorFromConfigs): a route bridged +// between two structurally distinct configs that share the aces_interchange +// role reproduces the destination config's own transform (probe pixel agrees +// within 1e-6); a config with no interchange role fails with a null processor +// and the OCIO role message set on the destination; and a context key/value +// pair smoke-drives the context-aware overload. +static void +test_cross_config_processor() +{ + using OIIO::pvt::cross_config_probe; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // Two-config GetProcessorFromConfigs / the interchange-role machinery needs + // OCIO >= 2.3. + if (ColorConfig::OpenColorIO_version_hex() < 0x02030000) + return; + + // Source config: the scene interchange (aces_interchange -> ref) is the + // route's source endpoint. + static const char* src_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref +)"; + // Destination config: shares the interchange (ref) and adds a gamma space + // reachable from it. Structurally distinct from src (it has g22), so the + // route genuinely crosses configs. + static const char* dst_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: g22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + // A config with no scene interchange role at all: unbridgeable. + static const char* noninterop_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref +)"; + + std::string src_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconfig_src.ocio"; + std::string dst_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconfig_dst.ocio"; + std::string non_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconfig_non.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(src_path, src_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(dst_path, dst_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(non_path, noninterop_yaml)); + + ColorConfig src_cc(src_path); + ColorConfig dst_cc(dst_path); + ColorConfig non_cc(non_path); + OIIO_CHECK_ASSERT(!src_cc.has_error()); + OIIO_CHECK_ASSERT(!dst_cc.has_error()); + OIIO_CHECK_ASSERT(!non_cc.has_error()); + + const float probe[3] = { 0.18f, 0.42f, 0.73f }; + + // --- Success: cross-config route equals the destination's own transform -- + { + auto got = cross_config_probe(src_cc, "ref", dst_cc, "g22", probe); + OIIO_CHECK_EQUAL(got.size(), size_t(3)); + OIIO_CHECK_ASSERT(!dst_cc.has_error()); + + // Independent reference: the destination config's own ref->g22 + // processor, applied to the same probe pixel. + auto ref = dst_cc.createColorProcessor("ref", "g22"); + OIIO_CHECK_ASSERT(ref.get() != nullptr); + float expected[3] = { probe[0], probe[1], probe[2] }; + if (ref) + ref->apply(expected); + if (got.size() == 3) + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(got[c], expected[c], 1e-6f); + + // Second, INDEPENDENT anchor: a hand-computed value that does not + // come from any OCIO/OIIO code path at all (the check above still + // only cross-checks two OCIO entry points against each other, both + // evaluating the identical "g22" transform -- a bug in how that + // transform is applied would agree with itself either way). "g22" is + // authored as a from-scene-reference ExponentTransform{2.2}, and OCIO + // applies a color space's from-reference transform in its authored + // (forward) direction when building the reference-to-space half of a + // ref->g22 conversion; the forward exponent op is a plain per-channel + // powf(max(0,in), 2.2) (see OpenColorIO's ExponentOpCPU::apply). + // Hand-computing that directly, with no config/processor involved: + float hand_expected[3]; + for (int c = 0; c < 3; ++c) + hand_expected[c] = powf(probe[c], 2.2f); + if (got.size() == 3) + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(got[c], hand_expected[c], 1e-4f); + } + + // --- Missing-role failure: empty result + OCIO role message set on dst --- + { + auto got = cross_config_probe(src_cc, "ref", non_cc, "ref", probe); + OIIO_CHECK_ASSERT(got.empty()); + OIIO_CHECK_ASSERT(non_cc.has_error()); + std::string err = non_cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "aces_interchange")); + } + + // --- Context-aware overload smoke: a key/value pair builds a processor --- + { + auto got = cross_config_probe(src_cc, "ref", dst_cc, "g22", probe, + "LUT", "identity"); + OIIO_CHECK_EQUAL(got.size(), size_t(3)); + OIIO_CHECK_ASSERT(!dst_cc.has_error()); + } + + Filesystem::remove(src_path); + Filesystem::remove(dst_path); + Filesystem::remove(non_path); +} + + + +// Exercise the cross-config conversion route in ColorConfig::createColorProcessor: +// when a requested color space is absent from the current config +// but is a registry-known interop identity, and the config is color- +// interoperable (natively or via in-memory repair), the conversion routes +// through the built-in interop identities config instead of erroring on the +// name. The gate consults the interoperability state, not bare name-presence; +// OCIO strict parsing restores today's hard-error behavior; and non-strict +// parsing falls back to a pass-through so the pipeline continues. +static void +test_cross_config_conversion() +{ + using OIIO::pvt::identities_route_probe; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // Two-config GetProcessorFromConfigs / the interchange-role machinery needs + // OCIO >= 2.3. + if (ColorConfig::OpenColorIO_version_hex() < 0x02030000) + return; + // The route bridges the local AP0 reference to a registry AP1 (ACEScg) + // identity; skip if this build's identities config doesn't carry it. + if (!OIIO::pvt::interop_identities_config_resolves("lin_ap1_scene")) + return; + + // An interoperable config (aces_interchange -> ap0, a transformless scene + // reference) that LACKS the registry-known scene space "lin_ap1_scene". + // Non-strict parsing. + static const char* interop_yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +colorspaces: + - ! + name: ap0 +)"; + // Same config, but with OCIO strict parsing enabled. + static const char* interop_strict_yaml = R"(ocio_profile_version: 2.1 +strictparsing: true +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +colorspaces: + - ! + name: ap0 +)"; + // A NON-interoperable config: its only color space has a from-reference + // transform, so there is no transformless scene reference to anchor a + // repair on, and no interchange alias resolves -- the interopified copy + // resolves no scene interchange (gate stays closed). Non-strict parsing. + static const char* noninterop_yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: enc + scene_linear: enc +colorspaces: + - ! + name: enc + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + + std::string interop_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconv_interop.ocio"; + std::string strict_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconv_strict.ocio"; + std::string non_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconv_non.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(interop_path, interop_yaml)); + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(strict_path, interop_strict_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(non_path, noninterop_yaml)); + + const float probe[3] = { 0.18f, 0.42f, 0.73f }; + + // --- Success: registry-known name absent locally routes via the bridge ---- + { + ColorConfig cc(interop_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + auto handle = cc.createColorProcessor("ap0", "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + // A real AP0->AP1 transform, not a pass-through no-op. + if (handle) + OIIO_CHECK_FALSE(handle->isNoOp()); + OIIO_CHECK_FALSE(cc.has_error()); + + // The public bridge reproduces the direct chokepoint route against the + // identities config (probe-pixel agreement, abs 1e-6/channel). + float got[3] = { probe[0], probe[1], probe[2] }; + if (handle) + handle->apply(got); + auto ref = identities_route_probe(cc, "ap0", "lin_ap1_scene", probe); + OIIO_CHECK_EQUAL(ref.size(), size_t(3)); + if (ref.size() == 3) + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(got[c], ref[c], 1e-6f); + } + + // --- Reverse direction (registry src -> local dst): the foreign endpoint + // may be either side. "lin_ap1_scene" (registry) -> "ap0" (local) is the + // inverse of the block above and must bridge symmetrically. ------------ + { + ColorConfig cc(interop_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // identities_route_probe models only the local-src -> registry-dst + // direction, so the real, non-identity registry-src -> local-dst + // processor is the demonstration here. + auto handle = cc.createColorProcessor("lin_ap1_scene", "ap0"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + if (handle) + OIIO_CHECK_FALSE(handle->isNoOp()); // real AP1->AP0 transform + OIIO_CHECK_FALSE(cc.has_error()); + } + + // --- Both endpoints registry-known and locally absent: the route runs + // registry -> registry (both drawn from the identities config). + // "lin_ap0_scene" -> "lin_ap1_scene" is a real AP0->AP1 transform. ----- + if (OIIO::pvt::interop_identities_config_resolves("lin_ap0_scene")) { + ColorConfig cc(interop_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // The route runs registry -> registry (both from the identities + // config); the local->registry identities_route_probe reference does + // not model that path, so the real, non-identity processor is the + // demonstration here. + auto handle = cc.createColorProcessor("lin_ap0_scene", "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + if (handle) + OIIO_CHECK_FALSE(handle->isNoOp()); // real AP0->AP1 transform + OIIO_CHECK_FALSE(cc.has_error()); + } + + // --- Case 2 (CIID with a local equivalent): a valid registry CIID the + // config defines locally (here as an alias) resolves same-config and + // never touches the cross-config bridge. "lin_ap0_scene" aliases the + // local "ap0", so the conversion to "ap0" is a plain local no-op. ------ + { + static const char* alias_yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +colorspaces: + - ! + name: ap0 + aliases: [lin_ap0_scene] +)"; + std::string alias_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xconv_alias.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(alias_path, alias_yaml)); + ColorConfig cc(alias_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // resolve() maps the CIID to its local equivalent -- the case-2 test. + OIIO_CHECK_EQUAL(cc.resolve("lin_ap0_scene"), "ap0"); + auto handle = cc.createColorProcessor("lin_ap0_scene", "ap0"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + if (handle) + OIIO_CHECK_ASSERT(handle->isNoOp()); // same-config, not bridged + OIIO_CHECK_FALSE(cc.has_error()); + Filesystem::remove(alias_path); + } + + // --- Zero behavior change: a name this config defines still resolves ------- + { + ColorConfig cc(interop_path); + auto handle = cc.createColorProcessor("ap0", "ap0"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); // local no-op, unchanged + OIIO_CHECK_FALSE(cc.has_error()); + } + + // --- Case 4 (unresolvable): a name that is neither local nor registry-known + // is a genuine unknown -- the bridge declines and today's hard error + // stands (no pass-through masking a typo). ------------------------------ + { + ColorConfig cc(interop_path); + auto handle = cc.createColorProcessor("ap0", "no_such_space_xyzzy"); + OIIO_CHECK_ASSERT(handle.get() == nullptr); + OIIO_CHECK_ASSERT(cc.has_error()); + (void)cc.geterror(); + } + + // --- Gate respects interop state: a non-interoperable config does not + // bridge a registry-known name (no real cross-config transform) -------- + { + ColorConfig cc(non_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Confirm the fixture is non-interoperable and its repair is unusable. + // This triggers the lazy bootstrap (and its once-per-config warning). + OIIO_CHECK_FALSE(OIIO::pvt::color_config_is_interoperable(cc)); + OIIO_CHECK_FALSE( + OIIO::pvt::color_config_interopified_resolves_scene_interchange(cc)); + // R4/scope (c): the bootstrap warning never sets the error string -- + // has_error() stays false until a cross-config route is attempted. + OIIO_CHECK_FALSE(cc.has_error()); + + auto handle = cc.createColorProcessor("enc", "lin_ap1_scene"); + // Non-strict parsing: the gate is closed, so no bridge is built; the + // route falls back to a pass-through (identity -- pixels unchanged). + OIIO_CHECK_ASSERT(handle.get() != nullptr); + float passthru[3] = { probe[0], probe[1], probe[2] }; + if (handle) + handle->apply(passthru); + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(passthru[c], probe[c], 1e-6f); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "not color-interoperable")); + + // ...and the fallback did NOT reproduce a real bridge route: since the + // config resolves no interchange, the direct chokepoint route also + // fails to build a processor. + auto ref = identities_route_probe(cc, "enc", "lin_ap1_scene", probe); + OIIO_CHECK_ASSERT(ref.empty()); + } + + // --- Strict-off fallback: reconciliation failure continues with a + // pass-through and records a why + how-to-fix message ----------------- + { + ColorConfig cc(non_path); + auto handle = cc.createColorProcessor("enc", "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); // non-null fallback + float passthru[3] = { probe[0], probe[1], probe[2] }; + if (handle) + handle->apply(passthru); // pass-through: pixels unchanged + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(passthru[c], probe[c], 1e-6f); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + // Narration recorded on the error string: what failed and how to fix. + OIIO_CHECK_ASSERT(Strutil::contains(err, "lin_ap1_scene")); + OIIO_CHECK_ASSERT(Strutil::contains(err, "aces_interchange role")); + } + + // --- Lenient-fallback outcome travels WITH the processor: a cache hit of + // the fallback behaves exactly like its first computation, and IBA + // metadata never claims the conversion that didn't happen ------------ + { + ColorConfig cc(non_path); + auto h1 = cc.createColorProcessor("enc", "lin_ap1_scene"); + OIIO_CHECK_ASSERT(h1.get() != nullptr); + OIIO_CHECK_ASSERT(cc.has_error()); + (void)cc.geterror(); // consume (clears the shared error string) + + // Cache hit: the per-call outcome must be identical to the first + // computation -- the continue-message is re-signaled, not lost. + auto h2 = cc.createColorProcessor("enc", "lin_ap1_scene"); + OIIO_CHECK_ASSERT(h2.get() != nullptr); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err2 = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err2, "aces_interchange role")); + + // IBA honesty on BOTH calls: no pixels moved, so the output keeps the + // true (source) color space -- including when the fallback processor + // comes from the cache with the shared error string clean. + ImageBuf src(ImageSpec(2, 2, 3, TypeDesc::FLOAT)); + ImageBufAlgo::fill(src, { 0.25f, 0.5f, 0.75f }); + src.specmod().set_colorspace("enc"); + ImageBuf d1 = ImageBufAlgo::colorconvert(src, "enc", "lin_ap1_scene", + true, "", "", &cc); + OIIO_CHECK_ASSERT(!d1.has_error()); + OIIO_CHECK_EQUAL(d1.spec().get_string_attribute("oiio:ColorSpace"), + "enc"); + (void)cc.geterror(); // clear again: the cached path must not depend + // on leftover shared error state + ImageBuf d2 = ImageBufAlgo::colorconvert(src, "enc", "lin_ap1_scene", + true, "", "", &cc); + OIIO_CHECK_ASSERT(!d2.has_error()); + OIIO_CHECK_EQUAL(d2.spec().get_string_attribute("oiio:ColorSpace"), + "enc"); + } + + // --- Strict parsing: hard error (today's behavior) with why + how-to-fix -- + { + ColorConfig cc(strict_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Even though the config is interoperable (the bridge COULD resolve the + // name), strict parsing suppresses the bridge and restores the hard + // error. + OIIO_CHECK_ASSERT(OIIO::pvt::color_config_is_interoperable(cc)); + + auto handle = cc.createColorProcessor("ap0", "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() == nullptr); // hard error, no processor + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "strict parsing")); + OIIO_CHECK_ASSERT(Strutil::contains(err, "registry-known interop")); + } + + Filesystem::remove(interop_path); + Filesystem::remove(strict_path); + Filesystem::remove(non_path); +} + + + +// A DISPLAY-referred registry CIID (e.g. srgb_rec709_display) as a --colorconvert +// endpoint against a config that defines NO display space. It can never have a +// local equivalent, yet the registry lowers it colorimetrically to the scene +// reference through its own default view transform; so the cross-config bridge +// routes it through the scene interchange (spec 10 B2 fallback). Unlike a scene +// CIID, it is NOT subject to the strict-parsing hard-error opt-out -- it bridges +// under both strict and non-strict parsing. The conversion is colorimetric: white +// stays white, mid-gray follows the inverse sRGB EOTF, no tonescale/blow-up. +static void +test_cross_config_display_ciid_convert() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + if (ColorConfig::OpenColorIO_version_hex() < 0x02030000) + return; + if (!OIIO::pvt::interop_identities_config_resolves("srgb_rec709_display")) + return; + + // An interoperable, scene-only config (aces_interchange -> ACES2065-1, + // ACEScg via matrix) with NO display space. Two variants: default OCIO + // strict parsing, and explicit non-strict. + auto yaml = [](bool strict) { + return Strutil::fmt::format(R"(ocio_profile_version: 2.1 +strictparsing: {} +search_path: "" +roles: + default: ACEScg + scene_linear: ACEScg + aces_interchange: ACES2065-1 +colorspaces: + - ! + name: ACES2065-1 + encoding: scene-linear + - ! + name: ACEScg + encoding: scene-linear + to_scene_reference: ! {{matrix: [0.6954522414, 0.1406786965, 0.1638690622, 0, 0.0447945634, 0.8596711185, 0.0955343182, 0, -0.0055258826, 0.0040252103, 1.0015006723, 0, 0, 0, 0, 1]}} +)", + strict ? "true" : "false"); + }; + + for (bool strict : { true, false }) { + std::string path = Filesystem::temp_directory_path() + + (strict ? "/oiio_xconv_disp_strict.ocio" + : "/oiio_xconv_disp_nonstrict.ocio"); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path, yaml(strict))); + ColorConfig cc(path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + auto handle = cc.createColorProcessor("srgb_rec709_display", "ACEScg"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); // bridges under BOTH modes + if (handle) { + OIIO_CHECK_FALSE( + handle->isNoOp()); // a real colorimetric transform + + // Colorimetric: display white -> scene white (no blow-up), matrix + // preserves neutral (equal channels stay equal). + float white[3] = { 1.0f, 1.0f, 1.0f }; + handle->apply(white); + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(white[c], 1.0f, 2e-3f); + + // Mid-gray 0.5 follows the inverse sRGB EOTF (~0.214), NOT a + // tonescale and NOT a pass-through (which would leave 0.5). + float mid[3] = { 0.5f, 0.5f, 0.5f }; + handle->apply(mid); + for (int c = 0; c < 3; ++c) { + OIIO_CHECK_EQUAL_THRESH(mid[c], 0.214f, 3e-3f); + OIIO_CHECK_ASSERT(mid[c] < 0.49f); // definitely not a no-op + } + } + // No error recorded on a clean bridge success (either parsing mode). + OIIO_CHECK_FALSE(cc.has_error()); + Filesystem::remove(path); + } +} + + + +// Step 2 (spec 10 B2): the display interchange. interopify synthesizes a +// colorimetric cie_xyz_d65_interchange on the in-memory copy, and a +// display-referred CIID PREFERS it -- enabling a colorimetric display->display +// route (a display CIID to a display-referred space in the user's config) that +// the scene anchor cannot express by a single matrix. The local display space +// here uses P3-D65 primaries (distinct from the sRGB CIID) so resolve() cannot +// map the CIID to it locally, forcing the cross-config bridge. +static void +test_cross_config_display_interchange() +{ + using OIIO::pvt::interopified_display_interchange_probe; + + if (!ColorConfig::supportsOpenColorIO()) + return; + if (ColorConfig::OpenColorIO_version_hex() < 0x02030000) + return; + if (!OIIO::pvt::interop_identities_config_resolves("srgb_rec709_display")) + return; + + // Interoperable config with a P3-D65 display space and NO cie_xyz_d65 + // interchange of its own -- interopify must synthesize one. + static const char* yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ACEScg + scene_linear: ACEScg + aces_interchange: ACES2065-1 +displays: + P3: + - ! {name: Raw, colorspace: my_p3_display} +colorspaces: + - ! + name: ACES2065-1 + encoding: scene-linear + - ! + name: ACEScg + encoding: scene-linear + to_scene_reference: ! {matrix: [0.6954522414, 0.1406786965, 0.1638690622, 0, 0.0447945634, 0.8596711185, 0.0955343182, 0, -0.0055258826, 0.0040252103, 1.0015006723, 0, 0, 0, 0, 1]} +display_colorspaces: + - ! + name: my_p3_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} +)"; + std::string path = Filesystem::temp_directory_path() + + "/oiio_xconv_disp_interchange.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path, yaml)); + ColorConfig cc(path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // --- (c) The synthesized cie_xyz_d65_interchange is colorimetric: XYZ-D65 + // white -> scene white, matrix-only (no tonescale/offset). ------------ + { + // XYZ-D65 white (the CIE white point). A colorimetric anchor maps it to + // the scene space's white (1,1,1). + const float xyz_white[3] = { 0.95047f, 1.0f, 1.08883f }; + auto w = interopified_display_interchange_probe(cc, "ACEScg", + xyz_white); + OIIO_CHECK_EQUAL(w.size(), size_t(3)); + if (w.size() == 3) + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(w[c], 1.0f, 2e-3f); + + // Matrix-only (linear, no offset): scaling the input scales the output. + const float xyz_half[3] = { 0.475235f, 0.5f, 0.544415f }; + auto h = interopified_display_interchange_probe(cc, "ACEScg", xyz_half); + OIIO_CHECK_EQUAL(h.size(), size_t(3)); + if (w.size() == 3 && h.size() == 3) + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(h[c], 0.5f * w[c], 2e-3f); + } + + // --- (b) display CIID -> a display-referred space in the user's config, + // bridged through the display interchange, colorimetric (white->white, + // neutral stays neutral, no tonescale/blow-up). ---------------------- + { + auto handle = cc.createColorProcessor("srgb_rec709_display", + "my_p3_display"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + if (handle) { + OIIO_CHECK_FALSE(handle->isNoOp()); // real cross-config transform + + float white[3] = { 1.0f, 1.0f, 1.0f }; + handle->apply(white); + for (int c = 0; c < 3; ++c) { + OIIO_CHECK_EQUAL_THRESH(white[c], 1.0f, 3e-3f); // white->white + OIIO_CHECK_ASSERT(white[c] < 1.05f); // no blow-up + } + + // Mid-gray stays neutral (equal channels) and bounded -- an + // encoding change, never a tonescale. + float mid[3] = { 0.5f, 0.5f, 0.5f }; + handle->apply(mid); + OIIO_CHECK_EQUAL_THRESH(mid[0], mid[1], 2e-3f); + OIIO_CHECK_EQUAL_THRESH(mid[1], mid[2], 2e-3f); + OIIO_CHECK_ASSERT(mid[0] > 0.0f && mid[0] < 1.0f); + } + OIIO_CHECK_FALSE(cc.has_error()); + } + + Filesystem::remove(path); +} + + + +// Exercise the cross-config DISPLAY route in ColorConfig::createDisplayTransform: +// when the INPUT color space is absent from the current config +// but is a registry-known interop identity, and the config defines the requested +// display/view, the display transform routes the foreign source through the +// built-in interop identities config into this config's display/view -- the same +// strict/lenient/narration contract as the color-space route. On bridge failure, +// the input is deliberately NOT reinterpreted as scene_linear; instead the +// strict-aware fallback applies: strict OFF -> pass-through (pixels UNCHANGED, NOT +// reinterpreted as scene_linear); strict ON -> hard error. +static void +test_cross_config_display() +{ + using OIIO::pvt::identities_display_route_probe; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // The two-config display-view GetProcessorFromConfigs overload needs + // OCIO >= 2.3. + if (ColorConfig::OpenColorIO_version_hex() < 0x02030000) + return; + // The route bridges a registry AP1 (ACEScg) identity into the config's + // display/view; skip if this build's identities config doesn't carry it. + if (!OIIO::pvt::interop_identities_config_resolves("lin_ap1_scene")) + return; + + // An interoperable config (aces_interchange -> ap0) that defines a display/ + // view locally but LACKS the registry-known scene space "lin_ap1_scene". + static const char* interop_yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +displays: + disp: + - ! {name: view1, colorspace: g22} +colorspaces: + - ! + name: ap0 + + - ! + name: g22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + // Same config, but with OCIO strict parsing enabled. + static const char* strict_yaml = R"(ocio_profile_version: 2.1 +strictparsing: true +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +displays: + disp: + - ! {name: view1, colorspace: g22} +colorspaces: + - ! + name: ap0 + + - ! + name: g22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + // A NON-interoperable config that still defines a display/view. Its spaces + // are all from-reference (gamma) with no transformless scene reference to + // anchor a repair, so the interopified copy resolves no scene interchange + // (gate stays closed). The view color space "out" is a REAL transform from + // scene_linear, so a scene_linear->display transform is non-identity -- this + // is what makes the trap-1 regression assertion meaningful. + static const char* non_yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: enc + scene_linear: enc +displays: + disp: + - ! {name: view1, colorspace: out} +colorspaces: + - ! + name: enc + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: out + from_scene_reference: ! {value: [3.0, 3.0, 3.0, 1]} +)"; + + std::string interop_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xdisp_interop.ocio"; + std::string strict_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xdisp_strict.ocio"; + std::string non_path = Filesystem::temp_directory_path() + + "/oiio_color_test_xdisp_non.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(interop_path, interop_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(strict_path, strict_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(non_path, non_yaml)); + + const float probe[3] = { 0.18f, 0.42f, 0.73f }; + + // --- Success: registry-known input absent locally routes via the display + // bridge, reproducing the direct chokepoint route (abs 1e-6/channel) ---- + { + ColorConfig cc(interop_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + auto handle = cc.createDisplayTransform("disp", "view1", + "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + // A real AP1->display transform, not a pass-through no-op. + if (handle) + OIIO_CHECK_FALSE(handle->isNoOp()); + OIIO_CHECK_FALSE(cc.has_error()); + + float got[3] = { probe[0], probe[1], probe[2] }; + if (handle) + handle->apply(got); + auto ref = identities_display_route_probe(cc, "lin_ap1_scene", "disp", + "view1", probe); + OIIO_CHECK_EQUAL(ref.size(), size_t(3)); + if (ref.size() == 3) + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(got[c], ref[c], 1e-6f); + } + + // --- Zero behavior change: a local input space resolves as before --------- + { + ColorConfig cc(interop_path); + auto handle = cc.createDisplayTransform("disp", "view1", "ap0"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); + OIIO_CHECK_FALSE(cc.has_error()); + } + + // --- Trap-1 regression, strict OFF: the foreign input is NOT silently + // treated as scene_linear -- the route falls back to a pass-through + // (pixels UNCHANGED) and records a why + how-to-fix message. THIS is the + // test of the slice. -------------------------------------------------- + { + ColorConfig cc(non_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_FALSE(OIIO::pvt::color_config_is_interoperable(cc)); + OIIO_CHECK_FALSE( + OIIO::pvt::color_config_interopified_resolves_scene_interchange(cc)); + + auto handle = cc.createDisplayTransform("disp", "view1", + "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() != nullptr); // non-null fallback + float passthru[3] = { probe[0], probe[1], probe[2] }; + if (handle) + handle->apply(passthru); + // Pixels unchanged: the input was NOT reinterpreted as scene_linear. + for (int c = 0; c < 3; ++c) + OIIO_CHECK_EQUAL_THRESH(passthru[c], probe[c], 1e-6f); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "not color-interoperable")); + OIIO_CHECK_ASSERT(Strutil::contains(err, "display transform")); + + // Prove the pass-through is meaningful, not a coincidental identity: had + // the source been reinterpreted as scene_linear (the role space "enc"), + // the display transform WOULD have changed the pixels. + ColorConfig cc2(non_path); + auto trap = cc2.createDisplayTransform("disp", "view1", "enc"); + OIIO_CHECK_ASSERT(trap.get() != nullptr); + float trapped[3] = { probe[0], probe[1], probe[2] }; + if (trap) + trap->apply(trapped); + bool trap_changes = false; + for (int c = 0; c < 3; ++c) + if (std::abs(trapped[c] - probe[c]) > 1e-4f) + trap_changes = true; + OIIO_CHECK_ASSERT(trap_changes); + } + + // --- Trap-1 regression, strict ON: hard error (today's behavior) ---------- + { + ColorConfig cc(strict_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_ASSERT(OIIO::pvt::color_config_is_interoperable(cc)); + + auto handle = cc.createDisplayTransform("disp", "view1", + "lin_ap1_scene"); + OIIO_CHECK_ASSERT(handle.get() == nullptr); // hard error, no processor + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "strict parsing")); + OIIO_CHECK_ASSERT(Strutil::contains(err, "registry-known interop")); + } + + Filesystem::remove(interop_path); + Filesystem::remove(strict_path); + Filesystem::remove(non_path); +} + + + +// Set/clear an environment variable portably (the same idiom OIIO uses +// elsewhere, e.g. src/iv/ivmain.cpp). +static void +set_env_var(const char* name, const char* value) +{ +#ifdef _MSC_VER + _putenv_s(name, value); +#else + setenv(name, value, 1); +#endif +} +static void +unset_env_var(const char* name) +{ +#ifdef _MSC_VER + _putenv_s(name, ""); +#else + unsetenv(name); #endif +} - getargs(argc, argv); - test_sRGB_conversion(); - test_Rec709_conversion(); + +// Exercise the process-global flyweight fingerprint cache: a repeated lookup is +// a hit (the cache does not grow); a context-invariant space collapses to one +// bucket across two different contexts of the same structural config, while a +// context-sensitive space keeps a bucket per context; a different structural +// config keys separately (old entries orphan, no crash); the bulk warm pass +// populates one entry per simple space; and reset empties it. +static void +test_color_space_fingerprint_cache() +{ + using OIIO::pvt::color_space_fingerprint_cache_reset; + using OIIO::pvt::color_space_fingerprint_cache_size; + using OIIO::pvt::color_space_fingerprint_cached; + using OIIO::pvt::color_space_fingerprint_order; + using OIIO::pvt::color_space_fingerprint_warm; + using OIIO::pvt::ColorSpaceFingerprint; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // The interchange role + IdentifyBuiltinColorSpace path needs OCIO >= 2.2. + if (ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + + // Interoperable config with a declared context variable, one context- + // invariant simple space (matrix_inv) and one context-sensitive simple + // space (ctx_space, which resolves $CTX_CS). Written to two distinct paths + // with identical content so both share one structural cache id but each + // loads its own context. + static const char* cfg_yaml = R"(ocio_profile_version: 2.1 +environment: + CTX_CS: gamma_a +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: gamma_a + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: gamma_b + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} + + - ! + name: matrix_inv + from_scene_reference: ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} + + - ! + name: ctx_space + to_scene_reference: ! {src: $CTX_CS, dst: ref} +)"; + // A structurally different config, for the distinct-key check. + static const char* other_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: base + scene_linear: base + aces_interchange: base +displays: + disp: + - ! {name: main, colorspace: base} +colorspaces: + - ! + name: base + + - ! + name: doubler + from_scene_reference: ! {matrix: [3, 0, 0, 0, 0, 3, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1]} +)"; + + std::string dir = Filesystem::temp_directory_path(); + std::string path_a = dir + "/oiio_color_test_fpcache_a.ocio"; + std::string path_b = dir + "/oiio_color_test_fpcache_b.ocio"; + std::string path_o = dir + "/oiio_color_test_fpcache_other.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path_a, cfg_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path_b, cfg_yaml)); + OIIO_CHECK_ASSERT(Filesystem::write_text_file(path_o, other_yaml)); + + color_space_fingerprint_cache_reset(); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), size_t(0)); + + // --- All of cc1's work happens under CTX_CS=gamma_a -------------------- + set_env_var("CTX_CS", "gamma_a"); + ColorConfig cc1(path_a); + OIIO_CHECK_ASSERT(!cc1.has_error()); + + // (1) Same-name lookup twice: the first is a miss that publishes one entry, + // the second is a hit that returns the identical fingerprint and does not + // grow the cache. + ColorSpaceFingerprint m1 = color_space_fingerprint_cached(cc1, + "matrix_inv"); + OIIO_CHECK_ASSERT(m1.computed()); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), size_t(1)); + ColorSpaceFingerprint m1b = color_space_fingerprint_cached(cc1, + "matrix_inv"); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), size_t(1)); // hit + OIIO_CHECK_ASSERT(m1.values == m1b.values); + + // Cache the context-sensitive space under cc1's context (gamma_a). + ColorSpaceFingerprint s1 = color_space_fingerprint_cached(cc1, "ctx_space"); + OIIO_CHECK_ASSERT(s1.computed()); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), size_t(2)); + + // --- cc2 is a second context (gamma_b) of the SAME structural config --- + set_env_var("CTX_CS", "gamma_b"); + ColorConfig cc2(path_b); + OIIO_CHECK_ASSERT(!cc2.has_error()); + + // (2a) The context-invariant space collapses to the single bucket cc1 + // already populated: querying it through cc2 is a hit (no growth). + ColorSpaceFingerprint m2 = color_space_fingerprint_cached(cc2, + "matrix_inv"); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), + size_t(2)); // collapsed + OIIO_CHECK_ASSERT(m1.values == m2.values); + + // (2b) The context-sensitive space does NOT collapse: cc2's different + // context keys a separate bucket. This is airtight given (2a): matrix_inv + // collapsing proved cc1 and cc2 share one structural config id, so the only + // thing that can grow the cache here is ctx_space's differing context id. + ColorSpaceFingerprint s2 = color_space_fingerprint_cached(cc2, "ctx_space"); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), + size_t(3)); // new bucket + OIIO_CHECK_ASSERT(s2.computed()); + // And the VALUES differ: each instance probes under its OWN current + // context (gamma_a's 2.2 curve vs gamma_b's 1.8 curve), even though both + // share one process-memoized structural probe copy. The cache key's + // context id is exactly the context the probe ran under. + OIIO_CHECK_ASSERT(s1.values != s2.values); + + // (3) A structurally different config keys separately; the earlier entries + // just orphan (content-addressed, no eviction, no crash). + { + ColorConfig cco(path_o); + OIIO_CHECK_ASSERT(!cco.has_error()); + size_t before = color_space_fingerprint_cache_size(); + ColorSpaceFingerprint d = color_space_fingerprint_cached(cco, + "doubler"); + if (d.computed()) + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), before + 1); + } + + // (5) The bulk warm pass populates exactly one entry per simple color space + // (the same deterministic set color_space_fingerprint_order reports). + set_env_var("CTX_CS", "gamma_a"); + std::vector simple = color_space_fingerprint_order(cc1); + color_space_fingerprint_cache_reset(); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), size_t(0)); + size_t warmed = color_space_fingerprint_warm(cc1); + OIIO_CHECK_ASSERT(warmed > 0); + OIIO_CHECK_EQUAL(warmed, simple.size()); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), warmed); + + // (4) Reset empties the cache. + color_space_fingerprint_cache_reset(); + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), size_t(0)); + + unset_env_var("CTX_CS"); + Filesystem::remove(path_a); + Filesystem::remove(path_b); + Filesystem::remove(path_o); +} + + + +// Exercise the enriched ColorConfig::resolve() read-side tiers: the +// stripped-namespace retry, the config-local ":local:" form, +// the explicit interop_id attribute match (one-side-stripped only, never +// both), the data/bypass utility-token ranking (and "unknown"'s deliberate +// exclusion from it), and the registry-equivalence (fingerprint) tier -- +// plus the historical passthrough-on-total-miss regression guard. Small +// hand-built OCIO configs, same pattern as test_color_space_classification / +// test_config_interoperability, isolate each tier so one config's fixtures +// can't accidentally satisfy a different tier's assertion. +static void +test_interop_resolve() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + // OCIO >= 2.5 is required for the `interop_id:` color space attribute + // (native getInteropID()); tiers that depend on it are gated below. + const bool has_interop_id_attr = ColorConfig::OpenColorIO_version_hex() + >= 0x02050000; + + // ---- Base fixture: stripped-namespace, config-local, literal-unknown, + // and total-miss passthrough. No interop_id attributes -- safe to parse + // on any linked OCIO version. ----------------------------------------- + static const char* base_yaml = R"(ocio_profile_version: 2.1 +name: resolvetest +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: gamma24_space + aliases: [g24_rec709_scene] + from_scene_reference: ! {value: [2.4, 2.4, 2.4, 1]} + + - ! + name: local_target + aliases: [my_local_alias, unknown] + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} +)"; + std::string base_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_base.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(base_path, base_yaml)); + { + ColorConfig cc(base_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // Tier 1a': stripped-namespace retry -- the full string + // "myapp:g24_rec709_scene" matches no name/alias/role, but stripping + // the one leftmost namespace reaches the real alias. + OIIO_CHECK_EQUAL(cc.resolve("myapp:g24_rec709_scene"), "gamma24_space"); + + // Tier 1a'': config-local ":local:" form, matched + // against names/aliases only. A hit through an alias... + OIIO_CHECK_EQUAL(cc.resolve("resolvetest:local:my_local_alias"), + "local_target"); + // ...and a miss when the base names nothing in this config (proves + // the tier doesn't fall back to a fuzzy match). + OIIO_CHECK_EQUAL(cc.resolve("resolvetest:local:no_such_space"), + "resolvetest:local:no_such_space"); + + // "unknown" is a literal name/alias lookup only -- never routed + // through the ranked data-space search. Reachable here because + // local_target happens to carry it as a literal alias (ordinary + // tier 1a), not because of any utility-token machinery. + OIIO_CHECK_EQUAL(cc.resolve("unknown"), "local_target"); + + // Regression guard: a name that matches nothing in any tier is + // still passed through unchanged (main's historical behavior). + OIIO_CHECK_EQUAL(cc.resolve("totally_unrecognized_id"), + "totally_unrecognized_id"); + + // The failover overload: a miss yields the caller's failover (an + // empty one making "not recognized" distinguishable from a name + // that resolves to itself), while a hit is unaffected by it. + OIIO_CHECK_EQUAL(cc.resolve("totally_unrecognized_id", ""), ""); + OIIO_CHECK_EQUAL(cc.resolve("totally_unrecognized_id", "sentinel"), + "sentinel"); + OIIO_CHECK_EQUAL(cc.resolve("resolvetest:local:my_local_alias", ""), + "local_target"); + // "local_target" resolves to itself -- a hit, not a passthrough. + OIIO_CHECK_EQUAL(cc.resolve("local_target", ""), "local_target"); + } + Filesystem::remove(base_path); + + // ---- Uppercase fixture: an OCIO name/alias lookup is case-insensitive, + // so a literal (capitalized) "Unknown"/"Bypass" color space is reachable + // via tier 1a's pre-existing OCIO lookup regardless of the CIF grammar's + // lowercase-only validity rule (is_valid_interop_id, already covered by + // test_interop_id_grammar) -- resolve() is not gated on id validity, by + // design, so it doesn't re-derive that grammar-level invariant. What IS + // decisive and worth guarding here: the new utility-ranking tier + // (resolve_data_utility) never even runs for these, because tier 1a's + // OCIO-native lookup already satisfied the query first. + { + static const char* upper_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: Uppercase_Utility + aliases: [Unknown, Bypass] + isdata: true +)"; + std::string upper_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_upper.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(upper_path, upper_yaml)); + ColorConfig cc(upper_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(cc.resolve("unknown"), "Uppercase_Utility"); + OIIO_CHECK_EQUAL(cc.resolve("bypass"), "Uppercase_Utility"); + Filesystem::remove(upper_path); + } + + // ---- Real "Raw" data space: a config with a data space literally named + // "Raw" alongside other spaces is NOT the synthetic one-space + // OCIO::Config::CreateRaw() config, so the utility-token ranking must treat + // its "Raw" as a valid target -- "bypass"/"data" resolve to it. (The + // synthetic-raw skip is now keyed on the config's single-colorspace shape, + // not the name alone.) No interop_id attribute -- safe on any OCIO version. + { + static const char* raw_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: Raw + isdata: true +)"; + std::string raw_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_raw.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(raw_path, raw_yaml)); + ColorConfig cc(raw_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(cc.resolve("bypass"), "Raw"); + OIIO_CHECK_EQUAL(cc.resolve("data"), "Raw"); + Filesystem::remove(raw_path); + } + + if (has_interop_id_attr) { + // ---- Explicit interop_id attribute: safe directions -- exactly + // one side stripped -- still match. --------------------------- + static const char* safe_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: attr_bare_y + interop_id: "y" + from_scene_reference: ! {value: [1.5, 1.5, 1.5, 1]} + + - ! + name: attr_ns_z + interop_id: "app2:z" + from_scene_reference: ! {value: [1.6, 1.6, 1.6, 1]} +)"; + std::string safe_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_safe.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(safe_path, safe_yaml)); + { + ColorConfig cc(safe_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Query-side stripped: bare attribute "y" matches namespaced + // query "app:y". + OIIO_CHECK_EQUAL(cc.resolve("app:y"), "attr_bare_y"); + // Attribute-side stripped: namespaced attribute "app2:z" + // matches bare query "z". + OIIO_CHECK_EQUAL(cc.resolve("z"), "attr_ns_z"); + } + Filesystem::remove(safe_path); + + // ---- Explicit interop_id attribute: the both-sides-stripped + // cross-namespace false positive is rejected. ------------------- + static const char* reject_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: attr_oiio_x + interop_id: "oiio:x" + from_scene_reference: ! {value: [1.7, 1.7, 1.7, 1]} +)"; + std::string reject_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_reject.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(reject_path, reject_yaml)); + { + ColorConfig cc(reject_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // "oiio:x" and "ocio:x" both strip to "x", but neither raw side + // matches -- a miss, not a false positive. + OIIO_CHECK_EQUAL(cc.resolve("ocio:x"), "ocio:x"); + } + Filesystem::remove(reject_path); + + // ---- Reserved `local` namespace: a declared interop_id attribute + // whose leftmost segment is `local` is never matched by the + // attribute tier -- otherwise a grammar-legal "local:x" declaration + // would poach OTHER configs' private ":local:x" IDs via the + // stripped-attribute match. The genuine config-local tier and + // ordinary declared attributes are unaffected. + static const char* localns_yaml = R"(ocio_profile_version: 2.1 +name: localns +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: poacher + interop_id: "local:x" + from_scene_reference: ! {value: [1.9, 1.9, 1.9, 1]} + + - ! + name: inner_target + aliases: [xbase] + from_scene_reference: ! {value: [2.0, 2.0, 2.0, 1]} + + - ! + name: normal_attr + interop_id: "app9:q" + from_scene_reference: ! {value: [2.1, 2.1, 2.1, 1]} +)"; + std::string localns_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_localns.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(localns_path, localns_yaml)); + { + ColorConfig cc(localns_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Another config's config-local ID strips to "local:x", which + // equals the declared attribute -- but the reserved-namespace + // exclusion makes it a total miss, not a poach. + OIIO_CHECK_EQUAL(cc.resolve("othercfg:local:x"), + "othercfg:local:x"); + // The bare declared form itself is unreachable too. + OIIO_CHECK_EQUAL(cc.resolve("local:x"), "local:x"); + // The genuine config-local tier still resolves for THIS config. + OIIO_CHECK_EQUAL(cc.resolve("localns:local:xbase"), "inner_target"); + // Ordinary declared attributes are unaffected (attribute-side + // strip still matches). + OIIO_CHECK_EQUAL(cc.resolve("q"), "normal_attr"); + } + Filesystem::remove(localns_path); + + // ---- Utility-token ranking: rank 0 (self-identity via interop_id) + // short-circuits for both "bypass" and "data". -------------------- + static const char* rank_full_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: bypass_named + interop_id: bypass + isdata: true + + - ! + name: data_named + interop_id: data + isdata: true + + - ! + name: plain_data_space + isdata: true +)"; + std::string rank_full_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_rank_full.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(rank_full_path, rank_full_yaml)); + { + ColorConfig cc(rank_full_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(cc.resolve("bypass"), "bypass_named"); + OIIO_CHECK_EQUAL(cc.resolve("data"), "data_named"); + // "unknown" is never ranked -- no literal "unknown" name/alias + // exists here, so it's a total miss even though data spaces do. + OIIO_CHECK_EQUAL(cc.resolve("unknown"), "unknown"); + } + Filesystem::remove(rank_full_path); + + // ---- Utility-token ranking: without a self-identified space, a + // plain data space (rank 1) beats one identified as the OTHER + // token (rank 2). The "data" query's mirror case runs through the + // identical ranking code path (data_space_identifies_as / rank + // computation are symmetric in token/other), so one direction is + // sufficient coverage here. + static const char* rank_partial_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref +colorspaces: + - ! + name: ref + + - ! + name: data_named + interop_id: data + isdata: true + + - ! + name: plain_data_space + isdata: true +)"; + std::string rank_partial_path + = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_rank_partial.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(rank_partial_path, rank_partial_yaml)); + { + ColorConfig cc(rank_partial_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // No space here self-identifies as "bypass"; data_named + // identifies as the OTHER token (rank 2), so the plain data + // space (rank 1) wins. + OIIO_CHECK_EQUAL(cc.resolve("bypass"), "plain_data_space"); + } + Filesystem::remove(rank_partial_path); + } + + // ---- Registry equivalence (tier 2): a query that names no local + // space, but is fingerprint-identical to a registry identity, resolves + // to this config's OWN equivalent space -- never a cross-config + // processor. Both this config's "aces_interchange" anchor and the + // queried space are identity (no transform), matching the registry's + // own AP0 reference space (ACES2065-1) exactly. A utility token stays + // an automatic miss even though this config is otherwise interoperable + // and reaches this tier. ------------------------------------------- + { + static const char* registry_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: my_ap0_ref + scene_linear: my_ap0_ref + aces_interchange: my_ap0_ref +colorspaces: + - ! + name: my_ap0_ref + + - ! + name: another_ap0_identity_space +)"; + std::string registry_path = Filesystem::temp_directory_path() + + "/oiio_color_test_resolve_registry.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(registry_path, registry_yaml)); + ColorConfig cc(registry_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // Neither space is named or aliased "lin_ap0_scene" -- only a + // registry fingerprint match can reach one via that id. Both + // "my_ap0_ref" and "another_ap0_identity_space" are identity (no + // transform), so both are genuinely fingerprint-equivalent; the + // tier walks the config's simple spaces in deterministic sorted + // order and returns the first match, which alphabetically is + // "another_ap0_identity_space". + OIIO_CHECK_EQUAL(cc.resolve("lin_ap0_scene"), + "another_ap0_identity_space"); + + // A utility token has no registry fingerprint and must not attempt + // one, even on a config that is otherwise interoperable and would + // reach tier 2. + OIIO_CHECK_EQUAL(cc.resolve("data"), "data"); + OIIO_CHECK_EQUAL(cc.resolve("bypass"), "bypass"); + OIIO_CHECK_EQUAL(cc.resolve("unknown"), "unknown"); + + Filesystem::remove(registry_path); + } +} + + + +// Exercise the cheap, OCIO-free ICC byte-inspection primitives +// (is_icc_profile / icc_profile_identifier / icc_embedded_cicp). +static void +test_icc_utils() +{ + using OIIO::pvt::icc_embedded_cicp; + using OIIO::pvt::icc_profile_identifier; + using OIIO::pvt::is_icc_profile; + + Strutil::print("Testing ICC identification primitives\n"); + + // Build a minimal structurally-valid ICC blob: 128-byte header with the + // 'acsp' signature at byte 36 and `version` in header byte 8, a + // big-endian tag count at 128, then `tagcount` 12-byte tag entries. + auto make_icc = [](uint8_t version, uint32_t tagcount) { + std::vector blob(132 + size_t(tagcount) * 12, 0); + memcpy(blob.data() + 36, "acsp", 4); + blob[8] = version; + blob[131] = uint8_t(tagcount); // BE tag count (< 256 here) + return blob; + }; + + // ---- is_icc_profile: the sole header gate ---------------------------- + OIIO_CHECK_ASSERT(!is_icc_profile(cspan())); + std::vector junk(200, 0x42); + OIIO_CHECK_ASSERT(!is_icc_profile(junk)); + std::vector tiny(make_icc(2, 0)); + tiny.resize(131); // one byte short of header + tag count + OIIO_CHECK_ASSERT(!is_icc_profile(tiny)); + OIIO_CHECK_ASSERT(is_icc_profile(make_icc(2, 0))); + OIIO_CHECK_EQUAL(icc_profile_identifier(junk), ""); + + // ---- identifier, v2: XXH64 over raw bytes, 16 lowercase hex. --------- + auto v2 = make_icc(2, 0); + const std::string v2id = icc_profile_identifier(v2); + OIIO_CHECK_EQUAL(v2id.size(), 16); + OIIO_CHECK_EQUAL(icc_profile_identifier(v2), v2id); // deterministic + auto v2tampered = v2; + for (size_t i = 84; i < 100; ++i) + v2tampered[i] = 0xAB; + const std::string v2tamperedid = icc_profile_identifier(v2tampered); + OIIO_CHECK_EQUAL(v2tamperedid.size(), 16); + OIIO_CHECK_ASSERT(v2tamperedid != v2id); // raw bytes differ -> id differs + + // ---- identifier, v4: byte-exact contract. The embedded Profile ID + // field (bytes 84-99) is NEVER trusted as identity -- two different + // bodies sharing one embedded ID must not collide, and identical bytes + // must agree. ---------------------------------------------------------- + auto v4 = make_icc(4, 0); + OIIO_CHECK_EQUAL(icc_profile_identifier(v4).size(), 16); + for (size_t i = 84; i < 100; ++i) + v4[i] = uint8_t(i - 84); + const std::string v4id = icc_profile_identifier(v4); + OIIO_CHECK_EQUAL(v4id.size(), 16); // hash, not the embedded hex field + OIIO_CHECK_EQUAL(icc_profile_identifier(v4), v4id); // same bytes, same id + // A body change with an UNCHANGED embedded Profile ID (the stale/forged + // ID scenario) must change the identifier. + auto v4body = v4; + v4body[100] = 0x7F; + OIIO_CHECK_ASSERT(icc_profile_identifier(v4body) != v4id); + + // ---- embedded cicpTag reader ---------------------------------------- + // Well-formed v4 cicp tag: entry at 132, tag data at 144 = 'cicp' + 4 + // reserved zero bytes + (P,T,M,R). + auto make_cicp_icc = [&](uint8_t p, uint8_t t, uint8_t m, uint8_t r) { + auto blob = make_icc(4, 1); + blob.resize(156, 0); + memcpy(blob.data() + 132, "cicp", 4); + blob[139] = 144; // BE tag offset + blob[143] = 12; // BE tag size + memcpy(blob.data() + 144, "cicp", 4); + blob[152] = p; + blob[153] = t; + blob[154] = m; + blob[155] = r; + return blob; + }; + int cicp[4] = { -1, -1, -1, -1 }; + OIIO_CHECK_ASSERT(icc_embedded_cicp(make_cicp_icc(1, 13, 0, 1), cicp)); + OIIO_CHECK_EQUAL(cicp[0], 1); + OIIO_CHECK_EQUAL(cicp[1], 13); + OIIO_CHECK_EQUAL(cicp[2], 0); + OIIO_CHECK_EQUAL(cicp[3], 1); + + // No cicp tag; v2 profile; junk: all false, cicp untouched. + int untouched[4] = { -1, -1, -1, -1 }; + OIIO_CHECK_ASSERT(!icc_embedded_cicp(make_icc(4, 0), untouched)); + auto v2cicp = make_cicp_icc(1, 13, 0, 1); + v2cicp[8] = 2; // v2: cicpTag is an ICC.1:2022 (v4) construct + OIIO_CHECK_ASSERT(!icc_embedded_cicp(v2cicp, untouched)); + OIIO_CHECK_ASSERT(!icc_embedded_cicp(junk, untouched)); + + // Malformed flavors: wrong tag size, non-zero reserved bytes, range + // flag > 1, out-of-bounds offset. + auto badsize = make_cicp_icc(1, 13, 0, 1); + badsize[143] = 16; // size != 12 + OIIO_CHECK_ASSERT(!icc_embedded_cicp(badsize, untouched)); + auto badresv = make_cicp_icc(1, 13, 0, 1); + badresv[148] = 1; // reserved must be zero + OIIO_CHECK_ASSERT(!icc_embedded_cicp(badresv, untouched)); + OIIO_CHECK_ASSERT(!icc_embedded_cicp(make_cicp_icc(1, 13, 0, 2), + untouched)); // range > 1 + auto badoffset = make_cicp_icc(1, 13, 0, 1); + badoffset[139] = 200; // tag data beyond the blob + OIIO_CHECK_ASSERT(!icc_embedded_cicp(badoffset, untouched)); + for (int v : untouched) + OIIO_CHECK_EQUAL(v, -1); +} + + + +// --------------------------------------------------------------------------- +// ICC identification fixtures, built in-memory (port of the proven POC +// fixture generator). The rXYZ/gXYZ/bXYZ colorant matrices are the +// primaries' NPM Bradford-adapted from D65 to the ICC PCS illuminant D50 +// (dst = the header illuminant XYZ 0.9642/1.0/0.8249, NOT xy-derived D50), +// which is the exact inverse of the hardcoded D50->D65 adaptation OCIO's +// ICC reader composes in on decode -- so the decoded fixture recovers its +// nominal D65 primaries. The matrix values are precomputed s15Fixed16 +// integers; the construction math is not repeated here. +// --------------------------------------------------------------------------- + +namespace icc_fixture { + +static void +be16(std::vector& v, uint16_t x) +{ + v.push_back(uint8_t(x >> 8)); + v.push_back(uint8_t(x)); +} + +static void +be32(std::vector& v, uint32_t x) +{ + v.push_back(uint8_t(x >> 24)); + v.push_back(uint8_t(x >> 16)); + v.push_back(uint8_t(x >> 8)); + v.push_back(uint8_t(x)); +} + +static void +tag4(std::vector& v, const char* sig) +{ + v.insert(v.end(), sig, sig + 4); +} + +// 'XYZ ' tag from three s15Fixed16 raw integers. +static std::vector +xyz_tag(int32_t x, int32_t y, int32_t z) +{ + std::vector t; + tag4(t, "XYZ "); + be32(t, 0); + be32(t, uint32_t(x)); + be32(t, uint32_t(y)); + be32(t, uint32_t(z)); + return t; +} + +// 'curv' tag: gamma (one u8Fixed8 entry) or a full u16 table. +static std::vector +curv_gamma(float g) +{ + std::vector t; + tag4(t, "curv"); + be32(t, 0); + be32(t, 1); + be16(t, uint16_t(std::lround(g * 256.0f))); + return t; +} + +static std::vector +curv_srgb_table(int n = 1024) +{ + std::vector t; + tag4(t, "curv"); + be32(t, 0); + be32(t, uint32_t(n)); + for (int i = 0; i < n; ++i) { + double x = double(i) / (n - 1); + double y = x <= 0.04045 ? x / 12.92 + : std::pow((x + 0.055) / 1.055, 2.4); + be16(t, + uint16_t(std::lround(std::min(std::max(y, 0.0), 1.0) * 65535.0))); + } + return t; +} + +// ICC v2 'desc' (textDescription) tag, ASCII record only. +static std::vector +desc_tag(const char* text) +{ + std::vector t; + tag4(t, "desc"); + be32(t, 0); + const size_t len = strlen(text) + 1; // include NUL + be32(t, uint32_t(len)); + t.insert(t.end(), text, text + len); + t.insert(t.end(), 8 + 3 + 67, 0); // unicode + scriptcode + mac records + return t; +} + +// D50 header illuminant / wtpt as s15Fixed16 (0.9642, 1.0, 0.8249). +static const int32_t kD50[3] = { 63190, 65536, 54061 }; + +// Assemble header + tag table + 4-byte-aligned bodies; profile_size at 0. +static std::vector +assemble(const std::vector>>& tags) +{ + std::vector h(128, 0); + memcpy(h.data() + 4, "oici", 4); // CMM + h[8] = 2, h[9] = 0x40; // version 2.4 + memcpy(h.data() + 12, "mntr", 4); // device class + memcpy(h.data() + 16, "RGB ", 4); // data color space + memcpy(h.data() + 20, "XYZ ", 4); // PCS + h[24] = 0x07, h[25] = 0xEA, h[27] = 7, h[29] = 9; // fixed date + memcpy(h.data() + 36, "acsp", 4); // signature + std::vector illum; + be32(illum, uint32_t(kD50[0])); + be32(illum, uint32_t(kD50[1])); + be32(illum, uint32_t(kD50[2])); + std::copy(illum.begin(), illum.end(), h.begin() + 68); + + const uint32_t n = uint32_t(tags.size()); + uint32_t offset = 128 + 4 + 12 * n; + std::vector table, body; + be32(table, n); + for (const auto& [sig, data] : tags) { + tag4(table, sig); + be32(table, offset); + be32(table, uint32_t(data.size())); + body.insert(body.end(), data.begin(), data.end()); + const size_t pad = (4 - data.size() % 4) % 4; + body.insert(body.end(), pad, 0); + offset += uint32_t(data.size() + pad); + } + std::vector blob = std::move(h); + blob.insert(blob.end(), table.begin(), table.end()); + blob.insert(blob.end(), body.begin(), body.end()); + blob[0] = uint8_t(blob.size() >> 24); + blob[1] = uint8_t(blob.size() >> 16); + blob[2] = uint8_t(blob.size() >> 8); + blob[3] = uint8_t(blob.size()); + return blob; +} + +// Standard sRGB: Rec.709 primaries / D65, Bradford-adapted to D50 +// (s15Fixed16 columns R,G,B), tabulated sRGB EOTF. +static std::vector +srgb_profile() +{ + auto trc = curv_srgb_table(); + return assemble({ + { "desc", desc_tag("oiio sRGB v2 fixture") }, + { "rXYZ", xyz_tag(28576, 14581, 912) }, + { "gXYZ", xyz_tag(25239, 46983, 6361) }, + { "bXYZ", xyz_tag(9375, 3972, 46787) }, + { "wtpt", xyz_tag(kD50[0], kD50[1], kD50[2]) }, + { "rTRC", trc }, + { "gTRC", trc }, + { "bTRC", trc }, + }); +} + +// Decodable but nonstandard: wide-gamut primaries, gamma 1.8 -- matches no +// registry identity. +static std::vector +wide_profile() +{ + auto trc = curv_gamma(1.8f); + return assemble({ + { "desc", desc_tag("oiio custom wide-gamut g1.8 fixture") }, + { "rXYZ", xyz_tag(53445, 19596, -193) }, + { "gXYZ", xyz_tag(10487, 47072, 629) }, + { "bXYZ", xyz_tag(-742, -1131, 53624) }, + { "wtpt", xyz_tag(kD50[0], kD50[1], kD50[2]) }, + { "rTRC", trc }, + { "gTRC", trc }, + { "bTRC", trc }, + }); +} + +// cLUT-only profile: A2B0 (lut8Type), no matrix/TRC tags -> OCIO's +// matrix/TRC reader cannot build a transform and must refuse it. +static std::vector +clut_profile() +{ + std::vector a2b; + tag4(a2b, "mft1"); + be32(a2b, 0); + a2b.push_back(3); // in channels + a2b.push_back(3); // out channels + a2b.push_back(2); // grid points + a2b.push_back(0); + for (int v : { 1, 0, 0, 0, 1, 0, 0, 0, 1 }) // identity matrix s15f16 + be32(a2b, uint32_t(v * 65536)); + for (int i = 0; i < 3; ++i) // input tables + a2b.insert(a2b.end(), { 0x00, 0xFF }); + for (int r : { 0, 255 }) // 2^3 CLUT grid, 3 outputs + for (int g : { 0, 255 }) + for (int b : { 0, 255 }) + a2b.insert(a2b.end(), { uint8_t(r), uint8_t(g), uint8_t(b) }); + for (int i = 0; i < 3; ++i) // output tables + a2b.insert(a2b.end(), { 0x00, 0xFF }); + return assemble({ + { "desc", desc_tag("oiio cLUT A2B fixture") }, + { "wtpt", xyz_tag(kD50[0], kD50[1], kD50[2]) }, + { "A2B0", a2b }, + }); +} + +} // namespace icc_fixture + + + +static void +test_identify_icc() +{ + using OIIO::pvt::icc_profile_identifier; + using OIIO::pvt::identify_icc_profile; + + if (!ColorConfig::supportsOpenColorIO()) + return; + + Strutil::print("Testing ICC profile identification\n"); + ColorConfig config("ocio://default"); + + // Non-ICC bytes: empty id, not decodable (invalid input, not a color + // answer). + { + std::vector junk(200, 0x42); + auto r = identify_icc_profile(config, junk); + OIIO_CHECK_EQUAL(r.id, ""); + OIIO_CHECK_EQUAL(r.decodable, false); + } + + // Standard sRGB profile: decodes and fingerprint-matches the registry + // sRGB display identity -- the result must carry srgb_rec709_display + // semantics (caller-local name or the bare CIID) and must NOT be an + // "icc:" token (identify-first: no token for a matched profile). + const auto srgb = icc_fixture::srgb_profile(); + { + auto r = identify_icc_profile(config, srgb); + OIIO_CHECK_EQUAL(r.decodable, true); + OIIO_CHECK_ASSERT(!r.id.empty()); + OIIO_CHECK_ASSERT(!Strutil::starts_with(r.id, "icc:")); + const bool srgb_semantics + = r.id == "srgb_rec709_display" + || OIIO::pvt::derive_color_interop_id(config, r.id) + == "srgb_rec709_display"; + OIIO_CHECK_ASSERT(srgb_semantics); + if (!srgb_semantics) + Strutil::print(" (identified as '{}')\n", r.id); + } + + // Decodable but nonstandard profile: no registry identity matches, so + // the answer is the bare deterministic "icc:" token + // (16-hex XXH64 for a v2 profile). Idempotent across calls. + const auto wide = icc_fixture::wide_profile(); + { + const std::string token = "icc:" + icc_profile_identifier(wide); + OIIO_CHECK_EQUAL(token.size(), 4 + 16); + auto r = identify_icc_profile(config, wide); + OIIO_CHECK_EQUAL(r.decodable, true); + OIIO_CHECK_EQUAL(r.id, token); + auto again = identify_icc_profile(config, wide); + OIIO_CHECK_EQUAL(again.id, token); + } + + // cLUT/AToB profile: structurally ICC but OCIO's matrix/TRC reader + // refuses it -> bare token, decodable false. + const auto clut = icc_fixture::clut_profile(); + { + auto r = identify_icc_profile(config, clut); + OIIO_CHECK_EQUAL(r.decodable, false); + OIIO_CHECK_EQUAL(r.id, "icc:" + icc_profile_identifier(clut)); + } + + // Distinct profiles stay distinct across interleaved identifications: + // the content-unique virtual filename keeps OCIO's process-global file + // hash cache from handing one profile's processor to another (the + // classic collision would "decode" the cLUT as the previously-seen + // sRGB). + { + auto r1 = identify_icc_profile(config, srgb); + auto r2 = identify_icc_profile(config, clut); + auto r3 = identify_icc_profile(config, srgb); + OIIO_CHECK_EQUAL(r2.decodable, false); + OIIO_CHECK_EQUAL(r1.decodable, true); + OIIO_CHECK_EQUAL(r3.id, r1.id); + OIIO_CHECK_ASSERT(r1.id != r2.id); + } +} + + + +static void +test_mastering_volume() +{ + using OIIO::pvt::derive_mastering_volume; + using OIIO::pvt::MasteringDisplayVolume; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // The fixture declares interop_id attributes (OCIO >= 2.5) and 2.5 + // builtin styles. + if (ColorConfig::OpenColorIO_version_hex() < 0x02050000) + return; + + Strutil::print("Testing mastering display volume derivation\n"); + + // Identity 3D LUT for the pure-LUT ODT fixtures. + std::string lut_path = Filesystem::temp_directory_path() + + "/oiio_mdcv_identity.spi3d"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(lut_path, + "SPILUT 1.0\n" + "3 3\n" + "2 2 2\n" + "0 0 0 0.0 0.0 0.0\n" + "0 0 1 0.0 0.0 1.0\n" + "0 1 0 0.0 1.0 0.0\n" + "0 1 1 0.0 1.0 1.0\n" + "1 0 0 1.0 0.0 0.0\n" + "1 0 1 1.0 0.0 1.0\n" + "1 1 0 1.0 1.0 0.0\n" + "1 1 1 1.0 1.0 1.0\n")); + + // mDCV fixture (ported from the proven POC): ACES builtin HDR/SDR + // views, custom RangeTransform views for the probe path, v1-style + // colorspace-based views with GroupTransform nesting, gamma-2.6 + // theatrical and PQ-DCDM flavors for the cinema-anchor cases, and + // pure-LUT ODTs (tagged and untagged) for the identity tier. + static const char* mdcv_yaml = R"(ocio_profile_version: 2.5 +name: mdcv-fixture +search_path: . +roles: + aces_interchange: ACES2065-1 + cie_xyz_d65_interchange: CIE-XYZ-D65 + default: ACES2065-1 + scene_linear: ACES2065-1 +file_rules: + - ! {name: Default, colorspace: default} +displays: + Rec2100PQ: + - ! {name: HDR 1000 nit P3 lim, view_transform: HDR-1000-P3lim, display_colorspace: ST2084-P3-D65} + - ! {name: SDR Video, view_transform: SDR-Video, display_colorspace: sRGB - Display} + - ! {name: Custom Clamp SDRish, view_transform: Custom-Clamp-1, display_colorspace: ST2084-P3-D65} + - ! {name: Custom Clamp HDRish, view_transform: Custom-Clamp-10, display_colorspace: ST2084-P3-D65} + LegacyHDR: + - ! {name: Output HDR Video, colorspace: Output - HDR Video 2020} + LegacySDR: + - ! {name: Output sRGB, colorspace: Output - SDR Video} + LegacyCinema: + - ! {name: Output DCI, colorspace: Output - SDR Cinema DCI} + - ! {name: Output D60, colorspace: Output - SDR Cinema D60} + DCDMPQ: + - ! {name: PQ DCDM Clamp, view_transform: Custom-Clamp-10, display_colorspace: ST2084-DCDM} + CinemaVT: + - ! {name: DCI VT, view_transform: SDR-Cinema-DCI-VT, display_colorspace: G2.6-P3-DCI} + LegacyLUT: + - ! {name: Film LUT, colorspace: Output - Film LUT} + - ! {name: Mystery LUT, colorspace: Output - Mystery LUT} + - ! {name: Lin P3DCI LUT, colorspace: Output - Lin P3DCI LUT} +default_view_transform: SDR-Video +view_transforms: + - ! + name: HDR-1000-P3lim + from_scene_reference: ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - HDR-VIDEO-1000nit-15nit-P3lim_1.1} + - ! + name: SDR-Video + from_scene_reference: ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0} + - ! + name: Custom-Clamp-1 + from_scene_reference: ! {min_in_value: 0., max_in_value: 1., min_out_value: 0., max_out_value: 1.} + - ! + name: Custom-Clamp-10 + from_scene_reference: ! {min_in_value: 0., max_in_value: 10., min_out_value: 0., max_out_value: 10.} + - ! + name: SDR-Cinema-DCI-VT + from_scene_reference: ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-CINEMA-D60sim-DCI_1.0} +display_colorspaces: + - ! + name: CIE-XYZ-D65 + encoding: display-linear + isdata: false + - ! + name: ST2084-P3-D65 + encoding: hdr-video + isdata: false + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_ST2084-P3-D65} + - ! + name: sRGB - Display + encoding: sdr-video + isdata: false + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_sRGB} + - ! + name: G2.6-P3-DCI + encoding: sdr-video + isdata: false + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_G2.6-P3-DCI-BFD} + - ! + name: ST2084-DCDM + encoding: hdr-video + isdata: false + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_ST2084-DCDM-D65} +colorspaces: + - ! + name: ACES2065-1 + encoding: scene-linear + isdata: false + - ! + name: Output - HDR Video 2020 + encoding: hdr-video + isdata: false + from_scene_reference: ! + children: + - ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - HDR-VIDEO-1000nit-15nit-REC2020lim_1.1} + - ! {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ} + - ! + name: Output - SDR Video + encoding: sdr-video + isdata: false + from_scene_reference: ! + children: + - ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0} + - ! {style: DISPLAY - CIE-XYZ-D65_to_sRGB} + - ! + name: Output - Film LUT + encoding: sdr-video + isdata: false + interop_id: srgb_rec709_display + from_scene_reference: ! {src: oiio_mdcv_identity.spi3d, interpolation: best} + - ! + name: Output - Mystery LUT + encoding: sdr-video + isdata: false + from_scene_reference: ! {src: oiio_mdcv_identity.spi3d, interpolation: best} + - ! + name: Output - Lin P3DCI LUT + encoding: sdr-video + isdata: false + interop_id: oiio:lin_p3dci_display + from_scene_reference: ! {src: oiio_mdcv_identity.spi3d, interpolation: best} + - ! + name: Output - SDR Cinema DCI + encoding: sdr-video + isdata: false + from_scene_reference: ! + children: + - ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-CINEMA-D60sim-DCI_1.0} + - ! {style: DISPLAY - CIE-XYZ-D65_to_G2.6-P3-DCI-BFD} + - ! + name: Output - SDR Cinema D60 + encoding: sdr-video + isdata: false + from_scene_reference: ! + children: + - ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-CINEMA-D60sim-DCI_1.0} + - ! {style: DISPLAY - CIE-XYZ-D65_to_G2.6-P3-D60-BFD} +)"; + std::string config_path = Filesystem::temp_directory_path() + + "/oiio_mdcv_fixture.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(config_path, mdcv_yaml)); + ColorConfig config(config_path); + OIIO_CHECK_ASSERT(!config.has_error()); + + static const float kRec709[4][2] = { { 0.64f, 0.33f }, + { 0.30f, 0.60f }, + { 0.15f, 0.06f }, + { 0.3127f, 0.329f } }; + static const float kP3D65[4][2] = { { 0.68f, 0.32f }, + { 0.265f, 0.69f }, + { 0.15f, 0.06f }, + { 0.3127f, 0.329f } }; + static const float kRec2020[4][2] = { { 0.708f, 0.292f }, + { 0.170f, 0.797f }, + { 0.131f, 0.046f }, + { 0.3127f, 0.329f } }; + auto check_primaries = [](const MasteringDisplayVolume& vol, + const float want[4][2], float tol) { + for (int i = 0; i < 4; ++i) { + OIIO_CHECK_EQUAL_THRESH(vol.primaries[i][0], want[i][0], tol); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[i][1], want[i][1], tol); + } + }; + + // Tier 1, view_transform-based: nominal peak + limiting gamut from the + // ACES-OUTPUT style table. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT(derive_mastering_volume(config, "Rec2100PQ", + "HDR 1000 nit P3 lim", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 1000.0); + OIIO_CHECK_EQUAL(vol.min_luminance, 0.0); + OIIO_CHECK_ASSERT(Strutil::contains(vol.style, "P3lim")); + check_primaries(vol, kP3D65, 1e-6f); + } + + // Tier 1, v1-style with the builtin nested inside a GroupTransform. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT(derive_mastering_volume(config, "LegacyHDR", + "Output HDR Video", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 1000.0); + OIIO_CHECK_ASSERT(Strutil::contains(vol.style, "REC2020lim")); + check_primaries(vol, kRec2020, 1e-6f); + } + + // Tier 1, tokenless SDR-VIDEO: ACES defines it as Rec.709 / 100 nit, + // in both the view_transform-based and v1-style flavors. + for (auto&& [d, v] : + { std::pair { "Rec2100PQ", "SDR Video" }, + { "LegacySDR", "Output sRGB" } }) { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT(derive_mastering_volume(config, d, v, vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 100.0); + OIIO_CHECK_EQUAL(vol.min_luminance, 0.0); + check_primaries(vol, kRec709, 1e-6f); + } + + // Tier 2 probe, SDR-ish clamp (XYZ Y=1 -> 100 nits): primaries are the + // display ENCODING gamut; provenance style is empty (no ACES builtin + // anywhere in the custom view). + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT(derive_mastering_volume(config, "Rec2100PQ", + "Custom Clamp SDRish", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 100.0); + OIIO_CHECK_ASSERT(vol.min_luminance < 1e-6); + OIIO_CHECK_EQUAL(vol.style, ""); + check_primaries(vol, kP3D65, 1e-4f); + } + + // Tier 2 probe, HDR-ish clamp (Y=10 -> 1000 nits, snapped to nominal). + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT(derive_mastering_volume(config, "Rec2100PQ", + "Custom Clamp HDRish", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 1000.0); + check_primaries(vol, kP3D65, 1e-4f); + } + + // Tier 3, v1-style DCI: the ACES style parses no gamut (DCI white has + // no table entry), so the DISPLAY tail decodes INVERSE. The BFD builtin + // bakes a Bradford DCI->D65 adaptation (white lands at D65) and the + // gamma-2.6 family anchors at the 48-nit projector calibration white: + // the D60sim white sits at Y_rel 0.883 -> ~42.4 cd/m^2, between + // nominals so reported raw. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT( + derive_mastering_volume(config, "LegacyCinema", "Output DCI", vol)); + OIIO_CHECK_EQUAL_THRESH(vol.max_luminance, 0.8828 * 48.0, 0.05); + OIIO_CHECK_ASSERT(vol.min_luminance < 1e-5); + OIIO_CHECK_EQUAL(vol.style, "DISPLAY - CIE-XYZ-D65_to_G2.6-P3-DCI-BFD"); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[3][0], 0.3127f, 1e-4f); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[3][1], 0.329f, 1e-4f); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[0][0], 0.68f, 0.01f); + } + + // Tier 3, same shape re-encoded with the G2.6-P3-D60-BFD tail: a + // 48-nit theatrical encoding whose style carries NO DCI/DCDM token. + // The anchor is classified from the leading encoding family + // (G2.6-P3-*), not device-token matching -- the same ~42.4 cd/m^2, not + // a 2x-overstated 88.3. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT( + derive_mastering_volume(config, "LegacyCinema", "Output D60", vol)); + OIIO_CHECK_EQUAL_THRESH(vol.max_luminance, 0.8828 * 48.0, 0.05); + OIIO_CHECK_EQUAL(vol.style, "DISPLAY - CIE-XYZ-D65_to_G2.6-P3-D60-BFD"); + } + + // Tier 2, PQ in the DCDM XYZ container: pure PQ decodes to absolute + // nits/100 -- the "DCDM" token must NOT drag it to the 48-nit cinema + // anchor (which would understate luminance 2.08x). Clamp at Y=10 -> + // 1000 nits on the nominal; encoding gamut is the raw XYZ container + // axes with white at illuminant E. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT( + derive_mastering_volume(config, "DCDMPQ", "PQ DCDM Clamp", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 1000.0); + OIIO_CHECK_ASSERT(vol.min_luminance < 1e-5); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[3][0], 1.0f / 3.0f, 1e-4f); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[3][1], 1.0f / 3.0f, 1e-4f); + } + + // Tier 2, view_transform-based DCI cinema: the ACES style parses a + // 48-nit peak but no gamut token, so it falls to the CST probe. Cinema + // is detected from the display colorspace's structural evidence (its + // DCI DISPLAY-builtin tail), anchoring at 48: the CST exactly cancels + // the display colorspace's forward builtin, so the measured peak is + // the raw ACES SDR-CINEMA-D60sim-DCI output, ~42.375 cd/m^2. The + // provenance is the unparseable ACES style tier 1 found. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT( + derive_mastering_volume(config, "CinemaVT", "DCI VT", vol)); + OIIO_CHECK_EQUAL_THRESH(vol.max_luminance, 42.375443, 1e-3); + OIIO_CHECK_ASSERT(vol.min_luminance < 1e-5); + OIIO_CHECK_ASSERT( + Strutil::contains(vol.style, "SDR-CINEMA-D60sim-DCI")); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[3][0], 0.3127f, 1e-4f); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[0][0], 0.68f, 0.01f); + } + + // Tier 4, pure-LUT ODT tagged with a LINEAR P3DCI display identity: + // the decode comes from the REGISTRY definition of the id. The 1e5 + // drive saturates the identity LUT to code (1,1,1); decoded as linear + // P3DCI that is the display white at relative luminance 1.0, and the + // display-linear + p3dci identity anchors at the 48-nit cinema peak. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT( + derive_mastering_volume(config, "LegacyLUT", "Lin P3DCI LUT", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 48.0); + OIIO_CHECK_EQUAL(vol.min_luminance, 0.0); + OIIO_CHECK_EQUAL(vol.style, "oiio:lin_p3dci_display"); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[3][0], 0.3127f, 1e-4f); + OIIO_CHECK_EQUAL_THRESH(vol.primaries[0][0], 0.68f, 0.01f); + } + + // Tier 4, pure-LUT ODT tagged srgb_rec709_display: registry decode, + // video anchor. + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT( + derive_mastering_volume(config, "LegacyLUT", "Film LUT", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 100.0); + OIIO_CHECK_EQUAL(vol.min_luminance, 0.0); + OIIO_CHECK_EQUAL(vol.style, "srgb_rec709_display"); + check_primaries(vol, kRec709, 1e-4f); + } + + // Tier 5: same LUT with no identity, no DISPLAY tail, no parseable + // style -- code->nits is underdetermined; honestly no record. + { + MasteringDisplayVolume vol; + OIIO_CHECK_FALSE( + derive_mastering_volume(config, "LegacyLUT", "Mystery LUT", vol)); + } + + // Defaults: no display/view resolves to the first declared display and + // its default view (the 1000-nit P3-limited volume). + { + MasteringDisplayVolume vol; + OIIO_CHECK_ASSERT(derive_mastering_volume(config, "", "", vol)); + OIIO_CHECK_EQUAL(vol.max_luminance, 1000.0); + check_primaries(vol, kP3D65, 1e-6f); + } + + // Unknown display or view: no record. + { + MasteringDisplayVolume vol; + OIIO_CHECK_FALSE(derive_mastering_volume(config, "NoSuchDisplay", + "NoSuchView", vol)); + OIIO_CHECK_FALSE( + derive_mastering_volume(config, "Rec2100PQ", "NoSuchView", vol)); + } +} + + + +static void +test_interop_derive() +{ + using OIIO::pvt::derive_color_interop_id; + using OIIO::pvt::sanitize_id_token; + + if (!ColorConfig::supportsOpenColorIO()) + return; + + // The `interop_id:` color space attribute (native getInteropID()) needs + // OCIO >= 2.5; gate the fixtures that declare it. + const bool has_interop_id_attr = ColorConfig::OpenColorIO_version_hex() + >= 0x02050000; + + // ---- Base fixture (no interop_id attribute -- safe on any linked OCIO + // version): isData sub-case, registry fingerprint match, no-match falling + // through to a generated local id, and an unresolvable query. The config + // name and the generated space's name both carry spaces/mixed case, so a + // passing local-id assertion also proves both segments are sanitized + // independently. ------------------------------------------------------ + static const char* named_yaml = R"(ocio_profile_version: 2.1 +name: "MyDerive Config" +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: implicit_data_space + isdata: true + + - ! + name: registry_equivalent_space + + - ! + name: My Unmatched Curve + aliases: [my_unmatched_curve] + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} +)"; + std::string named_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive_named.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(named_path, named_yaml)); + { + ColorConfig cc(named_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // Step 1, utility sub-case: a data space with no declared interop_id + // resolves to "data" -- before any fingerprint tier runs, even though + // this identity space would ALSO fingerprint-match the registry's + // lin_ap0_scene identity. (This tier is also part of the cheap public + // lookup.) + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "implicit_data_space"), + "data"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("implicit_data_space"), + "data"); + + // Step 2: an identity-transform space with no declared id and no + // registry-precluding classification is genuinely fingerprint- + // equivalent to the built-in registry's "lin_ap0_scene" identity + // (also an identity transform) -- returns the REGISTRY identity's own + // id, not the query's own name. Fingerprinting is derive-only: the + // cheap public lookup must NOT identify this space. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, + "registry_equivalent_space"), + "lin_ap0_scene"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("registry_equivalent_space"), + ""); + + // Step 2 miss -> step 3: no registry scene-side entry is a bare + // gamma-exponent curve, so this space has no fingerprint match; the + // config has a name and the query resolves to a real space, so a + // config-local id is generated. Both segments are sanitized + // independently per the CIF grammar (spaces -> '_', lowercased) -- + // built here via the landed pvt::sanitize_id_token so this assertion + // exercises the same function the production code calls, rather than + // a hand-typed guess at its output shape. + std::string expected_local = sanitize_id_token("MyDerive Config") + + ":local:" + + sanitize_id_token("My Unmatched Curve"); + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "my_unmatched_curve"), + expected_local); + // Local-id manufacture is derive-only: the cheap public lookup never + // manufactures an id. + OIIO_CHECK_EQUAL(cc.get_color_interop_id("my_unmatched_curve"), ""); + + // Step 3 precondition: the query itself must resolve to a real space + // -- a config-local id is never generated for a name this config + // doesn't know, even though the config has a name. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "no_such_space_at_all"), + ""); + + // The bare "unknown" token with no backing config space is a + // cannot-determine: the derivation omits (returns empty), never + // emits bare "unknown". (The cheap public lookup still answers the + // literal utility token from the static table.) + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "unknown"), ""); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("unknown"), "unknown"); + } + Filesystem::remove(named_path); + + // ---- Config-declared unknown fixture: a space NAMED "unknown" (with no + // contradicting declared interop_id) is the config's own declaration of + // unknownness -- the derivation emits the "ocio:unknown" marker, never + // bare "unknown". Bare "unknown" on disk is reserved for a user's own + // explicitly-set colorInteropID attribute, which writers emit verbatim. + { + static const char* unknown_yaml = R"(ocio_profile_version: 2.1 +name: unknowncfg +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: unknown + from_scene_reference: ! {value: [2.0, 2.0, 2.0, 1]} +)"; + std::string unknown_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive_unknown.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(unknown_path, unknown_yaml)); + ColorConfig cc(unknown_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "unknown"), + "ocio:unknown"); + + // Marker-vs-marker precedence (ADR-0020). THIS config is the shape + // that used to corrupt the signal: it contains a space literally + // NAMED "unknown", so resolve()'s CIF strip-leftmost fall-back turned + // "error:unknown" into bare "unknown", which then hit the + // config-declared branch above and answered "ocio:unknown" -- a + // resolution FAILURE silently relabeled as a config DECLARATION. + // Same corruption for OIIO's own synthetic treatment marker. + // An incoming marker is terminal: it derives to itself, unchanged. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "error:unknown"), + "error:unknown"); + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "oiio:unknown"), + "oiio:unknown"); + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "ocio:unknown"), + "ocio:unknown"); + // Case-insensitive per the marker vocabulary, and canonically spelled + // on the way out so callers can compare against the literal. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "ERROR:Unknown"), + "error:unknown"); + Filesystem::remove(unknown_path); + } + + // ---- Unnamed-config fixture: the same "no registry match" curve space, + // but the config has no `name:` set. Step 3 requires a non-empty config + // name, so this is empty -- and since nothing earlier in the cascade + // matches either, this doubles as the decisive "total miss returns + // empty" guard: the legacy static id/CICP table (step 2.5) never fires + // as a guessed default here, and no other tier steps in to fill the gap. + // ----------------------------------------------------------------------- + { + static const char* unnamed_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: My Unmatched Curve + aliases: [my_unmatched_curve] + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} +)"; + std::string unnamed_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive_unnamed.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(unnamed_path, unnamed_yaml)); + ColorConfig cc(unnamed_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "my_unmatched_curve"), ""); + Filesystem::remove(unnamed_path); + } + + // ---- Sanitizer-collision fixture: two distinct spaces ("Foo Bar" and + // "foo_bar") whose names sanitize to the SAME token. Serializing a + // config-local id for either would be ambiguous -- resolution could not + // uniquely reverse it -- so step 3 must OMIT (never-guess) rather than + // emit a lossy id, and read-side resolution of the colliding token must + // refuse to pick a winner. A third, collision-free space proves the + // guard doesn't disturb the ordinary step-3 path. ---------------------- + { + static const char* collide_yaml = R"(ocio_profile_version: 2.1 +name: collide_cfg +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: Foo Bar + from_scene_reference: ! {value: [1.7, 1.7, 1.7, 1]} + + - ! + name: foo_bar + from_scene_reference: ! {value: [1.9, 1.9, 1.9, 1]} + + - ! + name: Solo Space + from_scene_reference: ! {value: [2.1, 2.1, 2.1, 1]} +)"; + std::string collide_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive_collide.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(collide_path, collide_yaml)); + ColorConfig cc(collide_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Both colliding spaces omit -- neither may claim the shared token. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "Foo Bar"), ""); + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "foo_bar"), ""); + // The collision-free space still gets its config-local id. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "Solo Space"), + "collide_cfg:local:solo_space"); + // Read side: the ambiguous token resolves to NEITHER space (the + // total-miss passthrough), while the unique token still resolves. + OIIO_CHECK_EQUAL(cc.resolve("collide_cfg:local:foo_bar"), + "collide_cfg:local:foo_bar"); + OIIO_CHECK_EQUAL(cc.resolve("collide_cfg:local:solo_space"), + "Solo Space"); + Filesystem::remove(collide_path); + } + + if (has_interop_id_attr) { + // ---- Declared interop_id precedence: the single most important + // regression vector for step 1 -- an explicit, author-declared + // interop_id is unconditionally authoritative, beating even a + // fingerprint match that a same-shaped identity space would + // otherwise win at step 2. (Some implementations exercise this vector + // across a (strict, explicitUnknown) flag matrix; OIIO's + // get_color_interop_id(string_view) takes no such flags -- there is + // nothing else to vary -- so one config proves the same precedence.) + // ------------------------------------------------------------- + static const char* declared_yaml = R"(ocio_profile_version: 2.1 +name: gatedcfg +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +colorspaces: + - ! + name: ref + + - ! + name: declared_explicit_space + interop_id: "custom:explicit_id" + + - ! + name: declared_unknown_space + interop_id: "unknown" +)"; + std::string declared_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive_declared.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(declared_path, declared_yaml)); + ColorConfig cc(declared_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + // Would fingerprint-match "lin_ap0_scene" (identity transform) if the + // declared attribute didn't win first. The declared tier is shared: + // both the derive cascade and the cheap public lookup honor it. + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "declared_explicit_space"), + "custom:explicit_id"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("declared_explicit_space"), + "custom:explicit_id"); + // A declared interop_id of literally "unknown" is the config-side + // declaration of unknownness: the derivation emits the + // "ocio:unknown" marker rather than bare "unknown". + OIIO_CHECK_EQUAL(derive_color_interop_id(cc, "declared_unknown_space"), + "ocio:unknown"); + Filesystem::remove(declared_path); + } +} + + + +// True-cold construction helper: re-exec'd as a fresh subprocess by +// run_bench_phases() below (see comment there for why). Prints +// "construct_ms " and exits -- no other tests run. +static int +bench_child_construct_and_exit() +{ + Timer timer; + ColorConfig cc("ocio://default"); + std::cout << Strutil::fmt::format("construct_ms {:.6f}\n", + timer() * 1000.0); + return cc.has_error() ? 1 : 0; +} + + + +// --bench mode: cold/warm phase timings and cardinality counts for the +// color space fingerprint engine, feeding the numbers a design write-up +// needs. Not a pass/fail gate -- prints only, asserts nothing about perf. +// Uses OCIO's built-in default config (ocio:// requires OCIO >= 2.2). +static void +run_bench_phases() +{ + using OIIO::pvt::color_space_analysis_flags; + using OIIO::pvt::color_space_fingerprint_cache_reset; + using OIIO::pvt::color_space_fingerprint_cached; + using OIIO::pvt::color_space_fingerprint_order; + + if (!ColorConfig::supportsOpenColorIO() + || ColorConfig::OpenColorIO_version_hex() < 0x02020000) { + std::cout << "--bench: OCIO built-in configs unavailable, skipping.\n"; + return; + } + + // The same probe name test_color_space_fingerprint() already relies on + // being present in ocio://default. + static const char* probe_name = "ACES2065-1"; + + // Phase 1: load_ms -- cold ColorConfig construction, before this process + // has touched any interop machinery. Re-measured at the end (after every + // phase below has run) to show construction cost stays flat regardless + // of how much interop work has happened elsewhere in-process -- the + // "zero construction cost" evidence for the fully-lazy claim. + Timer t_load; + ColorConfig cc("ocio://default"); + double load_ms = t_load() * 1000.0; + if (cc.has_error() || cc.getNumColorSpaces() == 0) { + std::cout << "--bench: built-in config unavailable, skipping.\n"; + return; + } + std::cout << Strutil::fmt::format("load_ms : {:10.4f}\n", + load_ms); + + // Phase 2: simple_catalog_ms -- the first classification query for any + // name triggers the config-wide "simple color space" catalog scan + // (cached thereafter). This is the cold-classify number. + Timer t_catalog; + color_space_analysis_flags(cc, probe_name); + double simple_catalog_ms = t_catalog() * 1000.0; + std::cout << Strutil::fmt::format("simple_catalog_ms : {:10.4f}\n", + simple_catalog_ms); + + // Phase 3: cold_resolve_ms / warm_resolve_ms -- first-vs-second + // fingerprint-cache lookup for one name. + color_space_fingerprint_cache_reset(); + Timer t_cold_resolve; + color_space_fingerprint_cached(cc, probe_name); + double cold_resolve_ms = t_cold_resolve() * 1000.0; + Timer t_warm_resolve; + color_space_fingerprint_cached(cc, probe_name); + double warm_resolve_ms = t_warm_resolve() * 1000.0; + std::cout << Strutil::fmt::format("cold_resolve_ms : {:10.4f}\n", + cold_resolve_ms); + std::cout << Strutil::fmt::format("warm_resolve_ms : {:10.4f}\n", + warm_resolve_ms); + + // Phase 4: fingerprint_vector_ms -- the bulk all-simple-spaces + // fingerprint pass (uncached; the classification catalog is already + // warm from phase 2, so this isolates fingerprint compute cost). + Timer t_vector; + std::vector order = color_space_fingerprint_order(cc); + double fingerprint_vector_ms = t_vector() * 1000.0; + std::cout << Strutil::fmt::format( + "fingerprint_vector_ms : {:10.4f} (simple_candidate_count={}, " + "fingerprint_vector_count={})\n", + fingerprint_vector_ms, order.size(), order.size()); + + // Re-measure load_ms after all the interop machinery above has run, to + // show it hasn't grown. + Timer t_load_after; + ColorConfig cc_after("ocio://default"); + double load_ms_after = t_load_after() * 1000.0; + (void)cc_after; + std::cout << Strutil::fmt::format( + "load_ms (after interop): {:10.4f} (baseline load_ms={:.4f})\n", + load_ms_after, load_ms); + + // True-cold construction: a same-process measurement understates the + // fully-lazy claim, since process-level OCIO/OS caches (file reads, + // etc.) persist across ColorConfig instances within this same run -- + // spawn a fresh subprocess per config so construct_ms isn't + // contaminated by that. + std::string cmd = "\"" + Sysutil::this_program_path() + + "\" --bench-child-construct"; +#ifdef _MSC_VER + FILE* pipe = _popen(cmd.c_str(), "r"); +#else + FILE* pipe = popen(cmd.c_str(), "r"); +#endif + double subprocess_construct_ms = -1.0; + if (pipe) { + char line[256]; + if (fgets(line, sizeof(line), pipe)) + sscanf(line, "construct_ms %lf", &subprocess_construct_ms); +#ifdef _MSC_VER + _pclose(pipe); +#else + pclose(pipe); +#endif + } + std::cout << Strutil::fmt::format( + "construct_ms (true cold, subprocess): {:10.4f}\n", + subprocess_construct_ms); +} + + + +// Exercise the built-in color interop ID <-> CICP table via the public +// ColorConfig API (the table itself is a private, static array in +// color_ocio.cpp, so it can only be reached through get_color_interop_id() +// and get_cicp()). +static void +test_color_interop_ids() +{ + const ColorConfig& cc = ColorConfig::default_colorconfig(); + + // A representative sample of built-in interop IDs should resolve to + // themselves (case-insensitively) and, where the table records a CICP + // correspondence, get_cicp() should return the expected 4 values. + OIIO_CHECK_EQUAL(cc.get_color_interop_id("srgb_rec709_scene"), + "srgb_rec709_scene"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("SRGB_REC709_SCENE"), + "srgb_rec709_scene"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("lin_ap1_scene"), "lin_ap1_scene"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("g24_rec709_display"), + "g24_rec709_display"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("data"), "data"); + OIIO_CHECK_EQUAL(cc.get_color_interop_id("unknown"), "unknown"); + + // Unknown names (and the empty string) return an empty interop ID. + OIIO_CHECK_EQUAL(cc.get_color_interop_id("not_a_real_interop_id"), ""); + OIIO_CHECK_EQUAL(cc.get_color_interop_id(""), ""); + + // Entries that carry a CICP mapping: primaries (cicp[0]) and transfer + // (cicp[1]) round-trip through get_cicp() / get_color_interop_id(cicp). + cspan cicp = cc.get_cicp("srgb_rec709_scene"); + OIIO_CHECK_EQUAL(cicp.size(), 4); + if (cicp.size() == 4) { + OIIO_CHECK_EQUAL(cicp[0], 1); // CICPPrimaries::Rec709 + OIIO_CHECK_EQUAL(cicp[1], 13); // CICPTransfer::sRGB + OIIO_CHECK_EQUAL(cicp[2], 1); // CICPMatrix::BT709 + // The reverse lookup matches on (primaries, transfer) alone, and + // srgb_rec709_display is listed ahead of srgb_rec709_scene, so the + // shared Rec709/sRGB tuple resolves display-referred. + OIIO_CHECK_EQUAL(cc.get_color_interop_id(cicp.data()), + "srgb_rec709_display"); + } + + // Entries with no CICP mapping (e.g. AP1/AP0 scene-linear, "data", + // "unknown") return an empty span from get_cicp(). + OIIO_CHECK_EQUAL(cc.get_cicp("lin_ap1_scene").size(), 0); + OIIO_CHECK_EQUAL(cc.get_cicp("data").size(), 0); + OIIO_CHECK_EQUAL(cc.get_cicp("unknown").size(), 0); + + // get_color_interop_id(cicp) picks the first table match by + // (primaries, transfer) alone -- matrix and range are not part of the + // lookup key. srgb_rec709_display is listed ahead of srgb_rec709_scene, + // so the shared Rec709/sRGB tuple resolves display-referred. + const int rec709_srgb_cicp[4] = { 1, 13, 1, 1 }; + OIIO_CHECK_EQUAL(cc.get_color_interop_id(rec709_srgb_cicp), + "srgb_rec709_display"); +} + + + +// The CICP tuple with Rec.709 primaries (1) and the IEC 61966-2-1 / sRGB +// transfer (13) describes display-referred sRGB. The reverse CICP -> interop-ID +// lookup (matched on primaries + transfer) must therefore resolve it to the +// display-referred identity, not the scene-referred one. +static void +test_cicp_interop_id() +{ + ColorConfig config; + const int cicp_srgb[4] = { 1, 13, 0, 1 }; + OIIO_CHECK_EQUAL(config.get_color_interop_id(cicp_srgb), + string_view("srgb_rec709_display")); + // Forward mapping round-trips to a Rec.709 / sRGB tuple. + cspan cicp = config.get_cicp("srgb_rec709_display"); + OIIO_CHECK_ASSERT(cicp.size() >= 2 && cicp[0] == 1 && cicp[1] == 13); +} + + + +// Regression guard: the internal copy_config() must preserve a config's +// explicit default view transform name across an editable copy. OCIO < 2.3.1's +// createEditableCopy() drops it; without the restore, interopify_config's +// display bridge would see no default view transform and synthesize a +// scene_to_display_bridge that shadows a config's own. The pvt probe runs the +// real copy_config() on a two-view-transform config whose explicit default is +// the non-first one (so a dropped default is observable, not masked by OCIO's +// first-VT implicit default). Passes on all OCIO versions -- natively on +// >= 2.3.1, via the workaround below it. +static void +test_copy_config_default_view_transform() +{ + OIIO_CHECK_ASSERT( + OIIO::pvt::copy_config_preserves_default_view_transform()); +} + + + +// Concurrency stress for the process-global color caches. Many threads race +// the SHARED lazy state -- the registry fingerprint index (a C++11 magic-static +// built once on first registry-equivalence resolve), the flyweight +// ColorSpaceFingerprint cache, and the interopify structural memo -- through +// resolve(), get_color_interop_id(), equivalent(), and the pvt fingerprint +// cache. Runs FIRST in main() so the caches are genuinely cold and the workers' +// opening iterations are the true first touch (the prime suspect for a race). +// Under ThreadSanitizer this section is the payload; without TSan it is a cheap +// smoke test that the shared caches survive concurrent use. All threads spin on +// a start gate so they hit the cold caches simultaneously. +static void +test_thread_stress() +{ + using OIIO::pvt::color_space_fingerprint_cache_reset; + using OIIO::pvt::color_space_fingerprint_cached; + using OIIO::pvt::ColorSpaceFingerprint; + + if (!ColorConfig::supportsOpenColorIO() + || ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + + // cc1: built-in ACES config (registry-rich). cc2: a small hand-built + // config. Both constructed here, single-threaded -- the workers race the + // shared process-global caches, not per-config construction. + ColorConfig cc1("ocio://default"); + if (cc1.has_error()) + return; // built-in config unavailable on this OCIO build + + static const char* cfg2_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: gamma22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: doubler + from_scene_reference: ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} +)"; + std::string cfg2_path = Filesystem::temp_directory_path() + + "/oiio_color_test_stress2.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(cfg2_path, cfg2_yaml)); + ColorConfig cc2(cfg2_path); + OIIO_CHECK_ASSERT(!cc2.has_error()); + + // cc3: an interoperable scene+display config that LACKS every registry CIID + // the workers request, so a createColorProcessor with a registry endpoint + // can never resolve locally and MUST take the cross-config bridge. It carries + // aces_interchange (scene bridge) and a P3-D65 display space but NO + // cie_xyz_d65 interchange (interopify must synthesize the display interchange). + // Sharing ONE cc3 across all workers is deliberate: it races both the + // per-Impl ensure_interop() lazy publish AND the process-global interopify_config + // memo (s_memo, first-writer-wins, keyed by structural config id) on their + // genuinely cold first touch -- the state the prior stress predates. Gated on + // OCIO >= 2.3 (two-config display bridge) and registry CIID availability. + const bool do_xconfig = ColorConfig::OpenColorIO_version_hex() >= 0x02030000 + && OIIO::pvt::interop_identities_config_resolves( + "lin_ap1_scene") + && OIIO::pvt::interop_identities_config_resolves( + "srgb_rec709_display"); + static const char* cfg3_yaml = R"(ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ACEScg + scene_linear: ACEScg + aces_interchange: ACES2065-1 +displays: + P3: + - ! {name: Raw, colorspace: my_p3_display} +colorspaces: + - ! + name: ACES2065-1 + encoding: scene-linear + - ! + name: ACEScg + encoding: scene-linear + to_scene_reference: ! {matrix: [0.6954522414, 0.1406786965, 0.1638690622, 0, 0.0447945634, 0.8596711185, 0.0955343182, 0, -0.0055258826, 0.0040252103, 1.0015006723, 0, 0, 0, 0, 1]} +display_colorspaces: + - ! + name: my_p3_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} +)"; + std::string cfg3_path = Filesystem::temp_directory_path() + + "/oiio_color_test_stress3.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(cfg3_path, cfg3_yaml)); + ColorConfig cc3(cfg3_path); + OIIO_CHECK_ASSERT(!cc3.has_error()); + + std::vector names1 = cc1.getColorSpaceNames(); + std::vector names2 = cc2.getColorSpaceNames(); + + // CIID strings that drive resolve()'s registry-equivalence tier; constant + // regardless of config, so resolve() reaches the registry fingerprint index. + static const char* ciids[] = { + "lin_ap1_scene", "srgb_rec709_scene", "g24_rec709_display", + "data", "srgb_rec709_display", "unknown", + }; + + // Cold start: nothing has warmed the shared caches. The workers are the + // first touch. + color_space_fingerprint_cache_reset(); + + const int nthreads = 12; + const int iters = 300; + std::atomic ready { 0 }; + std::atomic go { false }; + + auto worker = [&](int tid) { + ready.fetch_add(1); + while (!go.load()) + std::this_thread::yield(); // all workers hit the cold caches together + for (int it = 0; it < iters; ++it) { + const ColorConfig& cc = (tid & 1) ? cc2 : cc1; + const std::vector& names = (tid & 1) ? names2 : names1; + + // resolve() -- registry-equivalence tier -> registry index bootstrap + for (const char* id : ciids) + (void)cc.resolve(id); + + if (!names.empty()) { + const std::string& nm = names[(size_t)(it + tid) % names.size()]; + // fingerprint + registry + interopify memo + (void)cc.get_color_interop_id(nm); + // flyweight fingerprint cache: first-insert / publish / hit + ColorSpaceFingerprint fp = color_space_fingerprint_cached(cc, + nm); + (void)fp; + } + + // equivalent() -- each side is resolve()d first + if (names.size() >= 2) + (void)cc.equivalent(names[0], names[1]); + + // cross-config: two distinct configs pushing the same shared index + (void)cc1.get_color_interop_id("ACEScg"); + (void)cc2.get_color_interop_id("doubler"); + + // Cross-config createColorProcessor bridge on the SHARED cc3: cold + // first touch of ensure_interop() + the process-global interopify + // memo + bootstrap_display_interchange, hit concurrently by every + // worker. Both scene CIIDs (via aces_interchange) and a display CIID + // (via the synthesized display interchange) exercise reconcile_cross_ + // config / reconcile_cross_config_display. Handles are discarded; the + // point is the shared construction/caching, not the transform. + if (do_xconfig) { + (void)cc3.createColorProcessor("ACEScg", "lin_ap1_scene"); + (void)cc3.createColorProcessor("lin_ap0_scene", "ACEScg"); + (void)cc3.createColorProcessor("srgb_rec709_display", "ACEScg"); + (void)cc3.createColorProcessor("srgb_rec709_display", + "my_p3_display"); + } + } + }; + + std::vector pool; + pool.reserve(nthreads); + for (int t = 0; t < nthreads; ++t) + pool.emplace_back(worker, t); + while (ready.load() < nthreads) + std::this_thread::yield(); + go.store(true); + for (auto& th : pool) + th.join(); + + // Reaching here without a TSan report, deadlock, or crash is the pass. + OIIO_CHECK_ASSERT(true); + Filesystem::remove(cfg2_path); + Filesystem::remove(cfg3_path); +} + + + +// The internal cheap characterization facade: ColorSpaceInfo + +// ColorConfig::get_color_space_info (scalar and batch). Field semantics +// (direct facts computed; derivable facts uncomputed with no cached data; +// range never guessed), the error convention, batch order/duplicates, and +// the never-derives guarantee (no fingerprint work, no cache publication). +static void +test_color_space_info() +{ + using OIIO::pvt::characterization_cache_reset; + using OIIO::pvt::characterization_cache_size; + using OIIO::pvt::color_space_fingerprint_cache_size; + using F = ColorSpaceInfoField; + + // A default-constructed info is the one honest "nothing" object -- no + // OCIO needed for that check. + { + ColorSpaceInfo none; + OIIO_CHECK_FALSE(none.valid()); + OIIO_CHECK_EQUAL(none.name(), ""); + OIIO_CHECK_EQUAL(none.encoding(), ""); + OIIO_CHECK_ASSERT(none.chromaticities().empty()); + OIIO_CHECK_EQUAL(int(none.transfer_function_kind()), + int(ColorTransferFunctionKind::Undetermined)); + OIIO_CHECK_FALSE(none.computed(F::ImageState)); + OIIO_CHECK_FALSE(none.available(F::ImageState)); + OIIO_CHECK_FALSE(none.derived(F::ImageState)); + } + + if (!ColorConfig::supportsOpenColorIO()) + return; + + // Fixture: a scene space named for a static-table interop id (the cheap + // table tier identifies it with no interop_id attribute, so this is + // OCIO-version-independent), a data space, a display space, and a space + // with no authored facts at all. + static const char* config_yaml = R"(ocio_profile_version: 2.1 +name: infocfg +search_path: "" +roles: + default: ref + scene_linear: ref +displays: + disp: + - ! {name: main, colorspace: screen} +display_colorspaces: + - ! + name: screen + encoding: sdr-video + from_display_reference: ! {value: [2.4, 2.4, 2.4, 1]} +colorspaces: + - ! + name: ref + + - ! + name: srgb_rec709_scene + aliases: [my_srgb] + encoding: sdr-video + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: rawdata + isdata: true + + - ! + name: plain_space + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} +)"; + std::string config_path = Filesystem::temp_directory_path() + + "/oiio_color_test_info.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(config_path, config_yaml)); + ColorConfig cc(config_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + characterization_cache_reset(); + const size_t fp_cache_before = color_space_fingerprint_cache_size(); + + // Direct facts of a fully described scene space, queried by alias (the + // record reports the canonical name). + { + ColorSpaceInfo info = cc.get_color_space_info("my_srgb"); + OIIO_CHECK_ASSERT(info.valid()); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(info.name(), "srgb_rec709_scene"); + OIIO_CHECK_ASSERT(info.computed(F::ImageState) + && info.available(F::ImageState)); + OIIO_CHECK_EQUAL(info.image_state(), "scene"); + OIIO_CHECK_ASSERT(info.computed(F::ColorInteropID) + && info.available(F::ColorInteropID)); + OIIO_CHECK_EQUAL(info.color_interop_id(), "srgb_rec709_scene"); + OIIO_CHECK_ASSERT(info.computed(F::Encoding) + && info.available(F::Encoding)); + OIIO_CHECK_EQUAL(info.encoding(), "sdr-video"); + // Direct facts are direct, not behavioral derivations. + OIIO_CHECK_FALSE(info.derived(F::ImageState)); + OIIO_CHECK_FALSE(info.derived(F::ColorInteropID)); + OIIO_CHECK_FALSE(info.derived(F::Encoding)); + // Range: attempted, but nothing registers one -- a stable negative, + // never a guessed "full". + OIIO_CHECK_ASSERT(info.computed(F::Range)); + OIIO_CHECK_FALSE(info.available(F::Range)); + OIIO_CHECK_EQUAL(info.range(), ""); + // Derivable fields with no cached derived data: not attempted (the + // cheap getter never silently derives). + OIIO_CHECK_FALSE(info.computed(F::EqualityID)); + OIIO_CHECK_FALSE(info.computed(F::Chromaticities)); + OIIO_CHECK_FALSE(info.computed(F::TransferFunction)); + OIIO_CHECK_ASSERT(info.chromaticities().empty()); + OIIO_CHECK_EQUAL(int(info.transfer_function_kind()), + int(ColorTransferFunctionKind::Undetermined)); + } + + // A display space reports state "display"; a data space's state is + // honestly undetermined and its cheap id is the "data" utility token; a + // space with no authored facts has an unavailable id and encoding. + { + ColorSpaceInfo screen = cc.get_color_space_info("screen"); + OIIO_CHECK_ASSERT(screen.valid()); + OIIO_CHECK_EQUAL(screen.image_state(), "display"); + + ColorSpaceInfo data = cc.get_color_space_info("rawdata"); + OIIO_CHECK_ASSERT(data.valid()); + OIIO_CHECK_ASSERT(data.computed(F::ImageState)); + OIIO_CHECK_FALSE(data.available(F::ImageState)); + OIIO_CHECK_EQUAL(data.color_interop_id(), "data"); + + ColorSpaceInfo plain = cc.get_color_space_info("plain_space"); + OIIO_CHECK_ASSERT(plain.valid()); + OIIO_CHECK_ASSERT(plain.computed(F::ColorInteropID)); + OIIO_CHECK_FALSE(plain.available(F::ColorInteropID)); + OIIO_CHECK_ASSERT(plain.computed(F::Encoding)); + OIIO_CHECK_FALSE(plain.available(F::Encoding)); + } + + // Error convention, scalar: unknown name -> invalid record + the + // ColorConfig error state. + { + ColorSpaceInfo bad = cc.get_color_space_info("no_such_space_xyzzy"); + OIIO_CHECK_FALSE(bad.valid()); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "unknown color space") + && Strutil::contains(err, "no_such_space_xyzzy")); + } + + // Batch: input order and duplicates preserved, one record per input. + { + std::vector names { "plain_space", "rawdata", "my_srgb", + "plain_space" }; + std::vector infos = cc.get_color_space_infos(names); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(infos.size(), 4); + if (infos.size() == 4) { + OIIO_CHECK_EQUAL(infos[0].name(), "plain_space"); + OIIO_CHECK_EQUAL(infos[1].name(), "rawdata"); + OIIO_CHECK_EQUAL(infos[2].name(), "srgb_rec709_scene"); + OIIO_CHECK_EQUAL(infos[3].name(), "plain_space"); + } + // An empty input span is an empty batch, not "all spaces". + OIIO_CHECK_ASSERT( + cc.get_color_space_infos(cspan()).empty()); + OIIO_CHECK_ASSERT(!cc.has_error()); + } + + // Error convention, batch: any invalid input fails the whole batch with + // one INDEXED error and an empty result. + { + std::vector names { "ref", "bogus_name", "plain_space" }; + std::vector infos = cc.get_color_space_infos(names); + OIIO_CHECK_ASSERT(infos.empty()); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "get_color_space_infos[1]") + && Strutil::contains(err, "bogus_name")); + } + + // The never-derives guarantee: none of the calls above woke the + // fingerprint engine or published a characterization record. + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), fp_cache_before); + OIIO_CHECK_EQUAL(characterization_cache_size(), 0); + + Filesystem::remove(config_path); +} + + + +// The internal field-selective characterization engine +// (pvt::characterize_color_space) and its cache: full derivation on request, +// publication of successful AND negative attempts, cache merge into later +// cheap getters, no-retry of settled fields, and immutable snapshots. +static void +test_characterize_color_space() +{ + using OIIO::pvt::characterization_cache_reset; + using OIIO::pvt::characterization_cache_size; + using OIIO::pvt::characterize_color_space; + using CF = OIIO::pvt::CharacterizationField; + using F = ColorSpaceInfoField; + + if (!ColorConfig::supportsOpenColorIO()) + return; + // ocio:// built-in configs require OCIO >= 2.2. + if (ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + ColorConfig cc("ocio://default"); + if (cc.has_error() || cc.getNumColorSpaces() == 0) + return; // built-in configs unavailable in this OCIO build + + characterization_cache_reset(); + + // Snapshot the cheap view before any derivation. + ColorSpaceInfo before = cc.get_color_space_info("sRGB - Texture"); + OIIO_CHECK_ASSERT(before.valid()); + OIIO_CHECK_FALSE(before.computed(F::EqualityID)); + OIIO_CHECK_FALSE(before.computed(F::Chromaticities)); + OIIO_CHECK_FALSE(before.computed(F::TransferFunction)); + // Cheap gets never publish. + OIIO_CHECK_EQUAL(characterization_cache_size(), 0); + + // Full derivation through the engine: every field attempted. + auto rec = characterize_color_space(cc, "sRGB - Texture", CF::All); + OIIO_CHECK_ASSERT(rec.valid()); + // Equality identity by fingerprint equivalence against the registry. + OIIO_CHECK_ASSERT(rec.computed(CF::EqualityID)); + OIIO_CHECK_ASSERT(rec.available(CF::EqualityID) + && rec.derived(CF::EqualityID)); + OIIO_CHECK_EQUAL(rec.equality_id, "srgb_rec709_scene"); + // Chromaticities: 8 floats, RGBW xy. + OIIO_CHECK_ASSERT(rec.available(CF::Chromaticities)); + OIIO_CHECK_EQUAL(rec.chromaticities.size(), 8); + // Transfer: a recognized named family. + OIIO_CHECK_ASSERT(rec.available(CF::TransferFunction)); + OIIO_CHECK_EQUAL(int(rec.transfer_kind), + int(ColorTransferFunctionKind::Named)); + OIIO_CHECK_EQUAL(rec.transfer_function, "srgb"); + // The attempt was published. + OIIO_CHECK_EQUAL(characterization_cache_size(), 1); + + // A later cheap get merges the cached derived facts... + ColorSpaceInfo after = cc.get_color_space_info("sRGB - Texture"); + OIIO_CHECK_ASSERT(after.available(F::EqualityID) + && after.derived(F::EqualityID)); + OIIO_CHECK_EQUAL(after.equality_id(), "srgb_rec709_scene"); + OIIO_CHECK_ASSERT(after.available(F::Chromaticities)); + OIIO_CHECK_EQUAL(after.chromaticities().size(), 8); + OIIO_CHECK_EQUAL(after.transfer_function(), "srgb"); + // ...and the merge itself published nothing new. + OIIO_CHECK_EQUAL(characterization_cache_size(), 1); + // Previously returned snapshots are immutable: the pre-derive object + // still reports its fields unattempted. + OIIO_CHECK_FALSE(before.computed(F::EqualityID)); + OIIO_CHECK_FALSE(before.computed(F::TransferFunction)); + + // Negative results are results: a data space derives no equality id, + // chromaticities, or transfer -- computed but unavailable -- and the + // negative record is cached so the space is not re-probed every query. + if (cc.getColorSpaceIndex("Raw") >= 0) { + auto draw = characterize_color_space(cc, "Raw", CF::All); + OIIO_CHECK_ASSERT(draw.valid()); + OIIO_CHECK_EQUAL(draw.color_interop_id, "data"); + OIIO_CHECK_ASSERT(draw.computed(CF::EqualityID)); + OIIO_CHECK_FALSE(draw.available(CF::EqualityID)); + OIIO_CHECK_ASSERT(draw.computed(CF::Chromaticities)); + OIIO_CHECK_FALSE(draw.available(CF::Chromaticities)); + OIIO_CHECK_ASSERT(draw.computed(CF::TransferFunction)); + OIIO_CHECK_FALSE(draw.available(CF::TransferFunction)); + const size_t published = characterization_cache_size(); + // Settled fields (positive or negative) are not retried: a repeat + // full characterization adds no cache entries. + auto again = characterize_color_space(cc, "Raw", CF::All); + OIIO_CHECK_EQUAL(characterization_cache_size(), published); + OIIO_CHECK_ASSERT(again.computed(CF::Chromaticities)); + OIIO_CHECK_FALSE(again.available(CF::Chromaticities)); + // The cheap getter sees the cached negative as computed-but- + // unavailable -- a stable answer, not an error. + ColorSpaceInfo raw = cc.get_color_space_info("Raw"); + OIIO_CHECK_ASSERT(raw.computed(F::Chromaticities)); + OIIO_CHECK_FALSE(raw.available(F::Chromaticities)); + OIIO_CHECK_ASSERT(!cc.has_error()); + } + + characterization_cache_reset(); +} + + + +// The internal derive facade: ColorConfig::derive_color_space_info (scalar and +// batch). Complete-record semantics on an uncharacterizable space +// (computed-but-unavailable, never an error), range never guessed even under +// derive, batch order/duplicates and the indexed error, cache publication +// observable through the cheap getter, and two-context derive isolation. +static void +test_derive_color_space_info() +{ + using OIIO::pvt::characterization_cache_reset; + using F = ColorSpaceInfoField; + + if (!ColorConfig::supportsOpenColorIO()) + return; + + characterization_cache_reset(); + + // --- A NON-interoperable config (no aces_interchange): probes cannot + // run, so derive produces stable negatives -- computed but unavailable + // -- on a valid record, with no error. + { + static const char* config_yaml = R"(ocio_profile_version: 2.1 +name: derivecfg +search_path: "" +roles: + default: ref + scene_linear: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: srgb_rec709_scene + encoding: sdr-video + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: plain_space + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} +)"; + std::string config_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(config_path, config_yaml)); + ColorConfig cc(config_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + // The uncharacterizable space: every field attempted, the derivable + // ones honestly unavailable. That is a complete record, not an + // error. + ColorSpaceInfo plain = cc.derive_color_space_info("plain_space"); + OIIO_CHECK_ASSERT(plain.valid()); + OIIO_CHECK_ASSERT(!cc.has_error()); + for (F f : + { F::EqualityID, F::ColorInteropID, F::Encoding, F::ImageState, + F::Range, F::Chromaticities, F::TransferFunction }) + OIIO_CHECK_ASSERT(plain.computed(f)); + OIIO_CHECK_FALSE(plain.available(F::EqualityID)); + OIIO_CHECK_FALSE(plain.available(F::Chromaticities)); + OIIO_CHECK_FALSE(plain.available(F::TransferFunction)); + // Range is never guessed, derive path included: nothing registers a + // genuine per-space range, so the full attempt is a stable negative. + OIIO_CHECK_FALSE(plain.available(F::Range)); + OIIO_CHECK_EQUAL(plain.range(), ""); + + // A table-identified space still derives its reserved-table + // chromaticities without any probe (registry association, not a + // guess); the transfer probe itself stays unavailable here. + ColorSpaceInfo srgb = cc.derive_color_space_info("srgb_rec709_scene"); + OIIO_CHECK_ASSERT(srgb.valid()); + OIIO_CHECK_EQUAL(srgb.color_interop_id(), "srgb_rec709_scene"); + OIIO_CHECK_ASSERT(srgb.available(F::Chromaticities) + && srgb.derived(F::Chromaticities)); + OIIO_CHECK_EQUAL(srgb.chromaticities().size(), 8); + OIIO_CHECK_ASSERT(srgb.computed(F::TransferFunction)); + + // Batch: order and duplicates preserved; per-field failure is not a + // batch failure. + { + std::vector names { "plain_space", "srgb_rec709_scene", + "plain_space" }; + std::vector infos = cc.derive_color_space_infos( + names); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(infos.size(), 3); + if (infos.size() == 3) { + OIIO_CHECK_EQUAL(infos[0].name(), "plain_space"); + OIIO_CHECK_EQUAL(infos[1].name(), "srgb_rec709_scene"); + OIIO_CHECK_EQUAL(infos[2].name(), "plain_space"); + } + OIIO_CHECK_ASSERT( + cc.derive_color_space_infos(cspan()).empty()); + OIIO_CHECK_ASSERT(!cc.has_error()); + } + + // Batch error: validate-all-first, one indexed error, empty result. + { + std::vector names { "ref", "bogus_name", + "plain_space" }; + std::vector infos = cc.derive_color_space_infos( + names); + OIIO_CHECK_ASSERT(infos.empty()); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT( + Strutil::contains(err, "derive_color_space_infos[1]") + && Strutil::contains(err, "bogus_name")); + } + + // Scalar error convention. + { + ColorSpaceInfo bad = cc.derive_color_space_info("no_such_space"); + OIIO_CHECK_FALSE(bad.valid()); + OIIO_CHECK_ASSERT(cc.has_error()); + std::string err = cc.geterror(); + OIIO_CHECK_ASSERT(Strutil::contains(err, "derive_color_space_info") + && Strutil::contains(err, "no_such_space")); + } + + Filesystem::remove(config_path); + } + + // --- Cache publication is observable through the PUBLIC surface: a + // derive fills the fields, a later cheap get sees them (the cheap + // getter itself still derives nothing). + if (ColorConfig::OpenColorIO_version_hex() >= 0x02020000) { + ColorConfig cc("ocio://default"); + if (!cc.has_error() && cc.getNumColorSpaces() > 0) { + characterization_cache_reset(); + ColorSpaceInfo cheap = cc.get_color_space_info("sRGB - Texture"); + OIIO_CHECK_ASSERT(cheap.valid()); + OIIO_CHECK_FALSE(cheap.computed(F::EqualityID)); + + ColorSpaceInfo full = cc.derive_color_space_info("sRGB - Texture"); + OIIO_CHECK_ASSERT(full.valid()); + OIIO_CHECK_ASSERT(full.available(F::EqualityID) + && full.derived(F::EqualityID)); + OIIO_CHECK_EQUAL(full.equality_id(), "srgb_rec709_scene"); + OIIO_CHECK_ASSERT(full.available(F::Chromaticities)); + OIIO_CHECK_EQUAL(full.transfer_function(), "srgb"); + // Complete record, range still honestly absent. + OIIO_CHECK_ASSERT(full.computed(F::Range)); + OIIO_CHECK_FALSE(full.available(F::Range)); + + ColorSpaceInfo seen = cc.get_color_space_info("sRGB - Texture"); + OIIO_CHECK_ASSERT(seen.available(F::EqualityID)); + OIIO_CHECK_EQUAL(seen.equality_id(), "srgb_rec709_scene"); + OIIO_CHECK_EQUAL(seen.transfer_function(), "srgb"); + // The earlier cheap snapshot is immutable. + OIIO_CHECK_FALSE(cheap.computed(F::EqualityID)); + } + } + + // --- Two-context derive isolation: a context-sensitive space derived + // under context A publishes into A's bucket only; a cheap get under + // context B sees none of it. (The interchange role + the OCIO context + // API used here need OCIO >= 2.2.) + if (ColorConfig::OpenColorIO_version_hex() >= 0x02020000) { + static const char* ctx_yaml = R"(ocio_profile_version: 2.1 +environment: + CTX_CS: ref +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: gamma_a + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: ctx_space + to_scene_reference: ! {src: $CTX_CS, dst: ref} +)"; + std::string ctx_path = Filesystem::temp_directory_path() + + "/oiio_color_test_derive_ctx.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(ctx_path, ctx_yaml)); + ColorConfig cc(ctx_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + characterization_cache_reset(); + + ColorSpaceInfoOptions ctx_ident, ctx_gamma; + ctx_ident.context = { { "CTX_CS", "ref" } }; + ctx_gamma.context = { { "CTX_CS", "gamma_a" } }; + + // Under the identity context the space measures linear; under the + // gamma context it does not. Each derivation runs under its own + // per-call context. + ColorSpaceInfo ident = cc.derive_color_space_info("ctx_space", + ctx_ident); + OIIO_CHECK_ASSERT(ident.valid()); + OIIO_CHECK_ASSERT(ident.available(F::TransferFunction)); + OIIO_CHECK_EQUAL(int(ident.transfer_function_kind()), + int(ColorTransferFunctionKind::Linear)); + + ColorSpaceInfo gamma = cc.derive_color_space_info("ctx_space", + ctx_gamma); + OIIO_CHECK_ASSERT(gamma.valid()); + OIIO_CHECK_ASSERT(gamma.available(F::TransferFunction)); + OIIO_CHECK_ASSERT(int(gamma.transfer_function_kind()) + != int(ColorTransferFunctionKind::Linear)); + + // Isolation: the cached facts are context-bucketed. A cheap get + // under each context sees exactly its own derivation. + ColorSpaceInfo cheap_ident = cc.get_color_space_info("ctx_space", + ctx_ident); + OIIO_CHECK_ASSERT(cheap_ident.computed(F::TransferFunction)); + OIIO_CHECK_EQUAL(int(cheap_ident.transfer_function_kind()), + int(ColorTransferFunctionKind::Linear)); + ColorSpaceInfo cheap_gamma = cc.get_color_space_info("ctx_space", + ctx_gamma); + OIIO_CHECK_ASSERT(cheap_gamma.computed(F::TransferFunction)); + OIIO_CHECK_ASSERT(int(cheap_gamma.transfer_function_kind()) + != int(ColorTransferFunctionKind::Linear)); + + characterization_cache_reset(); + Filesystem::remove(ctx_path); + } + + characterization_cache_reset(); +} + + + +// Search/engine convergence: find_color_spaces' per-candidate +// characterization consumes the same shared field-selective cache the +// public derive verbs publish into. A repeat search is cache-only and +// result-identical; a prior complete derive of every space leaves search +// cache-only; results never change. +static void +test_search_engine_coupling() +{ + using OIIO::pvt::characterization_cache_reset; + using OIIO::pvt::characterization_cache_size; + + if (!ColorConfig::supportsOpenColorIO()) + return; + if (ColorConfig::OpenColorIO_version_hex() < 0x02020000) + return; + + // Interoperable, deliberately unnamed config (no generated-local id + // tier): a table-identified 2.2-gamma space, an unidentified 2.2-gamma + // twin, and the linear anchor. + static const char* config_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ref + scene_linear: ref + aces_interchange: ref +displays: + disp: + - ! {name: main, colorspace: ref} +colorspaces: + - ! + name: ref + + - ! + name: srgb_rec709_scene + encoding: sdr-video + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: gamma_a + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + std::string config_path = Filesystem::temp_directory_path() + + "/oiio_color_test_searchconv.ocio"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file(config_path, config_yaml)); + ColorConfig cc(config_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + characterization_cache_reset(); + + OIIO::pvt::FindColorSpacesOptions o; + o.transfer_functions = { "srgb_rec709_scene" }; + o.chromaticities = { "srgb_rec709_scene" }; + + const auto r1 = OIIO::pvt::find_color_spaces(cc, o); + OIIO_CHECK_ASSERT(std::find(r1.begin(), r1.end(), "srgb_rec709_scene") + != r1.end()); + const size_t published = characterization_cache_size(); + // The walk published its per-candidate characterizations (partial + // records: only the requested axes). + OIIO_CHECK_ASSERT(published > 0); + + // Repeat search: cache-only (settled fields are never re-derived, so + // nothing new publishes) and result-identical. + const auto r2 = OIIO::pvt::find_color_spaces(cc, o); + OIIO_CHECK_ASSERT(r2 == r1); + OIIO_CHECK_EQUAL(characterization_cache_size(), published); + + // A prior COMPLETE derive of every space makes a fresh search + // cache-only too -- and the results are identical to the from-scratch + // walk. + characterization_cache_reset(); + const auto all = cc.getColorSpaceNames(); + OIIO_CHECK_ASSERT(!cc.derive_color_space_infos(all).empty()); + const size_t post_derive = characterization_cache_size(); + OIIO_CHECK_ASSERT(post_derive > 0); + const auto r3 = OIIO::pvt::find_color_spaces(cc, o); + OIIO_CHECK_ASSERT(r3 == r1); + OIIO_CHECK_EQUAL(characterization_cache_size(), post_derive); + + characterization_cache_reset(); + Filesystem::remove(config_path); +} + + + +// --------------------------------------------------------------------------- +// 3.2 config-utility bundle (serialize / from_text / archive / evolve / +// debug info / caches / scoped context). +// --------------------------------------------------------------------------- + +// A small config whose reference space is positively identifiable as a scene +// interchange (named "ACES2065-1"), so the in-memory interoperability repair +// binds the aces_interchange role -- observable through serialize(). +static const char* utility_config_yaml = R"(ocio_profile_version: 2.1 +search_path: "" +roles: + default: ACES2065-1 + scene_linear: ACES2065-1 +displays: + disp: + - ! {name: main, colorspace: ACES2065-1} +colorspaces: + - ! + name: ACES2065-1 + + - ! + name: gamma22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} +)"; + + +static void +test_config_serialize() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + std::string config_path = Filesystem::temp_directory_path() + + "/oiio_color_test_serialize.ocio"; + OIIO_CHECK_ASSERT( + Filesystem::write_text_file(config_path, utility_config_yaml)); + + ColorConfig cc(config_path); + OIIO_CHECK_ASSERT(!cc.has_error()); + + std::string text = cc.serialize(); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_ASSERT(Strutil::starts_with(text, "ocio_profile_version")); + OIIO_CHECK_ASSERT(text.find("ACES2065-1") != std::string::npos); + // The authored config has no aces_interchange role... + OIIO_CHECK_ASSERT(text.find("aces_interchange") == std::string::npos); + // ...but the interopified in-memory copy's serialization shows the + // repair: evidence of what is in memory, not what was on disk. + ColorConfig::SerializeOptions iopts; + iopts.interopified = true; + std::string repaired = cc.serialize(iopts); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_ASSERT(repaired.find("aces_interchange") != std::string::npos); + // Serialization is deterministic. + OIIO_CHECK_EQUAL(text, cc.serialize()); + + Filesystem::remove(config_path); +} + + + +static void +test_config_from_text() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + // A config constructed from memory matches the same config loaded from + // a file. + ColorConfig cc = ColorConfig::from_text(utility_config_yaml); + OIIO_CHECK_ASSERT(!cc.has_error()); + OIIO_CHECK_EQUAL(cc.getNumColorSpaces(), 2); + OIIO_CHECK_EQUAL(cc.getColorSpaceIndex("gamma22"), 1); + OIIO_CHECK_ASSERT(Strutil::starts_with(cc.configname(), "text:")); + + // serialize() -> from_text() round-trips the config. + ColorConfig rt = ColorConfig::from_text(cc.serialize()); + OIIO_CHECK_ASSERT(!rt.has_error()); + OIIO_CHECK_EQUAL(rt.getColorSpaceNames(), cc.getColorSpaceNames()); + OIIO_CHECK_EQUAL(rt.serialize(), cc.serialize()); + + // The result is movable (the factory relies on it). + ColorConfig moved = std::move(rt); + OIIO_CHECK_EQUAL(moved.getNumColorSpaces(), 2); + + // Unparsable text is an error, not a throw. Like the file constructor + // handed a bad path, the failed config still carries the built-in + // minimal inventory fallback. + ColorConfig bad = ColorConfig::from_text("this is not an OCIO config"); + OIIO_CHECK_ASSERT(bad.has_error()); + std::string errmsg = bad.geterror(); + OIIO_CHECK_ASSERT( + Strutil::contains(errmsg, "Error reading OCIO config from text")); + OIIO_CHECK_EQUAL(bad.getColorSpaceIndex("gamma22"), -1); +} + + + +static void +test_config_archive() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + std::string wd = Filesystem::temp_directory_path() + + "/oiio_color_test_archive_wd"; + OIIO_CHECK_ASSERT(Filesystem::create_directory(wd)); + std::string arc = Filesystem::temp_directory_path() + + "/oiio_color_test_archive.ocioz"; + + // A config with no working directory is not archivable (OCIO must know + // where to gather candidate LUT files from)... + ColorConfig cc = ColorConfig::from_text(utility_config_yaml); + OIIO_CHECK_FALSE(cc.archive(arc)); + OIIO_CHECK_ASSERT(cc.has_error()); + OIIO_CHECK_ASSERT(Strutil::contains(cc.geterror(), "not archivable")); + + // ...supplying one per-call makes the archive succeed. + ColorConfig::ArchiveOptions aopts; + aopts.working_dir = wd; + OIIO_CHECK_ASSERT(cc.archive(arc, aopts)); + OIIO_CHECK_FALSE(cc.has_error()); + OIIO_CHECK_ASSERT(Filesystem::exists(arc)); + // .ocioz archives are zip containers ("PK" magic). + char magic[2] = { 0, 0 }; + OIIO_CHECK_EQUAL(Filesystem::read_bytes(arc, magic, 2), size_t(2)); + OIIO_CHECK_ASSERT(magic[0] == 'P' && magic[1] == 'K'); + + // The archive is itself a loadable config, equivalent to the original. + ColorConfig back(arc); + OIIO_CHECK_ASSERT(!back.has_error()); + OIIO_CHECK_EQUAL(back.getColorSpaceNames(), cc.getColorSpaceNames()); + + // A from_text config constructed WITH a working directory is archivable + // with default options. + ColorConfig cc2 = ColorConfig::from_text(utility_config_yaml, wd); + OIIO_CHECK_ASSERT(cc2.archive(arc)); + OIIO_CHECK_FALSE(cc2.has_error()); + + Filesystem::remove(arc); + Filesystem::remove(wd); +} + + + +static void +test_config_evolve() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + ColorConfig cc = ColorConfig::from_text(utility_config_yaml); + OIIO_CHECK_ASSERT(!cc.has_error()); + const std::string original_text = cc.serialize(); + + // A default evolve is a plain, independent copy (ColorConfig itself is + // non-copyable; evolve() IS the copy mechanism), marked by provenance. + ColorConfig copy = cc.evolve(); + OIIO_CHECK_ASSERT(!copy.has_error()); + OIIO_CHECK_EQUAL(copy.serialize(), original_text); + OIIO_CHECK_ASSERT(Strutil::ends_with(copy.configname(), "#evolved")); + + // Context-variable overrides serialize with the evolved config (they + // change its structural cache identity); the source is untouched. + ColorConfig::EvolveOptions copts; + copts.context = { { "SHOT", "sh010" } }; + ColorConfig ctxcfg = cc.evolve(copts); + OIIO_CHECK_ASSERT(!ctxcfg.has_error()); + OIIO_CHECK_ASSERT(ctxcfg.serialize().find("SHOT") != std::string::npos); + OIIO_CHECK_ASSERT(original_text.find("SHOT") == std::string::npos); + OIIO_CHECK_EQUAL(cc.serialize(), original_text); + + // An evolve chain doesn't stack provenance suffixes... + ColorConfig chain = ctxcfg.evolve(); + OIIO_CHECK_ASSERT(Strutil::ends_with(chain.configname(), "#evolved")); + OIIO_CHECK_ASSERT( + !Strutil::ends_with(chain.configname(), "#evolved#evolved")); + // ...and reset returns to the ORIGINAL root, dropping prior overrides. + ColorConfig::EvolveOptions ropts; + ropts.reset = true; + ColorConfig back = ctxcfg.evolve(ropts); + OIIO_CHECK_ASSERT(!back.has_error()); + OIIO_CHECK_EQUAL(back.serialize(), original_text); + + // working_dir re-points runtime file resolution: it turns this + // unarchivable (no working directory) config archivable. + std::string wd = Filesystem::temp_directory_path() + + "/oiio_color_test_evolve_wd"; + OIIO_CHECK_ASSERT(Filesystem::create_directory(wd)); + std::string arc = Filesystem::temp_directory_path() + + "/oiio_color_test_evolve.ocioz"; + ColorConfig::EvolveOptions wopts; + wopts.working_dir = wd; + ColorConfig wdcfg = cc.evolve(wopts); + OIIO_CHECK_ASSERT(!wdcfg.has_error()); + OIIO_CHECK_ASSERT(!cc.archive(arc)); // source still has no working dir + OIIO_CHECK_ASSERT(cc.has_error() && cc.geterror().size()); + OIIO_CHECK_ASSERT(wdcfg.archive(arc)); + OIIO_CHECK_FALSE(wdcfg.has_error()); + Filesystem::remove(arc); + Filesystem::remove(wd); +} + + + +static void +test_config_debug_info() +{ + if (!ColorConfig::supportsOpenColorIO()) + return; + + ColorConfig cc = ColorConfig::from_text(utility_config_yaml); + ColorConfigDebugInfo info = cc.get_debug_info(); + OIIO_CHECK_FALSE(cc.has_error()); + // Identity: versions, config name, registry data version. + OIIO_CHECK_ASSERT(info.oiio_version.size()); + OIIO_CHECK_ASSERT(info.ocio_version.size()); + OIIO_CHECK_EQUAL(info.config_name, cc.configname()); + OIIO_CHECK_ASSERT(Strutil::contains(info.registry_data_version, + "interop-identities-config")); + OIIO_CHECK_ASSERT(info.cache_entries.size()); + // Reporting is lazy: a fresh config's interchange discovery is pending, + // and get_debug_info itself must not have triggered it. + OIIO_CHECK_ASSERT(info.interchange_state == ColorInterchangeState::Pending); + OIIO_CHECK_ASSERT(info.interchange_name.empty()); + OIIO_CHECK_ASSERT(cc.get_debug_info().interchange_state + == ColorInterchangeState::Pending); + // to_string() renders the same paste-able report. (The exact formatting + // is documented as unstable; assert only stable tokens.) + std::string report = info.to_string(); + OIIO_CHECK_ASSERT(Strutil::contains(report, "OpenImageIO")); + OIIO_CHECK_ASSERT(Strutil::contains(report, "OpenColorIO")); + OIIO_CHECK_ASSERT(Strutil::contains(report, cc.configname())); + OIIO_CHECK_ASSERT(Strutil::contains(report, "interop-identities-config")); + OIIO_CHECK_ASSERT(Strutil::contains(report, "pending")); + // Once a query runs the discovery, the result is reported. + ColorConfig::SerializeOptions iopts; + iopts.interopified = true; + (void)cc.serialize(iopts); + info = cc.get_debug_info(); + OIIO_CHECK_ASSERT(info.interchange_state + == ColorInterchangeState::Interoperable); + OIIO_CHECK_ASSERT(Strutil::contains(info.interchange_name, "ACES2065-1")); + OIIO_CHECK_ASSERT(Strutil::contains(info.to_string(), "ACES2065-1")); +} + + + +static void +test_config_clear_caches() +{ + using OIIO::pvt::characterization_cache_size; + using OIIO::pvt::color_space_fingerprint_cache_size; + using OIIO::pvt::color_space_fingerprint_cached; + + if (!ColorConfig::supportsOpenColorIO()) + return; + + // A structurally UNIQUE config (an extra space no other test's config + // declares), so the process-global entries this test creates -- and + // clear_caches() then erases -- are provably its own. + std::string yaml = std::string(utility_config_yaml) + + " - !\n name: clear_caches_space\n"; + ColorConfig cc = ColorConfig::from_text(yaml); + OIIO_CHECK_ASSERT(!cc.has_error()); + + const size_t fp_base = color_space_fingerprint_cache_size(); + const size_t char_base = characterization_cache_size(); + + // Warm this config's per-instance processor cache and its entries in + // the process-global fingerprint and characterization caches. + auto proc = cc.createColorProcessor("gamma22", "ACES2065-1"); + OIIO_CHECK_ASSERT(proc); + auto fp = color_space_fingerprint_cached(cc, "gamma22"); + OIIO_CHECK_ASSERT(fp.computed()); + OIIO_CHECK_ASSERT(color_space_fingerprint_cache_size() > fp_base); + // (The derive path -- the cheap get_color_space_info tier reads the + // shared cache but only derivation publishes into it.) + (void)cc.derive_color_space_info("gamma22"); + OIIO_CHECK_ASSERT(characterization_cache_size() > char_base); + + cc.clear_caches(); + OIIO_CHECK_FALSE(cc.has_error()); + // Only THIS config's process-global entries were dropped: the totals + // return to the pre-warm baseline, other configs' entries survive. + OIIO_CHECK_EQUAL(color_space_fingerprint_cache_size(), fp_base); + OIIO_CHECK_EQUAL(characterization_cache_size(), char_base); + // Clearing is semantics-free: everything repopulates on demand. + proc = cc.createColorProcessor("gamma22", "ACES2065-1"); + OIIO_CHECK_ASSERT(proc); + auto fp2 = color_space_fingerprint_cached(cc, "gamma22"); + OIIO_CHECK_ASSERT(fp2.computed()); + OIIO_CHECK_ASSERT(fp2.values == fp.values); +} + + + +int +main(int argc, char* argv[]) +{ +#if !defined(NDEBUG) || defined(OIIO_CI) || defined(OIIO_CODE_COVERAGE) + // For the sake of test time, reduce the default iterations for DEBUG, + // CI, and code coverage builds. Explicit use of --iters or --trials + // will override this, since it comes before the getargs() call. + iterations /= 10; + ntrials = 1; +#endif + + getargs(argc, argv); + + // Internal subprocess entry point for run_bench_phases()'s true-cold + // construction measurement -- runs nothing else. + if (bench_child) + return bench_child_construct_and_exit(); + + // First, while the process-global color caches are still cold, so the + // worker threads race the one-time lazy init. + test_thread_stress(); + + test_sRGB_conversion(); + test_Rec709_conversion(); + test_interop_identities_config(); + test_interop_id_grammar(); + test_registry_invariants(); + test_registry_round_trip(); + test_builtin_interop_ids_sync(); + test_legacy_table_registry_sync(); + test_color_space_classification(); + test_color_space_fingerprint(); + test_transfer_curve_discrimination(); + test_config_interoperability(); + test_cross_config_processor(); + test_cross_config_conversion(); + test_cross_config_display_ciid_convert(); + test_cross_config_display_interchange(); + test_cross_config_display(); + test_color_space_fingerprint_cache(); + test_interop_resolve(); + test_icc_utils(); + test_identify_icc(); + test_mastering_volume(); + test_interop_derive(); + test_color_space_info(); + test_characterize_color_space(); + test_derive_color_space_info(); + test_search_engine_coupling(); + test_copy_config_default_view_transform(); + test_config_serialize(); + test_config_from_text(); + test_config_archive(); + test_config_evolve(); + test_config_debug_info(); + test_config_clear_caches(); + + // --bench is opt-in and heavy; the default `ctest -R unit_color` run + // never sets it. + if (bench_mode) + run_bench_phases(); + test_color_interop_ids(); + test_cicp_interop_id(); return unit_test_failures != 0; } diff --git a/src/libOpenImageIO/curve_family.cpp b/src/libOpenImageIO/curve_family.cpp new file mode 100644 index 0000000000..980bd6f5ae --- /dev/null +++ b/src/libOpenImageIO/curve_family.cpp @@ -0,0 +1,100 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Curve-family normalization for the transfer-function ("curve") named +// transforms an interop identities config may declare. Such a curve name +// carries a suffix naming its negative-axis policy: `_tx` for the +// pass-through variant, and a bare (suffixless) name for the mirror variant +// that shares the identical positive-axis curve. Older configs may still use +// the legacy `_scene` / `_display` spellings; both normalize to the same +// family. These helpers reduce a curve name to a family token/name so two +// spaces sharing a transfer curve compare equal regardless of the +// reference-space state their name encodes. +// +// Pure, stateless string functions with no OCIO dependency -- kept in their +// own translation unit so color_ocio.cpp doesn't have to grow to hold them. + +#include "color_pvt.h" +#include "imageio_pvt.h" + +#include + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +namespace { + + // State suffixes a curve name may carry, tried in order; at most one is + // stripped. No suffix is a suffix of another, so the order is immaterial in + // practice -- but the strip-once semantics are deliberate: a name like + // `crv_g24_tx_display` (none exist) would strip only the trailing suffix. + constexpr const char* kCurveStateSuffixes[] = { "_scene", "_display", + "_tx" }; + + // True if `name` ends with `suffix` and is strictly longer than it, so a name + // that *is* a bare suffix ("_tx") passes through untouched. + inline bool has_curve_suffix(string_view name, string_view suffix) + { + return name.size() > suffix.size() && Strutil::ends_with(name, suffix); + } + + // Strip at most one trailing state suffix (first match wins). + string_view strip_curve_suffix(string_view name) + { + for (string_view suffix : kCurveStateSuffixes) { + if (has_curve_suffix(name, suffix)) { + name.remove_suffix(suffix.size()); + break; + } + } + return name; + } + +} // namespace + + +std::string +family_token(string_view name) +{ + if (Strutil::starts_with(name, "crv_")) + name.remove_prefix(4); + return std::string(strip_curve_suffix(name)); +} + + +std::string +family_name(string_view name) +{ + return std::string(strip_curve_suffix(name)); +} + + +bool +curve_is_passthrough(string_view name) +{ + return has_curve_suffix(name, "_scene") || has_curve_suffix(name, "_tx"); +} + + +bool +curve_is_mirror(string_view name, cspan catalog_names) +{ + if (has_curve_suffix(name, "_display")) + return true; + // v10 companion lookup: a suffixless mirror is identified by the presence + // of its `_tx` pass-through twin in the same catalog. Self-adapts to + // whichever config generation built the catalog. + // Linear scan -- a hot matcher loop should pre-build a set of catalog + // names, but no such caller exists yet. + const std::string companion = std::string(name) + "_tx"; + for (const std::string& n : catalog_names) + if (n == companion) + return true; + return false; +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/curve_family_test.cpp b/src/libOpenImageIO/curve_family_test.cpp new file mode 100644 index 0000000000..aecfdc5404 --- /dev/null +++ b/src/libOpenImageIO/curve_family_test.cpp @@ -0,0 +1,118 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for the pvt:: curve-family normalization helpers +// (family_token / family_name + pass-through/mirror discrimination). +// Pure functions -- no config or OCIO needed. + +#include +#include + +#include "color_pvt.h" + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; + + +static void +test_family_token() +{ + // Current (_tx / bare mirror) and legacy (_scene / _display) spellings of + // one family all reduce to the same token. + OIIO_CHECK_EQUAL(pvt::family_token("crv_g24_tx"), "g24"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_g24"), "g24"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_g24_scene"), "g24"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_g24_display"), "g24"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_srgb_tx"), "srgb"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_srgb_display"), "srgb"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_arrilogc3"), "arrilogc3"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_dcdm_display"), "dcdm"); + OIIO_CHECK_EQUAL(pvt::family_token("crv_lin"), "lin"); + + // Degenerate: a bare suffix is strictly-not-longer, so it passes through; + // empty maps to empty. + OIIO_CHECK_EQUAL(pvt::family_token("_tx"), "_tx"); + OIIO_CHECK_EQUAL(pvt::family_token(""), ""); + + // A name without the crv_ prefix still normalizes its suffix. + OIIO_CHECK_EQUAL(pvt::family_token("g24_tx"), "g24"); +} + + +static void +test_family_name() +{ + // Same suffix strip, but the crv_ prefix is preserved -- for comparing two + // matched catalog names for family equality. + OIIO_CHECK_EQUAL(pvt::family_name("crv_g24_tx"), "crv_g24"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_g24"), "crv_g24"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_g24_scene"), "crv_g24"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_g24_display"), "crv_g24"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_srgb_tx"), "crv_srgb"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_srgb_display"), "crv_srgb"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_arrilogc3"), "crv_arrilogc3"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_dcdm_display"), "crv_dcdm"); + OIIO_CHECK_EQUAL(pvt::family_name("crv_lin"), "crv_lin"); + OIIO_CHECK_EQUAL(pvt::family_name("_tx"), "_tx"); + OIIO_CHECK_EQUAL(pvt::family_name(""), ""); + + // family_token(x) == family_name(x) with the crv_ stripped. + OIIO_CHECK_EQUAL("crv_" + pvt::family_token("crv_srgb_tx"), + pvt::family_name("crv_srgb_tx")); +} + + +static void +test_passthrough_mirror() +{ + // A catalog carrying paired families (both twins), pass-through-only, and + // state-neutral curves. + const std::vector catalog { + "crv_g24_tx", "crv_g24", // paired: _tx + suffixless mirror + "crv_srgb_tx", "crv_srgb", // paired + "crv_g18_tx", // pass-through only, no twin + "crv_pq", "crv_dcdm", // state-neutral: no _tx companion + "crv_arrilogc3", // state-neutral (log) + }; + + // Pass-through: _tx suffix (and legacy _scene). + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_g24_tx"), true); + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_srgb_tx"), true); + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_g18_tx"), true); + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_g24_scene"), true); + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_g24"), false); + + // Mirror: suffixless twin found via companion `_tx` lookup, or legacy + // `_display` suffix (no catalog needed for the suffix path). + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_g24", catalog), true); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_srgb", catalog), true); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_g24_display", {}), true); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_g24_tx", catalog), false); + + // State-neutral: bare curves with no `_tx` companion are neither variant + // (accepted-as-is behavior; visible to both states). + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_pq"), false); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_pq", catalog), false); + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_dcdm"), false); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_dcdm", catalog), false); + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("crv_arrilogc3"), false); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("crv_arrilogc3", catalog), false); + + // Degenerate bare suffixes classify as neither (strictly-longer guard). + OIIO_CHECK_EQUAL(pvt::curve_is_passthrough("_tx"), false); + OIIO_CHECK_EQUAL(pvt::curve_is_mirror("_display", {}), false); +} + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_family_token(); + test_family_name(); + test_passthrough_mirror(); + return unit_test_failures; +} diff --git a/src/libOpenImageIO/formatspec.cpp b/src/libOpenImageIO/formatspec.cpp index d1c056ad00..aa98f23d60 100644 --- a/src/libOpenImageIO/formatspec.cpp +++ b/src/libOpenImageIO/formatspec.cpp @@ -1275,8 +1275,13 @@ ImageSpec::decode_compression_metadata(string_view defaultcomp, void ImageSpec::set_colorspace(string_view colorspace) { + // Routes through the default config's set_colorspace, which carries + // the two-bucket hygiene contract (see ColorConfig::set_colorspace). ColorConfig::default_colorconfig().set_colorspace(*this, colorspace); - // Invalidate potentially contradictory metadata + // This convenience method additionally invalidates a CICP tuple even + // on the paths the hygiene leaves facts alone (first tagging, or + // re-asserting the current space) -- longstanding behavior the + // read-time metadata reconciler relies on. erase_attribute("CICP"); } diff --git a/src/libOpenImageIO/icc.cpp b/src/libOpenImageIO/icc.cpp index 13d22478bc..a27f398f45 100644 --- a/src/libOpenImageIO/icc.cpp +++ b/src/libOpenImageIO/icc.cpp @@ -3,14 +3,21 @@ // https://github.com/AcademySoftwareFoundation/OpenImageIO +#include +#include #include #include #include +#include #include #include +#include #include +#include "color_pvt.h" +#include "imageio_pvt.h" + OIIO_NAMESPACE_BEGIN using namespace pvt; @@ -232,6 +239,84 @@ extract(cspan iccdata, size_t& offset, ICCTag& result, } // namespace + + +namespace pvt { + +namespace { + // Big-endian 32-bit read (all ICC integers are big-endian). + inline uint32_t icc_be32(const uint8_t* p) + { + return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) + | (uint32_t(p[2]) << 8) | uint32_t(p[3]); + } +} // namespace + + +bool +is_icc_profile(cspan iccdata) +{ + return std::size(iccdata) >= 132 && !memcmp(iccdata.data() + 36, "acsp", 4); +} + + + +std::string +icc_profile_identifier(cspan iccdata) +{ + if (!is_icc_profile(iccdata)) + return {}; + // Always hash the exact bytes. ICC.1 v4 defines bytes 84-99 as a + // precomputed Profile ID, but that embedded field is written by the + // profile's creator and is routinely stale or forged: two different + // blobs carrying the same Profile ID would collide as cache / + // virtual-filename identity and hand one profile the other's cached + // processor. The embedded field may be *reported* wherever it is + // surfaced as metadata, but it is never a safe cache key. + return Strutil::fmt::format("{:016x}", + xxhash::XXH64(iccdata.data(), + std::size(iccdata), 0)); +} + + + +bool +icc_embedded_cicp(cspan iccdata, int cicp[4]) +{ + if (!is_icc_profile(iccdata) || iccdata[8] != 4) + return false; + const uint8_t* data = iccdata.data(); + const size_t size = std::size(iccdata); + const uint32_t count = icc_be32(data + 128); + if (count > (size - 132) / 12) + return false; + for (uint32_t i = 0; i < count; ++i) { + const uint8_t* entry = data + 132 + size_t(i) * 12; + if (memcmp(entry, "cicp", 4) != 0) + continue; + const uint32_t tagoffset = icc_be32(entry + 4); + const uint32_t tagsize = icc_be32(entry + 8); + // The cicpType tag is exactly 12 bytes: 'cicp', 4 reserved zero + // bytes, then the four H.273 code points. + if (tagsize != 12 || size_t(tagoffset) > size + || size_t(tagsize) > size - size_t(tagoffset)) + return false; + const uint8_t* tag = data + tagoffset; + if (memcmp(tag, "cicp", 4) != 0 + || std::any_of(tag + 4, tag + 8, [](uint8_t b) { return b != 0; }) + || tag[11] > 1) + return false; + cicp[0] = tag[8]; + cicp[1] = tag[9]; + cicp[2] = tag[10]; + cicp[3] = tag[11]; + return true; + } + return false; +} + +} // namespace pvt + OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/imagebuf.cpp b/src/libOpenImageIO/imagebuf.cpp index 46b23b5dce..5e880dfe9b 100644 --- a/src/libOpenImageIO/imagebuf.cpp +++ b/src/libOpenImageIO/imagebuf.cpp @@ -1270,6 +1270,7 @@ ImageBufImpl::init_spec(string_view filename, int subimage, int miplevel, m_badfile = false; m_spec_valid = true; m_fileformat = ustring(input->format_name()); + OIIO::pvt::set_source_provenance(m_spec, m_fileformat, filename); m_nativespec = m_spec; set_bufspan(nullptr); m_blackpixel.resize( diff --git a/src/libOpenImageIO/imageio.cpp b/src/libOpenImageIO/imageio.cpp index 761e3a8e7c..50357269f7 100644 --- a/src/libOpenImageIO/imageio.cpp +++ b/src/libOpenImageIO/imageio.cpp @@ -85,6 +85,13 @@ namespace { static std::recursive_mutex attrib_mutex; static const int maxthreads = 512; // reasonable maximum for sanity check +// Storage for the global `oiio:colorpolicy:*` attribute namespace, consumed +// via OIIO::getattribute() by the color policy snapshots (see +// ColorPolicySnapshot in color_pvt.h). Generic name->value storage guarded +// by attrib_mutex like the other globals; the grammar and defaults are owned +// by the policy readers, not here. +static ParamValueList colorpolicy_attribs; + class TimingLog { public: spin_mutex mutex; @@ -374,6 +381,10 @@ attribute(string_view name, TypeDesc type, const void* val) // Things below here need to buarded by the attrib_mutex std::lock_guard lock(attrib_mutex); + if (Strutil::starts_with(name, "oiio:colorpolicy:")) { + colorpolicy_attribs.attribute(name, type, val); + return true; + } if (name == "debug" && type == TypeInt) { oiio_print_debug = *(const int*)val; return true; @@ -520,6 +531,9 @@ getattribute(string_view name, TypeDesc type, void* val) // Things below here need to buarded by the attrib_mutex std::lock_guard lock(attrib_mutex); + if (Strutil::starts_with(name, "oiio:colorpolicy:")) { + return colorpolicy_attribs.getattribute(name, type, val); + } if (name == "debug" && type == TypeInt) { *(int*)val = oiio_print_debug; return true; diff --git a/src/libOpenImageIO/imageoutput.cpp b/src/libOpenImageIO/imageoutput.cpp index 4ec5300c3a..5e40467a00 100644 --- a/src/libOpenImageIO/imageoutput.cpp +++ b/src/libOpenImageIO/imageoutput.cpp @@ -1049,6 +1049,15 @@ ImageOutput::check_open(OpenMode mode, const ImageSpec& userspec, ROI range, // Note: we only overwrite m_spec if the requested mode was valid. m_spec = userspec; + // CICP (ITU-T H.273) is transport metadata: it may only ride in a format's + // native CICP slot (PNG's cICP chunk, HEIF's nclx, ...). A format that + // declares no such slot via supports("cicp") must strip it here so it never + // leaks into the container as a generic custom attribute -- the same + // write-side discipline the P6b provenance attributes follow. Formats with + // a slot keep it and emit it through their own writer path. + if (!supports("cicp")) + m_spec.erase_attribute("CICP"); + // Check for sensible resolutions, etc. if (m_spec.width > range.width() || m_spec.height > range.height()) { errorfmt("{} image resolution may not exceed {}x{}, you asked for {}x{}", diff --git a/src/libOpenImageIO/interop-identities-config.ocio b/src/libOpenImageIO/interop-identities-config.ocio new file mode 100644 index 0000000000..2acdf69928 --- /dev/null +++ b/src/libOpenImageIO/interop-identities-config.ocio @@ -0,0 +1,1010 @@ +ocio_profile_version: 2.3 # Keep in sync with OpenImageIO minimum OCIO version + +name: interop-identities-config-v3.2.0.3 + +description: | + OpenImageIO Reference Interop Identities + ---------------------------------------- + A minimal config defining a core set of simple color spaces that OpenImageIO + knows about and can reliably recognize and relate in other OpenColorIO configs. + +# There is a 1:1 relationship between a color interop ID and a color space. +# (No two color spaces share the same interop_id value) + +# This config provides functionally equivalent color spaces found in the following configs: +# - core-display-config-v1.0.0 +# - core-renderer-config-v1.1.0 +# - ocio://studio-config-v4.0.0_aces-v2.0_ocio-v2.5 + +# Note: This config provides OCIO-2.3-compatible apprixmations of the following color spaces: +# - applelog_rec2020_scene +# - applelog_applewg_scene +# + +# Additionally, this config provides the following color spaces: +# - tlog_egamut2_scene +# - tlog_egamut_scene +# - lin_egamut2_scene +# - lin_egamut_scene + + +# Note: + +roles: + aces_interchange: lin_ap0_scene + cie_xyz_d65_interchange: lin_ciexyzd65_display + compositing_log: ocio:acescct_ap1_scene + color_timing: ocio:acescct_ap1_scene + scene_linear: lin_ap1_scene + default: data + +file_rules: + - ! {name: Default, colorspace: default} + +shared_views: + - ! {name: None, colorspace: data} + +displays: + g24_rec709_display: + - ! [None] + +default_view_transform: scene_to_display_bridge + +view_transforms: + - ! + name: scene_to_display_bridge + from_scene_reference: ! {style: UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD} + + - ! + name: video_colorimetry + from_display_reference: ! {} + + +display_colorspaces: + - ! + name: ocio:lin_ciexyzd65_display + aliases: [lin_ciexyzd65_display] + interop_id: ocio:lin_ciexyzd65_display + encoding: display-linear + + - ! + name: g24_rec709_display + interop_id: g24_rec709_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.24096994190452, -1.53738317757009, -0.498610760293003, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0556300796969936, -0.203976958888976, 1.05697151424288, 0, 0, 0, 0, 1]} + - ! {value: 2.4, style: mirror, direction: inverse} + + - ! + name: srgb_rec709_display + interop_id: srgb_rec709_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.24096994190452, -1.53738317757009, -0.498610760293003, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0556300796969936, -0.203976958888976, 1.05697151424288, 0, 0, 0, 0, 1]} + - ! {gamma: 2.4, offset: 0.055, style: mirror, direction: inverse} + + - ! + name: srgb_p3d65_display + interop_id: srgb_p3d65_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {gamma: 2.4, offset: 0.055, style: mirror, direction: inverse} + + - ! + name: srgbe_p3d65_display + interop_id: srgbe_p3d65_display + encoding: hdr-video + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {gamma: 2.4, offset: 0.055, style: mirror, direction: inverse} + + - ! + name: pq_p3d65_display + interop_id: pq_p3d65_display + encoding: hdr-video + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_ST2084-P3-D65} + + - ! + name: pq_rec2020_display + interop_id: pq_rec2020_display + encoding: hdr-video + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ} + + - ! + name: hlg_rec2020_display + interop_id: hlg_rec2020_display + encoding: hdr-video + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-HLG-1000nit} + + - ! + name: g22_rec709_display + interop_id: g22_rec709_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.24096994190452, -1.53738317757009, -0.498610760293003, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0556300796969936, -0.203976958888976, 1.05697151424288, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} + + - ! + name: g22_adobergb_display + interop_id: g22_adobergb_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [2.04158790381075, -0.56500697427886, -0.34473135077833, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0134442806320311, -0.118362392231018, 1.01517499439121, 0, 0, 0, 0, 1]} + - ! {value: 2.19921875, style: mirror, direction: inverse} + + - ! + name: g26_xyzd65_display + aliases: [dcdm_xyzd65] + interop_id: g26_xyzd65_display + encoding: sdr-cinema + from_display_reference: ! + children: + - ! {name: dci_white_headroom_scaling, min_in_value: 0, max_in_value: 1, min_out_value: 0, max_out_value: 0.916555279740309, style: noClamp} + - ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: pq_xyzd65_display + interop_id: pq_xyzd65_display + encoding: hdr-cinema + from_display_reference: ! {style: CURVE - LINEAR_to_ST-2084} + + - ! + name: g26_p3d65_display + interop_id: g26_p3d65_display + encoding: sdr-cinema + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: dcdm_p3d65_display + interop_id: dcdm_p3d65_display + encoding: sdr-cinema + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {name: dci_white_headroom_scaling, min_in_value: 0, max_in_value: 1, min_out_value: 0, max_out_value: 0.916555279740309, style: noClamp} + - ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: lin_rec709_display + interop_id: lin_rec709_display + encoding: display-linear + from_display_reference: ! {matrix: [3.24096994190452, -1.53738317757009, -0.498610760293003, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0556300796969936, -0.203976958888976, 1.05697151424288, 0, 0, 0, 0, 1]} + + - ! + name: lin_rec2020_display + interop_id: lin_rec2020_display + encoding: display-linear + from_display_reference: ! {matrix: [1.71665118797127, -0.355670783776392, -0.25336628137366, 0, -0.666684351832489, 1.61648123663494, 0.0157685458139111, 0, 0.0176398574453108, -0.0427706132578085, 0.942103121235474, 0, 0, 0, 0, 1]} + + - ! + name: lin_p3d65_display + interop_id: lin_p3d65_display + encoding: display-linear + from_display_reference: ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_p3d60_display + aliases: [lin_p3d60_display] + interop_id: oiio:lin_p3d60_display + encoding: display-linear + from_display_reference: ! {matrix: [2.428254486659579, -0.8829845365909165, -0.3902128535864839, 0, -0.8298796755579264, 1.761010282177743, 0.02548420795558374, 0, 0.0357496871967511, -0.07725558667884613, 0.9579630500444209, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_p3dci_display + aliases: [lin_p3dci_display] + interop_id: oiio:lin_p3dci_display + encoding: display-linear + from_display_reference: ! {matrix: [2.690225911625597, -1.094001937366136, -0.4250823476747523, 0, -0.820082184273491, 1.750480908292057, 0.02660195421220572, 0, 0.03624575465400463, -0.07858083680558862, 0.9587469936609856, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_rec601_display + aliases: [lin_rec601_display] + interop_id: oiio:lin_rec601_display + encoding: display-linear + from_display_reference: ! {matrix: [3.50600328272467, -1.73979072630283, -0.544058268362742, 0, -1.06904755985382, 1.97777888272879, 0.0351714193371952, 0, 0.0563065917341277, -0.196975654820772, 1.04995232821873, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_rec601pal_display + aliases: [lin_rec601pal_display] + interop_id: oiio:lin_rec601pal_display + encoding: display-linear + from_display_reference: ! {matrix: [3.06336109008327, -1.39339017490737, -0.47582373799753, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0678610475535669, -0.228799269620496, 1.06908961801603, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_prophoto_display + aliases: [lin_prophoto_display] + interop_id: oiio:lin_prophoto_display + encoding: display-linear + from_display_reference: ! {matrix: [1.403202937830473, -0.2230228699288882, -0.1016104785179563, 0, -0.5262303211926748, 1.481617422559838, 0.0170250890727386, 0, -0.0112022652862215, 0.01824640347962093, 0.9112472274915043, 0, 0, 0, 0, 1]} + + - ! + name: oiio:g22_p3d65_display + aliases: [g22_p3d65_display] + interop_id: oiio:g22_p3d65_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [2.49349691194143, -0.931383617919124, -0.402710784450717, 0, -0.829488969561575, 1.76266406031835, 0.0236246858419436, 0, 0.0358458302437845, -0.0761723892680418, 0.956884524007688, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} + + - ! + name: oiio:g22_p3d50_display + aliases: [g22_p3d50_display] + interop_id: oiio:g22_p3d50_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [2.269934220031717, -0.7640647616398722, -0.3612367402299742, 0, -0.8319646652340708, 1.758261999687511, 0.02982738744488601, 0, 0.03538246899197704, -0.08050866718457697, 0.9612705929386961, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} + + - ! + name: oiio:g22_adobergbd50_display + aliases: [g22_adobergbd50_display] + interop_id: oiio:g22_adobergbd50_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [1.89962028078498, -0.4629418709191543, -0.3145503384567722, 0, -0.9844639775403073, 1.882267684831269, 0.04905335603507256, 0, 0.001647427195160767, -0.1387994903192507, 1.044236343374566, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: mirror, direction: inverse} + + - ! + name: oiio:g24_rec601_display + aliases: [g24_rec601_display] + interop_id: oiio:g24_rec601_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.50600328272467, -1.73979072630283, -0.544058268362742, 0, -1.06904755985382, 1.97777888272879, 0.0351714193371952, 0, 0.0563065917341277, -0.196975654820772, 1.04995232821873, 0, 0, 0, 0, 1]} + - ! {value: 2.4, style: mirror, direction: inverse} + + - ! + name: oiio:g24_rec601pal_display + aliases: [g24_rec601pal_display] + interop_id: oiio:g24_rec601pal_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [3.06336109008327, -1.39339017490737, -0.47582373799753, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0678610475535669, -0.228799269620496, 1.06908961801603, 0, 0, 0, 0, 1]} + - ! {value: 2.4, style: mirror, direction: inverse} + + - ! + name: oiio:g24_rec2020_display + aliases: [g24_rec2020_display] + interop_id: oiio:g24_rec2020_display + encoding: sdr-video + from_display_reference: ! + children: + - ! {matrix: [1.71665118797127, -0.355670783776392, -0.25336628137366, 0, -0.666684351832489, 1.61648123663494, 0.0157685458139111, 0, 0.0176398574453108, -0.0427706132578085, 0.942103121235474, 0, 0, 0, 0, 1]} + - ! {value: 2.4, style: mirror, direction: inverse} + + - ! + name: oiio:g26_p3dci_display + aliases: [g26_p3dci_display] + interop_id: oiio:g26_p3dci_display + encoding: sdr-cinema + from_display_reference: ! + children: + - ! {matrix: [2.690225911625597, -1.094001937366136, -0.4250823476747523, 0, -0.820082184273491, 1.750480908292057, 0.02660195421220572, 0, 0.03624575465400463, -0.07858083680558862, 0.9587469936609856, 0, 0, 0, 0, 1]} + - ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: oiio:g26_p3d60_display + aliases: [g26_p3d60_display] + interop_id: oiio:g26_p3d60_display + encoding: sdr-cinema + from_display_reference: ! + children: + - ! {matrix: [2.428254486659579, -0.8829845365909165, -0.3902128535864839, 0, -0.8298796755579264, 1.761010282177743, 0.02548420795558374, 0, 0.0357496871967511, -0.07725558667884613, 0.9579630500444209, 0, 0, 0, 0, 1]} + - ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: oiio:pq_rec709_display + aliases: [pq_rec709_display] + interop_id: oiio:pq_rec709_display + encoding: hdr-video + from_display_reference: ! + children: + - ! {matrix: [3.24096994190452, -1.53738317757009, -0.498610760293003, 0, -0.96924363628088, 1.87596750150772, 0.0415550574071756, 0, 0.0556300796969936, -0.203976958888976, 1.05697151424288, 0, 0, 0, 0, 1]} + - ! {style: CURVE - LINEAR_to_ST-2084} + + - ! + name: data + interop_id: data + encoding: data + isdata: true + +colorspaces: + - ! + name: lin_ap0_scene + interop_id: lin_ap0_scene + encoding: scene-linear + + - ! + name: lin_ap1_scene + interop_id: lin_ap1_scene + encoding: scene-linear + to_scene_reference: ! {style: ACEScg_to_ACES2065-1} + + - ! + name: lin_rec709_scene + interop_id: lin_rec709_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.439632981919491, 0.382988698151554, 0.177378319928955, 0, 0.0897764429588424, 0.813439428748981, 0.0967841282921771, 0, 0.0175411703831727, 0.111546553302387, 0.87091227631444, 0, 0, 0, 0, 1]} + + - ! + name: lin_p3d65_scene + interop_id: lin_p3d65_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.518933487597981, 0.28625658638669, 0.194809926015329, 0, 0.0738593830470598, 0.819845163936986, 0.106295453015954, 0, -0.000307011368446647, 0.0438070502536223, 0.956499961114824, 0, 0, 0, 0, 1]} + + - ! + name: lin_rec2020_scene + interop_id: lin_rec2020_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.679085634706912, 0.157700914643159, 0.163213450649929, 0, 0.0460020030800595, 0.859054673002908, 0.0949433239170327, 0, -0.000573943187616196, 0.0284677684080264, 0.97210617477959, 0, 0, 0, 0, 1]} + + - ! + name: lin_adobergb_scene + interop_id: lin_adobergb_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.614763305501725, 0.200243702572018, 0.184992991926256, 0, 0.125539404683864, 0.773521622216629, 0.100938973099507, 0, 0.0245287963611042, 0.0671715435381276, 0.908299660100768, 0, 0, 0, 0, 1]} + + - ! + name: lin_ciexyzd65_scene + interop_id: lin_ciexyzd65_scene + encoding: scene-linear + to_scene_reference: ! { matrix: [1.0634954914942, 0.00640891019711789, -0.0158067866176054, 0, -0.492074127923892, 1.36822340747333, 0.0913370883144736, 0, -0.00281646163925351, 0.00464417105680067, 0.916418574593656, 0, 0, 0, 0, 1]} + + - ! + name: ocio:acescc_ap1_scene + aliases: [acescc_ap1_scene] + interop_id: ocio:acescc_ap1_scene + encoding: log + to_scene_reference: ! {style: ACEScc_to_ACES2065-1} + + - ! + name: ocio:acescct_ap1_scene + aliases: [acescct_ap1_scene] + interop_id: ocio:acescct_ap1_scene + encoding: log + to_scene_reference: ! {style: ACEScct_to_ACES2065-1} + + - ! + name: ocio:adx10_apd_scene + aliases: [adx10_apd_scene] + interop_id: ocio:adx10_apd_scene + encoding: log + to_scene_reference: ! {style: ADX10_to_ACES2065-1} + + - ! + name: ocio:adx16_apd_scene + aliases: [adx16_apd_scene] + interop_id: ocio:adx16_apd_scene + encoding: log + to_scene_reference: ! {style: ADX16_to_ACES2065-1} + + - ! + name: ocio:arrilogc3_awg3_scene + aliases: [arrilogc3_awg3_scene] + interop_id: ocio:arrilogc3_awg3_scene + encoding: log + to_scene_reference: ! {style: ARRI_ALEXA-LOGC-EI800-AWG_to_ACES2065-1} + + - ! + name: ocio:lin_awg3_scene + aliases: [lin_awg3_scene] + interop_id: ocio:lin_awg3_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.680205505106279, 0.236136601606481, 0.0836578932872398, 0, 0.0854149797421404, 1.01747087860704, -0.102885858349182, 0, 0.00205652166929683, -0.0625625003847921, 1.06050597871549, 0, 0, 0, 0, 1]} + + - ! + name: ocio:arrilogc4_awg4_scene + aliases: [arrilogc4_awg4_scene] + interop_id: ocio:arrilogc4_awg4_scene + encoding: log + to_scene_reference: ! {style: ARRI_LOGC4_to_ACES2065-1} + + - ! + name: ocio:lin_awg4_scene + aliases: [lin_awg4_scene] + interop_id: ocio:lin_awg4_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.750957362824734, 0.144422786709757, 0.104619850465509, 0, 0.000821837079380207, 1.007397584885, -0.00821942196438358, 0, -0.000499952143533471, -0.000854177231436971, 1.00135412937497, 0, 0, 0, 0, 1]} + + - ! + name: ocio:bmdfilm5_wg5_scene + aliases: [bmdfilm5_wg5_scene] + interop_id: ocio:bmdfilm5_wg5_scene + encoding: log + to_scene_reference: ! + children: + - ! {base: 2.71828182845905, log_side_slope: 0.0869287606549122, log_side_offset: 0.530013339229194, lin_side_offset: 0.00549407243225781, lin_side_break: 0.005, direction: inverse} + - ! {matrix: [0.647091325580708, 0.242595385134207, 0.110313289285085, 0, 0.0651915997328519, 1.02504756760476, -0.0902391673376125, 0, -0.0275570729194699, -0.0805887097177784, 1.10814578263725, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_bmdwg5_scene + aliases: [lin_bmdwg5_scene] + interop_id: ocio:lin_bmdwg5_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.647091325580708, 0.242595385134207, 0.110313289285085, 0, 0.0651915997328519, 1.02504756760476, -0.0902391673376125, 0, -0.0275570729194699, -0.0805887097177784, 1.10814578263725, 0, 0, 0, 0, 1]} + + - ! + name: ocio:davinci_dwg_scene + aliases: [davinci_dwg_scene] + interop_id: ocio:davinci_dwg_scene + encoding: log + to_scene_reference: ! + children: + - ! {log_side_slope: 0.07329248, log_side_offset: 0.51304736, lin_side_offset: 0.0075, lin_side_break: 0.00262409, linear_slope: 10.44426855, direction: inverse} + - ! {matrix: [0.748270290272981, 0.167694659554328, 0.0840350501726906, 0, 0.0208421234689102, 1.11190474268894, -0.132746866157851, 0, -0.0915122574225729, -0.127746712807307, 1.21925897022988, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_dwg_scene + aliases: [lin_dwg_scene] + interop_id: ocio:lin_dwg_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.748270290272981, 0.167694659554328, 0.0840350501726906, 0, 0.0208421234689102, 1.11190474268894, -0.132746866157851, 0, -0.0915122574225729, -0.127746712807307, 1.21925897022988, 0, 0, 0, 0, 1]} + + - ! + name: ocio:canonlog3_cgamutd55_scene + aliases: [canonlog3_cgamutd55_scene] + interop_id: ocio:canonlog3_cgamutd55_scene + encoding: log + to_scene_reference: ! {style: CANON_CLOG3-CGAMUT_to_ACES2065-1} + + - ! + name: ocio:canonlog2_cgamutd55_scene + aliases: [canonlog2_cgamutd55_scene] + interop_id: ocio:canonlog2_cgamutd55_scene + encoding: log + to_scene_reference: ! {style: CANON_CLOG2-CGAMUT_to_ACES2065-1} + + - ! + name: ocio:lin_cgamutd55_scene + aliases: [lin_cgamutd55_scene] + interop_id: ocio:lin_cgamutd55_scene + encoding: scene-linear + to_scene_reference: ! {name: cgamutd65_to_ap0-bfd, matrix: [0.763064454775734, 0.14902116113706, 0.0879143840872056, 0, 0.00365745670512393, 1.10696038037622, -0.110617837081339, 0, -0.0094077940457189, -0.218383304989987, 1.22779109903571, 0, 0, 0, 0, 1]} + + - ! + name: ocio:djilog_dgamut_scene + aliases: [djilog_dgamut_scene] + interop_id: ocio:djilog_dgamut_scene + encoding: log + to_scene_reference: ! + children: + - ! {name: djilog, base: 10, log_side_slope: 0.256662970719888, log_side_offset: 0.58455504907396, lin_side_slope: 0.9892, lin_side_offset: 0.0108, lin_side_break: 0.00758078675, direction: inverse} + - ! {matrix: [0.691279245585754, 0.214382527745956, 0.0943382266682902, 0, 0.0662224037667752, 1.0116160801876, -0.0778384839543733, 0, -0.0172985410341745, -0.0773788501012682, 1.09467739113544, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_dgamut_scene + aliases: [lin_dgamut_scene] + interop_id: ocio:lin_dgamut_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.691279245585754, 0.214382527745956, 0.0943382266682902, 0, 0.0662224037667752, 1.0116160801876, -0.0778384839543733, 0, -0.0172985410341745, -0.0773788501012682, 1.09467739113544, 0, 0, 0, 0, 1]} + + - ! + name: ocio:vlog_vgamut_scene + aliases: [vlog_vgamut_scene] + interop_id: ocio:vlog_vgamut_scene + encoding: log + to_scene_reference: ! {style: PANASONIC_VLOG-VGAMUT_to_ACES2065-1} + + - ! + name: ocio:lin_vgamut_scene + aliases: [lin_vgamut_scene] + interop_id: ocio:lin_vgamut_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.72461670413153, 0.166915288193706, 0.108468007674764, 0, 0.021390245413146, 0.984908155703054, -0.00629840111620089, 0, -0.00923556287076561, -0.00105690563900513, 1.01029246850977, 0, 0, 0, 0, 1]} + + - ! + name: ocio:redlog3g10_rwg_scene + aliases: [redlog3g10_rwg_scene] + interop_id: ocio:redlog3g10_rwg_scene + encoding: log + to_scene_reference: ! {style: RED_LOG3G10-RWG_to_ACES2065-1} + + - ! + name: ocio:lin_rwg_scene + aliases: [lin_rwg_scene] + interop_id: ocio:lin_rwg_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.785058804068092, 0.0838587565440846, 0.131082439387823, 0, 0.0231738348454756, 1.08789754919233, -0.111071384037806, 0, -0.0737604353682082, -0.314590072290208, 1.38835050765842, 0, 0, 0, 0, 1]} + + - ! + name: ocio:slog3_sgamut3_scene + aliases: [slog3_sgamut3_scene] + interop_id: ocio:slog3_sgamut3_scene + encoding: log + to_scene_reference: ! {style: SONY_SLOG3-SGAMUT3_to_ACES2065-1} + + - ! + name: ocio:slog3_sgamut3cine_scene + aliases: [slog3_sgamut3cine_scene] + interop_id: ocio:slog3_sgamut3cine_scene + encoding: log + to_scene_reference: ! {style: SONY_SLOG3-SGAMUT3.CINE_to_ACES2065-1} + + - ! + name: ocio:slog3_sgamut3venice_scene + aliases: [slog3_sgamut3venice_scene] + interop_id: ocio:slog3_sgamut3venice_scene + encoding: log + to_scene_reference: ! {style: SONY_SLOG3-SGAMUT3-VENICE_to_ACES2065-1} + + - ! + name: ocio:slog3_sgamut3cinevenice_scene + aliases: [slog3_sgamut3cinevenice_scene] + interop_id: ocio:slog3_sgamut3cinevenice_scene + encoding: log + to_scene_reference: ! {style: SONY_SLOG3-SGAMUT3.CINE-VENICE_to_ACES2065-1} + + - ! + name: ocio:lin_sgamut3_scene + aliases: [lin_sgamut3_scene] + interop_id: ocio:lin_sgamut3_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.75298259539984, 0.143370216235557, 0.103647188364603, 0, 0.0217076974414429, 1.01531883550528, -0.0370265329467195, 0, -0.00941605274963355, 0.00337041785882367, 1.00604563489081, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_sgamut3cine_scene + aliases: [lin_sgamut3cine_scene] + interop_id: ocio:lin_sgamut3cine_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.638788667185978, 0.272351433711262, 0.0888598991027595, 0, -0.00391590602528224, 1.0880732308974, -0.0841573248721177, 0, -0.0299072021239151, -0.0264325799101947, 1.05633978203411, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_sgamut3venice_scene + aliases: [lin_sgamut3venice_scene] + interop_id: ocio:lin_sgamut3venice_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.793329741146434, 0.089078625620677, 0.117591633232888, 0, 0.0155810585252582, 1.03271230692988, -0.0482933654551394, 0, -0.0188647477991488, 0.0127694120973433, 1.00609533570181, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_sgamut3cinevenice_scene + aliases: [lin_sgamut3cinevenice_scene] + interop_id: ocio:lin_sgamut3cinevenice_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.674257092126512, 0.220571735923397, 0.10517117195009, 0, -0.00931360607857167, 1.10595886142466, -0.0966452553460855, 0, -0.0382090673002312, -0.017938376600236, 1.05614744390047, 0, 0, 0, 0, 1]} + + - ! + name: g18_rec709_scene + interop_id: g18_rec709_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {value: 1.8, style: pass_thru} + - ! {matrix: [0.439632981919491, 0.382988698151554, 0.177378319928955, 0, 0.0897764429588424, 0.813439428748981, 0.0967841282921771, 0, 0.0175411703831727, 0.111546553302387, 0.87091227631444, 0, 0, 0, 0, 1]} + + - ! + name: g22_rec709_scene + interop_id: g22_rec709_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {value: 2.2, style: pass_thru} + - ! {matrix: [0.439632981919491, 0.382988698151554, 0.177378319928955, 0, 0.0897764429588424, 0.813439428748981, 0.0967841282921771, 0, 0.0175411703831727, 0.111546553302387, 0.87091227631444, 0, 0, 0, 0, 1]} + + - ! + name: srgb_rec709_scene + interop_id: srgb_rec709_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {gamma: 2.4, offset: 0.055} + - ! {matrix: [0.439632981919491, 0.382988698151554, 0.177378319928955, 0, 0.0897764429588424, 0.813439428748981, 0.0967841282921771, 0, 0.0175411703831727, 0.111546553302387, 0.87091227631444, 0, 0, 0, 0, 1]} + + - ! + name: g22_ap1_scene + interop_id: g22_ap1_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {value: 2.2, style: pass_thru} + - ! {style: ACEScg_to_ACES2065-1} + + - ! + name: srgb_ap1_scene + interop_id: srgb_ap1_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {gamma: 2.4, offset: 0.055} + - ! {style: ACEScg_to_ACES2065-1} + + - ! + name: srgb_p3d65_scene + interop_id: srgb_p3d65_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {gamma: 2.4, offset: 0.055} + - ! {matrix: [0.518933487597981, 0.28625658638669, 0.194809926015329, 0, 0.0738593830470598, 0.819845163936986, 0.106295453015954, 0, -0.000307011368446647, 0.0438070502536223, 0.956499961114824, 0, 0, 0, 0, 1]} + + - ! + name: g22_adobergb_scene + interop_id: g22_adobergb_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {value: 2.19921875, style: pass_thru} + - ! {matrix: [0.614763305501725, 0.200243702572018, 0.184992991926256, 0, 0.125539404683864, 0.773521622216629, 0.100938973099507, 0, 0.0245287963611042, 0.0671715435381276, 0.908299660100768, 0, 0, 0, 0, 1]} + + - ! + name: g24_rec709_scene + interop_id: g24_rec709_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {value: 2.4, style: pass_thru} + - ! {matrix: [0.439632981919491, 0.382988698151554, 0.177378319928955, 0, 0.0897764429588424, 0.813439428748981, 0.0967841282921771, 0, 0.0175411703831727, 0.111546553302387, 0.87091227631444, 0, 0, 0, 0, 1]} + + - ! + name: ocio:itu709_rec709_scene + aliases: [itu709_rec709_scene] + interop_id: ocio:itu709_rec709_scene + encoding: sdr-video + to_scene_reference: ! + children: + - ! {gamma: 2.22222222222222, offset: 0.099} + - ! {matrix: [0.439632981919491, 0.382988698151554, 0.177378319928955, 0, 0.0897764429588424, 0.813439428748981, 0.0967841282921771, 0, 0.0175411703831727, 0.111546553302387, 0.87091227631444, 0, 0, 0, 0, 1]} + + - ! + name: oiio:tlog_egamut2_scene + aliases: [tlog_egamut2_scene] + interop_id: oiio:tlog_egamut2_scene + encoding: log + to_scene_reference: ! + name: FilmLight T-Log E-Gamut 2 to ACES2065-1 (Bradford) + children: + - ! {base: 2.71828182845905, log_side_slope: 0.0923290259657735, log_side_offset: 0.552012656860665, lin_side_offset: 0.00570482440424738, lin_side_break: 0, linear_slope: 16.1843764896659, direction: inverse} + - ! {matrix: [0.7869672130650944, 0.1457254369940862, 0.06730734994081916, 0, 0.002609205419855227, 1.060618803211994, -0.06322800863184939, 0, -0.1146390279150544, -0.0763975967506527, 1.191036624665707, 0, 0, 0, 0, 1]} + + - ! + name: oiio:tlog_egamut_scene + aliases: [tlog_egamut_scene] + interop_id: oiio:tlog_egamut_scene + encoding: log + to_scene_reference: ! + name: FilmLight T-Log E-Gamut to ACES2065-1 (Bradford) + children: + - ! {base: 2.71828182845905, log_side_slope: 0.0923290259657735, log_side_offset: 0.552012656860665, lin_side_offset: 0.00570482440424738, lin_side_break: 0, linear_slope: 16.1843764896659, direction: inverse} + - ! {matrix: [0.753622174263, 0.180866271257401, 0.0655115693807602, 0, 0.0266947727650404, 1.03484630584717, -0.0615410692989826, 0, -0.0957930535078049, -0.0634663999080658, 1.15925943851471, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_egamut_scene + aliases: [lin_egamut_scene] + interop_id: oiio:lin_egamut_scene + encoding: scene-linear + to_scene_reference: ! + name: FilmLight E-Gamut to AP0 (Bradford) + children: + - ! {matrix: [0.753622174263, 0.180866271257401, 0.0655115693807602, 0, 0.0266947727650404, 1.03484630584717, -0.0615410692989826, 0, -0.0957930535078049, -0.0634663999080658, 1.15925943851471, 0, 0, 0, 0, 1]} + + - ! + name: oiio:lin_egamut2_scene + aliases: [lin_egamut2_scene] + interop_id: oiio:lin_egamut2_scene + encoding: scene-linear + to_scene_reference: ! + name: FilmLight E-Gamut 2 to AP0 (Bradford) + children: + - ! {matrix: [0.7869672130650944, 0.1457254369940862, 0.06730734994081916, 0, 0.002609205419855227, 1.060618803211994, -0.06322800863184939, 0, -0.1146390279150544, -0.0763975967506527, 1.191036624665707, 0, 0, 0, 0, 1]} + + - ! + name: oiio:applelog_applewg_scene + aliases: [applelog_applewg_scene] + interop_id: oiio:applelog_applewg_scene + encoding: log + to_scene_reference: ! + name: CURVE - APPLE_LOG_to_LINEAR (approx) + children: + - ! + style: log + master: { + control_points: [ + 0, -0.05641088, + 0.07142857, -0.01754535, + 0.1428571, -0.001446721, + 0.2142857, 0.0109339, + 0.2857143, 0.02707084, + 0.3571429, 0.05586433, + 0.4285714, 0.1072412, + 0.5, 0.1989139, + 0.5714286, 0.3624875, + 0.6428571, 0.6543552, + 0.7142857, 1.175141, + 0.7857143, 2.10439, + 0.8571429, 3.762468, + 0.9285714, 6.721011, + 1, 12 + ], + slopes: [ + 229.9316, 0.2720587, 0.1923746, 0.1667871, 0.2976018, + 0.5310171, 0.9475048, 1.690653, 3.016667, 5.382701, + 9.604466, 17.13745, 30.57871, 54.56223, 97.35654] + } + - ! {min_in_value: -0.05641088, min_out_value: -0.05641088} + - ! {matrix: [0.694961049318096, 0.241405268785364, 0.06363368189654, 0, 0.0473627464149325, 1.00429592505428, -0.0516586714692158, 0, -0.021989789359883, -0.0289891049714743, 1.05097889433136, 0, 0, 0, 0, 1]} + + - ! + name: oiio:applelog_rec2020_scene + aliases: [applelog_rec2020_scene] + interop_id: oiio:applelog_rec2020_scene + encoding: log + to_scene_reference: ! + name: CURVE - APPLE_LOG_to_LINEAR (approx) + children: + - ! + style: log + master: { + control_points: [ + 0, -0.05641088, + 0.07142857, -0.01754535, + 0.1428571, -0.001446721, + 0.2142857, 0.0109339, + 0.2857143, 0.02707084, + 0.3571429, 0.05586433, + 0.4285714, 0.1072412, + 0.5, 0.1989139, + 0.5714286, 0.3624875, + 0.6428571, 0.6543552, + 0.7142857, 1.175141, + 0.7857143, 2.10439, + 0.8571429, 3.762468, + 0.9285714, 6.721011, + 1, 12 + ], + slopes: [ + 229.9316, 0.2720587, 0.1923746, 0.1667871, 0.2976018, + 0.5310171, 0.9475048, 1.690653, 3.016667, 5.382701, + 9.604466, 17.13745, 30.57871, 54.56223, 97.35654] + } + - ! {min_in_value: -0.05641088, min_out_value: -0.05641088} + - ! {matrix: [0.679085634706912, 0.157700914643159, 0.163213450649929, 0, 0.0460020030800595, 0.859054673002908, 0.0949433239170327, 0, -0.000573943187616196, 0.0284677684080264, 0.97210617477959, 0, 0, 0, 0, 1]} + + - ! + name: ocio:lin_applewg_scene + aliases: [lin_applewg_scene] + interop_id: ocio:lin_applewg_scene + encoding: scene-linear + to_scene_reference: ! {matrix: [0.694961049318096, 0.241405268785364, 0.06363368189654, 0, 0.0473627464149325, 1.00429592505428, -0.0516586714692158, 0, -0.021989789359883, -0.0289891049714743, 1.05097889433136, 0, 0, 0, 0, 1]} + + +named_transforms: + # # Requires OCIO-2.4+ + # - ! + # name: crv_applelog + # encoding: log + # transform: ! {style: CURVE - APPLE_LOG_to_LINEAR} # OCIO-2.4+ + + - ! + name: crv_identity + aliases: [crv_g10_scene, crv_identity_scene] + encoding: scene-linear + transform: ! {style: identity} + + - ! + name: crv_identity_display + aliases: [crv_g10_display, crv_identity_display] + encoding: display-linear + transform: ! {style: identity} + + - ! + name: crv_applelog + aliases: [crv_applelog] + encoding: log + transform: ! + name: CURVE - APPLE_LOG_to_LINEAR (approx) + children: + - ! + style: log + master: { + control_points: [ + 0, -0.05641088, + 0.07142857, -0.01754535, + 0.1428571, -0.001446721, + 0.2142857, 0.0109339, + 0.2857143, 0.02707084, + 0.3571429, 0.05586433, + 0.4285714, 0.1072412, + 0.5, 0.1989139, + 0.5714286, 0.3624875, + 0.6428571, 0.6543552, + 0.7142857, 1.175141, + 0.7857143, 2.10439, + 0.8571429, 3.762468, + 0.9285714, 6.721011, + 1, 12 + ], + slopes: [ + 229.9316, 0.2720587, 0.1923746, 0.1667871, 0.2976018, + 0.5310171, 0.9475048, 1.690653, 3.016667, 5.382701, + 9.604466, 17.13745, 30.57871, 54.56223, 97.35654] + } + - ! {min_in_value: -0.05641088, min_out_value: -0.05641088} + + - ! + name: crv_acescct + encoding: log + transform: ! {base: 2, log_side_slope: 0.0570776, log_side_offset: 0.413745, lin_side_break: 0.0078125, direction: inverse} + + - ! + name: crv_acescc + encoding: log + transform: ! {base: 2, log_side_slope: 0.0570776, log_side_offset: 0.413745, direction: inverse} + + - ! + name: crv_jplog2 + encoding: log + transform: ! {base: 2, log_side_slope: 0.048875855327468, log_side_offset: 0.513176931573804, lin_side_break: 0.006801176276, direction: inverse} + + - ! + name: crv_arrilogc3 + aliases: [crv_arrilogc3_ei800] + encoding: log + transform: ! {base: 10, log_side_slope: 0.247189638318671, log_side_offset: 0.385536998692443, lin_side_slope: 5.55555555555556, lin_side_offset: 0.0522722750251688, lin_side_break: 0.0105909904954696, direction: inverse} + + - ! + name: crv_arrilogc4 + aliases: [crb_arrilogc4] + encoding: log + transform: ! {log_side_slope: 0.0647954196341293, log_side_offset: -0.295908392682586, lin_side_slope: 2231.82630906769, lin_side_offset: 64, lin_side_break: -0.0180569961199113, direction: inverse} + + - ! + name: crv_bmdfilm5 + encoding: log + transform: ! {base: 2.71828182845905, log_side_slope: 0.0869287606549122, log_side_offset: 0.530013339229194, lin_side_offset: 0.00549407243225781, lin_side_break: 0.005, direction: inverse} + + - ! + name: crv_davinci + encoding: log + transform: ! {log_side_slope: 0.07329248, log_side_offset: 0.51304736, lin_side_offset: 0.0075, lin_side_break: 0.00262409, linear_slope: 10.44426855, direction: inverse} + + - ! + name: crv_clog2 + encoding: log + transform: ! {style: CURVE - CANON_CLOG2_to_LINEAR} + + - ! + name: crv_clog3 + encoding: log + transform: ! {style: CURVE - CANON_CLOG3_to_LINEAR} + + - ! + name: crv_dlog + encoding: log + transform: ! {base: 10, log_side_slope: 0.256662970719888, log_side_offset: 0.58455504907396, lin_side_slope: 0.9892, lin_side_offset: 0.0108, lin_side_break: 0.00758078675, direction: inverse} + + - ! + name: crv_vlog + encoding: log + transform: ! {base: 10, log_side_slope: 0.241514, log_side_offset: 0.598206, lin_side_offset: 0.00873, lin_side_break: 0.01, direction: inverse} + + - ! + name: crv_redlog3g10 + encoding: log + transform: ! {base: 10, log_side_slope: 0.224282, lin_side_slope: 155.975327, lin_side_offset: 2.55975327, lin_side_break: -0.01, direction: inverse} + + - ! + name: crv_slog3 + encoding: log + transform: ! {base: 10, log_side_slope: 0.255620723362659, log_side_offset: 0.410557184750733, lin_side_slope: 5.26315789473684, lin_side_offset: 0.0526315789473684, lin_side_break: 0.01125, linear_slope: 6.62194371177582, direction: inverse} + + - ! + name: crv_g18_scene + encoding: sdr-video + inverse_transform: ! {value: 1.8, style: pass_thru, direction: inverse} + + - ! + name: crv_g22_scene + encoding: sdr-video + inverse_transform: ! {value: 2.2, style: pass_thru, direction: inverse} + + - ! + name: crv_g22_display + encoding: sdr-video + inverse_transform: ! {value: 2.2, style: mirror, direction: inverse} + + - ! + name: crv_g24_scene + encoding: sdr-video + inverse_transform: ! {value: 2.4, style: pass_thru, direction: inverse} + + - ! + name: crv_g24_display + encoding: sdr-video + inverse_transform: ! {value: 2.4, style: mirror, direction: inverse} + + - ! + name: crv_g26_scene + encoding: sdr-video + inverse_transform: ! {value: 2.6, style: pass_thru, direction: inverse} + + - ! + name: crv_g26_display + encoding: sdr-video + inverse_transform: ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: crv_adobe_scene + encoding: sdr-video + inverse_transform: ! {value: 2.19921875, style: pass_thru, direction: inverse} + + - ! + name: crv_adobe_display + encoding: sdr-video + inverse_transform: ! {value: 2.19921875, style: mirror, direction: inverse} + + - ! + name: crv_itu709_scene + encoding: sdr-video + inverse_transform: ! {gamma: 2.22222222222222, offset: 0.099, direction: inverse} + + - ! + name: crv_srgb_scene + encoding: sdr-video + inverse_transform: ! {gamma: 2.4, offset: 0.055, direction: inverse} + + - ! + name: crv_srgb_display + encoding: sdr-video + inverse_transform: ! {gamma: 2.4, offset: 0.055, direction: inverse, style: mirror} + + - ! + name: crv_dcdm_display + encoding: sdr-cinema + inverse_transform: ! + name: Gamma 2.6 (DCI Headroom scaling) + children: + - ! {matrix: [0.916555279740309, 0, 0, 0, 0, 0.916555279740309, 0, 0, 0, 0, 0.916555279740309, 0, 0, 0, 0, 1]} + - ! {value: 2.6, style: mirror, direction: inverse} + + - ! + name: crv_pq_display + aliases: [crv_pq, crv_st2084] + encoding: hdr-video + transform: ! {style: CURVE - ST-2084_to_LINEAR} + inverse_transform: ! {style: CURVE - LINEAR_to_ST-2084} + + # TODO: Requires OCIO-2.5+ + # - ! + # name: crv_hlg_display + # aliases: [crv_hlg, hlg_crv, hlgoetf_crv, crv_hlgoetf] + # encoding: hdr-video + # inverse_transform: ! {style: CURVE - HLG-OETF-INVERSE} + + - ! + name: crv_adx + aliases: [crv_adx10, crv_adx16] + encoding: log + transform: ! + name: ADX curve (matrices stripped via inv(pre) @ ADX10_builtin @ inv(post)) + children: + - ! {matrix: [0.663776386589531, -0.150104581885995, -0.0249132514288534, 0, -0.0443734428293291, 0.512862057089928, 0.0202699390140833, 0, -0.135766917390027, -0.018002472804857, 0.642527943469567, 0, 0, 0, 0, 1]} + - ! {style: ADX10_to_ACES2065-1} + - ! {matrix: [1.42259797810109, -0.21254450579002, -0.210055572866798, 0, -0.22160404257784, 1.36010316591325, -0.138500508340491, 0, -0.00232421023980401, -0.120267226196429, 1.12260266246286, 0, 0, 0, 0, 1]} + + - ! + name: full_to_narrow + transform: ! + name: Full-range to narrow-range (No clamp) + children: + - ! {min_in_value: 0, max_in_value: 1, min_out_value: 0.0625610948191593, max_out_value: 0.93841642228739, style: noClamp} + + - ! + name: narrow_to_full + transform: ! + name: Narrow-range to Full-range (No clamp) + children: + - ! {min_in_value: 0.0625610948191593, max_in_value: 0.93841642228739, min_out_value: 0, max_out_value: 1, style: noClamp} diff --git a/src/libOpenImageIO/interop_id.cpp b/src/libOpenImageIO/interop_id.cpp new file mode 100644 index 0000000000..e09fed7958 --- /dev/null +++ b/src/libOpenImageIO/interop_id.cpp @@ -0,0 +1,280 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// The ID grammar and sanitization rules from the CIF recommendation +// "An ID for Color Interop", Annex B (interop ID syntax) and Annex C +// (sanitizing name strings for interop ID usage): +// https://github.com/AcademySoftwareFoundation/ColorInterop/wiki +// +// Pure, stateless functions with no OCIO dependency -- kept in their own +// translation unit so color_ocio.cpp doesn't have to grow to hold them. + +#include "color_pvt.h" +#include "imageio_pvt.h" + +#include + +#include + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +namespace { + + // Annex B id-char set: lowercase a-z, 0-9, and the punctuation below. + // Built once at compile time into a 128-entry table keyed by ASCII byte, + // so the per-character check is a single indexed load (no hashing, no + // first-call guard needed since the table is constexpr-built). Shared by + // both sanitize_id_token's "already legal" fast path and + // parse_interop_id's token-validity check (which must NOT lowercase or + // map -- just accept/reject). + constexpr std::array make_allowed_id_char_table() + { + std::array table {}; + for (char c = 'a'; c <= 'z'; ++c) + table[static_cast(c)] = true; + for (char c = '0'; c <= '9'; ++c) + table[static_cast(c)] = true; + for (char c : { '.', '-', '_', '~', '/', '*', '#', '%', '^', '+', '(', + ')', '[', ']', '|' }) + table[static_cast(c)] = true; + return table; + } + + bool is_allowed_id_char(char c) + { + static constexpr std::array kAllowed + = make_allowed_id_char_table(); + const auto byte = static_cast(c); + return byte < 0x80u && kAllowed[byte]; + } + + // True if every byte of `s` is ASCII and allowed per is_allowed_id_char. + // Empty strings are NOT valid tokens (grammar requires 1*id-char). + bool is_valid_token(const std::string& s) + { + if (s.empty()) + return false; + for (unsigned char c : s) { + if (c >= 0x80 || !is_allowed_id_char(static_cast(c))) + return false; + } + return true; + } + + // Number of bytes (including the lead byte) in the UTF-8 sequence that + // starts with `lead`, per the standard lead-byte bit patterns. Returns 1 + // for ASCII or for a stray/invalid lead byte (defensive fallback). + int utf8_sequence_length(unsigned char lead) + { + if ((lead & 0xE0u) == 0xC0u) + return 2; + if ((lead & 0xF0u) == 0xE0u) + return 3; + if ((lead & 0xF8u) == 0xF0u) + return 4; + return 1; + } + + bool is_utf8_continuation(unsigned char b) { return (b & 0xC0u) == 0x80u; } + +} // namespace + + + +std::string +sanitize_id_token(const std::string& token) +{ + std::string result; + result.reserve(token.size()); + + std::size_t i = 0; + const std::size_t n = token.size(); + while (i < n) { + const unsigned char byte = static_cast(token[i]); + + if (byte < 0x80u) { + const char c = static_cast(byte); + switch (c) { + case ' ': + case '\t': + case '\n': + case '\r': result.push_back('_'); break; + case '{': + case '<': result.push_back('('); break; + case '}': + case '>': result.push_back(')'); break; + case ',': result.push_back('.'); break; + case ';': + case ':': result.push_back('|'); break; + case '\'': + case '"': result.push_back('#'); break; + case '\\': result.push_back('/'); break; + default: + if (is_allowed_id_char(c)) + result.push_back(c); + else if (c >= 'A' && c <= 'Z') + result.push_back(static_cast(c - 'A' + 'a')); + else + result.push_back('*'); + break; + } + ++i; + continue; + } + + // Non-ASCII: collapse the whole UTF-8 code point to a single '^', + // no matter how many bytes it takes. + int seqLen = utf8_sequence_length(byte); + std::size_t consumed = 1; + for (int k = 1; k < seqLen; ++k) { + if (i + static_cast(k) >= n + || !is_utf8_continuation(static_cast( + token[i + static_cast(k)]))) + break; + consumed = static_cast(k) + 1; + } + result.push_back('^'); + i += consumed; + } + + return result; +} + + + +InteropIdParts +parse_interop_id(const std::string& id) +{ + InteropIdParts parts; + + if (id.empty()) + return parts; // InteropIdForm::INVALID + + // Locate up to the first two colons. + const std::size_t firstColon = id.find(':'); + if (firstColon == std::string::npos) { + // 0 colons: the whole string is the base candidate. + if (is_valid_token(id)) { + parts.form = InteropIdForm::BASE; + parts.base = id; + } + return parts; + } + + const std::size_t secondColon = id.find(':', firstColon + 1); + if (secondColon == std::string::npos) { + // 1 colon: A:B + std::string a = id.substr(0, firstColon); + std::string b = id.substr(firstColon + 1); + if (is_valid_token(a) && is_valid_token(b)) { + parts.form = InteropIdForm::INNER_BASE; + parts.inner = a; + parts.base = b; + } + return parts; + } + + // 3+ colons is unconditionally invalid. + if (id.find(':', secondColon + 1) != std::string::npos) + return parts; + + // 2 colons: A:B:C + std::string a = id.substr(0, firstColon); + std::string b = id.substr(firstColon + 1, secondColon - firstColon - 1); + std::string c = id.substr(secondColon + 1); + + if (!is_valid_token(a) || !is_valid_token(c)) + return parts; + + if (b.empty()) { + parts.form = InteropIdForm::OUTER_BLANK_BASE; + parts.outer = a; + parts.base = c; + return parts; + } + + if (is_valid_token(b)) { + parts.form = InteropIdForm::OUTER_INNER_BASE; + parts.outer = a; + parts.inner = b; + parts.base = c; + return parts; + } + + return parts; // inner present but contains disallowed characters +} + + + +bool +is_valid_interop_id(const std::string& id) +{ + return parse_interop_id(id).form != InteropIdForm::INVALID; +} + + + +std::string +strip_leftmost_namespace(const std::string& id) +{ + const std::size_t firstColon = id.find(':'); + if (firstColon == std::string::npos) + return id; + return id.substr(firstColon + 1); +} + + + +InteropMarker +classify_interop_marker(string_view id) +{ + // Utility tokens are reserved case-sensitively; the deliberate + // unknown-marker family is honored case-insensitively, exactly as the + // resolver and scrubber always have. + if (id == "data") + return InteropMarker::UtilityData; + if (id == "bypass") + return InteropMarker::UtilityBypass; + if (id == "unknown") + return InteropMarker::BareUnknown; + if (Strutil::iequals(id, "ocio:unknown")) + return InteropMarker::OcioUnknown; + if (Strutil::iequals(id, "oiio:unknown")) + return InteropMarker::OiioUnknown; + if (Strutil::iequals(id, "error:unknown")) + return InteropMarker::ErrorUnknown; + return InteropMarker::Definite; +} + + + +bool +is_unknown_marker(string_view id) +{ + switch (classify_interop_marker(id)) { + case InteropMarker::OcioUnknown: + case InteropMarker::OiioUnknown: + case InteropMarker::ErrorUnknown: return true; + default: return false; + } +} + + + +bool +is_utility_interop_id(const std::string& id) +{ + switch (classify_interop_marker(id)) { + case InteropMarker::UtilityData: + case InteropMarker::UtilityBypass: + case InteropMarker::BareUnknown: return true; + default: return false; + } +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/interop_identities_config.h.in b/src/libOpenImageIO/interop_identities_config.h.in new file mode 100644 index 0000000000..52e7487667 --- /dev/null +++ b/src/libOpenImageIO/interop_identities_config.h.in @@ -0,0 +1,10 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Generated at configure time from interop-identities-config.ocio; do not +// edit. Re-run cmake to pick up changes to that file. + +#pragma once + +static const char kInteropIdentitiesConfig[] = { @INTEROP_IDENTITIES_CONFIG_HEX@ }; diff --git a/src/libOpenImageIO/transfer_signature.cpp b/src/libOpenImageIO/transfer_signature.cpp new file mode 100644 index 0000000000..1902de03e1 --- /dev/null +++ b/src/libOpenImageIO/transfer_signature.cpp @@ -0,0 +1,218 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Transfer-signature axis for color-space search by characterization. A +// candidate's transfer property is the triple { identity, family, signature }; +// a hint is matched against it in a fixed identity -> family -> signature +// order. The signature is behavioral: a fixed set of neutral-axis probe values +// pushed through the color space in the encode direction, reduced to adjacent +// slopes normalized by the mid-grey (0.18->0.50) anchor slope, so two spaces +// carrying the same curve through different gamut matrices compare equal. +// +// Everything here is pure and config-free: the caller runs the CPU processor +// over tf_probe_axis() and hands the outputs in, so these primitives unit-test +// without a live config. The probe set and tolerances are empirical, ported +// verbatim from the proven reference implementation. + +#include "color_pvt.h" +#include "imageio_pvt.h" + +#include +#include +#include +#include + +OIIO_NAMESPACE_BEGIN + +namespace pvt { + +namespace { + + // Neutral-axis probe values. Each is applied as R=G=B and the slope between + // adjacent outputs identifies the curve shape; the values sit at the + // characteristic inflection points of the known log and gamma curves: + // + // -0.005 negative clip / passthrough / mirror detection + // 0.0 black point + // 0.002 camera-log toe (separates log curves from gamma) + // 0.005 sRGB linear-toe break (~0.0031) + // 0.01 D-Log vs LogC3 linear-break divergence + // 0.05 low midtone + // 0.18 mid-grey -- slope normalization anchor + // 0.50 highlight + // 1.0 reference white / SDR clip boundary + // 1.1 superwhite -- SDR clipping vs log/HDR headroom + constexpr double kTFProbes[] = { -0.005, 0.0, 0.002, 0.005, 0.01, + 0.05, 0.18, 0.5, 1.0, 1.1 }; + constexpr int kTFProbeCount = int(std::size(kTFProbes)); + constexpr int kTFAnchorIndex = 6; // 0.18: slope normalization anchor + constexpr int kTFWhiteIndex = 8; // 1.0: white-gain tie-break probe + + // Scaled (dark, bright) linearity pair, 64x apart, mirroring OCIO's + // isColorSpaceLinear probe: | f(64x) - 64 f(x) | <= max(abs, rel * |f(64x)|). + // The absolute term matches OCIO's probe; the relative term absorbs inverse + // LUT1D evaluation error for spaces authored to-reference-only. + constexpr double kTFLinearityDark = 0.0625; + constexpr double kTFLinearityBright = 4.0; + constexpr double kTFLinearityRatio = 64.0; // 4.0 / 0.0625 + constexpr double kTFLinearityAbsTol = 1e-5; + constexpr double kTFLinearityRelTol = 1e-4; + + // Minimum fraction of within-tolerance slopes required to report a match. + constexpr double kTFMinMatchScore = 0.8; + + // White-gain tie-break tolerance (relative): normalized slopes discard + // constant gain, so a headroom-scaled curve (e.g. DCI-scaled gamma 2.6) would + // otherwise be indistinguishable from its unscaled twin. + constexpr double kWhiteGainTolerance = 0.01; + + + // Compare two normalized slope profiles under the per-encoding tolerance: + // index 0 (the negative-clip slope) is always masked; the last index is + // masked when either side clips superwhite; pass when at least + // kTFMinMatchScore of the compared slopes agree. + bool slope_profiles_match(cspan a, cspan avalues, + cspan b, cspan bvalues, + string_view encoding) + { + const int n = int(std::min(a.size(), b.size())); + if (n == 0) + return false; + const double tol = tf_slope_tolerance(encoding); + const bool clipsuper = tf_clips_superwhite(avalues) + || tf_clips_superwhite(bvalues); + int masked = 0; + int within = 0; + for (int i = 0; i < n; ++i) { + if (i == 0 || (i == n - 1 && clipsuper)) { + ++masked; + continue; + } + if (std::abs(a[i] - b[i]) <= tol) + ++within; + } + const int compared = n - masked; + return compared > 0 + && double(within) / double(compared) >= kTFMinMatchScore; + } + +} // namespace + + +cspan +tf_probe_axis() +{ + // 10 discriminating probes + the (dark, bright) scaled-linearity pair, + // built once from the constants so there is a single source of truth. + static const std::array axis = [] { + std::array a {}; + std::copy(std::begin(kTFProbes), std::end(kTFProbes), a.begin()); + a[kTFProbeCount] = kTFLinearityDark; + a[kTFProbeCount + 1] = kTFLinearityBright; + return a; + }(); + return axis; +} + + +double +tf_slope_tolerance(string_view encoding) +{ + if (encoding == "log") + return 0.05; + if (encoding == "hdr-video") + return 0.1; + return 0.02; // sdr-video and default +} + + +bool +tf_clips_superwhite(cspan values) +{ + return values.size() >= 2 + && std::abs(values.back() - values[values.size() - 2]) < 1e-6; +} + + +std::vector +tf_normalized_slopes(cspan values) +{ + if (values.size() != size_t(kTFProbeCount)) + return {}; + std::vector slopes(kTFProbeCount - 1); + for (int i = 0; i < kTFProbeCount - 1; ++i) + slopes[i] = (values[i + 1] - values[i]) + / (kTFProbes[i + 1] - kTFProbes[i]); + const double ref = slopes[kTFAnchorIndex]; + if (std::abs(ref) < 1e-12) + return {}; + for (double& s : slopes) + s /= ref; + return slopes; +} + + +std::optional +tf_signature_from_probes(cspan probe_outputs) +{ + if (probe_outputs.size() != size_t(kTFProbeCount) + 2) + return {}; + std::vector values(probe_outputs.begin(), + probe_outputs.begin() + kTFProbeCount); + auto slopes = tf_normalized_slopes(values); + if (slopes.empty()) + return {}; + + TransferFunctionSignature sig; + sig.slopes = std::move(slopes); + const double dark = probe_outputs[kTFProbeCount]; + const double bright = probe_outputs[kTFProbeCount + 1]; + sig.is_linear = std::abs(bright - kTFLinearityRatio * dark) + <= std::max(kTFLinearityAbsTol, + kTFLinearityRelTol * std::abs(bright)); + sig.values = std::move(values); + return sig; +} + + +bool +transfer_signatures_match(const TransferFunctionSignature& a, + const TransferFunctionSignature& b) +{ + // Encoding (slope tolerance) comes from whichever side declares one. + const std::string& encoding = !a.encoding.empty() ? a.encoding : b.encoding; + if (!slope_profiles_match(a.slopes, a.values, b.slopes, b.values, encoding)) + return false; + if (a.values.size() <= size_t(kTFWhiteIndex) + || b.values.size() <= size_t(kTFWhiteIndex)) + return true; + const double aw = a.values[kTFWhiteIndex]; + const double bw = b.values[kTFWhiteIndex]; + const double scale = std::max({ std::abs(aw), std::abs(bw), 1e-12 }); + return std::abs(aw - bw) / scale <= kWhiteGainTolerance; +} + + +bool +transfer_hint_matches(const TransferHint& hint, + const TransferProperty& property) +{ + if (hint.identity && property.identity) + return true; + // Both families identified: family equality short-circuits (behavior + // families beat signature comparison). + if (!hint.family.empty() && !property.family.empty()) + return hint.family == property.family; + if (!property.signature) + return false; + return std::any_of(hint.signatures.begin(), hint.signatures.end(), + [&](const TransferFunctionSignature& signature) { + return transfer_signatures_match(signature, + *property.signature); + }); +} + +} // namespace pvt + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/transfer_signature_test.cpp b/src/libOpenImageIO/transfer_signature_test.cpp new file mode 100644 index 0000000000..faa1068cad --- /dev/null +++ b/src/libOpenImageIO/transfer_signature_test.cpp @@ -0,0 +1,245 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Unit tests for the pvt:: transfer-signature axis: the { identity, family, +// signature } transfer property, the identity -> family -> signature match +// order, and the pure numerical primitives (normalized slopes, per-encoding +// slope tolerance, white-gain tie-break). All config-free -- signatures are +// built directly from synthetic probe data, no OCIO needed. + +#include +#include +#include + +#include "color_pvt.h" + +#include + +#include "imageio_pvt.h" + +using namespace OIIO; +using pvt::TransferFunctionSignature; +using pvt::TransferHint; +using pvt::TransferProperty; + + +// A synthetic "reference" signature: a plausible gamma-ish normalized slope +// profile (index 0 is the always-masked negative-clip slope) with a full +// 10-element value run that does not clip superwhite (values[8] != values[9]). +static TransferFunctionSignature +reference_sig(const std::string& encoding = "sdr-video") +{ + TransferFunctionSignature s; + s.slopes = { 5.0, 2.0, 1.5, 1.2, 1.0, 0.9, 0.8, 0.7, 0.6 }; + s.values = { 0.0, 0.0, 0.01, 0.02, 0.05, 0.1, 0.18, 0.35, 0.7, 0.75 }; + s.encoding = encoding; + return s; +} + + +static void +test_slope_tolerance() +{ + OIIO_CHECK_EQUAL(pvt::tf_slope_tolerance("log"), 0.05); + OIIO_CHECK_EQUAL(pvt::tf_slope_tolerance("hdr-video"), 0.1); + OIIO_CHECK_EQUAL(pvt::tf_slope_tolerance("sdr-video"), 0.02); + OIIO_CHECK_EQUAL(pvt::tf_slope_tolerance(""), 0.02); // default + OIIO_CHECK_EQUAL(pvt::tf_slope_tolerance("whatever"), 0.02); +} + + +static void +test_clips_superwhite() +{ + OIIO_CHECK_EQUAL(pvt::tf_clips_superwhite({ 0.1, 0.5, 0.5 }), true); + OIIO_CHECK_EQUAL(pvt::tf_clips_superwhite({ 0.1, 0.5, 0.6 }), false); + OIIO_CHECK_EQUAL(pvt::tf_clips_superwhite({ 0.5 }), false); // too short +} + + +static void +test_probe_axis() +{ + cspan axis = pvt::tf_probe_axis(); + // 10 discriminating probes + (dark, bright) linearity pair. + OIIO_CHECK_EQUAL(axis.size(), 12); + OIIO_CHECK_EQUAL(axis[0], -0.005); + OIIO_CHECK_EQUAL(axis[6], 0.18); // mid-grey anchor + OIIO_CHECK_EQUAL(axis[8], 1.0); // reference white + OIIO_CHECK_EQUAL(axis[9], 1.1); // superwhite + OIIO_CHECK_EQUAL(axis[10], 0.0625); // linearity dark + OIIO_CHECK_EQUAL(axis[11], 4.0); // linearity bright +} + + +static void +test_normalized_slopes() +{ + // A linear ramp f(x) = x: every adjacent slope is 1, and normalizing by + // the anchor slope (also 1) leaves all slopes at 1. + cspan axis = pvt::tf_probe_axis(); + std::vector linear(axis.begin(), axis.begin() + 10); + auto slopes = pvt::tf_normalized_slopes(linear); + OIIO_CHECK_EQUAL(slopes.size(), 9); + for (double s : slopes) + OIIO_CHECK_EQUAL_THRESH(s, 1.0, 1e-9); + + // Flat curve -> degenerate anchor slope -> empty. + OIIO_CHECK_EQUAL( + pvt::tf_normalized_slopes(std::vector(10, 0.5)).empty(), true); + + // Not a full 10-probe run -> empty. + OIIO_CHECK_EQUAL( + pvt::tf_normalized_slopes(std::vector(9, 0.1)).empty(), true); +} + + +static void +test_signature_from_probes() +{ + cspan axis = pvt::tf_probe_axis(); + + // Linear ramp outputs (identity): bright == 64 * dark, so is_linear. + std::vector linear(axis.begin(), axis.end()); // 12 outputs + auto lin = pvt::tf_signature_from_probes(linear); + OIIO_CHECK_ASSERT(lin.has_value()); + OIIO_CHECK_EQUAL(lin->values.size(), 10); // only the discriminating run + OIIO_CHECK_EQUAL(lin->slopes.size(), 9); + OIIO_CHECK_EQUAL(lin->is_linear, true); + + // Same slope run but a non-linear bright output -> is_linear false. + std::vector nonlin = linear; + nonlin[11] = 3.0; // bright, breaks the 64x ratio + auto nl = pvt::tf_signature_from_probes(nonlin); + OIIO_CHECK_ASSERT(nl.has_value()); + OIIO_CHECK_EQUAL(nl->is_linear, false); + + // Wrong-length span -> nullopt. + OIIO_CHECK_EQUAL( + pvt::tf_signature_from_probes(std::vector(11, 0.1)).has_value(), + false); +} + + +static void +test_signatures_match() +{ + const auto ref = reference_sig(); + + // Identical signatures match. + OIIO_CHECK_EQUAL(pvt::transfer_signatures_match(ref, ref), true); + + // A profile differing at four of the eight compared slopes (index 0 is + // masked) drops the score to 0.5 < 0.8 -> no match. + auto diff = ref; + diff.slopes = { 5.0, 2.5, 2.0, 1.7, 1.5, 0.9, 0.8, 0.7, 0.6 }; + OIIO_CHECK_EQUAL(pvt::transfer_signatures_match(ref, diff), false); + + // Encoding selects the tolerance. A profile off by 0.03 at two indices + // fails under sdr-video (tol 0.02, 6/8 = 0.75) but passes under log + // (tol 0.05, 8/8). Encoding is taken from the first side that declares one. + auto near = ref; + near.slopes = { 5.0, 2.03, 1.53, 1.2, 1.0, 0.9, 0.8, 0.7, 0.6 }; + near.encoding = ""; // let the ref side pick the tolerance + OIIO_CHECK_EQUAL(pvt::transfer_signatures_match(reference_sig("sdr-video"), + near), + false); + OIIO_CHECK_EQUAL(pvt::transfer_signatures_match(reference_sig("log"), near), + true); + + // White-gain tie-break: identical slopes but a headroom-scaled white + // (0.7 -> 1.05) is rejected even though the slope profiles agree. + auto scaled = ref; + scaled.values[8] = 1.05; // white probe + scaled.values[9] = 1.1; // keep superwhite distinct (no clip mask) + OIIO_CHECK_EQUAL(pvt::transfer_signatures_match(ref, scaled), false); +} + + +// The heart of the slice: the identity -> family -> signature match order. +static void +test_match_order() +{ + const auto sig = reference_sig(); + auto sig_diff = sig; + sig_diff.slopes = { 5.0, 2.5, 2.0, 1.7, 1.5, 0.9, 0.8, 0.7, 0.6 }; + + TransferProperty prop_identity; // linear/identity space + prop_identity.identity = true; + + TransferProperty prop_family_g24; + prop_family_g24.family = "g24"; + prop_family_g24.signature = sig; + + TransferProperty prop_family_g26; + prop_family_g26.family = "g26"; + prop_family_g26.signature = sig; + + TransferProperty prop_sig_only; // family unidentified, signature probed + prop_sig_only.signature = sig; + + TransferProperty prop_unknown; // nothing derivable + OIIO_CHECK_EQUAL(prop_unknown.known(), false); + OIIO_CHECK_EQUAL(prop_identity.known(), true); + OIIO_CHECK_EQUAL(prop_sig_only.known(), true); + + // --- identity wins first --- + TransferHint hint_identity; + hint_identity.identity = true; + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_identity, prop_identity), + true); + // An identity hint does not match a non-identity space... + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_identity, prop_family_g24), + false); + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_identity, prop_unknown), + false); + + // --- family short-circuits when both families are known --- + TransferHint hint_g24; + hint_g24.family = "g24"; + hint_g24.signatures = { sig_diff }; // would NOT match by signature + // Same family -> match, even though the carried signature differs. + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_g24, prop_family_g24), + true); + // Different family -> reject, even though the property's signature would + // match the hint if we fell through (behavior families beat signature). + TransferHint hint_g24_matchsig; + hint_g24_matchsig.family = "g24"; + hint_g24_matchsig.signatures = { sig }; + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_g24_matchsig, + prop_family_g26), + false); + + // --- signature fallback when a family is unknown on either side --- + // Hint carries family "g24" but candidate has no family -> compare signatures. + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_g24, prop_sig_only), + false); // sig_diff mismatches + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_g24_matchsig, + prop_sig_only), + true); + + // Pure signature hint (no family): matches any candidate whose signature + // agrees, family known or not. + TransferHint hint_sig; + hint_sig.signatures = { sig }; + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_sig, prop_sig_only), true); + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_sig, prop_family_g24), + true); + // An unknown candidate property never matches. + OIIO_CHECK_EQUAL(pvt::transfer_hint_matches(hint_sig, prop_unknown), false); +} + + +int +main(int /*argc*/, char* /*argv*/[]) +{ + test_slope_tolerance(); + test_clips_superwhite(); + test_probe_axis(); + test_normalized_slopes(); + test_signature_from_probes(); + test_signatures_match(); + test_match_order(); + return unit_test_failures; +} diff --git a/src/libOpenImageIO/xmp.cpp b/src/libOpenImageIO/xmp.cpp index 1cc61cdaca..51286d4b98 100644 --- a/src/libOpenImageIO/xmp.cpp +++ b/src/libOpenImageIO/xmp.cpp @@ -144,6 +144,7 @@ static XMPtag xmptag[] = { { "Iptc4xmpExt:PersonInImage", "IPTC:PersonInImage", TypeDesc::STRING, IsList }, { "aux:Firmware", "aux:Firmware", TypeDesc::STRING, 0}, + { "aux:ColorInteropID", "colorInteropID", TypeDesc::STRING, 0}, { "crs:AutoBrightness", "crs:AutoBrightness" , TypeDesc::INT, IsBool }, { "crs:AutoContrast", "crs:AutoContrast" , TypeDesc::INT, IsBool }, @@ -832,6 +833,11 @@ encode_xmp(const ImageSpec& spec, bool minimal) xmp += encode_xmp_category(list, "tiff", "tiff:", NULL, NULL, "http://ns.adobe.com/tiff/1.0/", minimal, XMP_attribs); //NOSONAR + // aux: namespace -- carries the forced colorInteropID (spec 09 Feature 1) + // as an aux string attribute for formats with no native slot. + xmp += encode_xmp_category(list, "aux", "aux:", NULL, NULL, + "http://ns.adobe.com/exif/1.0/aux/", minimal, + XMP_attribs); //NOSONAR #if 0 // Doesn't work yet xmp += encode_xmp_category (list, "xapRights", "xapRights:", NULL, NULL, diff --git a/src/libtexture/imagecache.cpp b/src/libtexture/imagecache.cpp index deaf583ed5..5eefae1fd7 100644 --- a/src/libtexture/imagecache.cpp +++ b/src/libtexture/imagecache.cpp @@ -846,6 +846,8 @@ ImageCacheFile::open(ImageCachePerThreadInfo* thread_info) "Images with more than 65535 channels are not supported."); tempspec = nativespec; if (nmip == 0) { + OIIO::pvt::set_source_provenance(tempspec, inp->format_name(), + m_filename.string()); sispec = find_or_create_spec(nsubimages, tempspec); OIIO_DASSERT(sispec); // Things to do on MIP level 0, i.e. once per subimage diff --git a/src/oiiotool/oiiotool.cpp b/src/oiiotool/oiiotool.cpp index 51dbf281cd..8032e13e24 100644 --- a/src/oiiotool/oiiotool.cpp +++ b/src/oiiotool/oiiotool.cpp @@ -39,6 +39,8 @@ #include #include +#include "color_pvt.h" + #ifndef NDEBUG # define OIIO_UNIT_TEST_QUIET_SUCCESS # include @@ -2382,6 +2384,66 @@ set_colorconfig(Oiiotool& ot, cspan argv) +// --colorspacesearch +// Query the color config for color spaces matching a partial characterization +// and print their names, one per line. Each axis is given as a comma-separated +// list of terms via the modifier options chromaticities= (shorthand chrm=, +// echoing PNG's cHRM chunk), transfer_function=, encoding=, image_state=; the inclusion toggles include_inactive=, +// include_context_sensitive=, include_complex=, and authored_encoding_only= +// (no interop-identity twin inference on the encoding axis) mirror the +// internal ColorSpaceSearchOptions fields. +static void +colorspacesearch(Oiiotool& ot, cspan argv) +{ + OIIO_DASSERT(argv.size() == 1); + string_view command = ot.express(argv[0]); + auto options = ot.extract_options(command); + + // Comma-splits each axis into terms. A term containing a colon (e.g. a + // custom:*, icc:*, or :local:* interop ID) can be given by + // quoting the modifier value -- extract_options() takes a single- or + // double-quoted value whole, colons included, as in + // --colorspacesearch:chromaticities="custom:acme:widegamut" + auto terms = [](string_view s) { + std::vector out; + for (auto& t : Strutil::splitsv(s, ",")) + if (t.size()) + out.emplace_back(t); + return out; + }; + // "chromaticities" is canonical; "chrm" is an accepted shorthand. + std::string chrm_terms = options.get_string("chromaticities"); + if (chrm_terms.empty()) + chrm_terms = options.get_string("chrm"); + std::vector chromaticities = terms(chrm_terms); + std::vector transfer = terms( + options.get_string("transfer_function")); + std::vector encoding = terms(options.get_string("encoding")); + std::vector image_state = terms( + options.get_string("image_state")); + + OIIO::ColorSpaceSearchOptions searchopts; + searchopts.include_inactive = options.get_int("include_inactive"); + searchopts.include_context_sensitive = options.get_int( + "include_context_sensitive"); + searchopts.include_complex = options.get_int("include_complex"); + searchopts.authored_encoding_only = options.get_int( + "authored_encoding_only"); + + std::vector results + = ot.colorconfig().find_color_spaces(chromaticities, transfer, encoding, + image_state, searchopts); + if (ot.colorconfig().has_error()) { + ot.errorfmt("--colorspacesearch", "{}", ot.colorconfig().geterror()); + } else { + for (auto& name : results) + Strutil::print("{}\n", name); + } + ot.printed_info = true; +} + + + // Special OiiotoolOp whose purpose is to set attributes on the top image. class OpSetColorSpace final : public OiiotoolOp { public: @@ -6036,6 +6098,31 @@ output_file(Oiiotool& ot, cspan argv) } } + // Spec 09 Feature B (reconciler write-shape): apply the ambient config's + // write-canonical PIXEL conversion. When the policy maps this space to a + // canonical target that is a real colorimetric change (the DCDM + // g26_p3d65_display -> g26_xyzd65_display P3->XYZ + DCI-headroom mapping), + // the pixels are CONVERTED (through the embedded interop registry) and the + // buffer retagged, so the written identity matches the pixels rather than + // just relabeling them. No-op when no mapping applies. + { + bool converted = false; + for (int s = 0, send = ir->subimages(); s < send; ++s) + for (int m = 0, mend = ir->miplevels(s); m < mend; ++m) + if (pvt::apply_write_canonical_conversion((*ir)(s, m), + &ot.colorconfig(), + filename)) { + // The pvt call retagged the ImageBuf's own spec in place; + // sync the ImageRec's outer spec copy so the write and the + // format writer's metadata plan see the canonical space. + ir->update_spec_from_imagebuf(s, m); + converted = true; + } + if (converted && ot.debug) + std::cout << " Applied write-canonical color conversion for output " + << "to " << filename << "\n"; + } + // Automatically crop out the negative areas if outputting to a format // that doesn't support negative origins. if (!supports_negativeorigin && autocrop @@ -6329,6 +6416,132 @@ action_printinfo(Oiiotool& ot, cspan argv) } + +// --colorwriteplan +static void +action_colorwriteplan(Oiiotool& ot, cspan argv) +{ + OIIO_DASSERT(argv.size() == 2); + if (ot.postpone_callback(1, action_colorwriteplan, argv)) + return; + string_view command = ot.express(argv[0]); + OTScopedTimer timer(ot, command); + std::string format = ot.express(argv[1]); + + if (!ot.read()) + return; + ImageRecRef top = ot.top(); + std::cout << pvt::render_color_write_plan(*top->spec(0, 0), format); + std::cout.flush(); + ot.printed_info = true; +} + + + +// --colorreadplan +static void +action_colorreadplan(Oiiotool& ot, cspan argv) +{ + OIIO_DASSERT(argv.size() == 1); + if (ot.postpone_callback(1, action_colorreadplan, argv)) + return; + string_view command = ot.express(argv[0]); + OTScopedTimer timer(ot, command); + + if (!ot.read()) + return; + ImageRecRef top = ot.top(); + std::cout << pvt::render_color_read_plan(*top->spec(0, 0)); + std::cout.flush(); + ot.printed_info = true; +} + + + +// --colorinfo +// Print the characterization info the color config can supply cheaply for +// each named color space (a comma-separated list; an empty list means the +// current top image's color space): one row per field with its +// computed/available/derived marker. This is the command-line consumer of +// the internal ColorConfig::get_color_space_info facade, and shares its cheap +// contract -- nothing here probes transforms or derives missing fields; +// underivable-so-far fields simply print as uncomputed. +static void +action_colorinfo(Oiiotool& ot, cspan argv) +{ + OIIO_DASSERT(argv.size() == 2); + string_view command = ot.express(argv[0]); + std::string namelist = ot.express(argv[1]); + + std::vector names; + for (auto& n : Strutil::splitsv(namelist, ",")) + if (n.size()) + names.emplace_back(n); + if (names.empty()) { + // No names given: report on the current top image's color space + // (only this form needs an image on the stack). + if (ot.postpone_callback(1, action_colorinfo, argv)) + return; + if (!ot.read()) + return; + std::string cs = ot.top()->spec(0, 0)->get_string_attribute( + "oiio:ColorSpace"); + if (cs.empty()) { + ot.errorfmt(command, + "the current image has no color space designation"); + return; + } + names.push_back(cs); + } + OTScopedTimer timer(ot, command); + + ColorConfig& cc = ot.colorconfig(); + const auto infos = cc.get_color_space_infos(names); + if (cc.has_error()) { + ot.errorfmt(command, "{}", cc.geterror()); + return; + } + + auto status = [](const ColorSpaceInfo& info, ColorSpaceInfoField field) { + if (!info.computed(field)) + return "uncomputed"; + if (!info.available(field)) + return "unavailable"; + return info.derived(field) ? "derived" : "available"; + }; + auto row = [&](const ColorSpaceInfo& info, const char* label, + ColorSpaceInfoField field, const std::string& value) { + Strutil::print(" {:<17} {:<12} {}\n", label, status(info, field), + value.empty() ? std::string("-") : value); + }; + for (const ColorSpaceInfo& info : infos) { + using F = ColorSpaceInfoField; + Strutil::print("Color space info for \"{}\":\n", info.name()); + row(info, "image_state", F::ImageState, + std::string(info.image_state())); + row(info, "color_interop_id", F::ColorInteropID, + std::string(info.color_interop_id())); + row(info, "encoding", F::Encoding, std::string(info.encoding())); + row(info, "range", F::Range, std::string(info.range())); + row(info, "equality_id", F::EqualityID, + std::string(info.equality_id())); + row(info, "chromaticities", F::Chromaticities, + Strutil::join(info.chromaticities(), ",")); + std::string transfer; + switch (info.transfer_function_kind()) { + case ColorTransferFunctionKind::Linear: transfer = "linear"; break; + case ColorTransferFunctionKind::Named: + transfer = info.transfer_function(); + break; + case ColorTransferFunctionKind::Sampled: transfer = "sampled"; break; + default: break; + } + row(info, "transfer_function", F::TransferFunction, transfer); + } + ot.printed_info = true; +} + + namespace pvtcrash { size_t crasher = 37; } @@ -7005,6 +7218,15 @@ Oiiotool::getargs(int argc, char* argv[]) ap.arg("--printstats") .help("Print pixel statistics of the current top image (options: allsubimages=, window=)") .OTACTION(action_printstats); + ap.arg("--colorwriteplan %s:FORMAT") + .help("Print the color metadata that would be written for the current top image if it were output to a file of the given format, and why (no file is written)") + .OTACTION(action_colorwriteplan); + ap.arg("--colorreadplan") + .help("Print how the current top image's color metadata was resolved: each read-side rule tried in order, its outcome, and the final color space") + .OTACTION(action_colorreadplan); + ap.arg("--colorinfo %s:COLORSPACES") + .help("Print the cheaply available characterization info (image state, color interop ID, encoding, range, and any cached derived facts) for each color space in the comma-separated list, or for the current top image's color space if the list is empty (\"\")") + .OTACTION(action_colorinfo); ap.arg("--colorcount %s:COLORLIST") .help("Count of how many pixels have the given color (argument: color;color;...) (options: eps=color)") .OTACTION(action_colorcount); @@ -7430,6 +7652,13 @@ Oiiotool::getargs(int argc, char* argv[]) ap.arg("--colorconfig %s:FILENAME") .help("Explicitly specify an OCIO configuration file") .OTACTION(set_colorconfig); + ap.arg("--colorspacesearch") + .help("Print the color spaces matching a partial characterization " + "(options: chromaticities= (or chrm=), transfer_function=, " + "encoding=, image_state=, " + "include_inactive=, include_context_sensitive=, " + "include_complex=, authored_encoding_only=)") + .OTACTION(colorspacesearch); ap.arg("--iscolorspace %s:COLORSPACE") .help("Set the assumed color space (without altering pixels)") .OTACTION(action_iscolorspace); diff --git a/src/openexr.imageio/exr_pvt.h b/src/openexr.imageio/exr_pvt.h index 454ce0b3a8..189cf816e6 100644 --- a/src/openexr.imageio/exr_pvt.h +++ b/src/openexr.imageio/exr_pvt.h @@ -230,6 +230,7 @@ class OpenEXRInput final : public ImageInput { int m_miplevel; ///< What MIP level are we looking at? std::vector m_missingcolor; ///< Color for missing tile/scanline std::string m_filename; // filename, if known + ImageSpec m_config; ///< Saved copy of configuration spec void init() { @@ -246,6 +247,7 @@ class OpenEXRInput final : public ImageInput { m_local_io.reset(); m_missingcolor.clear(); m_filename.clear(); + m_config = ImageSpec(); } bool read_native_scanlines_individually(int subimage, int miplevel, diff --git a/src/openexr.imageio/exrinput.cpp b/src/openexr.imageio/exrinput.cpp index 31dcb516a2..9ea17fc36f 100644 --- a/src/openexr.imageio/exrinput.cpp +++ b/src/openexr.imageio/exrinput.cpp @@ -22,6 +22,7 @@ #include #include +#include "color_pvt.h" #include "exr_pvt.h" #include "imageio_pvt.h" @@ -86,8 +87,8 @@ OIIO_PLUGIN_NAMESPACE_BEGIN // Defined in exrinput_c.cpp. Declare here at C++ namespace scope (not inside // the extern "C" block below) so the linkage matches the definition in the // non-embedded (dynamic plugin) build where OIIO_PLUGIN_EXPORTS_BEGIN is -// `extern "C"`. -extern ImageInput* +// `extern "C"`. Use OIIO_EXPORT to ensure safe unity build on Windows. +extern OIIO_EXPORT ImageInput* openexrcore_input_imageio_create(); #endif @@ -250,6 +251,7 @@ OpenEXRInput::open(const std::string& name, ImageSpec& newspec, // Check any other configuration hints m_filename = name; + m_config = config; // save config spec (per-open policy hints etc.) // "missingcolor" gives fill color for missing scanlines or tiles. if (const ParamValue* m = config.find_attribute("oiio:missingcolor")) { @@ -713,12 +715,17 @@ OpenEXRInput::PartInfo::parse_header(OpenEXRInput* in, spec.attribute("oiio:subimages", in->m_nsubimages); - // Try to figure out the color space for some unambiguous cases - if (spec.get_int_attribute("acesImageContainerFlag") == 1) { - spec.set_colorspace("lin_ap0_scene"); - } else if (auto c = spec.find_attribute("colorInteropID", TypeString)) { - spec.set_colorspace(c->get_ustring()); - } + // Hand the raw color attributes the header deposited to the one central + // color-metadata reconciler, which applies the audited precedence + // cascade (replacing this reader's former inline ACES-flag/colorInteropID + // special-casing). Per-open config hints override the global policy tier. + // With policy at its defaults the result is identical. + pvt::reconcile_color_metadata( + spec, + pvt::ColorReadPolicy::snapshot(&in->m_config, + pvt::ambient_color_config(), + in->m_filename), + "openexr"); // Squash some problematic texture metadata if we suspect it's wrong pvt::check_texture_metadata_sanity(spec); diff --git a/src/openexr.imageio/exrinput_c.cpp b/src/openexr.imageio/exrinput_c.cpp index 8933d3e77e..8747379a44 100644 --- a/src/openexr.imageio/exrinput_c.cpp +++ b/src/openexr.imageio/exrinput_c.cpp @@ -18,6 +18,7 @@ #include +#include "color_pvt.h" #include "imageio_pvt.h" #include #include @@ -210,6 +211,7 @@ class OpenEXRCoreInput final : public ImageInput { int m_nsubimages; ///< How many subimages are there? std::vector m_missingcolor; ///< Color for missing tile/scanline std::string m_filename; // filename, if known + ImageSpec m_config; ///< Saved copy of configuration spec void init() { @@ -219,6 +221,7 @@ class OpenEXRCoreInput final : public ImageInput { m_local_io.reset(); m_missingcolor.clear(); m_filename.clear(); + m_config = ImageSpec(); } bool valid_file_or_proxy(const std::string& filename, @@ -364,6 +367,7 @@ OpenEXRCoreInput::open(const std::string& name, ImageSpec& newspec, // Check any other configuration hints m_filename = name; + m_config = config; // save config spec (per-open policy hints etc.) // "missingcolor" gives fill color for missing scanlines or tiles. if (const ParamValue* m = config.find_attribute("oiio:missingcolor")) { @@ -811,12 +815,18 @@ OpenEXRCoreInput::PartInfo::parse_header(OpenEXRCoreInput* in, spec.attribute("oiio:subimages", in->m_nsubimages); - // Try to figure out the color space for some unambiguous cases - if (spec.get_int_attribute("acesImageContainerFlag") == 1) { - spec.set_colorspace("lin_ap0_scene"); - } else if (auto c = spec.find_attribute("colorInteropID", TypeString)) { - spec.set_colorspace(c->get_ustring()); - } + // Hand the raw color attributes the header deposited to the one central + // color-metadata reconciler, which applies the audited precedence + // cascade (replacing this reader's former inline ACES-flag/colorInteropID + // special-casing, and matching the non-core OpenEXRInput reader). + // Per-open config hints override the global policy tier. With policy at + // its defaults the result is identical. + pvt::reconcile_color_metadata( + spec, + pvt::ColorReadPolicy::snapshot(&in->m_config, + pvt::ambient_color_config(), + in->m_filename), + "openexr"); // Squash some problematic texture metadata if we suspect it's wrong pvt::check_texture_metadata_sanity(spec); diff --git a/src/openexr.imageio/exroutput.cpp b/src/openexr.imageio/exroutput.cpp index 300ce20fbe..3d8d4da447 100644 --- a/src/openexr.imageio/exroutput.cpp +++ b/src/openexr.imageio/exroutput.cpp @@ -22,7 +22,9 @@ #include #include +#include "color_pvt.h" #include "exr_pvt.h" +#include "imageio_pvt.h" // The way that OpenEXR uses dynamic casting for attributes requires // temporarily suspending "hidden" symbol visibility mode. @@ -1026,13 +1028,68 @@ OpenEXROutput::spec_to_header(ImageSpec& spec, int subimage, } } - // Set color interop ID from colorspace - if (spec.get_string_attribute("colorInteropID").empty()) { - const ColorConfig& colorconfig(ColorConfig::default_colorconfig()); - string_view colorspace = spec.get_string_attribute("oiio:ColorSpace"); - string_view interop_id = colorconfig.get_color_interop_id(colorspace); - if (!interop_id.empty()) - spec.attribute("colorInteropID", interop_id); + // Color metadata: consume the central write plan instead of deriving + // inline. EXR carries the color-interop-id string slot; the plan decides + // whether to emit an author-supplied id (already on the spec) or one + // derived from the color space. Chromaticities/CICP/etc. stay with EXR's + // existing (ACES-container) machinery -- generalizing those into the plan + // is a follow-on. + { + pvt::ColorWriteCaps caps = pvt::color_write_caps_for_format("openexr"); + // Ambient config drives write policy (spec 09 layers 2/3). Layer-5 + // (matched output-rule) is skipped here -- OpenEXROutput does not keep + // the output path handy, and EXR write caps are interop_id only; wiring + // the path through for EXR-write layer 5 is a small follow-up. + pvt::ColorMetadataPlan plan = pvt::plan_color_metadata( + nullptr, spec, caps, + pvt::ColorWritePolicy::snapshot(&spec, pvt::ambient_color_config())); + // B7 (spec 07): color identity is scoped to the whole file and + // belongs in the FIRST part's header only. Later parts are additional + // layers of one image, not independently-tagged images; the only + // colorInteropID a later part may carry is the "data" utility token + // (non-color layers forced onto R/G/B channels). Drop anything else so + // multi-part files are not over-tagged. + if (subimage != 0) { + if (spec.get_string_attribute("colorInteropID") != "data") + spec.erase_attribute("colorInteropID"); + } else { + if (plan.interop_id.action == pvt::ColorPlanAction::Derive) { + spec.attribute("colorInteropID", plan.interop_id.str); + } else if (plan.interop_id.action == pvt::ColorPlanAction::Write) { + // B9.1 (spec 07): an author-supplied id (emitted verbatim by + // the generic metadata loop below) must be grammar-valid per + // spec 01 or be omitted -- never write a malformed id. The + // writer does not sanitize on the author's behalf. + if (!pvt::is_valid_interop_id(plan.interop_id.str)) + spec.erase_attribute("colorInteropID"); + } else if (plan.interop_id.action + == pvt::ColorPlanAction::Suppress) { + // Enforce the Suppress verdict at the writer boundary: an + // author-supplied id still sits in extra_attribs, and the + // generic metadata loop below would emit it anyway. + spec.erase_attribute("colorInteropID"); + } + + // B5 (spec 07): writing a colorInteropID makes chromaticities + // redundant derivable metadata that drifts -- suppress it. The one + // exception (B4) is an ST 2065-4 / ACES container, which REQUIRES + // AP0 chromaticities (stamped just above by set_aces_container_ + // attributes and marked by acesImageContainerFlag); keep them + // there so this does not fight the container machinery. + if (plan.chromaticities.action == pvt::ColorPlanAction::Suppress + && spec.get_int_attribute("acesImageContainerFlag", 0) != 1) + spec.erase_attribute("chromaticities"); + + // Feature 2 (spec 09): verbose keeps the redundant chromaticities + // alongside the id. When the plan derived them (no authored value), + // stamp them so EXR's native chromaticities attribute carries them. + if (plan.chromaticities.action == pvt::ColorPlanAction::Derive + && plan.chromaticities.floats.size() == 8 + && !spec.find_attribute("chromaticities")) + spec.attribute("chromaticities", + OIIO::TypeDesc(OIIO::TypeDesc::FLOAT, 8), + plan.chromaticities.floats.data()); + } } // Deal with all other params diff --git a/src/png.imageio/png_pvt.h b/src/png.imageio/png_pvt.h index 6073ed9aee..9a3c9c1759 100644 --- a/src/png.imageio/png_pvt.h +++ b/src/png.imageio/png_pvt.h @@ -18,6 +18,9 @@ #include #include +#include "color_pvt.h" +#include "imageio_pvt.h" + #define OIIO_LIBPNG_VERSION \ (PNG_LIBPNG_VER_MAJOR * 10000 + PNG_LIBPNG_VER_MINOR * 100 \ @@ -183,7 +186,8 @@ decode_png_text_exif(string_view raw, ImageSpec& spec) inline bool read_info(png_structp& sp, png_infop& ip, int& bit_depth, int& color_type, int& interlace_type, Imath::Color3f& bg, ImageSpec& spec, - bool keep_unassociated_alpha) + bool keep_unassociated_alpha, const ImageSpec* config_hints, + string_view filename = {}) { // Must call this setjmp in every function that does PNG reads if (setjmp(png_jmpbuf(sp))) { // NOLINT(cert-err52-cpp) @@ -334,10 +338,31 @@ read_info(png_structp& sp, png_infop& ip, int& bit_depth, int& color_type, if (png_get_cICP(sp, ip, &pri, &trc, &mtx, &vfr)) { const int cicp[4] = { pri, trc, mtx, vfr }; spec.attribute(CICP_ATTR, TypeDesc(TypeDesc::INT, 4), cicp); - const ColorConfig& colorconfig(ColorConfig::default_colorconfig()); - string_view interop_id = colorconfig.get_color_interop_id(cicp); - if (!interop_id.empty()) - spec.attribute("oiio:ColorSpace", interop_id); + // The CICP -> color-space override is applied centrally below. + } + } +#endif + +#ifdef PNG_mDCV_SUPPORTED + // mDCV (SMPTE ST 2086) -> oicio's ST 2086 integer wire keys (spec 34): + // chromaticity xy x50000, luminance x10000, min floored at 1. libpng + // hands back WHITE-FIRST doubles; we reorder to the R,G,B,W attribute set. + { + double wx, wy, rx, ry, gx, gy, bx, by, maxl, minl; + if (png_get_mDCV(sp, ip, &wx, &wy, &rx, &ry, &gx, &gy, &bx, &by, &maxl, + &minl)) { + spec.attribute("mdcv_red_x", (int)std::lround(rx * 50000.0)); + spec.attribute("mdcv_red_y", (int)std::lround(ry * 50000.0)); + spec.attribute("mdcv_green_x", (int)std::lround(gx * 50000.0)); + spec.attribute("mdcv_green_y", (int)std::lround(gy * 50000.0)); + spec.attribute("mdcv_blue_x", (int)std::lround(bx * 50000.0)); + spec.attribute("mdcv_blue_y", (int)std::lround(by * 50000.0)); + spec.attribute("mdcv_white_x", (int)std::lround(wx * 50000.0)); + spec.attribute("mdcv_white_y", (int)std::lround(wy * 50000.0)); + spec.attribute("mdcv_max_luminance", + (int)std::lround(maxl * 10000.0)); + int64_t mn = (int64_t)std::lround(minl * 10000.0); + spec.attribute("mdcv_min_luminance", (int)(mn < 1 ? 1 : mn)); } } #endif @@ -367,6 +392,17 @@ read_info(png_structp& sp, png_infop& ip, int& bit_depth, int& color_type, // FIXME -- look for an XMP packet in an iTXt chunk. + // Hand the raw color attributes just deposited to the central color- + // metadata reconciler, which applies the audited precedence cascade + // (replacing this reader's former inline CICP -> color-space override). + // Per-open config hints (if any) override the global policy tier. With + // policy at its defaults the resolved color space is identical. + pvt::reconcile_color_metadata( + spec, + pvt::ColorReadPolicy::snapshot(config_hints, + pvt::ambient_color_config(), filename), + "png"); + return ok; } @@ -613,7 +649,7 @@ put_parameter(png_structp& sp, png_infop& ip, const std::string& _name, inline const std::string write_info(png_structp& sp, png_infop& ip, int& color_type, ImageSpec& spec, std::vector& text, bool& convert_alpha, bool& srgb, - float& gamma) + float& gamma, string_view filename = {}) { // Force either 16 or 8 bit integers if (spec.format == TypeDesc::UINT8 || spec.format == TypeDesc::INT8) @@ -761,22 +797,105 @@ write_info(png_structp& sp, png_infop& ip, int& color_type, ImageSpec& spec, (png_uint_32)(yres * scale), unittype); } + // Central write plan (spec 09): consume it instead of deriving inline. + // Drives the CICP chunk (below) and, under the verbose policy, the + // redundant cHRM/gAMA chunks (Feature 2). + const pvt::ColorMetadataPlan color_plan = pvt::plan_color_metadata( + nullptr, spec, pvt::color_write_caps_for_format("png"), + pvt::ColorWritePolicy::snapshot(&spec, pvt::ambient_color_config(), + filename)); + #ifdef PNG_cICP_SUPPORTED - // Only automatically determine CICP from oiio::ColorSpace if we didn't - // write colorspace metadata yet. - const ParamValue* p = spec.find_attribute(CICP_ATTR, - TypeDesc(TypeDesc::INT, 4)); - cspan cicp = (p) ? p->as_cspan() - : (!wrote_colorspace) ? colorconfig.get_cicp(colorspace) - : cspan(); - if (!cicp.empty()) { - png_byte vals[4]; - for (int i = 0; i < 4; ++i) - vals[i] = static_cast(cicp[i]); - if (setjmp(png_jmpbuf(sp))) // NOLINT(cert-err52-cpp) - return "Could not set PNG cICP chunk"; - // libpng will only write the chunk if the third byte is 0 - png_set_cICP(sp, ip, vals[0], vals[1], (png_byte)0, vals[3]); + // CICP: the plan emits an author-supplied CICP tuple verbatim, or one + // derived from the color space. PNG only auto-derives a tuple when no other + // color-space chunk was already written; an explicitly authored tuple is + // always emitted. + { + const auto& cicp = color_plan.cicp; + const bool emit = cicp.action == pvt::ColorPlanAction::Write + || (cicp.action == pvt::ColorPlanAction::Derive + && !wrote_colorspace); + if (emit && cicp.ints.size() == 4) { + png_byte vals[4]; + for (int i = 0; i < 4; ++i) + vals[i] = static_cast(cicp.ints[i]); + if (setjmp(png_jmpbuf(sp))) // NOLINT(cert-err52-cpp) + return "Could not set PNG cICP chunk"; + // libpng will only write the chunk if the third byte is 0 + png_set_cICP(sp, ip, vals[0], vals[1], (png_byte)0, vals[3]); + } + } +#endif + + // Feature 2 (spec 09): verbose/redundant emission. When the plan derives a + // chromaticities set and/or a pure-gamma value for a space that did not + // already write a color-space chunk, emit the redundant cHRM/gAMA chunks so + // every consumer finds a signal it understands. `floats` is R,G,B,W (x,y). + if (!wrote_colorspace) { + const auto& chrm = color_plan.chromaticities; + if (chrm.emit() && chrm.floats.size() == 8) { + if (setjmp(png_jmpbuf(sp))) // NOLINT(cert-err52-cpp) + return "Could not set PNG cHRM chunk"; + png_set_cHRM(sp, ip, chrm.floats[6], chrm.floats[7], // white x,y + chrm.floats[0], chrm.floats[1], // red x,y + chrm.floats[2], chrm.floats[3], // green x,y + chrm.floats[4], chrm.floats[5]); // blue x,y + } + const auto& g = color_plan.gamma; + if (g.emit() && g.gamma > 0.0f) { + if (setjmp(png_jmpbuf(sp))) // NOLINT(cert-err52-cpp) + return "Could not set PNG gAMA chunk"; + png_set_gAMA(sp, ip, 1.0f / g.gamma); // PNG stores file gamma + } + } + +#ifdef PNG_mDCV_SUPPORTED + // mDCV (SMPTE ST 2086 mastering-display colour volume). Adopt the sibling + // oicio project's exact wire keys (oicio spec 34, "Wire metadata keys"): + // mdcv_{red,green,blue,white}_{x,y} = chromaticity xy scaled x50000 + // mdcv_{max,min}_luminance = cd/m2 scaled x10000 (min floored 1) + // Source priority: explicit mdcv_* ImageSpec attributes win; otherwise, + // under the broadcast policy, bridge the plan's derived RGBW primaries + // (color_plan.mdcv.floats, a flat R,G,B,W xy float[8]) into a volume. + // libpng wants WHITE-FIRST doubles in [0,1] xy and nits luminance, so we + // reorder RGBW->WRGB and unscale (xy/50000, luminance/10000). + { + static const char* xykeys[8] + = { "mdcv_red_x", "mdcv_red_y", "mdcv_green_x", "mdcv_green_y", + "mdcv_blue_x", "mdcv_blue_y", "mdcv_white_x", "mdcv_white_y" }; + int64_t xy[8]; + bool have_xy = true; + for (int i = 0; i < 8; ++i) { + if (spec.find_attribute(xykeys[i])) + xy[i] = spec.get_int_attribute(xykeys[i]); + else { + have_xy = false; + break; + } + } + // Bridge the broadcast-derived primaries when no explicit xy present. + const auto& md = color_plan.mdcv; + if (!have_xy && md.emit() && md.floats.size() == 8) { + for (int i = 0; i < 8; ++i) + xy[i] = (int64_t)std::lround(md.floats[i] * 50000.0); + have_xy = true; + } + // ponytail: no photometric luminance probe here. oicio derives peak/ + // black via a probe ladder (spec 34 tiers 2-4) not ported to OIIO; the + // interim is supplied-or-default. Default = P3 broadcast mastering + // display: 1000 nits peak, 0.0001 nit black (ST2086 min floor of 1). + // Override via the mdcv_max_luminance / mdcv_min_luminance attributes. + int64_t maxlum = spec.get_int_attribute("mdcv_max_luminance", 10000000); + int64_t minlum = spec.get_int_attribute("mdcv_min_luminance", 1); + if (have_xy) { + if (setjmp(png_jmpbuf(sp))) // NOLINT(cert-err52-cpp) + return "Could not set PNG mDCV chunk"; + png_set_mDCV(sp, ip, xy[6] / 50000.0, xy[7] / 50000.0, // white x,y + xy[0] / 50000.0, xy[1] / 50000.0, // red x,y + xy[2] / 50000.0, xy[3] / 50000.0, // green x,y + xy[4] / 50000.0, xy[5] / 50000.0, // blue x,y + maxlum / 10000.0, minlum / 10000.0); + } } #endif @@ -787,6 +906,13 @@ write_info(png_structp& sp, png_infop& ip, int& color_type, ImageSpec& spec, reinterpret_cast(exifBlob.data())); #endif + // Feature 1 (spec 09): PNG has no native colorInteropID slot. Under the + // force_interop_id policy, stamp the derived id so the tEXt emission below + // carries it; otherwise strip it so the file stays untagged. Must run + // BEFORE the generic loop -- that loop would otherwise emit an authored id + // as a tEXt chunk verbatim, ignoring write:interop_id=never. + pvt::apply_forced_interop_id(spec, "png", filename); + // Deal with all other params for (size_t p = 0; p < spec.extra_attribs.size(); ++p) put_parameter(sp, ip, spec.extra_attribs[p].name().string(), diff --git a/src/png.imageio/pnginput.cpp b/src/png.imageio/pnginput.cpp index 02841868b8..1df0d43354 100644 --- a/src/png.imageio/pnginput.cpp +++ b/src/png.imageio/pnginput.cpp @@ -176,7 +176,8 @@ PNGInput::open(const std::string& name, ImageSpec& newspec) bool ok = PNG_pvt::read_info(m_png, m_info, m_bit_depth, m_color_type, m_interlace_type, m_bg, m_spec, - m_keep_unassociated_alpha); + m_keep_unassociated_alpha, m_config.get(), + m_filename); if (!ok || m_err || !check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 4 })) { close(); diff --git a/src/png.imageio/pngoutput.cpp b/src/png.imageio/pngoutput.cpp index ad2891db28..06744e8bd4 100644 --- a/src/png.imageio/pngoutput.cpp +++ b/src/png.imageio/pngoutput.cpp @@ -221,7 +221,7 @@ PNGOutput::open(const std::string& name, const ImageSpec& userspec, #endif s = PNG_pvt::write_info(m_png, m_info, m_color_type, m_spec, m_pngtext, - m_convert_alpha, m_srgb, m_gamma); + m_convert_alpha, m_srgb, m_gamma, m_filename); if (s.length()) { close(); diff --git a/src/python/py_colorconfig.cpp b/src/python/py_colorconfig.cpp index 075c1f3e84..57696d9e11 100644 --- a/src/python/py_colorconfig.cpp +++ b/src/python/py_colorconfig.cpp @@ -5,6 +5,7 @@ #include "py_oiio.h" #include #include +#include #include namespace PyOpenImageIO { diff --git a/src/python/stubs/OpenImageIO/__init__.pyi b/src/python/stubs/OpenImageIO/__init__.pyi index ea307f2e7f..663489e029 100644 --- a/src/python/stubs/OpenImageIO/__init__.pyi +++ b/src/python/stubs/OpenImageIO/__init__.pyi @@ -208,7 +208,7 @@ class ColorConfig: def getRoles(self) -> list[str]: ... def getViewNameByIndex(self, display: str = ..., *, index: typing.SupportsInt) -> str: ... def getViewNames(self, display: str = ...) -> list[str]: ... - def get_cicp(self, *args, **kwargs): ... + def get_cicp(self, arg0: str, /) -> list[int] | None: ... @overload def get_color_interop_id(self, arg0: str, /) -> str: ... @overload diff --git a/src/tiff.imageio/tiffoutput.cpp b/src/tiff.imageio/tiffoutput.cpp index 6f59678ec5..1bacacb4ac 100644 --- a/src/tiff.imageio/tiffoutput.cpp +++ b/src/tiff.imageio/tiffoutput.cpp @@ -23,6 +23,7 @@ #include #include +#include "color_pvt.h" #include "imageio_pvt.h" @@ -932,6 +933,11 @@ TIFFOutput::open(const std::string& name, const ImageSpec& userspec, TIFFSetField(m_tif, TIFFTAG_YPOSITION, std::max(0.0f, y)); } + // Feature 1 (spec 09): TIFF has no native colorInteropID slot. Under the + // force_interop_id policy, stamp the derived id so the XMP emission below + // carries it; otherwise strip it so the file stays untagged. + pvt::apply_forced_interop_id(m_spec, "tiff", name); + // Deal with all other params for (const auto& p : m_spec.extra_attribs) put_parameter(p); diff --git a/testsuite/cicp-write-strip/ref/out-nocicp.txt b/testsuite/cicp-write-strip/ref/out-nocicp.txt new file mode 100644 index 0000000000..76e94bcaa8 --- /dev/null +++ b/testsuite/cicp-write-strip/ref/out-nocicp.txt @@ -0,0 +1,3 @@ +ok written EXR: CICP is stripped (no native slot) +skip written PNG: cICP chunk still emitted (libpng lacks cICP support, needs 1.6.46) +done. diff --git a/testsuite/cicp-write-strip/ref/out.txt b/testsuite/cicp-write-strip/ref/out.txt new file mode 100644 index 0000000000..6f540cd4fe --- /dev/null +++ b/testsuite/cicp-write-strip/ref/out.txt @@ -0,0 +1,3 @@ +ok written EXR: CICP is stripped (no native slot) +ok written PNG: cICP chunk still emitted +done. diff --git a/testsuite/cicp-write-strip/run.py b/testsuite/cicp-write-strip/run.py new file mode 100644 index 0000000000..0dd6e99177 --- /dev/null +++ b/testsuite/cicp-write-strip/run.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Run the script +command += pythonbin + " src/test_cicp_write_strip.py > out.txt ;" + +# compare the outputs +outputs = [ "out.txt" ] diff --git a/testsuite/cicp-write-strip/src/test_cicp_write_strip.py b/testsuite/cicp-write-strip/src/test_cicp_write_strip.py new file mode 100644 index 0000000000..627ff6ddb5 --- /dev/null +++ b/testsuite/cicp-write-strip/src/test_cicp_write_strip.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# CICP (ITU-T H.273) is transport metadata: it may ride only in a format's +# native CICP slot (PNG's cICP chunk). A writer that declares no such slot via +# supports("cicp") -- e.g. OpenEXR -- must strip it on write so it never leaks +# into the container as a generic custom attribute. This locks both halves: +# EXR strips CICP; PNG still emits the cICP chunk (round-trip preserved). + +from __future__ import annotations + +import OpenImageIO as oiio + + +def check(desc, cond): + print(("ok " if cond else "FAIL ") + desc) + if not cond: + raise SystemExit("FAILED: " + desc) + + +CICP = (1, 13, 0, 1) + + +def make_buf(basetype): + spec = oiio.ImageSpec(4, 4, 3, basetype) + spec.attribute("CICP", oiio.TypeDesc("int[4]"), CICP) + return oiio.ImageBuf(spec) + + +# EXR has no native CICP slot: the attribute must be gone from the on-disk +# header. Read back with a bare ImageInput, which reports only what the plugin +# actually parsed from the file. +make_buf(oiio.FLOAT).write("out.exr") +exr_in = oiio.ImageInput.open("out.exr") +check("written EXR: CICP is stripped (no native slot)", + exr_in.spec().getattribute("CICP") is None) +exr_in.close() + +# PNG has a native cICP chunk: the tuple must survive the write -- but only +# where libpng can write one. png_set_cICP arrived in 1.6.46; below that +# PNG_cICP_SUPPORTED is undefined, the chunk is silently not written, and this +# half of the contract is untestable. Skip it loudly rather than report a +# failure the build could never have satisfied. (The EXR half above is +# libpng-independent and always runs.) +import re as _re +_deps = oiio.get_string_attribute("build:dependencies") +_m = _re.search(r"[Pp][Nn][Gg][^0-9]*([0-9]+)\.([0-9]+)\.([0-9]+)", _deps) +_png_has_cicp = bool(_m) and tuple(int(g) for g in _m.groups()) >= (1, 6, 46) + +if _png_has_cicp: + make_buf(oiio.UINT8).write("out.png") + png_in = oiio.ImageInput.open("out.png") + png_cicp = png_in.spec().getattribute("CICP") + check("written PNG: cICP chunk still emitted", + png_cicp is not None and tuple(png_cicp) == CICP) + png_in.close() +else: + print("skip written PNG: cICP chunk still emitted " + "(libpng lacks cICP support, needs 1.6.46)") + +print("done.") diff --git a/testsuite/color-interop-convert/ref/out-ocio24.txt b/testsuite/color-interop-convert/ref/out-ocio24.txt new file mode 100644 index 0000000000..f80da236bd --- /dev/null +++ b/testsuite/color-interop-convert/ref/out-ocio24.txt @@ -0,0 +1,26 @@ +OIIO DEBUG: OpenImageIO ColorConfig("src/interop.ocio"): reconciling color conversion across configs -- source "ap0" (this config) -> destination "lin_ap1_scene" (interop identities config) via aces_interchange +cross-config convert: wrote xconv-cross.exr +OIIO DEBUG: OpenImageIO ColorConfig("src/interop.ocio"): reconciling display transform across configs -- source "lin_ap1_scene" (interop identities config) -> display "disp" view "view1" (this config) +cross-config display: wrote xdisp-cross.exr +OIIO DEBUG: OpenImageIO ColorConfig("src/interop-strict.ocio"): Could not reconcile color conversion "ap0" -> "lin_ap1_scene": OCIO strict parsing is enabled. "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/interop-strict.ocio", or use a color space name this config defines. +oiiotool ERROR: colorconvert : Could not reconcile color conversion "ap0" -> "lin_ap1_scene": OCIO strict parsing is enabled. "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/interop-strict.ocio", or use a color space name this config defines. +Full command line was: +> oiiotool --colorconfig src/interop-strict.ocio probe.exr --colorconvert ap0 lin_ap1_scene -echo "strict-on: should not print" +OIIO DEBUG: OpenImageIO ColorConfig "src/noninterop.ocio" is not color-interoperable: no scene interchange role (aces_interchange) could be found or repaired. Cross-config color conversions and display transforms are unavailable for this config -- OCIO strict parsing will error on them, non-strict parsing will pass them through unchanged. Add the aces_interchange role (and a matching color space) to this config to enable cross-config features. +OIIO DEBUG: OpenImageIO ColorConfig("src/noninterop.ocio"): Could not reconcile color conversion "enc" -> "lin_ap1_scene": config "src/noninterop.ocio" is not color-interoperable (no scene interchange role could be found or repaired). "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/noninterop.ocio", or use a color space name this config defines. Continuing with a pass-through (non-strict parsing). +strict-off fallback: 0.18,0.42,0.73 +strict-off fallback colorspace tag: enc +local-only convert: wrote xconv-local.exr +OIIO DEBUG: OpenImageIO ColorConfig "src/noninterop.ocio" is not color-interoperable: no scene interchange role (aces_interchange) could be found or repaired. Cross-config color conversions and display transforms are unavailable for this config -- OCIO strict parsing will error on them, non-strict parsing will pass them through unchanged. Add the aces_interchange role (and a matching color space) to this config to enable cross-config features. +OIIO DEBUG: OpenImageIO ColorConfig("src/noninterop.ocio"): Could not reconcile display transform "lin_ap1_scene" -> display "disp" view "view1": config "src/noninterop.ocio" is not color-interoperable (no scene interchange role could be found or repaired). "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/noninterop.ocio", or use a color space name this config defines. Continuing with a pass-through (non-strict parsing). +inverse strict-off fallback colorspace tag: enc +OIIO DEBUG: OpenImageIO ColorConfig("src/interop-strict.ocio"): reconciling color conversion across configs -- source "srgb_rec709_display" (interop identities config) -> destination "ap0" (this config) via cie_xyz_d65_interchange +display-CIID convert: wrote xconv-disp.exr +Comparing "xconv-cross.exr" and "ref/xconv-cross.exr" +PASS +Comparing "xdisp-cross.exr" and "ref/xdisp-cross.exr" +PASS +Comparing "xconv-local.exr" and "ref/xconv-local.exr" +PASS +Comparing "xconv-disp.exr" and "ref/xconv-disp.exr" +PASS diff --git a/testsuite/color-interop-convert/ref/out.txt b/testsuite/color-interop-convert/ref/out.txt new file mode 100644 index 0000000000..413187b7db --- /dev/null +++ b/testsuite/color-interop-convert/ref/out.txt @@ -0,0 +1,26 @@ +OIIO DEBUG: OpenImageIO ColorConfig("src/interop.ocio"): reconciling color conversion across configs -- source "ap0" (this config) -> destination "ACEScg" (interop identities config) via aces_interchange +cross-config convert: wrote xconv-cross.exr +OIIO DEBUG: OpenImageIO ColorConfig("src/interop.ocio"): reconciling display transform across configs -- source "ACEScg" (interop identities config) -> display "disp" view "view1" (this config) +cross-config display: wrote xdisp-cross.exr +OIIO DEBUG: OpenImageIO ColorConfig("src/interop-strict.ocio"): Could not reconcile color conversion "ap0" -> "lin_ap1_scene": OCIO strict parsing is enabled. "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/interop-strict.ocio", or use a color space name this config defines. +oiiotool ERROR: colorconvert : Could not reconcile color conversion "ap0" -> "lin_ap1_scene": OCIO strict parsing is enabled. "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/interop-strict.ocio", or use a color space name this config defines. +Full command line was: +> oiiotool --colorconfig src/interop-strict.ocio probe.exr --colorconvert ap0 lin_ap1_scene -echo "strict-on: should not print" +OIIO DEBUG: OpenImageIO ColorConfig "src/noninterop.ocio" is not color-interoperable: no scene interchange role (aces_interchange) could be found or repaired. Cross-config color conversions and display transforms are unavailable for this config -- OCIO strict parsing will error on them, non-strict parsing will pass them through unchanged. Add the aces_interchange role (and a matching color space) to this config to enable cross-config features. +OIIO DEBUG: OpenImageIO ColorConfig("src/noninterop.ocio"): Could not reconcile color conversion "enc" -> "lin_ap1_scene": config "src/noninterop.ocio" is not color-interoperable (no scene interchange role could be found or repaired). "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/noninterop.ocio", or use a color space name this config defines. Continuing with a pass-through (non-strict parsing). +strict-off fallback: 0.18,0.42,0.73 +strict-off fallback colorspace tag: enc +local-only convert: wrote xconv-local.exr +OIIO DEBUG: OpenImageIO ColorConfig "src/noninterop.ocio" is not color-interoperable: no scene interchange role (aces_interchange) could be found or repaired. Cross-config color conversions and display transforms are unavailable for this config -- OCIO strict parsing will error on them, non-strict parsing will pass them through unchanged. Add the aces_interchange role (and a matching color space) to this config to enable cross-config features. +OIIO DEBUG: OpenImageIO ColorConfig("src/noninterop.ocio"): Could not reconcile display transform "lin_ap1_scene" -> display "disp" view "view1": config "src/noninterop.ocio" is not color-interoperable (no scene interchange role could be found or repaired). "lin_ap1_scene" is a registry-known interop identity this config does not define; add the aces_interchange role (and a matching color space) to "src/noninterop.ocio", or use a color space name this config defines. Continuing with a pass-through (non-strict parsing). +inverse strict-off fallback colorspace tag: enc +OIIO DEBUG: OpenImageIO ColorConfig("src/interop-strict.ocio"): reconciling color conversion across configs -- source "sRGB - Display" (interop identities config) -> destination "ap0" (this config) via cie_xyz_d65_interchange +display-CIID convert: wrote xconv-disp.exr +Comparing "xconv-cross.exr" and "ref/xconv-cross.exr" +PASS +Comparing "xdisp-cross.exr" and "ref/xdisp-cross.exr" +PASS +Comparing "xconv-local.exr" and "ref/xconv-local.exr" +PASS +Comparing "xconv-disp.exr" and "ref/xconv-disp.exr" +PASS diff --git a/testsuite/color-interop-convert/ref/xconv-cross.exr b/testsuite/color-interop-convert/ref/xconv-cross.exr new file mode 100644 index 0000000000..bf2d14bb5f Binary files /dev/null and b/testsuite/color-interop-convert/ref/xconv-cross.exr differ diff --git a/testsuite/color-interop-convert/ref/xconv-disp.exr b/testsuite/color-interop-convert/ref/xconv-disp.exr new file mode 100644 index 0000000000..5b027374e8 Binary files /dev/null and b/testsuite/color-interop-convert/ref/xconv-disp.exr differ diff --git a/testsuite/color-interop-convert/ref/xconv-local.exr b/testsuite/color-interop-convert/ref/xconv-local.exr new file mode 100644 index 0000000000..db6623f909 Binary files /dev/null and b/testsuite/color-interop-convert/ref/xconv-local.exr differ diff --git a/testsuite/color-interop-convert/ref/xdisp-cross.exr b/testsuite/color-interop-convert/ref/xdisp-cross.exr new file mode 100644 index 0000000000..d71b2ac912 Binary files /dev/null and b/testsuite/color-interop-convert/ref/xdisp-cross.exr differ diff --git a/testsuite/color-interop-convert/run.py b/testsuite/color-interop-convert/run.py new file mode 100644 index 0000000000..433e786fef --- /dev/null +++ b/testsuite/color-interop-convert/run.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Cross-config color conversion: when --colorconvert or --ociodisplay names a +# color space this OCIO config doesn't define, but the name is one OIIO +# recognizes from a small set of common, well-known identities (see +# src/libOpenImageIO/interop-identities-config.ocio), the conversion routes +# through those shared identities instead of failing outright -- as long as +# this config can itself be related to them via its "aces_interchange" role. +# OCIO's own "strictparsing" config setting opts out of this and restores the +# unconditional hard error. + +import os + +redirect = " >> out.txt 2>&1 " + +# The two-config GetProcessorFromConfigs overloads and the interchange-role +# machinery this relies on need OCIO >= 2.3. +if float(ociover) < 2.3 : + print("Skipping color-interop-convert tests: need OCIO >= 2.3, have", ociover) +else : + # Debug-gated narration (which config, which route, why a fallback was + # taken) goes to stderr; capture it in out.txt alongside everything else. + os.environ["OPENIMAGEIO_DEBUG"] = "1" + + command += oiiotool ("--pattern constant:color=0.18,0.42,0.73 64x64 3 " + + "-d float -o probe.exr") + + # (1) Cross-config conversion success: "ap0" (this config's own, + # transformless scene reference) -> "lin_ap1_scene" (a well-known ACEScg + # identity this config never defines). The config is interoperable (it + # declares an aces_interchange role), so the conversion is bridged through + # the shared identities and produces a real, non-identity transform. + # The actual pixel values depend on which built-in identities config OCIO + # linked against provides (see build_interop_identities_config()), which + # can shift slightly across OCIO versions -- so check the result as an + # image (toleranced idiff), not as an exact-precision printed string. + command += oiiotool ("--colorconfig src/interop.ocio probe.exr " + + "--colorconvert ap0 lin_ap1_scene " + + "-echo \"cross-config convert: wrote xconv-cross.exr\" " + + "-o xconv-cross.exr") + + # (2) Cross-config display success: the INPUT is the foreign, well-known + # identity; "disp"/"view1" are local. The foreign source is bridged + # through the shared identities into the local display/view. Same + # version-dependent-precision reasoning as (1): compare as an image. + command += oiiotool ("--colorconfig src/interop.ocio probe.exr " + + "--iscolorspace lin_ap1_scene " + + "--ociodisplay disp view1 " + + "-echo \"cross-config display: wrote xdisp-cross.exr\" " + + "-o xdisp-cross.exr") + + # (3) Strict-on hard error: the same conversion as (1), but this config + # has OCIO strict parsing enabled, which opts out of the bridge and + # restores today's hard error -- even though the config is otherwise + # interoperable and the bridge could have resolved the name. + command += oiiotool ("--colorconfig src/interop-strict.ocio probe.exr " + + "--colorconvert ap0 lin_ap1_scene " + + "-echo \"strict-on: should not print\"", + failureok=True) + + # (4) Strict-off fallback: this config is NOT interoperable at all (no + # aces_interchange role, nothing to repair), so the bridge cannot apply -- + # but strict parsing is off, so the conversion falls back to a + # pass-through (pixels unchanged) instead of failing, and narrates why. + # The pass-through must not mistag the result with the requested (never + # actually reached) destination space -- it should honestly keep + # documenting the source space "enc", since no conversion happened. This + # is checked as a color space NAME (not a float value), so it stays + # stable across OCIO versions. + command += oiiotool ("--colorconfig src/noninterop.ocio probe.exr " + + "--colorconvert enc lin_ap1_scene " + + "-echo \"strict-off fallback: {TOP.AVGCOLOR}\" " + + "-echo \"strict-off fallback colorspace tag: {TOP[\\\"oiio:ColorSpace\\\"]}\"") + + # (5) Untouched local-name conversion: both spaces are defined by this + # config directly, so none of the cross-config machinery above is + # involved -- ordinary local resolution behaves exactly as before. + # Checked as an image for consistency with (1)/(2), even though this + # route doesn't touch OCIO's built-in identities config. + command += oiiotool ("--colorconfig src/interop.ocio probe.exr " + + "--colorconvert ap0 g22 " + + "-echo \"local-only convert: wrote xconv-local.exr\" " + + "-o xconv-local.exr") + + # (6) Strict-off inverse-display fallback: same non-interoperable config + # as (4), but the INVERSE --ociodisplay direction. The input is + # display-encoded; we ask to invert back to the foreign, well-known + # identity "lin_ap1_scene" the config doesn't define. The bridge can't + # apply (not interoperable) and strict parsing is off, so it falls back to + # a pass-through -- the pixels never leave the "disp"/"view1" display + # encoding. The honest tag is therefore that display/view's color space + # ("enc"), NOT the "lin_ap1_scene" the inversion was reaching for and + # never produced. Like (4), checked as a NAME so it stays version-stable. + command += oiiotool ("--colorconfig src/noninterop.ocio probe.exr " + + "--ociodisplay:from=lin_ap1_scene:inverse=1 disp view1 " + + "-echo \"inverse strict-off fallback colorspace tag: {TOP[\\\"oiio:ColorSpace\\\"]}\"") + + # (7) Display-referred CIID cross-config convert (spec 10 B2): the source is + # a well-known DISPLAY-referred identity ("srgb_rec709_display") this config + # never defines, converted to a local scene space. It has no local + # equivalent, yet the registry lowers it colorimetrically to the scene + # reference through its own default view transform -- so it bridges through + # the interchange even under OCIO strict parsing (unlike a scene CIID). The + # result is colorimetric (white stays white, no tonescale); compare as an + # image (version-dependent precision) like (1)/(2). + command += oiiotool ("--colorconfig src/interop-strict.ocio probe.exr " + + "--colorconvert srgb_rec709_display ap0 " + + "-echo \"display-CIID convert: wrote xconv-disp.exr\" " + + "-o xconv-disp.exr") + +outputs = [ "xconv-cross.exr", "xdisp-cross.exr", "xconv-local.exr", + "xconv-disp.exr", "out.txt" ] diff --git a/testsuite/color-interop-convert/src/interop-strict.ocio b/testsuite/color-interop-convert/src/interop-strict.ocio new file mode 100644 index 0000000000..e630f538f3 --- /dev/null +++ b/testsuite/color-interop-convert/src/interop-strict.ocio @@ -0,0 +1,17 @@ +ocio_profile_version: 2.1 +strictparsing: true +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +displays: + disp: + - ! {name: view1, colorspace: g22} +colorspaces: + - ! + name: ap0 + + - ! + name: g22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} diff --git a/testsuite/color-interop-convert/src/interop.ocio b/testsuite/color-interop-convert/src/interop.ocio new file mode 100644 index 0000000000..90406e343b --- /dev/null +++ b/testsuite/color-interop-convert/src/interop.ocio @@ -0,0 +1,17 @@ +ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: ap0 + scene_linear: ap0 + aces_interchange: ap0 +displays: + disp: + - ! {name: view1, colorspace: g22} +colorspaces: + - ! + name: ap0 + + - ! + name: g22 + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} diff --git a/testsuite/color-interop-convert/src/noninterop.ocio b/testsuite/color-interop-convert/src/noninterop.ocio new file mode 100644 index 0000000000..6912d0fc18 --- /dev/null +++ b/testsuite/color-interop-convert/src/noninterop.ocio @@ -0,0 +1,13 @@ +ocio_profile_version: 2.1 +strictparsing: false +search_path: "" +roles: + default: enc + scene_linear: enc +displays: + disp: + - ! {name: view1, colorspace: enc} +colorspaces: + - ! + name: enc + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} diff --git a/testsuite/colorinfo/ref/out.txt b/testsuite/colorinfo/ref/out.txt new file mode 100644 index 0000000000..94417adbab --- /dev/null +++ b/testsuite/colorinfo/ref/out.txt @@ -0,0 +1,46 @@ +consumer: named spaces = +Color space info for "srgb_rec709_scene": + image_state available scene + color_interop_id available srgb_rec709_scene + encoding available sdr-video + range unavailable - + equality_id uncomputed - + chromaticities uncomputed - + transfer_function uncomputed - +Color space info for "rawdata": + image_state unavailable - + color_interop_id available data + encoding unavailable - + range unavailable - + equality_id uncomputed - + chromaticities uncomputed - + transfer_function uncomputed - +Color space info for "screen": + image_state available display + color_interop_id unavailable - + encoding available sdr-video + range unavailable - + equality_id uncomputed - + chromaticities uncomputed - + transfer_function uncomputed - +Color space info for "plain_space": + image_state available scene + color_interop_id unavailable - + encoding unavailable - + range unavailable - + equality_id uncomputed - + chromaticities uncomputed - + transfer_function uncomputed - +consumer: current image = +Color space info for "srgb_rec709_scene": + image_state available scene + color_interop_id available srgb_rec709_scene + encoding available sdr-video + range unavailable - + equality_id uncomputed - + chromaticities uncomputed - + transfer_function uncomputed - +consumer: invalid name = +oiiotool ERROR: --colorinfo : get_color_space_infos[1]: unknown color space "nope_xyzzy" +Full command line was: +> oiiotool -echo "consumer: invalid name =" --colorconfig src/colorinfo.ocio --colorinfo plain_space,nope_xyzzy diff --git a/testsuite/colorinfo/run.py b/testsuite/colorinfo/run.py new file mode 100644 index 0000000000..98a8982b46 --- /dev/null +++ b/testsuite/colorinfo/run.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# The tool-facing cheap characterization surface. The config is self-contained +# to this directory and identifies its +# interop-id-named space through the static table (no interop_id attribute), +# so the output does not vary by OCIO version. Everything here exercises the +# CHEAP contract only: no field is derived, so the derivable fields print as +# uncomputed. + +redirect = " >> out.txt 2>&1 " + +cfg = "src/colorinfo.ocio" + +# A batch of named spaces (order preserved; an alias reports its canonical +# name): a table-identified scene space, a data space, a display space, and +# a space with no authored facts. +command += oiiotool ("-echo \"consumer: named spaces =\" " + "--colorconfig " + cfg + " " + "--colorinfo my_srgb,rawdata,screen,plain_space") + +# An empty list means the current top image's color space. +command += oiiotool ("-echo \"consumer: current image =\" " + "--colorconfig " + cfg + " " + "--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--iscolorspace my_srgb " + "--colorinfo \"\"") + +# An unknown name fails the whole batch with one indexed error. +command += oiiotool ("-echo \"consumer: invalid name =\" " + "--colorconfig " + cfg + " " + "--colorinfo plain_space,nope_xyzzy", failureok = 1) + +outputs = [ "out.txt" ] diff --git a/testsuite/colorinfo/src/colorinfo.ocio b/testsuite/colorinfo/src/colorinfo.ocio new file mode 100644 index 0000000000..e881ae6e0f --- /dev/null +++ b/testsuite/colorinfo/src/colorinfo.ocio @@ -0,0 +1,35 @@ +ocio_profile_version: 2.1 + +name: infocfg +search_path: "" +roles: + default: ref + scene_linear: ref + +displays: + disp: + - ! {name: main, colorspace: screen} + +display_colorspaces: + - ! + name: screen + encoding: sdr-video + from_display_reference: ! {value: [2.4, 2.4, 2.4, 1]} + +colorspaces: + - ! + name: ref + + - ! + name: srgb_rec709_scene + aliases: [my_srgb] + encoding: sdr-video + from_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: rawdata + isdata: true + + - ! + name: plain_space + from_scene_reference: ! {value: [1.8, 1.8, 1.8, 1]} diff --git a/testsuite/colorspacesearch/ref/out.txt b/testsuite/colorspacesearch/ref/out.txt new file mode 100644 index 0000000000..e240d1c787 --- /dev/null +++ b/testsuite/colorspacesearch/ref/out.txt @@ -0,0 +1,35 @@ +consumer: rec709 gamut, nonlinear transfer = +Gamma 1.8 Encoded Rec.709 (sRGB) +Gamma 2.2 Encoded Rec.709 (sRGB) +Gamma 2.2 Rec.709 - Display +Gamma 2.4 Encoded Rec.709 (sRGB) +Gamma 2.6 Encoded Rec.709 (sRGB) +Rec.1886 Rec.709 - Display +sRGB (~2.22) - Display +sRGB Encoded Rec.709 (sRGB) +consumer: srgb display-referred = +sRGB (~2.22) - Display +consumer: default universe = +acme:special +active_simple +display_simple +unknown_encoding +consumer: include inactive = +acme:special +active_simple +display_simple +unknown_encoding +inactive_simple +consumer: encoding=scene-linear = +acme:special +active_simple +consumer: encoding=~scene-linear = +display_simple +consumer: encoding=-scene-linear = +display_simple +unknown_encoding +consumer: state=display = +display_simple +consumer: quoted colon-bearing term = +acme:special +active_simple diff --git a/testsuite/colorspacesearch/run.py b/testsuite/colorspacesearch/run.py new file mode 100644 index 0000000000..d774f24359 --- /dev/null +++ b/testsuite/colorspacesearch/run.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Color space search by characterization through the tool-facing private +# engine used by `oiiotool --colorspacesearch`. Refs are self-contained to this +# directory; the results below do not vary by OCIO version. + +redirect = " >> out.txt 2>&1 " + +acc = "src/oiio_test_config.ocio" # full config: chromaticity + transfer axes +core = "src/search_core.ocio" # small config: universe + encoding/state axes + +# --- oiiotool --colorspacesearch: the in-tree consumer, exercising each axis --- + +# chromaticity + transfer axes (rec709 gamut, any non-linear transfer) +command += oiiotool ("-echo \"consumer: rec709 gamut, nonlinear transfer =\" " + "--colorconfig " + acc + " " + "--colorspacesearch:chromaticities=lin_rec709_scene:transfer_function=~lin_rec709_scene") +# chromaticity + transfer + image-state axes (the sRGB display space) +command += oiiotool ("-echo \"consumer: srgb display-referred =\" " + "--colorconfig " + acc + " " + "--colorspacesearch:chrm=srgb_rec709_display:transfer_function=srgb_rec709_display:image_state=display") +# encoding axis, three-valued split (plain / inverse / exclude) + visibility +command += oiiotool ("-echo \"consumer: default universe =\" " + "--colorconfig " + core + " --colorspacesearch") +command += oiiotool ("-echo \"consumer: include inactive =\" " + "--colorconfig " + core + " --colorspacesearch:include_inactive=1") +command += oiiotool ("-echo \"consumer: encoding=scene-linear =\" " + "--colorconfig " + core + " --colorspacesearch:encoding=scene-linear") +command += oiiotool ("-echo \"consumer: encoding=~scene-linear =\" " + "--colorconfig " + core + " --colorspacesearch:encoding=~scene-linear") +command += oiiotool ("-echo \"consumer: encoding=-scene-linear =\" " + "--colorconfig " + core + " --colorspacesearch:encoding=-scene-linear") +command += oiiotool ("-echo \"consumer: state=display =\" " + "--colorconfig " + core + " --colorspacesearch:image_state=display") +# A quoted modifier value passes a colon-bearing term whole (the literal +# double quotes must reach oiiotool, so they are backslash-escaped for the +# shell). "acme:special" is a color space NAME containing a colon; hint-by- +# example reads its authored encoding (scene-linear) and returns the spaces +# sharing it. +command += oiiotool ("-echo \"consumer: quoted colon-bearing term =\" " + "--colorconfig " + core + " " + "--colorspacesearch:encoding=\\\"acme:special\\\"") + +outputs = [ "out.txt" ] diff --git a/testsuite/colorspacesearch/src/oiio_test_config.ocio b/testsuite/colorspacesearch/src/oiio_test_config.ocio new file mode 100644 index 0000000000..12ed8b53e8 --- /dev/null +++ b/testsuite/colorspacesearch/src/oiio_test_config.ocio @@ -0,0 +1,290 @@ +ocio_profile_version: 2.2 + +environment: + {} +search_path: "" +strictparsing: true +luma: [0.2126, 0.7152, 0.0722] +name: oiio-test_v0.9.2 +description: | + OIIO Test config, modified from "studio-config-v2.2.0_aces-v1.3_ocio-v2.4" on 2025-05-20T21:09:54.258778 + + +roles: + aces_interchange: ACES2065-1 + cie_xyz_d65_interchange: CIE XYZ-D65 - Display-referred + color_picking: srgb_tx + color_timing: ACEScct + compositing_log: ACEScct + data: Raw + matte_paint: ACEScct + scene_linear: lin_rec709 + texture_paint: srgb_tx + +file_rules: + # The ColorSpaceNamePathSearch rule is the "classic" "parse-colorspace-from-string" heuristic (with support for aliases) + - ! {name: ColorSpaceNamePathSearch} + # The following rules implement lines 5649-5657 of oiiotool.cpp + - ! {name: JPG images, colorspace: srgb_tx, pattern: "*", extension: jpg} # jpeginput.cpp, 261; jpegoutput.cpp, 262 + - ! {name: JPEG images, colorspace: srgb_tx, pattern: "*", extension: jpeg} + - ! {name: GIF images, colorspace: srgb_tx, pattern: "*", extension: gif} + - ! {name: WebP images, colorspace: srgb_tx, pattern: "*", extension: webp} # webpinput.cpp, 165; + - ! {name: PPM images, colorspace: rec709_tx, pattern: "*", extension: ppm} + - ! {name: PMN images, colorspace: rec709_tx, pattern: "*", extension: pmn} + # Fallback to "lin_rec709" for all other files + - ! {name: Default, colorspace: lin_rec709} + +viewing_rules: + - ! {name: video, encodings: [sdr-video, hdr-video, display-linear]} + - ! {name: log-or-linear, encodings: [log, scene-linear]} + +shared_views: + - ! {name: ACES 1.0 - SDR Video, view_transform: ACES 1.0 - SDR Video, display_colorspace: , rule: log-or-linear} + - ! {name: Un-tone-mapped, view_transform: Un-tone-mapped, display_colorspace: , rule: log-or-linear} + - ! {name: Colorimetry, view_transform: Un-tone-mapped, display_colorspace: , rule: video} + +displays: + sRGB (~2.22) - Display: + - ! {name: Raw, colorspace: Raw} + - ! [ACES 1.0 - SDR Video, Un-tone-mapped, Colorimetry] + Gamma 2.2 Rec.709 - Display: + - ! {name: Raw, colorspace: Raw} + - ! [ACES 1.0 - SDR Video, Un-tone-mapped, Colorimetry] + Rec.1886 Rec.709 - Display: + - ! {name: Raw, colorspace: Raw} + - ! [ACES 1.0 - SDR Video, Un-tone-mapped, Colorimetry] + +active_displays: [sRGB (~2.22) - Display, Gamma 2.2 Rec.709 - Display, Rec.1886 Rec.709 - Display] +active_views: [ACES 1.0 - SDR Video, Un-tone-mapped, Colorimetry, Raw] +inactive_colorspaces: [CIE XYZ-D65 - Display-referred, CIE XYZ-D65 - Scene-referred, linear, rec709_tx] + +looks: + - ! + name: ACES 1.3 Reference Gamut Compression + process_space: ACES2065-1 + transform: ! {style: ACES-LMT - ACES 1.3 Reference Gamut Compression} + + +default_view_transform: Un-tone-mapped + +view_transforms: + - ! + name: ACES 1.0 - SDR Video + from_scene_reference: ! {style: ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0} + + - ! + name: Un-tone-mapped + from_scene_reference: ! {style: UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD} + +display_colorspaces: + - ! + name: CIE XYZ-D65 - Display-referred + aliases: [cie_xyz_d65_display, lin_ciexyzd65_display] + encoding: display-linear + + - ! + name: sRGB (~2.22) - Display + aliases: [srgb_display, srgb_rec709_display, sRGB - Display] + categories: [file-io] + encoding: sdr-video + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_sRGB} + + - ! + name: Gamma 2.2 Rec.709 - Display + aliases: [g22_rec709_display] + categories: [file-io] + encoding: sdr-video + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_G2.2-REC.709} + + - ! + name: Rec.1886 Rec.709 - Display + aliases: [rec1886_rec709_display, g24_rec709_display] + categories: [file-io] + encoding: sdr-video + from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709} + + +colorspaces: + - ! + name: ACES2065-1 + aliases: [aces2065_1, aces, ACES - ACES2065-1, lin_ap0, lin_ap0_scene] + categories: [file-io, texture] + encoding: scene-linear + + - ! + name: ACEScct + aliases: [ACES - ACEScct, acescct_ap1] + categories: [file-io, working-space] + encoding: log + to_scene_reference: ! {style: ACEScct_to_ACES2065-1} + + - ! + name: ACEScg + aliases: [ACES - ACEScg, lin_ap1, lin_ap1_scene] + categories: [file-io, working-space, texture] + encoding: scene-linear + to_scene_reference: ! {style: ACEScg_to_ACES2065-1} + + - ! + name: sRGB Encoded Rec.709 (sRGB) + aliases: [srgb_encoded_rec709_srgb, Utility - sRGB - Texture, srgb_texture, srgb_rec709_scene, Input - Generic - sRGB - Texture, sRGB - Texture, srgb_tx, sRGB] + categories: [file-io, texture] + encoding: sdr-video + from_scene_reference: ! + name: AP0 to sRGB Encoded Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + - ! {gamma: 2.4, offset: 0.055, direction: inverse} + + - ! + name: Rec.709 (~1.95) Encoded Rec.709 (sRGB) + aliases: [rec709_encoded_rec709_srgb, rec709_tx, rec709_rec709, Rec.709] + categories: [file-io, texture] + encoding: sdr-video + from_scene_reference: ! + name: AP0 to Rec.709-Camera (~1.95) Encoded Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + - ! {gamma: 2.22222222222222, offset: 0.099, direction: inverse} + + - ! + name: Gamma 1.8 Encoded Rec.709 (sRGB) + aliases: [gamma18_encoded_rec709_srgb, gamma18_tx, gamma18_rec709, g18_rec709] + categories: [file-io, texture] + encoding: sdr-video + from_scene_reference: ! + name: AP0 to Gamma 1.8 Encoded Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + - ! {value: 1.8, style: pass_thru, direction: inverse} + + - ! + name: Gamma 2.2 Encoded Rec.709 (sRGB) + aliases: [gamma22_encoded_rec709_srgb, gamma22_tx, gamma22_rec709, g22_rec709] + categories: [file-io, texture] + encoding: sdr-video + from_scene_reference: ! + name: AP0 to Gamma 2.2 Encoded Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + - ! {value: 2.2, style: pass_thru, direction: inverse} + + - ! + name: Gamma 2.4 Encoded Rec.709 (sRGB) + aliases: [gamma24_encoded_rec709_srgb, gamma24_tx, gamma24_rec709, g24_rec709] + categories: [file-io, texture] + encoding: sdr-video + from_scene_reference: ! + name: AP0 to Gamma 2.4 Encoded Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + - ! {value: 2.4, style: pass_thru, direction: inverse} + + - ! + name: Gamma 2.6 Encoded Rec.709 (sRGB) + aliases: [gamma26_encoded_rec709_srgb, gamma26_tx, gamma26_rec709, g26_rec709] + categories: [file-io, texture] + encoding: sdr-video + from_scene_reference: ! + name: AP0 to Gamma 2.6 Encoded Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + - ! {value: 2.6, style: pass_thru, direction: inverse} + + - ! + name: Linear Rec.709 (sRGB) + aliases: [lin_rec709_srgb, Utility - Linear - Rec.709, lin_rec709, lin_rec709_scene, lin_srgb, Utility - Linear - sRGB] + categories: [file-io, working-space, texture] + encoding: scene-linear + from_scene_reference: ! + name: AP0 to Linear Rec.709 (sRGB) + children: + - ! {matrix: [2.52168618674388, -1.13413098823972, -0.387555198504164, 0, -0.276479914229922, 1.37271908766826, -0.096239173438334, 0, -0.0153780649660342, -0.152975335867399, 1.16835340083343, 0, 0, 0, 0, 1]} + + - ! + name: CIE XYZ-D65 - Scene-referred + aliases: [cie_xyz_d65_scene, lin_ciexyzd65_scene] + categories: [file-io] + encoding: scene-linear + from_scene_reference: ! + name: AP0 to CIE XYZ-D65 + children: + - ! {matrix: [0.938279849239345, -0.00445144581227847, 0.0166275235564231, 0, 0.337368890823117, 0.729521566676754, -0.066890457499083, 0, 0.00117395084939056, -0.00371070640198378, 1.09159450636463, 0, 0, 0, 0, 1]} + + - ! + name: Raw + aliases: [Utility - Raw, none] + isdata: true + categories: [file-io, texture] + encoding: data + + - ! + name: linear + aliases: [lnf] + description: | + An OIIO-specific alias for the `scene_linear` role. + categories: [file-io] + encoding: scene-linear + from_scene_reference: ! {src: aces_interchange, dst: scene_linear} + + +named_transforms: + - ! + name: sRGB - Curve + aliases: [srgb_crv, Utility - Curve - sRGB, crv_srgb] + encoding: sdr-video + inverse_transform: ! + name: Linear to sRGB + children: + - ! {gamma: 2.4, offset: 0.055, direction: inverse} + + - ! + name: Gamma 1.8 - Curve + aliases: [g18_crv, Utility - Curve - Gamma 1.8, crv_gamma18] + encoding: sdr-video + inverse_transform: ! + name: Linear to Gamma 1.8 + children: + - ! {value: 1.8, style: pass_thru, direction: inverse} + + - ! + name: Gamma 2.2 - Curve + aliases: [g22_crv, Utility - Curve - Gamma 2.2, crv_gamma22] + encoding: sdr-video + inverse_transform: ! + name: Linear to Gamma 2.2 + children: + - ! {value: 2.2, style: pass_thru, direction: inverse} + + - ! + name: Rec.1886 - Curve + aliases: [rec1886_crv, Utility - Curve - Rec.1886, crv_rec1886, crv_gamma24] + encoding: sdr-video + inverse_transform: ! + name: Linear to Rec.1886 + children: + - ! {value: 2.4, style: pass_thru, direction: inverse} + + - ! + name: Gamma 2.6 - Curve + aliases: [g26_crv, Utility - Curve - Gamma 2.6, crv_gamma26] + encoding: sdr-video + inverse_transform: ! + name: Linear to Gamma 2.6 + children: + - ! {value: 2.6, style: pass_thru, direction: inverse} + + - ! + name: Rec.709 - Curve + aliases: [rec709_crv, Utility - Curve - Rec.709, crv_rec709] + encoding: sdr-video + inverse_transform: ! + name: Linear to Rec.709 + children: + - ! {gamma: 2.22222222222222, offset: 0.099, direction: inverse} + + - ! + name: ST-2084 - Curve + aliases: [st_2084_crv] + encoding: hdr-video + inverse_transform: ! {style: CURVE - LINEAR_to_ST-2084} diff --git a/testsuite/colorspacesearch/src/search_core.ocio b/testsuite/colorspacesearch/src/search_core.ocio new file mode 100644 index 0000000000..4cf8b54dc4 --- /dev/null +++ b/testsuite/colorspacesearch/src/search_core.ocio @@ -0,0 +1,31 @@ +ocio_profile_version: 2.1 +roles: + aces_interchange: active_simple + default: active_simple + scene_linear: active_simple +file_rules: + - ! {name: Default, colorspace: active_simple} +displays: + disp: + - ! {name: main, colorspace: active_simple} +inactive_colorspaces: [inactive_simple] +colorspaces: + - ! + name: active_simple + encoding: scene-linear + - ! + name: "acme:special" + encoding: scene-linear + - ! + name: inactive_simple + encoding: scene-linear + - ! + name: unknown_encoding + from_scene_reference: ! {matrix: [0.73, 0.02, 0.01, 0, 0.01, 0.91, 0.03, 0, 0.04, 0.02, 1.17, 0, 0, 0, 0, 1]} + - ! + name: data_space + isdata: true +display_colorspaces: + - ! + name: display_simple + encoding: display-linear diff --git a/testsuite/colorspacesearch/src/search_escapes.ocio b/testsuite/colorspacesearch/src/search_escapes.ocio new file mode 100644 index 0000000000..f054e2b3b4 --- /dev/null +++ b/testsuite/colorspacesearch/src/search_escapes.ocio @@ -0,0 +1,22 @@ +ocio_profile_version: 2.1 +roles: + default: foo + scene_linear: foo +file_rules: + - ! {name: Default, colorspace: foo} +colorspaces: + - ! + name: foo + encoding: scene-linear + - ! + name: "-foo" + encoding: display-linear + - ! + name: "~foo" + encoding: log + - ! + name: '\foo' + encoding: hdr-video + - ! + name: all_encoding + encoding: all diff --git a/testsuite/colorspacesearch/src/search_twin.ocio b/testsuite/colorspacesearch/src/search_twin.ocio new file mode 100644 index 0000000000..634bc31c3e --- /dev/null +++ b/testsuite/colorspacesearch/src/search_twin.ocio @@ -0,0 +1,20 @@ +ocio_profile_version: 2.3 +roles: + aces_interchange: reference + default: reference + scene_linear: reference +file_rules: + - ! {name: Default, colorspace: reference} +colorspaces: + - ! + name: reference + encoding: scene-linear + - ! + name: theatrical_output + encoding: sdr-video + interop_id: g26_p3d65_display + from_scene_reference: ! {value: 2.6, style: mirror, direction: inverse} + - ! + name: plain_video + encoding: sdr-video + from_scene_reference: ! {value: 2.2, style: mirror, direction: inverse} diff --git a/testsuite/oiiotool-colorpolicy-config/ref/out-nocicp.txt b/testsuite/oiiotool-colorpolicy-config/ref/out-nocicp.txt new file mode 100644 index 0000000000..b0b4fb6ce0 --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/ref/out-nocicp.txt @@ -0,0 +1,12 @@ +plain: srgb_rec709_scene +decl: srgb_rec709_scene +override: srgb_rec709_scene +matched: srgb_rec709_scene +matched-vs-global: srgb_rec709_scene + cicp write explicit metadata 1/13/0/1 + cicp suppress config declared - +w_plain CICP chunk: +w_wdecl CICP chunk (suppressed by config): + interop_id derive builtin default unknown +w_unknown colorInteropID (marker collapsed to bare token): + colorInteropID: "unknown" diff --git a/testsuite/oiiotool-colorpolicy-config/ref/out.txt b/testsuite/oiiotool-colorpolicy-config/ref/out.txt new file mode 100644 index 0000000000..198e4c3009 --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/ref/out.txt @@ -0,0 +1,13 @@ +plain: srgb_rec709_display +decl: srgb_rec709_scene +override: srgb_rec709_display +matched: srgb_rec709_scene +matched-vs-global: srgb_rec709_scene + cicp write explicit metadata 1/13/0/1 + cicp suppress config declared - +w_plain CICP chunk: + CICP: 1, 13, 0, 1 +w_wdecl CICP chunk (suppressed by config): + interop_id derive builtin default unknown +w_unknown colorInteropID (marker collapsed to bare token): + colorInteropID: "unknown" diff --git a/testsuite/oiiotool-colorpolicy-config/run.py b/testsuite/oiiotool-colorpolicy-config/run.py new file mode 100644 index 0000000000..d8218cf923 --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/run.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Spec 09 -- the ambient OCIO config drives OIIO's read color-metadata policy. +# A config author declares policy inside the config via `oiio:` FileRule custom +# keys (OCIO round-trips them byte-stably; other apps ignore them). When such a +# config is the ambient/current one ($OCIO), OIIO's readers honor the declared +# policy on REAL reads -- no OIIO attribute set. A config that declares nothing +# behaves exactly as before (no-surprise). An explicit global attribute still +# overrides the config-declared key (precedence layer 4 > 2). +# +# Proof vehicle: a CICP (1,13,0,1) tuple is state-ambiguous. Its default +# resolution is display-referred; a config declaring read:cicp_state=scene +# flips the SAME file to the scene-referred twin, purely from the config. + +redirect = " >> out.txt 2>&1 " + +plaincfg = "src/plain.ocio" +declcfg = "src/decl.ocio" + + +def read_cs(cfg, label, extra=""): + # Read cicp.png under the given ambient OCIO config and echo the resolved + # color space. `env OCIO=` switches the ambient config for just this + # invocation (the whole test is one shell script, so per-call env beats + # mutating os.environ, which would collapse to its last value). + return ("env OCIO=" + cfg + " " + + oiiotool(extra + "cicp.png -echo \"" + label + + ": {TOP.'oiio:ColorSpace'}\"")) + + +# Build a CICP (1,13,0,1)-tagged PNG once (ambient config irrelevant here). +command += oiiotool("--create 4x4 3 '--attrib:type=int[4]' CICP 1,13,0,1 " + "-o cicp.png") + +# (1) No declared policy -> ambiguous CICP resolves display-referred (builtin +# default). Nothing in the config changed OIIO's behavior. +command += read_cs(plaincfg, "plain") + +# (2) The config's oiio:default rule declares read:cicp_state=scene -> the SAME +# file resolves scene-referred. The flip comes purely from the ambient config; +# no OIIO attribute is set. +command += read_cs(declcfg, "decl") + +# (3) Precedence: an explicit global attribute (layer 4) overrides the +# config-declared default key (layer 2) -> back to display, under the decl +# config (whose oiio:default declares scene). +command += read_cs(declcfg, "override", + "--oiioattrib oiio:colorpolicy:read:cicp_state display ") + +# (4) Layer 5 (matched file-rule per-file opinion) outranks layer 2: layer5.ocio +# declares the oiio:default baseline as display, but a rule matching *.png as +# scene. Reading a .png resolves scene -- the more specific rule wins. +layer5cfg = "src/layer5.ocio" +command += read_cs(layer5cfg, "matched") + +# (5) The documented CSS-specificity footgun: layer 5 beats layer 4. Even with +# an explicit global attribute set to display, the matched .png rule (scene) +# still wins. +command += read_cs(layer5cfg, "matched-vs-global", + "--oiioattrib oiio:colorpolicy:read:cicp_state display ") + + +# --- Write side (spec 09): the ambient config drives write policy too. --- +wdeclcfg = "src/wdecl.ocio" + +# An image carrying an explicit CICP tuple. A PNG write emits that tuple by +# default; a config declaring `write:cicp never` suppresses it. +mk = ("--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace srgb_rec709_display " + "'--attrib:type=int[4]' CICP 1,13,0,1 ") + +# (6) --colorwriteplan under the plain config: the cicp tuple is written, +# attributed to the explicit metadata. Under wdecl: suppressed, attributed to +# the config-declared tier -- with NO OIIO attribute set. (Grep to the cicp row +# so the reference is stable.) +command += ("env OCIO=" + plaincfg + " " + oiio_app("oiiotool") + " " + mk + + "--colorwriteplan png | grep '^ cicp' " + redirect + " ;\n") +command += ("env OCIO=" + wdeclcfg + " " + oiio_app("oiiotool") + " " + mk + + "--colorwriteplan png | grep '^ cicp' " + redirect + " ;\n") + +# (7) Actual writes: emit real PNGs, then read the CICP chunk back. Under the +# plain config the cICP chunk is present in the file; under wdecl it was +# suppressed at write time and is absent. `grep CICP` prints the line when +# present and nothing when absent (|| true so a no-match is not a failure); +# echo labels each so the reference is self-describing. +command += ("env OCIO=" + plaincfg + " " + oiio_app("oiiotool") + " " + mk + + "-o w_plain.png " + redirect + " ;\n") +command += ("env OCIO=" + wdeclcfg + " " + oiio_app("oiiotool") + " " + mk + + "-o w_wdecl.png " + redirect + " ;\n") +command += ("echo 'w_plain CICP chunk:' " + redirect + " ;\n") +command += ("( env OCIO=" + plaincfg + " " + oiio_app("oiiotool") + + " --info -v w_plain.png 2>&1 | grep CICP || true )" + redirect + + " ;\n") +command += ("echo 'w_wdecl CICP chunk (suppressed by config):' " + redirect + + " ;\n") +command += ("( env OCIO=" + plaincfg + " " + oiio_app("oiiotool") + + " --info -v w_wdecl.png 2>&1 | grep CICP || true )" + redirect + + " ;\n") + + +# --- Writer boundary for the unknown-marker family (ADR-0020 Amendment 2) --- +# A config that DECLARES unknownness (a space named `unknown`) makes OIIO +# derive its internal "ocio:unknown" marker. That marker is OIIO's private +# taxonomy -- it carries the *why* -- and `ocio:` is a namespace the Color +# Interop Forum reserves to the OpenColorIO project, so it must not reach a +# file. On the way out it collapses to the Forum's registered bare `unknown` +# utility id: the FACT that the space is unknown persists, only OIIO's private +# reason for it is dropped. The plan row shows `derive unknown` (not +# `ocio:unknown`) and the written EXR carries the bare token. +unkcfg = "src/unknowndecl.ocio" +umk = ("--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace unknown ") + +command += ("env OCIO=" + unkcfg + " " + oiio_app("oiiotool") + " " + umk + + "--colorwriteplan exr | grep '^ interop_id' " + redirect + " ;\n") +command += ("env OCIO=" + unkcfg + " " + oiio_app("oiiotool") + " " + umk + + "-o w_unknown.exr " + redirect + " ;\n") +command += ("echo 'w_unknown colorInteropID (marker collapsed to bare token):' " + + redirect + " ;\n") +command += ("( env OCIO=" + unkcfg + " " + oiio_app("oiiotool") + + " --info -v w_unknown.exr 2>&1 | grep colorInteropID || true )" + + redirect + " ;\n") + +outputs = ["out.txt"] diff --git a/testsuite/oiiotool-colorpolicy-config/src/decl.ocio b/testsuite/oiiotool-colorpolicy-config/src/decl.ocio new file mode 100644 index 0000000000..d95b1f0567 --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/src/decl.ocio @@ -0,0 +1,19 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:read:cicp_state: scene}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorpolicy-config/src/layer5.ocio b/testsuite/oiiotool-colorpolicy-config/src/layer5.ocio new file mode 100644 index 0000000000..574ec8b7fd --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/src/layer5.ocio @@ -0,0 +1,20 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:read:cicp_state: display}} + - ! {name: pngscene, colorspace: raw_data, pattern: "*", extension: "png", custom: {oiio:colorpolicy:read:cicp_state: scene}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorpolicy-config/src/plain.ocio b/testsuite/oiiotool-colorpolicy-config/src/plain.ocio new file mode 100644 index 0000000000..e6d25dfad4 --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/src/plain.ocio @@ -0,0 +1,18 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorpolicy-config/src/unknowndecl.ocio b/testsuite/oiiotool-colorpolicy-config/src/unknowndecl.ocio new file mode 100644 index 0000000000..4d388bea38 --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/src/unknowndecl.ocio @@ -0,0 +1,14 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: Default, colorspace: raw_data} +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + # The config's own declaration that this data's color space is unknown. + # OIIO derives the internal "ocio:unknown" marker from it (ADR-0020). + - ! {name: unknown} diff --git a/testsuite/oiiotool-colorpolicy-config/src/wdecl.ocio b/testsuite/oiiotool-colorpolicy-config/src/wdecl.ocio new file mode 100644 index 0000000000..8f64fb1f5f --- /dev/null +++ b/testsuite/oiiotool-colorpolicy-config/src/wdecl.ocio @@ -0,0 +1,19 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:write:cicp: never}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorprofile/ref/out-nocicp.txt b/testsuite/oiiotool-colorprofile/ref/out-nocicp.txt new file mode 100644 index 0000000000..d9c49a74d8 --- /dev/null +++ b/testsuite/oiiotool-colorprofile/ref/out-nocicp.txt @@ -0,0 +1 @@ +=== SKIPPED: libpng lacks cICP support (needs 1.6.46) -- this test observes profile selection through a cICP-tagged PNG === diff --git a/testsuite/oiiotool-colorprofile/ref/out.txt b/testsuite/oiiotool-colorprofile/ref/out.txt new file mode 100644 index 0000000000..f3a0d21547 --- /dev/null +++ b/testsuite/oiiotool-colorprofile/ref/out.txt @@ -0,0 +1,12 @@ +=== baseline: no selection -> display === + srgb_rec709_display +=== env selects profile -> scene === + srgb_rec709_scene +=== profile then -key drops the key -> display === + srgb_rec709_display +=== +key=value sets a key directly -> scene === + srgb_rec709_scene +=== attribute subtracts env-added profile -> display === + srgb_rec709_display +=== attribute alone selects profile -> scene === + srgb_rec709_scene diff --git a/testsuite/oiiotool-colorprofile/run.py b/testsuite/oiiotool-colorprofile/run.py new file mode 100644 index 0000000000..c47a591fc1 --- /dev/null +++ b/testsuite/oiiotool-colorprofile/run.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Spec 09 Feature 3 -- composable +/- layer-3 profile selection. Active profiles +# are selected from TWO entry points that compose: the env var +# OPENIMAGEIO_COLORPOLICY (the base) and the global attribute +# oiio:colorpolicy:profile (composed on top, so it can subtract what the env var +# added). Each entry is [+|-], where is a whole profile name +# (a config rule name, e.g. oiio:blender:textures) or a single policy key +# (read:cicp_state[=value]). +# +# Proof vehicle (as in oiiotool-colorpolicy-config): a state-ambiguous CICP +# (1,13,0,1) tuple resolves display-referred by default; the profile +# oiio:blender:textures declares read:cicp_state=scene, which flips the SAME +# file to the scene-referred twin. So the resolved color space reports which +# policy keys are active after selection. +# +# The harness splits `command` on ';' and runs each fragment through its own +# shell, so note text carries no ';' and no apostrophe. + +redirect = " >> out.txt 2>&1 " +ot = oiio_app("oiiotool") +cfg = "src/profiles.ocio" + + +def show(label, env="", attr=""): + # Read cicp.png under the profiles config with an optional env-var selection + # and/or an oiio:colorpolicy:profile attribute selection, and echo the + # resolved color space. + line = "echo '=== " + label + " ==='" + redirect + " ;\n" + prefix = "env OCIO=" + cfg + " " + if env: + prefix += "OPENIMAGEIO_COLORPOLICY='" + env + "' " + attrarg = "" + if attr: + attrarg = "--oiioattrib oiio:colorpolicy:profile '" + attr + "' " + line += (prefix + ot + " " + attrarg + "cicp.png -echo " + + "\" {TOP.'oiio:ColorSpace'}\"" + redirect + " ;\n") + return line + + +# This whole test observes profile selection THROUGH a cICP-tagged PNG: it +# writes cicp.png, reads it back under various selections, and echoes the +# resolved color space. That only discriminates if the PNG actually carries +# the cICP chunk, which needs libpng >= 1.6.46. +# +# Below that, png_set_cICP compiles out, the chunk is never written, and the +# reader falls back to its fixed sRGB-scene assumption -- so ALL SIX cases +# echo "srgb_rec709_scene" and the test silently stops discriminating +# anything. Asserting that collapsed output against a variant reference would +# be worse than failing: a green test that verifies nothing. So skip loudly +# instead, and say why in the output. +if not png_has_cicp : + # Marker text is deliberately version-free so one reference matches every + # too-old libpng. + command += ("echo '=== SKIPPED: libpng lacks cICP support (needs 1.6.46)" + " -- this test observes profile selection through a" + " cICP-tagged PNG ==='" + redirect + " ;\n") + outputs = ["out.txt"] +else : + + # Build the CICP-tagged PNG once (ambient config irrelevant here). + command += oiiotool("--create 4x4 3 '--attrib:type=int[4]' CICP 1,13,0,1 " + "-o cicp.png") + + command += show("baseline: no selection -> display") + command += show("env selects profile -> scene", + env="oiio:blender:textures") + command += show("profile then -key drops the key -> display", + env="oiio:blender:textures,-read:cicp_state") + command += show("+key=value sets a key directly -> scene", + env="+read:cicp_state=scene") + command += show("attribute subtracts env-added profile -> display", + env="oiio:blender:textures", attr="-oiio:blender:textures") + command += show("attribute alone selects profile -> scene", + attr="oiio:blender:textures") + + outputs = ["out.txt"] diff --git a/testsuite/oiiotool-colorprofile/src/profiles.ocio b/testsuite/oiiotool-colorprofile/src/profiles.ocio new file mode 100644 index 0000000000..220c7815ab --- /dev/null +++ b/testsuite/oiiotool-colorprofile/src/profiles.ocio @@ -0,0 +1,16 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: {default: raw_data, scene_linear: lin_ap1_scene} +file_rules: + - ! {name: oiio:default, colorspace: raw_data, regex: "$^"} + - ! {name: oiio:blender:textures, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:read:cicp_state: scene}} + - ! {name: Default, colorspace: raw_data} +displays: {disp: [! {name: view, colorspace: srgb_rec709_display}]} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorroundtrip/ref/out-nocicp.txt b/testsuite/oiiotool-colorroundtrip/ref/out-nocicp.txt new file mode 100644 index 0000000000..d9da1ee620 --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/ref/out-nocicp.txt @@ -0,0 +1,28 @@ +=== EXR: colorInteropID preserved -- CICP stripped === + colorInteropID: "srgb_rec709_display" + oiio:ColorSpace: "srgb_rec709_display" +=== PNG: round-trips as cICP chunk -- not colorInteropID === + oiio:ColorSpace: "srgb_rec709_scene" +=== TIF: color identity dropped -- reads back untagged === +=== JPG: reader fixed sRGB assumption -- srgb_rec709_scene === + oiio:ColorSpace: "srgb_rec709_scene" +=== TIF forced: config-declared force -- colorInteropID carried === + colorInteropID: "srgb_rec709_display" +=== JPG forced: config-declared force -- colorInteropID carried === + colorInteropID: "srgb_rec709_display" +=== authored id at the writer boundary (default policy) === +leak.png: colorInteropID in file bytes = False +leak.fits: colorInteropID in file bytes = False +leak.tif: colorInteropID in file bytes = False +leak.jpg: colorInteropID in file bytes = False +leak.exr: colorInteropID in file bytes = True +=== g26 default: canonicalized to g26_xyzd65_display on write === + colorInteropID: "g26_xyzd65_display" +=== g26 default: pixels CONVERTED not relabeled (differ from baseline) === +FAILURE +=== broadcast: P3 -> Rec.2020 signal + narrow range (PNG cICP readback) === +=== broadcast: write plan for png -- Rec.2020 cICP + P3 MDCV volume === + cicp derive builtin default 9/17/0/0 + mdcv derive builtin default 0.68,0.32,0.265,0.69,0.15,0.06,0.3127,0.329 +=== read-only: space declaring dcdm_p3d65 must NOT emit it on write === + (no dcdm_p3d65 emitted) diff --git a/testsuite/oiiotool-colorroundtrip/ref/out.txt b/testsuite/oiiotool-colorroundtrip/ref/out.txt new file mode 100644 index 0000000000..7340fc76f3 --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/ref/out.txt @@ -0,0 +1,41 @@ +=== EXR: colorInteropID preserved -- CICP stripped === + colorInteropID: "srgb_rec709_display" + oiio:ColorSpace: "srgb_rec709_display" +=== PNG: round-trips as cICP chunk -- not colorInteropID === + CICP: 1, 13, 0, 1 + oiio:ColorSpace: "srgb_rec709_display" +=== TIF: color identity dropped -- reads back untagged === +=== JPG: reader fixed sRGB assumption -- srgb_rec709_scene === + oiio:ColorSpace: "srgb_rec709_scene" +=== TIF forced: config-declared force -- colorInteropID carried === + colorInteropID: "srgb_rec709_display" +=== JPG forced: config-declared force -- colorInteropID carried === + colorInteropID: "srgb_rec709_display" +=== authored id at the writer boundary (default policy) === +leak.png: colorInteropID in file bytes = False +leak.fits: colorInteropID in file bytes = False +leak.tif: colorInteropID in file bytes = False +leak.jpg: colorInteropID in file bytes = False +leak.exr: colorInteropID in file bytes = True +=== g26 default: canonicalized to g26_xyzd65_display on write === + colorInteropID: "g26_xyzd65_display" +=== g26 default: pixels CONVERTED not relabeled (differ from baseline) === +FAILURE +=== broadcast: P3 -> Rec.2020 signal + narrow range (PNG cICP readback) === + CICP: 9, 17, 0, 0 +=== broadcast: write plan for png -- Rec.2020 cICP + P3 MDCV volume === + cicp derive builtin default 9/17/0/0 + mdcv derive builtin default 0.68,0.32,0.265,0.69,0.15,0.06,0.3127,0.329 +=== broadcast: PNG mDCV chunk round-trip (ST 2086 P3-D65 volume, xy x50000 / luminance x10000) === + mdcv_blue_x: 7500 + mdcv_blue_y: 3000 + mdcv_green_x: 13250 + mdcv_green_y: 34500 + mdcv_max_luminance: 10000000 + mdcv_min_luminance: 1 + mdcv_red_x: 34000 + mdcv_red_y: 16000 + mdcv_white_x: 15635 + mdcv_white_y: 16450 +=== read-only: space declaring dcdm_p3d65 must NOT emit it on write === + (no dcdm_p3d65 emitted) diff --git a/testsuite/oiiotool-colorroundtrip/run.py b/testsuite/oiiotool-colorroundtrip/run.py new file mode 100644 index 0000000000..407e1ee3b5 --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/run.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Spec 09 -- per-format color-identity round-trip contract. Not every format +# round-trips color identity 1:1, but each format's behavior is PREDICTABLE and +# LOCKED here. We tag ONE source image (color space srgb_rec709_display, plus an +# explicit CICP tuple), write it to each format, read it back, and assert the +# DOCUMENTED contract for that format -- not byte-identity. +# +# EXR -- carries colorInteropID (native slot). CICP is never written, even +# when the source has an explicit tuple (EXR has no CICP convention, +# so it is stripped). +# PNG -- carries the display identity as a cICP chunk (H.273 tuple). It +# round-trips as CICP, resolving back to the display space -- NOT as +# colorInteropID. +# TIFF -- carries NO color identity in this build (no native colorInteropID or +# CICP slot wired): a round-tripped TIFF reads back untagged. Lossy but +# predictable. +# JPEG -- carries no color identity either. The JPEG reader fixed sRGB +# assumption resolves every JPEG to srgb_rec709_scene regardless of the +# source tag. Predictable, not a preserved identity. +# +# The ambient config is pinned to src/plain.ocio so the resolved names are +# deterministic (it defines srgb_rec709_display / _scene, lin_ap1_scene). +# +# NOTE: the test harness splits `command` on ';' and runs each fragment through +# its own shell, so note text below carries no ';' and no apostrophe. + +redirect = " >> out.txt 2>&1 " +ot = oiio_app("oiiotool") +cfg = "src/plain.ocio" + +# One tagged source: a display identity plus an explicit CICP (1,13,0,1) tuple. +# The CICP is here to prove EXR strips it; PNG derives its own tuple regardless. +mksrc = ("--create 8x8 3 --attrib oiio:ColorSpace srgb_rec709_display " + "'--attrib:type=int[4]' CICP 1,13,0,1 ") + + +def section(fmt, note): + out = "rt." + fmt + lines = "" + # Header (self-describing contract). + lines += "echo '=== " + fmt.upper() + ": " + note + " ==='" + redirect + " ;\n" + # Write the tagged source to this format (muted -- the write itself is noise). + lines += ("env OCIO=" + cfg + " " + ot + " " + mksrc + "-o " + out + + " >/dev/null 2>&1 ;\n") + # Read back and capture ONLY the color-identity lines (grepped, so the + # varying build hash / timestamp of --info -v never reach the reference). + # `|| true` so a format that carries nothing (TIFF) is not a shell failure. + lines += ("( env OCIO=" + cfg + " " + ot + " --info -v " + out + + " 2>/dev/null | grep -E " + + "'colorInteropID|oiio:ColorSpace|^ CICP:' || true )" + + redirect + " ;\n") + return lines + + +command += section("exr", "colorInteropID preserved -- CICP stripped") +command += section("png", "round-trips as cICP chunk -- not colorInteropID") +command += section("tif", "color identity dropped -- reads back untagged") +command += section("jpg", "reader fixed sRGB assumption -- srgb_rec709_scene") + + +# Spec 09 Feature 1: force_interop_id. The same slotless formats (TIFF, JPEG) +# now DO carry colorInteropID when the CONFIG declares +# oiio:colorpolicy:write:force_interop_id -- with NO attribute set (the policy +# is config-declared, layer 2). The forced id is emitted as an aux string +# attribute that round-trips through XMP and reads back. Contrast with the TIF +# section above (same source, plain config -> untagged). +forcecfg = "src/force_interop.ocio" + + +def forced_section(fmt, note): + out = "force." + fmt + lines = "" + lines += "echo '=== " + fmt.upper() + " forced: " + note + " ==='" + redirect + " ;\n" + lines += ("env OCIO=" + forcecfg + " " + ot + " " + mksrc + "-o " + out + + " >/dev/null 2>&1 ;\n") + lines += ("( env OCIO=" + forcecfg + " " + ot + " --info -v " + out + + " 2>/dev/null | grep -E 'colorInteropID' || true )" + + redirect + " ;\n") + return lines + + +command += forced_section("tif", "config-declared force -- colorInteropID carried") +command += forced_section("jpg", "config-declared force -- colorInteropID carried") + + +# Spec 09 Feature 1, the OTHER direction: the DEFAULT contract. A slotless +# format carries no transport identity, so an AUTHORED colorInteropID must be +# stripped at the writer boundary rather than falling through to the format's +# generic attribute emission. Regression for the leak where the policy verdict +# was computed and then silently not applied because the writer was never +# wired to pvt::apply_forced_interop_id (PNG tEXt, FITS header card, JPEG XL +# XMP aux:ColorInteropID -- TIFF and JPEG were wired, the rest were not). +# +# Checked against the file's RAW BYTES, not a read-back: PNG's reader does not +# map its tEXt colorInteropID key back to an attribute, so a round-trip +# assertion passes while the file still carries the id. EXR is the positive +# control -- it HAS a native slot, so it must keep the id. +leaksrc = ("--create 8x8 3 --attrib oiio:ColorSpace srgb_rec709_display " + "--attrib colorInteropID my-studio:secret_space ") +checker = "src/check_interop_leak.py" + +command += "echo '=== authored id at the writer boundary (default policy) ==='" + redirect + " ;\n" +for fmt in ("png", "fits", "tif", "jpg", "exr"): + command += ("env OCIO=" + cfg + " " + ot + " " + leaksrc + "-o leak." + fmt + + " >/dev/null 2>&1 ;\n") +# One checker invocation over all of them, so the reference block stays compact. +command += (pythonbin + " " + checker + " my-studio:secret_space " + + " ".join("leak." + f for f in ("png", "fits", "tif", "jpg", "exr")) + + redirect + " ;\n") + + +# Spec 09 Feature B (reconciler write-shape): write-canonical CONVERSION in +# oiio:default. The config's oiio:default profile declares +# oiio:colorpolicy:write:canonicalize, so a g26_p3d65_display source is CONVERTED +# (not merely relabeled) to g26_xyzd65_display on write -- a real P3->XYZ +# primaries + DCI white headroom pixel conversion (the XYZ DCDM form, gamma 2.6 + +# 48/52.37 white scaling, alias dcdm_xyzd65), applied through OIIO's embedded +# interop registry. No attribute is set anywhere -- the mapping is config-declared +# (layer 2). We prove BOTH halves: (a) EXR carries the canonical id in its native +# slot; (b) the written pixels actually changed vs a no-conversion baseline (same +# source under the plain config, which declares no canonicalize) -- a +# metadata-only relabel would leave the pixels identical. +bcfg = "src/broadcast.ocio" +g26src = ("--pattern constant:color=0.5,0.5,0.5 8x8 3 " + "--attrib oiio:ColorSpace g26_p3d65_display ") + +# (a) tag proof. +command += "echo '=== g26 default: canonicalized to g26_xyzd65_display on write ==='" + redirect + " ;\n" +command += ("env OCIO=" + bcfg + " " + ot + " " + g26src + "-o g26def.exr" + + " >/dev/null 2>&1 ;\n") +command += ("( env OCIO=" + bcfg + " " + ot + " --info -v g26def.exr" + + " 2>/dev/null | grep -E 'colorInteropID' || true )" + redirect + " ;\n") + +# (b) pixel proof: the canonicalized pixels DIFFER from the un-converted baseline +# (same source written under the plain config). --diff prints FAILURE when the +# images differ -> the conversion really touched the pixels. (A metadata-only +# relabel would print PASS.) +command += "echo '=== g26 default: pixels CONVERTED not relabeled (differ from baseline) ==='" + redirect + " ;\n" +command += ("env OCIO=" + cfg + " " + ot + " " + g26src + "-o g26plain.exr" + + " >/dev/null 2>&1 ;\n") +command += ("( env OCIO=" + cfg + " " + ot + " g26def.exr g26plain.exr --diff" + + " 2>/dev/null | grep -E 'PASS|FAILURE' || true )" + redirect + " ;\n") + + +# Spec 09 Feature A: the oiio:broadcast profile. Selecting it (env-var layer 3) +# routes P3 display content into the broadcast delivery container -- Rec.2020 +# encoding primaries SIGNALED (CICP primaries code 9), narrow (limited) range +# (video_full_range_flag 0), and the true P3(D65) gamut carried in the MDCV +# mastering-display volume (not re-gamut'd). This supersedes the oiio:default +# canonicalize mapping for P3 content. PNG carries the tuple as a cICP chunk, so +# the primaries + range read back; the MDCV volume is shown via --colorwriteplan +# (no format in this build carries mDCV to a file yet). +bcast = "env OCIO=" + bcfg + " OPENIMAGEIO_COLORPOLICY=oiio:broadcast " + +command += "echo '=== broadcast: P3 -> Rec.2020 signal + narrow range (PNG cICP readback) ==='" + redirect + " ;\n" +command += (bcast + ot + " " + g26src + "-o bcast.png >/dev/null 2>&1 ;\n") +command += ("( env OCIO=" + bcfg + " " + ot + " --info -v bcast.png" + + " 2>/dev/null | grep -E '^ CICP:' || true )" + redirect + " ;\n") + +command += "echo '=== broadcast: write plan for png -- Rec.2020 cICP + P3 MDCV volume ==='" + redirect + " ;\n" +command += (bcast + ot + " " + g26src + "--colorwriteplan png" + + " 2>/dev/null | grep -E 'cicp|mdcv'" + redirect + " ;\n") + + +# oicio spec 34 + RFC 0006: the derived P3(D65) mastering-display volume now +# lands in the PNG file itself, as an SMPTE ST 2086 mDCV chunk, and reads back. +# The written bcast.png above carries it. We adopt oicio's EXACT wire keys +# ("Wire metadata keys", spec 34 lines 84-89): mdcv_{red,green,blue,white}_{x,y} +# = chromaticity xy scaled x50000, mdcv_max/min_luminance = cd/m2 x10000 (min +# floored at 1). The mDCV chunk needs libpng >= 1.6.50 (PNG_mDCV_SUPPORTED); +# on older libpng the writer/reader silently no-op, so this section is gated on +# the build's libpng version and skipped (not failed) when unsupported. +import re +libdeps = subprocess.check_output( + [oiio_app('oiiotool').strip(), '--echo', + '{getattribute(build:dependencies)}']).decode('utf-8') +_m = re.search(r'[Pp][Nn][Gg][^0-9]*([0-9]+)\.([0-9]+)\.([0-9]+)', libdeps) +png_has_mdcv = bool(_m) and (int(_m.group(1)), int(_m.group(2)), + int(_m.group(3))) >= (1, 6, 50) + +if png_has_mdcv: + command += ("echo '=== broadcast: PNG mDCV chunk round-trip" + " (ST 2086 P3-D65 volume, xy x50000 / luminance x10000) ==='" + + redirect + " ;\n") + command += ("( env OCIO=" + bcfg + " " + ot + " --info -v bcast.png" + + " 2>/dev/null | grep -E 'mdcv_' || true )" + redirect + " ;\n") + +# Spec 09: read-only interop identities are never a write/derive target. +# `mycs` declares interop_id dcdm_p3d65_display (a read-only interpretation id); +# writing from it must emit NO colorInteropID -- the derive path excludes +# read-only ids (is_readonly_interop_id). Its canonical write form would be +# g26_xyzd65_display, but a bare declaration derives nothing here. +rocfg = "src/readonly.ocio" +command += ("echo '=== read-only: space declaring dcdm_p3d65 must NOT emit it" + " on write ==='" + redirect + " ;\n") +command += ("env OCIO=" + rocfg + " " + ot + + " --create 4x4 3 --attrib oiio:ColorSpace mycs -o ro.exr" + " >/dev/null 2>&1 ;\n") +command += ("( env OCIO=" + rocfg + " " + ot + " --info -v ro.exr 2>/dev/null" + " | grep -E 'colorInteropID' || echo ' (no dcdm_p3d65 emitted)' )" + + redirect + " ;\n") + +outputs = ["out.txt"] diff --git a/testsuite/oiiotool-colorroundtrip/src/broadcast.ocio b/testsuite/oiiotool-colorroundtrip/src/broadcast.ocio new file mode 100644 index 0000000000..057b845e75 --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/src/broadcast.ocio @@ -0,0 +1,27 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + # Spec 09 Feature B: oiio:default declares the write-canonical space mapping + # (g26_p3d65_display -> g26_xyzd65_display, the XYZ DCDM form). Spec 09 + # Feature A: oiio:broadcast is a selectable profile that routes P3 content + # into the broadcast container (Rec.2020 signal + P3 MDCV + narrow range), + # superseding the default mapping. Both rules carry policy only (regex $^ so + # they never match a file); no OIIO attribute is set anywhere -- the policy + # comes from the CONFIG. + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:write:canonicalize: "1"}} + - ! {name: oiio:broadcast, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:write:broadcast: "1"}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: g26_p3d65_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: g26_p3d65_display} + - ! {name: g26_xyzd65_display} diff --git a/testsuite/oiiotool-colorroundtrip/src/check_interop_leak.py b/testsuite/oiiotool-colorroundtrip/src/check_interop_leak.py new file mode 100644 index 0000000000..2f7fb2be87 --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/src/check_interop_leak.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Spec 09 Feature 1 -- writer-boundary leak check. +# +# Scans a written file's RAW BYTES for an authored colorInteropID. This is +# deliberately NOT a read-back check: a slotless format can embed the id in a +# container field its own reader never maps back to a `colorInteropID` +# attribute (PNG's tEXt is exactly this case), so a round-trip assertion +# passes while the file still leaks. The policy contract is about what reaches +# the FILE, so the file is what we inspect. +# +# Prints one deterministic line per file: the id token must not appear when +# the format has no native colorInteropID slot and the policy says not to +# force one. + +from __future__ import print_function +import sys + + +def main(argv): + # argv: [ ...] + token = argv[1].encode("utf-8") + for path in argv[2:]: + try: + with open(path, "rb") as f: + data = f.read() + except IOError: + print("{}: MISSING".format(path)) + continue + # Report the token, not the file's whole payload -- keeps the + # reference output stable across libpng/libtiff versions. + found = token in data + print("{}: colorInteropID in file bytes = {}".format(path, found)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/testsuite/oiiotool-colorroundtrip/src/force_interop.ocio b/testsuite/oiiotool-colorroundtrip/src/force_interop.ocio new file mode 100644 index 0000000000..8ddd1ea85b --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/src/force_interop.ocio @@ -0,0 +1,22 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + # Spec 09 Feature 1: the config author declares force_interop_id in the + # reserved oiio:default profile rule (regex $^ so it never matches a file). + # No OIIO attribute is set anywhere -- the policy comes from the CONFIG. + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:write:force_interop_id: "1"}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorroundtrip/src/plain.ocio b/testsuite/oiiotool-colorroundtrip/src/plain.ocio new file mode 100644 index 0000000000..e6d25dfad4 --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/src/plain.ocio @@ -0,0 +1,18 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: srgb_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: srgb_rec709_scene} + - ! {name: srgb_rec709_display} diff --git a/testsuite/oiiotool-colorroundtrip/src/readonly.ocio b/testsuite/oiiotool-colorroundtrip/src/readonly.ocio new file mode 100644 index 0000000000..883389a44a --- /dev/null +++ b/testsuite/oiiotool-colorroundtrip/src/readonly.ocio @@ -0,0 +1,17 @@ +ocio_profile_version: 2 +# A space that DECLARES a read-only interop identity (dcdm_p3d65_display). +# On write, derivation must NEVER emit a read-only id -- dcdm_p3d65_display is a +# read (interpretation) identity only; its canonical write form is +# g26_xyzd65_display. So a file written from `mycs` must carry no +# colorInteropID: dcdm_p3d65_display. +roles: {default: raw_data} +file_rules: + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: v, colorspace: mycs} +active_displays: [disp] +active_views: [v] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: mycs, interop_id: dcdm_p3d65_display} diff --git a/testsuite/oiiotool-colorverbose/ref/out.txt b/testsuite/oiiotool-colorverbose/ref/out.txt new file mode 100644 index 0000000000..40127b9a02 --- /dev/null +++ b/testsuite/oiiotool-colorverbose/ref/out.txt @@ -0,0 +1,25 @@ +=== PLAN PNG minimal: just cICP === + cicp derive builtin default 1/1/1/1 + chromaticities omit format incapable - + gamma omit format incapable - + interop_id omit format incapable - +=== PLAN PNG verbose: cICP + cHRM + gAMA === + cicp derive builtin default 1/1/1/1 + chromaticities derive builtin default 0.64,0.33,0.3,0.6,0.15,0.06,0.3127,0.329 + gamma derive builtin default 2.4 + interop_id omit format incapable - +=== PLAN EXR minimal: just colorInteropID === + cicp omit format incapable - + chromaticities suppress format incapable - + gamma omit format incapable - + interop_id derive builtin default g24_rec709_display +=== PLAN EXR verbose: colorInteropID + chromaticities === + cicp omit format incapable - + chromaticities derive builtin default 0.64,0.33,0.3,0.6,0.15,0.06,0.3127,0.329 + gamma derive builtin default 2.4 + interop_id derive builtin default g24_rec709_display +=== WRITE EXR verbose: colorInteropID + chromaticities read back === + chromaticities: 0.64, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329 + colorInteropID: "g24_rec709_display" +=== WRITE PNG verbose: gAMA reads back as oiio:Gamma === + oiio:Gamma: 2.4 diff --git a/testsuite/oiiotool-colorverbose/run.py b/testsuite/oiiotool-colorverbose/run.py new file mode 100644 index 0000000000..2e054ccb4d --- /dev/null +++ b/testsuite/oiiotool-colorverbose/run.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Spec 09 Feature 2 -- oiio:colorpolicy:write:verbose. A display space that +# minimally emits just a subset (cICP on PNG, colorInteropID on EXR) emits the +# FULL redundant-but-correct set under the verbose policy. The policy is +# CONFIG-declared in src/verbose.ocio's oiio:default profile -- NO OIIO +# attribute is set. The tagged source is g24_rec709_display: a pure-gamma (2.4) +# rec709 display space, so all of cICP + chromaticities + gamma are derivable +# and consistent. +# +# The harness splits `command` on ';' and runs each fragment through its own +# shell, so note text below carries no ';' and no apostrophe. + +redirect = " >> out.txt 2>&1 " +ot = oiio_app("oiiotool") +cfg = "src/verbose.ocio" +mksrc = "--create 8x8 3 --attrib oiio:ColorSpace g24_rec709_display " + + +def plan(fmt, note): + lines = "echo '=== PLAN " + fmt.upper() + " verbose: " + note + " ==='" + redirect + " ;\n" + lines += ("env OCIO=" + cfg + " " + ot + " " + mksrc + "--colorwriteplan " + + fmt + " 2>/dev/null | grep -E " + + "'cicp|chromaticities|gamma|interop_id'" + redirect + " ;\n") + return lines + + +def plan_minimal(fmt, note): + # Same source, NO config -> minimal plan (contrast). Uses the builtin + # default config for name->id derivation (no OCIO env), so cICP/interop_id + # still resolve but the redundant signals stay minimal. + lines = "echo '=== PLAN " + fmt.upper() + " minimal: " + note + " ==='" + redirect + " ;\n" + lines += (ot + " " + mksrc + "--colorwriteplan " + fmt + + " 2>/dev/null | grep -E " + + "'cicp|chromaticities|gamma|interop_id'" + redirect + " ;\n") + return lines + + +def written(fmt, note, grep): + out = "vb." + fmt + lines = "echo '=== WRITE " + fmt.upper() + " verbose: " + note + " ==='" + redirect + " ;\n" + lines += ("env OCIO=" + cfg + " " + ot + " " + mksrc + "-o " + out + + " >/dev/null 2>&1 ;\n") + lines += ("( env OCIO=" + cfg + " " + ot + " --info -v " + out + + " 2>/dev/null | grep -E '" + grep + "' || true )" + + redirect + " ;\n") + return lines + + +# Plan proofs: verbose flips chromaticities+gamma (PNG) and chromaticities (EXR) +# from omit/suppress to derive; the minimal plans keep them out. +command += plan_minimal("png", "just cICP") +command += plan("png", "cICP + cHRM + gAMA") +command += plan_minimal("exr", "just colorInteropID") +command += plan("exr", "colorInteropID + chromaticities") + +# Actual writes: EXR carries colorInteropID + chromaticities that read back; +# PNG's gAMA reads back as oiio:Gamma (the cHRM chunk is emitted too, verified +# by the plan above -- the PNG reader folds it rather than surfacing it). +command += written("exr", "colorInteropID + chromaticities read back", + "colorInteropID|chromaticities") +command += written("png", "gAMA reads back as oiio:Gamma", "oiio:Gamma") + +outputs = ["out.txt"] diff --git a/testsuite/oiiotool-colorverbose/src/verbose.ocio b/testsuite/oiiotool-colorverbose/src/verbose.ocio new file mode 100644 index 0000000000..719f3fea75 --- /dev/null +++ b/testsuite/oiiotool-colorverbose/src/verbose.ocio @@ -0,0 +1,22 @@ +ocio_profile_version: 2 +environment: {} +search_path: "" +roles: + default: raw_data + scene_linear: lin_ap1_scene +file_rules: + # Spec 09 Feature 2: the config author declares verbose/redundant emission in + # the reserved oiio:default profile rule (regex $^ so it never matches a + # file). No OIIO attribute is set anywhere -- the policy comes from the + # CONFIG. + - ! {name: oiio:default, colorspace: raw_data, regex: "$^", custom: {oiio:colorpolicy:write:verbose: "1"}} + - ! {name: Default, colorspace: raw_data} +displays: + disp: + - ! {name: view, colorspace: g24_rec709_display} +active_displays: [disp] +active_views: [view] +colorspaces: + - ! {name: raw_data, isdata: true, aliases: [data]} + - ! {name: lin_ap1_scene} + - ! {name: g24_rec709_display} diff --git a/testsuite/oiiotool-colorwriteplan/ref/out.txt b/testsuite/oiiotool-colorwriteplan/ref/out.txt new file mode 100644 index 0000000000..26d5861ad0 --- /dev/null +++ b/testsuite/oiiotool-colorwriteplan/ref/out.txt @@ -0,0 +1,49 @@ +Color write plan for format "exr": + cicp omit format incapable - + chromaticities omit format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id omit builtin default - + mdcv omit format incapable - +Color write plan for format "png": + cicp omit builtin default - + chromaticities omit format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id omit format incapable - + mdcv omit format incapable - +Color write plan for format "png": + cicp write explicit metadata 1/13/0/1 + chromaticities omit format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id omit format incapable - + mdcv omit format incapable - +Color write plan for format "png": + cicp suppress global attribute - + chromaticities omit format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id omit format incapable - + mdcv omit format incapable - +Color write plan for format "png": + cicp write explicit metadata 1/13/0/1 + chromaticities omit format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id omit format incapable - + mdcv omit format incapable - +Color write plan for format "png": + cicp suppress per-spec attribute - + chromaticities omit format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id omit format incapable - + mdcv omit format incapable - +Color write plan for format "exr": + cicp omit format incapable - + chromaticities suppress format incapable - + gamma omit format incapable - + icc omit format incapable - + interop_id write explicit metadata lin_adobergb_scene + mdcv omit format incapable - diff --git a/testsuite/oiiotool-colorwriteplan/run.py b/testsuite/oiiotool-colorwriteplan/run.py new file mode 100644 index 0000000000..a1b9b03345 --- /dev/null +++ b/testsuite/oiiotool-colorwriteplan/run.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# oiiotool --colorwriteplan: a dry-run preview of the color-metadata write +# plan -- what OIIO would write for the top image to a given format, and +# which layer (builtin default / global attribute / per-spec attribute / +# explicit metadata / format incapability) decided each signal. No file is +# written. Every vector below uses explicit metadata, policy attributes, or +# an unresolvable color space name, so no name->id derivation against an +# ambient OCIO config is exercised and the output is config- and +# OCIO-version-independent. + +redirect = " >> out.txt 2>&1 " + +# (1) Default plan, no usable color metadata: EXR (interop_id capable, +# nothing determinable) and PNG (cicp capable, nothing determinable); +# everything else is format-incapable. +command += oiiotool ("--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace not-a-real-space-xyzzy " + "--colorwriteplan exr " + "--colorwriteplan png") + +# (2) Explicit-CICP passthrough: the author's tuple is written verbatim, +# attributed to the explicit metadata. +command += oiiotool ("--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace not-a-real-space-xyzzy " + "'--attrib:type=int[4]' CICP 1,13,0,1 " + "--colorwriteplan png") + +# (3) A global 'never' suppresses the same explicit tuple, attributed to the +# global attribute tier. +command += oiiotool ("--oiioattrib oiio:colorpolicy:write:cicp never " + "--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace not-a-real-space-xyzzy " + "'--attrib:type=int[4]' CICP 1,13,0,1 " + "--colorwriteplan png") + +# (4) A per-spec hint outranks the global 'never': back to writing, and a +# per-spec 'never' is attributed to the per-spec tier. +command += oiiotool ("--oiioattrib oiio:colorpolicy:write:cicp never " + "--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace not-a-real-space-xyzzy " + "'--attrib:type=int[4]' CICP 1,13,0,1 " + "--attrib oiio:colorpolicy:write:cicp auto " + "--colorwriteplan png " + "--attrib oiio:colorpolicy:write:cicp never " + "--colorwriteplan png") + +# (5) An author-supplied colorInteropID on EXR is written verbatim. +command += oiiotool ("--pattern constant:color=0.5,0.5,0.5 16x16 3 " + "--attrib oiio:ColorSpace not-a-real-space-xyzzy " + "--attrib colorInteropID lin_adobergb_scene " + "--colorwriteplan exr") + +outputs = [ "out.txt" ] diff --git a/testsuite/oiiotool-control/ref/out.txt b/testsuite/oiiotool-control/ref/out.txt index 5ed7d887bd..9be585edb6 100644 --- a/testsuite/oiiotool-control/ref/out.txt +++ b/testsuite/oiiotool-control/ref/out.txt @@ -320,6 +320,8 @@ Meta native: 128 x 96, 3 channel, uint8 tiff XResolution: 72 YResolution: 72 oiio:BitsPerSample: 8 + oiio:SourceFormat: "tiff" + oiio:SourcePath: "../common/tahoe-tiny.tif" tiff:Compression: 5 tiff:PhotometricInterpretation: 2 tiff:PlanarConfiguration: 1 diff --git a/testsuite/png/ref/out-libpng15.txt b/testsuite/png/ref/out-libpng15.txt index 2c46ae17af..5e4127bc9d 100644 --- a/testsuite/png/ref/out-libpng15.txt +++ b/testsuite/png/ref/out-libpng15.txt @@ -41,6 +41,8 @@ alphagamma: Exif:ImageHistory: "oiiotool -no-autopremult SLEEP_MM.png -cut 1x1+227+1211 -o kaka.png" oiio:ColorSpace: "Gamma2.2" oiio:Gamma: 2.2 + oiio:SourceFormat: "png" + oiio:SourcePath: "src/alphagamma.png" Stats Min: 186 186 186 127 (of 255) Stats Max: 186 186 186 127 (of 255) Stats Avg: 186.00 186.00 186.00 127.00 (of 255) @@ -79,6 +81,8 @@ gimp_gradient: ICCProfile:profile_version: "4.4.0" ICCProfile:rendering_intent: "Perceptual" oiio:ColorSpace: "srgb_rec709_scene" + oiio:SourceFormat: "png" + oiio:SourcePath: "src/gimp_gradient.png" Stats Min: 0 0 0 0 (of 255) Stats Max: 255 255 0 255 (of 255) Stats Avg: 142.37 105.72 0.00 154.72 (of 255) @@ -94,13 +98,19 @@ cicp: 16 x 16, 4 channel, float png channel list: R, G, B, A oiio:ColorSpace: "srgb_rec709_scene" + oiio:SourceFormat: "png" + oiio:SourcePath: "test16.png" removed_cicp: 16 x 16, 4 channel, float png channel list: R, G, B, A oiio:ColorSpace: "srgb_rec709_scene" + oiio:SourceFormat: "png" + oiio:SourcePath: "test16.png" remove_cicp_via_set_colorspace: 16 x 16, 4 channel, float png channel list: R, G, B, A oiio:ColorSpace: "g22_rec709_display" + oiio:SourceFormat: "png" + oiio:SourcePath: "test16.png" Comparing "test16.png" and "ref/test16.png" PASS diff --git a/testsuite/png/ref/out.txt b/testsuite/png/ref/out.txt index 49b9933234..66f0480cde 100644 --- a/testsuite/png/ref/out.txt +++ b/testsuite/png/ref/out.txt @@ -41,6 +41,8 @@ alphagamma: Exif:ImageHistory: "oiiotool -no-autopremult SLEEP_MM.png -cut 1x1+227+1211 -o kaka.png" oiio:ColorSpace: "Gamma2.2" oiio:Gamma: 2.2 + oiio:SourceFormat: "png" + oiio:SourcePath: "src/alphagamma.png" Stats Min: 186 186 186 127 (of 255) Stats Max: 186 186 186 127 (of 255) Stats Avg: 186.00 186.00 186.00 127.00 (of 255) @@ -79,6 +81,8 @@ gimp_gradient: ICCProfile:profile_version: "4.4.0" ICCProfile:rendering_intent: "Perceptual" oiio:ColorSpace: "srgb_rec709_scene" + oiio:SourceFormat: "png" + oiio:SourcePath: "src/gimp_gradient.png" Stats Min: 0 0 0 0 (of 255) Stats Max: 255 255 0 255 (of 255) Stats Avg: 142.37 105.72 0.00 154.72 (of 255) @@ -94,14 +98,20 @@ cicp: 16 x 16, 4 channel, float png channel list: R, G, B, A CICP: 1, 13, 0, 1 - oiio:ColorSpace: "srgb_rec709_scene" + oiio:ColorSpace: "srgb_rec709_display" + oiio:SourceFormat: "png" + oiio:SourcePath: "test16.png" removed_cicp: 16 x 16, 4 channel, float png channel list: R, G, B, A - oiio:ColorSpace: "srgb_rec709_scene" + oiio:ColorSpace: "srgb_rec709_display" + oiio:SourceFormat: "png" + oiio:SourcePath: "test16.png" remove_cicp_via_set_colorspace: 16 x 16, 4 channel, float png channel list: R, G, B, A oiio:ColorSpace: "g22_rec709_display" + oiio:SourceFormat: "png" + oiio:SourcePath: "test16.png" Comparing "test16.png" and "ref/test16.png" PASS diff --git a/testsuite/python-colorconfig/ref/out-ocio230.txt b/testsuite/python-colorconfig/ref/out-ocio230.txt index 17071b04df..fbdf1bcafa 100644 --- a/testsuite/python-colorconfig/ref/out-ocio230.txt +++ b/testsuite/python-colorconfig/ref/out-ocio230.txt @@ -28,7 +28,7 @@ equivalent('ACEScg', 'scene_linear'): True equivalent('lnf', 'scene_linear'): False get_color_interop_id('ACEScg') = lin_ap1_scene get_color_interop_id('lin_srgb') = lin_rec709_scene -get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_scene +get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_display get_cicp('pq_rec2020_display') = [9, 16, 9, 1] get_cicp('unknown_interop_id') = None isColorSpaceLinear('scene_linear') = True @@ -43,6 +43,8 @@ getColorSpaceFromFilepath('none.exr') = unknown getColorSpaceFromFilepath('noclue.exr') = unknown Loaded test OCIO config: oiio_test_v0.9.2.ocio +resolve('myapp:g24_rec709'): Gamma 2.4 Encoded Rec.709 (sRGB) +get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): Parsed color space for filepath 'foo_lin_ap1.exr': ACEScg Default color space: lin_rec709 Default display: sRGB (~2.22) - Display diff --git a/testsuite/python-colorconfig/ref/out-ocio230b.txt b/testsuite/python-colorconfig/ref/out-ocio230b.txt index d52992d9dd..63841cf19a 100644 --- a/testsuite/python-colorconfig/ref/out-ocio230b.txt +++ b/testsuite/python-colorconfig/ref/out-ocio230b.txt @@ -28,7 +28,7 @@ equivalent('ACEScg', 'scene_linear'): True equivalent('lnf', 'scene_linear'): False get_color_interop_id('ACEScg') = lin_ap1_scene get_color_interop_id('lin_srgb') = lin_rec709_scene -get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_scene +get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_display get_cicp('pq_rec2020_display') = [9, 16, 9, 1] get_cicp('unknown_interop_id') = None isColorSpaceLinear('scene_linear') = True @@ -43,6 +43,8 @@ getColorSpaceFromFilepath('none.exr') = unknown getColorSpaceFromFilepath('noclue.exr') = unknown Loaded test OCIO config: oiio_test_v0.9.2.ocio +resolve('myapp:g24_rec709'): Gamma 2.4 Encoded Rec.709 (sRGB) +get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): Parsed color space for filepath 'foo_lin_ap1.exr': ACEScg Default color space: lin_rec709 Default display: sRGB (~2.22) - Display diff --git a/testsuite/python-colorconfig/ref/out-ocio232.txt b/testsuite/python-colorconfig/ref/out-ocio232.txt index c3452050e1..5b1995fae3 100644 --- a/testsuite/python-colorconfig/ref/out-ocio232.txt +++ b/testsuite/python-colorconfig/ref/out-ocio232.txt @@ -28,7 +28,7 @@ equivalent('ACEScg', 'scene_linear'): True equivalent('lnf', 'scene_linear'): False get_color_interop_id('ACEScg') = lin_ap1_scene get_color_interop_id('lin_srgb') = lin_rec709_scene -get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_scene +get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_display get_cicp('pq_rec2020_display') = [9, 16, 9, 1] get_cicp('unknown_interop_id') = None isColorSpaceLinear('scene_linear') = True @@ -43,6 +43,8 @@ getColorSpaceFromFilepath('none.exr') = unknown getColorSpaceFromFilepath('noclue.exr') = unknown Loaded test OCIO config: oiio_test_v0.9.2.ocio +resolve('myapp:g24_rec709'): Gamma 2.4 Encoded Rec.709 (sRGB) +get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): Parsed color space for filepath 'foo_lin_ap1.exr': ACEScg Default color space: lin_rec709 Default display: sRGB (~2.22) - Display diff --git a/testsuite/python-colorconfig/ref/out-ocio24.txt b/testsuite/python-colorconfig/ref/out-ocio24.txt index c568d2266d..94c22b0c95 100644 --- a/testsuite/python-colorconfig/ref/out-ocio24.txt +++ b/testsuite/python-colorconfig/ref/out-ocio24.txt @@ -28,7 +28,7 @@ equivalent('ACEScg', 'scene_linear'): True equivalent('lnf', 'scene_linear'): False get_color_interop_id('ACEScg') = lin_ap1_scene get_color_interop_id('lin_srgb') = lin_rec709_scene -get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_scene +get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_display get_cicp('pq_rec2020_display') = [9, 16, 9, 1] get_cicp('unknown_interop_id') = None isColorSpaceLinear('scene_linear') = True @@ -43,6 +43,8 @@ getColorSpaceFromFilepath('none.exr') = Raw getColorSpaceFromFilepath('noclue.exr') = unknown Loaded test OCIO config: oiio_test_v0.9.2.ocio +resolve('myapp:g24_rec709'): Gamma 2.4 Encoded Rec.709 (sRGB) +get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): Parsed color space for filepath 'foo_lin_ap1.exr': ACEScg Default color space: lin_rec709 Default display: sRGB (~2.22) - Display diff --git a/testsuite/python-colorconfig/ref/out-ocio25.txt b/testsuite/python-colorconfig/ref/out-ocio25.txt index ced1f69a1c..c43bd91750 100644 --- a/testsuite/python-colorconfig/ref/out-ocio25.txt +++ b/testsuite/python-colorconfig/ref/out-ocio25.txt @@ -28,7 +28,7 @@ equivalent('ACEScg', 'scene_linear'): True equivalent('lnf', 'scene_linear'): False get_color_interop_id('ACEScg') = lin_ap1_scene get_color_interop_id('lin_srgb') = lin_rec709_scene -get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_scene +get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_display get_cicp('pq_rec2020_display') = [9, 16, 9, 1] get_cicp('unknown_interop_id') = None isColorSpaceLinear('scene_linear') = True @@ -43,6 +43,8 @@ getColorSpaceFromFilepath('none.exr') = Raw getColorSpaceFromFilepath('noclue.exr') = unknown Loaded test OCIO config: oiio_test_v0.9.2.ocio +resolve('myapp:g24_rec709'): Gamma 2.4 Encoded Rec.709 (sRGB) +get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): Parsed color space for filepath 'foo_lin_ap1.exr': ACEScg Default color space: lin_rec709 Default display: sRGB (~2.22) - Display diff --git a/testsuite/python-colorconfig/ref/out.txt b/testsuite/python-colorconfig/ref/out.txt index 590a997026..f28e5e94a5 100644 --- a/testsuite/python-colorconfig/ref/out.txt +++ b/testsuite/python-colorconfig/ref/out.txt @@ -28,7 +28,7 @@ equivalent('ACEScg', 'scene_linear'): True equivalent('lnf', 'scene_linear'): False get_color_interop_id('ACEScg') = lin_ap1_scene get_color_interop_id('lin_srgb') = lin_rec709_scene -get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_scene +get_color_interop_id([1, 13, 1, 1]) = srgb_rec709_display get_cicp('pq_rec2020_display') = [9, 16, 9, 1] get_cicp('unknown_interop_id') = None isColorSpaceLinear('scene_linear') = True @@ -43,6 +43,8 @@ getColorSpaceFromFilepath('none.exr') = Raw getColorSpaceFromFilepath('noclue.exr') = unknown Loaded test OCIO config: oiio_test_v0.9.2.ocio +resolve('myapp:g24_rec709'): Gamma 2.4 Encoded Rec.709 (sRGB) +get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): Parsed color space for filepath 'foo_lin_ap1.exr': ACEScg Default color space: lin_rec709 Default display: sRGB (~2.22) - Display diff --git a/testsuite/python-colorconfig/src/test_colorconfig.py b/testsuite/python-colorconfig/src/test_colorconfig.py index 16f3ce0ef2..97f784a667 100755 --- a/testsuite/python-colorconfig/src/test_colorconfig.py +++ b/testsuite/python-colorconfig/src/test_colorconfig.py @@ -67,6 +67,16 @@ config = oiio.ColorConfig(str(TEST_CONFIG_PATH)) print (f"Loaded test OCIO config: {TEST_CONFIG_PATH.name}") + # Bindings parity: the enriched resolve() stripped-namespace tier reaches + # a real alias through a namespace prefix it has never seen, where it + # used to only echo the input back unchanged. + print (f"resolve('myapp:g24_rec709'): {config.resolve('myapp:g24_rec709')}") + # get_color_interop_id is a CHEAP lookup (declared interop_id attribute + # or table name/alias match only): a space with no declared id and no + # table-name match returns empty here. The full derivation cascade + # (fingerprint match, config-local id generation) runs internally at + # write-planning time, not through this getter. + print (f"get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)'): {config.get_color_interop_id('Gamma 2.6 Encoded Rec.709 (sRGB)')}") display = config.getDefaultDisplayName() default_cs = config.getColorSpaceFromFilepath("foo.exr") filepath_cs = config.getColorSpaceFromFilepath("foo_lin_ap1.exr") diff --git a/testsuite/python-imagebuf/ref/out-alt.txt b/testsuite/python-imagebuf/ref/out-alt.txt index cb2f545958..c1c230170b 100644 --- a/testsuite/python-imagebuf/ref/out-alt.txt +++ b/testsuite/python-imagebuf/ref/out-alt.txt @@ -71,6 +71,8 @@ Printing the whole spec to be sure: PixelAspectRatio = 1.0 oiio:AverageColor = "0.608983,0.608434,0.608728,1" oiio:SHA-1 = "233A1D3412A54A5F49814AB7BFFD04F56F46D3D7" + oiio:SourceFormat = "tiff" + oiio:SourcePath = "../common/textures/grid.tx" Resetting to a different MIP level: resolution 256x256+0+0 @@ -99,6 +101,8 @@ Resetting to a different MIP level: PixelAspectRatio = 1.0 oiio:AverageColor = "0.608983,0.608434,0.608728,1" oiio:SHA-1 = "233A1D3412A54A5F49814AB7BFFD04F56F46D3D7" + oiio:SourceFormat = "tiff" + oiio:SourcePath = "../common/textures/grid.tx" Making 2x2 RGB image: resolution 2x2+0+0 diff --git a/testsuite/python-imagebuf/ref/out.txt b/testsuite/python-imagebuf/ref/out.txt index cb2f545958..c1c230170b 100644 --- a/testsuite/python-imagebuf/ref/out.txt +++ b/testsuite/python-imagebuf/ref/out.txt @@ -71,6 +71,8 @@ Printing the whole spec to be sure: PixelAspectRatio = 1.0 oiio:AverageColor = "0.608983,0.608434,0.608728,1" oiio:SHA-1 = "233A1D3412A54A5F49814AB7BFFD04F56F46D3D7" + oiio:SourceFormat = "tiff" + oiio:SourcePath = "../common/textures/grid.tx" Resetting to a different MIP level: resolution 256x256+0+0 @@ -99,6 +101,8 @@ Resetting to a different MIP level: PixelAspectRatio = 1.0 oiio:AverageColor = "0.608983,0.608434,0.608728,1" oiio:SHA-1 = "233A1D3412A54A5F49814AB7BFFD04F56F46D3D7" + oiio:SourceFormat = "tiff" + oiio:SourcePath = "../common/textures/grid.tx" Making 2x2 RGB image: resolution 2x2+0+0 diff --git a/testsuite/runtest.py b/testsuite/runtest.py index 9ee9eb0157..cc0bf3a41c 100755 --- a/testsuite/runtest.py +++ b/testsuite/runtest.py @@ -10,6 +10,7 @@ import platform import subprocess import difflib +import re import filecmp import shutil @@ -74,6 +75,36 @@ def make_relpath (path: str, start: str=os.curdir) -> str: # Set it back so tests can use it (python-imagebufalgo) os.putenv('OIIO_TESTSUITE_IMAGEDIR', OIIO_TESTSUITE_IMAGEDIR) +# Silence OpenColorIO's own log output for the whole testsuite, unless the +# caller deliberately asked for a level. +# +# OCIO writes warnings to stderr, and which warnings it writes depends on the +# OCIO version -- e.g. a config carrying the `interop_id` ColorSpace key (legal +# since config version 2.0 and understood by OCIO 2.5+) makes every OCIO older +# than 2.5 emit "unknown key 'interop_id'" once per occurrence. That noise +# lands in captured output and fails the reference comparison on the noise +# alone, for tests whose behavior is identical. It made ~15 tests fail on the +# 2.4 containers while passing on 2.5. +# +# No test in this suite consumes OCIO log output: no reference file contains +# "OpenColorIO Warning" and no run.py asserts on it. So the log is pure noise +# here, and the version-dependence of that noise is a portability hazard. +# OCIO_LOGGING_LEVEL is read by OCIO itself (Logging.cpp) and overrides any +# programmatic SetLoggingLevel. This suppresses logging only -- OCIO reports +# real failures by throwing, not by logging. +if 'OCIO_LOGGING_LEVEL' not in os.environ : + os.environ['OCIO_LOGGING_LEVEL'] = 'none' + os.putenv('OCIO_LOGGING_LEVEL', 'none') + +# Likewise pin OIIO's own debug chatter: debug builds default +# OPENIMAGEIO_DEBUG=1, leaking advisory DBG lines into tests that capture +# raw output, whose references expect the release default. Guarded, so a +# test that deliberately captures the narration (color-interop-convert) or a +# developer export still overrides. +if 'OPENIMAGEIO_DEBUG' not in os.environ : + os.environ['OPENIMAGEIO_DEBUG'] = '0' + os.putenv('OPENIMAGEIO_DEBUG', '0') + refdir = "ref/" refdirlist = [ refdir ] mytest = os.path.split(os.path.abspath(os.getcwd()))[-1] @@ -99,6 +130,40 @@ def oiio_app (app: str) -> str: ociover = os.getenv('OCIO_VERSION_OVERRIDE', ociover) #print(f"OpenColorIO version = '{ociover}'") +# libpng chunk capabilities, computed once for every test that needs them. +# +# PNG is the reference carrier for a couple of color signals, but the libpng +# APIs for them are recent: png_set_cICP arrived in 1.6.46 and png_set_mDCV in +# 1.6.50. Below those the writer/reader compile out (PNG_cICP_SUPPORTED / +# PNG_mDCV_SUPPORTED) and silently no-op, so a test that asserts on the chunk +# is asserting on something the build cannot do. The CI containers ship +# PNG 1.6.34, which is exactly this case. +# +# Tests gate on these rather than parsing the version themselves, and a test +# whose SUBJECT is one of these chunks should skip (loudly, with a marker in +# its output) rather than assert a reduced result -- see the note in +# oiiotool-colorprofile/run.py for why a "passing" reduced result is worse +# than a skip. Override with PNG_VERSION_OVERRIDE for testing both paths. +_pngver = os.getenv('PNG_VERSION_OVERRIDE', '') +if not _pngver : + try : + _libdeps = subprocess.check_output( + [oiio_app('oiiotool').strip(), '--echo', + '{getattribute(build:dependencies)}']).decode('utf-8') + _m = re.search(r'[Pp][Nn][Gg][^0-9]*([0-9]+\.[0-9]+\.[0-9]+)', _libdeps) + _pngver = _m.group(1) if _m else '' + except Exception : + _pngver = '' +def _pngtuple(s) : + try : + return tuple(int(x) for x in s.split('.')[:3]) + except Exception : + return (0, 0, 0) +png_version = _pngver +png_has_cicp = _pngtuple(_pngver) >= (1, 6, 46) +png_has_mdcv = _pngtuple(_pngver) >= (1, 6, 50) +#print(f"libpng {png_version} cicp={png_has_cicp} mdcv={png_has_mdcv}") + command = "" outputs = [ "out.txt" ] # default diff --git a/testsuite/sourceprovenance/ref/out.exr b/testsuite/sourceprovenance/ref/out.exr new file mode 100644 index 0000000000..6dc3dcde85 Binary files /dev/null and b/testsuite/sourceprovenance/ref/out.exr differ diff --git a/testsuite/sourceprovenance/ref/out.txt b/testsuite/sourceprovenance/ref/out.txt new file mode 100644 index 0000000000..5f7cb83989 --- /dev/null +++ b/testsuite/sourceprovenance/ref/out.txt @@ -0,0 +1,13 @@ +ok plain ImageBuf: oiio:SourceFormat == 'tiff' +ok plain ImageBuf: oiio:SourcePath == filename +ok plain ImageBuf: matches live file_format_name() +ok plain ImageBuf: matches live name +ok ImageCache: oiio:SourceFormat == 'tiff' +ok ImageCache: oiio:SourcePath == filename +ok cache-backed ImageBuf: oiio:SourceFormat == 'tiff' +ok cache-backed ImageBuf: oiio:SourcePath == filename +ok post-IBA: oiio:SourceFormat survives IBA op +ok post-IBA: oiio:SourcePath survives IBA op +ok post-IBA: the result's own file_format_name is NOT set (would be if this were duplicating a live accessor instead of filling the gap) +ok written EXR: oiio:SourcePath is stripped by default +done. diff --git a/testsuite/sourceprovenance/run.py b/testsuite/sourceprovenance/run.py new file mode 100644 index 0000000000..061c4b86ff --- /dev/null +++ b/testsuite/sourceprovenance/run.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Run the script +command += pythonbin + " src/test_sourceprovenance.py > out.txt ;" + +# compare the outputs +outputs = [ "out.txt", "out.exr" ] diff --git a/testsuite/sourceprovenance/src/test_sourceprovenance.py b/testsuite/sourceprovenance/src/test_sourceprovenance.py new file mode 100644 index 0000000000..a38bae48ac --- /dev/null +++ b/testsuite/sourceprovenance/src/test_sourceprovenance.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Exercise the P6b source-provenance attributes: "oiio:SourceFormat" and +# "oiio:SourcePath". See src/doc/stdmetadata.rst. + +from __future__ import annotations + +import OpenImageIO as oiio + + +SRC_TIFF = "../common/tahoe-tiny.tif" +SRC_EXR = "../common/checker_with_alpha.exr" + + +def check(desc, cond): + print(("ok " if cond else "FAIL ") + desc) + if not cond: + raise SystemExit("FAILED: " + desc) + + +# 1. Plain ImageBuf read (no explicit ImageCache): the reader deposits both +# attributes on the spec, mirroring what ImageBuf::name() / +# file_format_name() already report live for this instance. +buf = oiio.ImageBuf(SRC_TIFF) +spec = buf.spec() +check("plain ImageBuf: oiio:SourceFormat == 'tiff'", + spec.get_string_attribute("oiio:SourceFormat") == "tiff") +check("plain ImageBuf: oiio:SourcePath == filename", + spec.get_string_attribute("oiio:SourcePath") == SRC_TIFF) +check("plain ImageBuf: matches live file_format_name()", + spec.get_string_attribute("oiio:SourceFormat") == buf.file_format_name) +check("plain ImageBuf: matches live name", + spec.get_string_attribute("oiio:SourcePath") == buf.name) + +# 2. ImageCache-backed read: this is the path the recon found *drops* +# ImageBuf::name()/file_format_name() information today. Confirm the +# spec attributes survive it. +ic = oiio.ImageCache() +ic_spec = ic.get_imagespec(SRC_TIFF) +check("ImageCache: oiio:SourceFormat == 'tiff'", + ic_spec.get_string_attribute("oiio:SourceFormat") == "tiff") +check("ImageCache: oiio:SourcePath == filename", + ic_spec.get_string_attribute("oiio:SourcePath") == SRC_TIFF) + +# 3. Same, but via a plain ImageBuf that is (transparently) backed by the +# shared ImageCache, exactly the "dropped by ImageCache" case the recon +# identified. +oiio.attribute("imagebuf:use_imagecache", 1) +buf_ic = oiio.ImageBuf(SRC_TIFF) +spec_ic = buf_ic.spec() +check("cache-backed ImageBuf: oiio:SourceFormat == 'tiff'", + spec_ic.get_string_attribute("oiio:SourceFormat") == "tiff") +check("cache-backed ImageBuf: oiio:SourcePath == filename", + spec_ic.get_string_attribute("oiio:SourcePath") == SRC_TIFF) +oiio.attribute("imagebuf:use_imagecache", 0) + +# 4. IBA boundary: run an ImageBufAlgo op that produces a brand new ImageBuf. +# The result's own name()/file_format_name() are empty/meaningless (it +# was never itself read from a file), but the source-provenance +# attributes on its spec (copied from the source spec) still identify +# where the pixels originally came from. +resized = oiio.ImageBufAlgo.resize(buf, roi=oiio.ROI(0, 32, 0, 32, 0, 1, 0, 3)) +rspec = resized.spec() +check("post-IBA: oiio:SourceFormat survives IBA op", + rspec.get_string_attribute("oiio:SourceFormat") == "tiff") +check("post-IBA: oiio:SourcePath survives IBA op", + rspec.get_string_attribute("oiio:SourcePath") == SRC_TIFF) +check("post-IBA: the result's own file_format_name is NOT set (would be if " + "this were duplicating a live accessor instead of filling the gap)", + resized.file_format_name == "") + +# 5. Write policy: "oiio:SourcePath" must never leak into a written file by +# default (privacy: no embedded local path). Verify against the file's +# own on-disk metadata -- read it back with a bare ImageInput, which +# reports only what the plugin actually parsed from the file, not a +# fresh re-deposit. (Both attributes are internal "oiio:*" metadata, and +# every writer already suppresses those by default; a future write-policy +# attribute may offer to preserve "oiio:SourceFormat" -- see +# stdmetadata.rst.) +src = oiio.ImageBuf(SRC_EXR) +src.write("out.exr") + +raw_in = oiio.ImageInput.open("out.exr") +raw_spec = raw_in.spec() +check("written EXR: oiio:SourcePath is stripped by default", + raw_spec.get_string_attribute("oiio:SourcePath") == "") +raw_in.close() + +print("done.")